Skip to content

Commit 8d297f4

Browse files
authored
feat: add list drafts and templates commands (#47)
1 parent 548c36c commit 8d297f4

File tree

18 files changed

+320
-44
lines changed

18 files changed

+320
-44
lines changed

poetry.lock

Lines changed: 31 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ dynamic = ["version"]
88
requires-python = ">=3.12"
99
dependencies = [
1010
"gitpython (>=3.1.44,<4)",
11-
"jinja2 (>=3.1.5,<4.0.0)",
12-
"xdg-base-dirs (>=6.0.2,<7.0.0)",
11+
"jinja2 (>=3.1.5,<4)",
12+
"prettytable (>=3.15.1,<4)",
13+
"xdg-base-dirs (>=6.0.2,<7)",
1314
]
1415

1516
[project.optional-dependencies]

src/git_draft/__main__.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from .common import PROGRAM, Config, UnreachableError, ensure_state_home
1414
from .drafter import Drafter
1515
from .editor import open_editor
16-
from .prompt import TemplatedPrompt
16+
from .prompt import TemplatedPrompt, template_source, templates_table
1717
from .store import Store
1818
from .toolbox import ToolVisitor
1919

@@ -40,12 +40,12 @@ def new_parser() -> optparse.OptionParser:
4040
dest="root",
4141
)
4242

43-
def add_command(name: str, **kwargs) -> None:
43+
def add_command(name: str, short: str | None = None, **kwargs) -> None:
4444
def callback(_option, _opt, _value, parser) -> None:
4545
parser.values.command = name
4646

4747
parser.add_option(
48-
f"-{name[0].upper()}",
48+
f"-{short or name[0].upper()}",
4949
f"--{name}",
5050
action="callback",
5151
callback=callback,
@@ -54,7 +54,9 @@ def callback(_option, _opt, _value, parser) -> None:
5454

5555
add_command("finalize", help="apply current draft to original branch")
5656
add_command("generate", help="start a new draft from a prompt")
57+
add_command("history", help="show history drafts or prompts")
5758
add_command("revert", help="discard the current draft")
59+
add_command("templates", help="show template information")
5860

5961
parser.add_option(
6062
"-b",
@@ -74,6 +76,13 @@ def callback(_option, _opt, _value, parser) -> None:
7476
help="delete draft after finalizing or discarding",
7577
action="store_true",
7678
)
79+
# TODO: Add edit option. Works both for prompts and templates.
80+
parser.add_option(
81+
"-j",
82+
"--json",
83+
help="use JSON for table output",
84+
action="store_true",
85+
)
7786
parser.add_option(
7887
"-r",
7988
"--reset",
@@ -151,6 +160,7 @@ def main() -> None:
151160
name = drafter.generate_draft(
152161
prompt,
153162
bot,
163+
bot_name=opts.bot,
154164
tool_visitors=[ToolPrinter()],
155165
reset=opts.reset,
156166
)
@@ -161,6 +171,16 @@ def main() -> None:
161171
elif command == "revert":
162172
name = drafter.revert_draft(delete=opts.delete)
163173
print(f"Reverted {name}.")
174+
elif command == "history":
175+
table = drafter.history_table(args[0] if args else None)
176+
if table:
177+
print(table.to_json() if opts.json else table)
178+
elif command == "templates":
179+
if args:
180+
print(template_source(args[0]))
181+
else:
182+
table = templates_table()
183+
print(table.to_json() if opts.json else table)
164184
else:
165185
raise UnreachableError()
166186

src/git_draft/bots/common.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import dataclasses
44
from pathlib import Path
55

6-
from ..common import ensure_state_home
6+
from ..common import ensure_state_home, qualified_class_name
77
from ..toolbox import Toolbox
88

99

@@ -21,9 +21,7 @@ class Action:
2121
class Bot:
2222
@classmethod
2323
def state_folder_path(cls, ensure_exists=False) -> Path:
24-
name = cls.__qualname__
25-
if cls.__module__:
26-
name = f"{cls.__module__}.{name}"
24+
name = qualified_class_name(cls)
2725
path = ensure_state_home() / "bots" / name
2826
if ensure_exists:
2927
path.mkdir(parents=True, exist_ok=True)

src/git_draft/common.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
import logging
88
from pathlib import Path
99
import random
10+
import sqlite3
1011
import string
1112
import textwrap
1213
import tomllib
13-
from typing import Any, Mapping, Self, Sequence
14+
from typing import Any, Mapping, Self, Sequence, Type
1415

16+
import prettytable
1517
import xdg_base_dirs
1618

1719

@@ -90,3 +92,37 @@ def reindent(s: str, width=0) -> str:
9092
return "\n\n".join(
9193
textwrap.fill(p, width=width) if width else p for p in paragraphs
9294
)
95+
96+
97+
def qualified_class_name(cls: Type) -> str:
98+
name = cls.__qualname__
99+
return f"{cls.__module__}.{name}" if cls.__module__ else name
100+
101+
102+
class Table:
103+
"""Pretty-printable table"""
104+
105+
_kwargs = dict(border=False) # Shared options
106+
107+
def __init__(self, data: prettytable.PrettyTable) -> None:
108+
self.data = data
109+
self.data.align = "l"
110+
111+
def __bool__(self) -> bool:
112+
return len(self.data.rows) > 0
113+
114+
def __str__(self) -> str:
115+
return str(self.data) if self else ""
116+
117+
def to_json(self) -> str:
118+
return self.data.get_json_string(header=False)
119+
120+
@classmethod
121+
def empty(cls) -> Self:
122+
return cls(prettytable.PrettyTable([], **cls._kwargs))
123+
124+
@classmethod
125+
def from_cursor(cls, cursor: sqlite3.Cursor) -> Self:
126+
table = prettytable.from_db_cursor(cursor, **cls._kwargs)
127+
assert table
128+
return cls(table)

src/git_draft/drafter.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import git
1616

1717
from .bots import Bot, Goal
18-
from .common import JSONObject, random_id
18+
from .common import JSONObject, Table, qualified_class_name, random_id
1919
from .prompt import PromptRenderer, TemplatedPrompt
2020
from .store import Store, sql
2121
from .toolbox import StagingToolbox, ToolVisitor
@@ -28,7 +28,7 @@
2828
class _Branch:
2929
"""Draft branch"""
3030

31-
_name_pattern = re.compile(r"draft/(.+)")
31+
_pattern = re.compile(r"draft/(.+)")
3232

3333
suffix: str
3434

@@ -40,11 +40,13 @@ def __str__(self) -> str:
4040
return self.name
4141

4242
@classmethod
43-
def active(cls, repo: git.Repo) -> _Branch | None:
43+
def active(cls, repo: git.Repo, name: str | None = None) -> _Branch | None:
4444
match: Match | None = None
45-
if not repo.head.is_detached:
46-
match = cls._name_pattern.fullmatch(repo.active_branch.name)
45+
if name or not repo.head.is_detached:
46+
match = cls._pattern.fullmatch(name or repo.active_branch.name)
4747
if not match:
48+
if name:
49+
raise ValueError(f"Not a valid draft branch name: {name!r}")
4850
return None
4951
return _Branch(match[1])
5052

@@ -64,12 +66,16 @@ def __init__(self, store: Store, repo: git.Repo) -> None:
6466

6567
@classmethod
6668
def create(cls, store: Store, path: str | None = None) -> Drafter:
67-
return cls(store, git.Repo(path, search_parent_directories=True))
69+
try:
70+
return cls(store, git.Repo(path, search_parent_directories=True))
71+
except git.NoSuchPathError:
72+
raise ValueError(f"No git repository at {path}")
6873

6974
def generate_draft(
7075
self,
7176
prompt: str | TemplatedPrompt,
7277
bot: Bot,
78+
bot_name: str | None = None,
7379
tool_visitors: Sequence[ToolVisitor] | None = None,
7480
reset: bool = False,
7581
sync: bool = False,
@@ -94,16 +100,19 @@ def generate_draft(
94100
tool_visitors = [operation_recorder] + list(tool_visitors or [])
95101
toolbox = StagingToolbox(self._repo, tool_visitors)
96102
if isinstance(prompt, TemplatedPrompt):
103+
template: str | None = prompt.template
97104
renderer = PromptRenderer.for_toolbox(toolbox)
98105
prompt_contents = renderer.render(prompt)
99106
else:
107+
template = None
100108
prompt_contents = prompt
101109

102110
with self._store.cursor() as cursor:
103111
[(prompt_id,)] = cursor.execute(
104112
sql("add-prompt"),
105113
{
106114
"branch_suffix": branch.suffix,
115+
"template": template,
107116
"contents": prompt_contents,
108117
},
109118
)
@@ -131,6 +140,8 @@ def generate_draft(
131140
{
132141
"commit_sha": commit.hexsha,
133142
"prompt_id": prompt_id,
143+
"bot_name": bot_name,
144+
"bot_class": qualified_class_name(bot.__class__),
134145
"walltime": walltime,
135146
},
136147
)
@@ -161,6 +172,26 @@ def revert_draft(self, delete=False) -> str:
161172
_logger.info("Reverted %s.", name)
162173
return name
163174

175+
def history_table(self, branch_name: str | None = None) -> Table:
176+
path = self._repo.working_dir
177+
branch = _Branch.active(self._repo, branch_name)
178+
if branch:
179+
with self._store.cursor() as cursor:
180+
results = cursor.execute(
181+
sql("list-prompts"),
182+
{
183+
"repo_path": path,
184+
"branch_suffix": branch.suffix,
185+
},
186+
)
187+
return Table.from_cursor(results)
188+
else:
189+
with self._store.cursor() as cursor:
190+
results = cursor.execute(
191+
sql("list-drafts"), {"repo_path": path}
192+
)
193+
return Table.from_cursor(results)
194+
164195
def _create_branch(self, sync: bool) -> _Branch:
165196
if self._repo.head.is_detached:
166197
raise RuntimeError("No currently active branch")

0 commit comments

Comments
 (0)