From e39b22f4a5eb4804b5563a8ce11a0cf6d4895f38 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Sat, 8 Mar 2025 17:45:56 -0800 Subject: [PATCH 1/5] feat: support prompt editing --- src/git_draft/__main__.py | 12 +++++------- src/git_draft/drafter.py | 23 ++++++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/git_draft/__main__.py b/src/git_draft/__main__.py index cfba438..9e7acea 100644 --- a/src/git_draft/__main__.py +++ b/src/git_draft/__main__.py @@ -142,7 +142,7 @@ def on_delete_file(self, path: PurePosixPath, _reason: str | None) -> None: print(f"Deleted {path}.") -def edit(text: str | None, path: Path | None) -> str | None: +def edit(*, text: str | None = None, path: Path | None = None) -> str | None: if sys.stdin.isatty(): return open_editor(text or "", path) else: @@ -180,15 +180,13 @@ def main() -> None: if args: prompt = TemplatedPrompt.parse(args[0], *args[1:]) else: - if sys.stdin.isatty(): - prompt = open_editor("Enter your prompt here...") - else: - prompt = sys.stdin.read() + prompt = "" # TODO: drafter.find_last_prompt() name = drafter.generate_draft( prompt, bot, bot_name=opts.bot, + prompt_transform=open_editor if opts.edit else None, tool_visitors=[ToolPrinter()], reset=config.auto_reset if opts.reset is None else opts.reset, sync=opts.sync, @@ -212,9 +210,9 @@ def main() -> None: tpl = Template.find(name) if opts.edit: if tpl: - edit(tpl.source, tpl.local_path()) + edit(text=tpl.source, path=tpl.local_path()) else: - edit("", Template.local_path_for(name)) + edit(path=Template.local_path_for(name)) else: if not tpl: raise ValueError(f"No template named {name!r}") diff --git a/src/git_draft/drafter.py b/src/git_draft/drafter.py index 3004592..f082d5c 100644 --- a/src/git_draft/drafter.py +++ b/src/git_draft/drafter.py @@ -10,7 +10,7 @@ import re import textwrap import time -from typing import Match, Sequence +from typing import Callable, Match, Sequence import git @@ -77,17 +77,17 @@ def generate_draft( bot: Bot, bot_name: str | None = None, tool_visitors: Sequence[ToolVisitor] | None = None, + prompt_transform: Callable[[str], str] | None = None, reset: bool = False, sync: bool = False, timeout: float | None = None, ) -> str: - if isinstance(prompt, str) and not prompt.strip(): - raise ValueError("Empty prompt") if self._repo.is_dirty(working_tree=False): if not reset: raise ValueError("Please commit or reset any staged changes") self._repo.index.reset() + # Ensure that we are on a draft branch. branch = _Branch.active(self._repo) if branch: self._stage_changes(sync) @@ -96,17 +96,18 @@ def generate_draft( branch = self._create_branch(sync) _logger.debug("Created branch %s.", branch) - operation_recorder = _OperationRecorder() - tool_visitors = [operation_recorder] + list(tool_visitors or []) - toolbox = StagingToolbox(self._repo, tool_visitors) + # Handle prompt templating and editing. if isinstance(prompt, TemplatedPrompt): template: str | None = prompt.template - renderer = PromptRenderer.for_toolbox(toolbox) + renderer = PromptRenderer.for_toolbox(StagingToolbox(self._repo)) prompt_contents = renderer.render(prompt) else: template = None prompt_contents = prompt - + if prompt_transform: + prompt_contents = prompt_transform(prompt_contents) + if not prompt_contents.strip(): + raise ValueError("Aborting: empty prompt") with self._store.cursor() as cursor: [(prompt_id,)] = cursor.execute( sql("add-prompt"), @@ -117,7 +118,11 @@ def generate_draft( }, ) + # Trigger code generation. _logger.debug("Running bot... [bot=%s]", bot) + operation_recorder = _OperationRecorder() + tool_visitors = [operation_recorder] + list(tool_visitors or []) + toolbox = StagingToolbox(self._repo, tool_visitors) start_time = time.perf_counter() goal = Goal(prompt_contents, timeout) action = bot.act(goal, toolbox) @@ -125,6 +130,7 @@ def generate_draft( walltime = end_time - start_time _logger.info("Completed bot action. [action=%s]", action) + # Generate an appropriate commit and update our database. toolbox.trim_index() title = action.title if not title: @@ -133,7 +139,6 @@ def generate_draft( f"draft! {title}\n\n{prompt_contents}", skip_hooks=True, ) - with self._store.cursor() as cursor: cursor.execute( sql("add-action"), From ebe0d0f3dd146d7019878f61ae2c28577b36e6e2 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Sat, 8 Mar 2025 18:08:36 -0800 Subject: [PATCH 2/5] fixup! feat: support prompt editing --- docs/git-draft.adoc | 3 +-- src/git_draft/__main__.py | 21 ++++++++++++----- src/git_draft/drafter.py | 25 ++++++++++++++++----- src/git_draft/queries/get-latest-prompt.sql | 6 +++++ 4 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 src/git_draft/queries/get-latest-prompt.sql diff --git a/docs/git-draft.adoc b/docs/git-draft.adoc index eed5f0e..93bcb70 100644 --- a/docs/git-draft.adoc +++ b/docs/git-draft.adoc @@ -18,8 +18,7 @@ IMPORTANT: `git-draft` is WIP. == Synopsis [verse] -git draft [options] [--generate] [--bot BOT] [--edit] [--reset | --no-reset] - [--sync] [TEMPLATE [VARIABLE...]] +git draft [options] [--generate] [--bot BOT] [--edit] [--reset | --no-reset] [--sync] [TEMPLATE [VARIABLE...]] git draft [options] --finalize [--clean | --revert] [--delete] git draft [options] --show-drafts [--json] git draft [options] --show-prompts [--json] [PROMPT] diff --git a/src/git_draft/__main__.py b/src/git_draft/__main__.py index 9e7acea..2d09f67 100644 --- a/src/git_draft/__main__.py +++ b/src/git_draft/__main__.py @@ -142,17 +142,20 @@ def on_delete_file(self, path: PurePosixPath, _reason: str | None) -> None: print(f"Deleted {path}.") -def edit(*, text: str | None = None, path: Path | None = None) -> str | None: +def edit(path: Path, text: str | None = None) -> str | None: if sys.stdin.isatty(): return open_editor(text or "", path) else: - if path and text is not None: + if text is not None: with open(path, "w") as f: f.write(text) print(path) return None +_PROMPT_PLACEHOLDER = "Enter your prompt here..." + + def main() -> None: config = Config.load() (opts, args) = new_parser().parse_args() @@ -177,16 +180,22 @@ def main() -> None: bot = load_bot(bot_config) prompt: str | TemplatedPrompt + editable = opts.edit if args: prompt = TemplatedPrompt.parse(args[0], *args[1:]) + elif sys.stdin.isatty(): + editable = False + prompt = open_editor( + drafter.latest_draft_prompt() or _PROMPT_PLACEHOLDER + ) else: - prompt = "" # TODO: drafter.find_last_prompt() + prompt = sys.stdin.read() name = drafter.generate_draft( prompt, bot, bot_name=opts.bot, - prompt_transform=open_editor if opts.edit else None, + prompt_transform=open_editor if editable else None, tool_visitors=[ToolPrinter()], reset=config.auto_reset if opts.reset is None else opts.reset, sync=opts.sync, @@ -210,9 +219,9 @@ def main() -> None: tpl = Template.find(name) if opts.edit: if tpl: - edit(text=tpl.source, path=tpl.local_path()) + edit(tpl.local_path(), text=tpl.source) else: - edit(path=Template.local_path_for(name)) + edit(Template.local_path_for(name)) else: if not tpl: raise ValueError(f"No template named {name!r}") diff --git a/src/git_draft/drafter.py b/src/git_draft/drafter.py index f082d5c..82109ab 100644 --- a/src/git_draft/drafter.py +++ b/src/git_draft/drafter.py @@ -237,8 +237,8 @@ def exit_draft(self, *, revert: bool, clean=False, delete=False) -> str: def history_table(self, branch_name: str | None = None) -> Table: path = self._repo.working_dir branch = _Branch.active(self._repo, branch_name) - if branch: - with self._store.cursor() as cursor: + with self._store.cursor() as cursor: + if branch: results = cursor.execute( sql("list-prompts"), { @@ -246,13 +246,26 @@ def history_table(self, branch_name: str | None = None) -> Table: "branch_suffix": branch.suffix, }, ) - return Table.from_cursor(results) - else: - with self._store.cursor() as cursor: + else: results = cursor.execute( sql("list-drafts"), {"repo_path": path} ) - return Table.from_cursor(results) + return Table.from_cursor(results) + + def latest_draft_prompt(self) -> str | None: + """Returns the latest prompt for the current draft""" + branch = _Branch.active(self._repo) + if not branch: + return None + with self._store.cursor() as cursor: + result = cursor.execute( + sql("get-latest-prompt"), + { + "repo_path": self._repo.working_dir, + "branch_suffix": branch.suffix, + }, + ).fetchone() + return result[0] if result else None def _create_branch(self, sync: bool) -> _Branch: if self._repo.head.is_detached: diff --git a/src/git_draft/queries/get-latest-prompt.sql b/src/git_draft/queries/get-latest-prompt.sql new file mode 100644 index 0000000..2c47220 --- /dev/null +++ b/src/git_draft/queries/get-latest-prompt.sql @@ -0,0 +1,6 @@ +select p.contents + from prompts as p + join branches as b on p.branch_suffix = b.suffix + where b.repo_path = :repo_path and b.suffix = :branch_suffix + order by created_at desc + limit 1; From bd42bdd08dabd2606dd0819622d1ebfe0cfd6949 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Sat, 8 Mar 2025 18:17:24 -0800 Subject: [PATCH 3/5] fixup! feat: support prompt editing --- src/git_draft/__main__.py | 2 +- src/git_draft/queries/get-latest-prompt.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/git_draft/__main__.py b/src/git_draft/__main__.py index 2d09f67..0eba9a3 100644 --- a/src/git_draft/__main__.py +++ b/src/git_draft/__main__.py @@ -183,7 +183,7 @@ def main() -> None: editable = opts.edit if args: prompt = TemplatedPrompt.parse(args[0], *args[1:]) - elif sys.stdin.isatty(): + elif opts.edit: editable = False prompt = open_editor( drafter.latest_draft_prompt() or _PROMPT_PLACEHOLDER diff --git a/src/git_draft/queries/get-latest-prompt.sql b/src/git_draft/queries/get-latest-prompt.sql index 2c47220..ea88b19 100644 --- a/src/git_draft/queries/get-latest-prompt.sql +++ b/src/git_draft/queries/get-latest-prompt.sql @@ -2,5 +2,5 @@ select p.contents from prompts as p join branches as b on p.branch_suffix = b.suffix where b.repo_path = :repo_path and b.suffix = :branch_suffix - order by created_at desc + order by p.created_at desc limit 1; From a0ea04f8104e252e5cbe13bcc02fdbc05be1b2af Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Sat, 8 Mar 2025 19:37:33 -0800 Subject: [PATCH 4/5] fixup! feat: support prompt editing --- README.md | 14 +++++++++++++- src/git_draft/__main__.py | 2 +- src/git_draft/bots/openai.py | 5 +++++ src/git_draft/drafter.py | 2 +- src/git_draft/prompt.py | 5 ++++- src/git_draft/queries/get-latest-prompt.sql | 4 ++-- tests/git_draft/drafter_test.py | 14 ++++++++++++++ 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e75d006..71c9c73 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## Highlights -* Concurrent edits. +* Concurrent edits. By default `git-draft` does not touch the working directory. * Customizable prompt templates. * Extensible bot API. @@ -16,3 +16,15 @@ ```sh pipx install git-draft[openai] ``` + + +## Next steps + +* Mechanism for reporting feedback from a bot, and possibly allowing user to + interactively respond. +* Add configuration option to auto sync and `--no-sync` flag. Similar to reset. +* Add "amend" commit when finalizing. This could be useful training data, + showing what the bot did not get right. +* Convenience functionality for simple cases: checkout option which applies the + changes, and finalizes the draft if specified multiple times. For example `git + draft -cc add-test symbol=foo` diff --git a/src/git_draft/__main__.py b/src/git_draft/__main__.py index 0eba9a3..7b5b82c 100644 --- a/src/git_draft/__main__.py +++ b/src/git_draft/__main__.py @@ -200,7 +200,7 @@ def main() -> None: reset=config.auto_reset if opts.reset is None else opts.reset, sync=opts.sync, ) - print(f"Generated {name}.") + print(f"Refined {name}.") elif command == "finalize": name = drafter.exit_draft( revert=opts.revert, clean=opts.clean, delete=opts.delete diff --git a/src/git_draft/bots/openai.py b/src/git_draft/bots/openai.py index 453aa03..4c70f2b 100644 --- a/src/git_draft/bots/openai.py +++ b/src/git_draft/bots/openai.py @@ -126,6 +126,11 @@ def params(self) -> Sequence[openai.types.chat.ChatCompletionToolParam]: You should stop when and ONLY WHEN all the files you need to change have been updated. + + If you stop for any reason before completing your task, explain why by + updating a REASON file before stopping. For example if you are missing some + information or noticed something inconsistent with the instructions, say so + there. DO NOT STOP without updating at least this file. """ diff --git a/src/git_draft/drafter.py b/src/git_draft/drafter.py index 82109ab..7cd78d9 100644 --- a/src/git_draft/drafter.py +++ b/src/git_draft/drafter.py @@ -164,7 +164,7 @@ def generate_draft( ], ) - _logger.info("Generated %s.", branch) + _logger.info("Completed generation for %s.", branch) return str(branch) def exit_draft(self, *, revert: bool, clean=False, delete=False) -> str: diff --git a/src/git_draft/prompt.py b/src/git_draft/prompt.py index 6afc6b9..4b1cc12 100644 --- a/src/git_draft/prompt.py +++ b/src/git_draft/prompt.py @@ -56,7 +56,10 @@ def for_toolbox(cls, toolbox: Toolbox) -> Self: def render(self, prompt: TemplatedPrompt) -> str: tpl = self._environment.get_template(f"{prompt.template}.{_extension}") - return tpl.render(prompt.context) + try: + return tpl.render(prompt.context) + except jinja2.UndefinedError as err: + raise ValueError(f"Unable to render template: {err}") def templates_table() -> Table: diff --git a/src/git_draft/queries/get-latest-prompt.sql b/src/git_draft/queries/get-latest-prompt.sql index ea88b19..6b55e38 100644 --- a/src/git_draft/queries/get-latest-prompt.sql +++ b/src/git_draft/queries/get-latest-prompt.sql @@ -2,5 +2,5 @@ select p.contents from prompts as p join branches as b on p.branch_suffix = b.suffix where b.repo_path = :repo_path and b.suffix = :branch_suffix - order by p.created_at desc - limit 1; + order by p.id desc + limit 2; diff --git a/tests/git_draft/drafter_test.py b/tests/git_draft/drafter_test.py index 2c86a50..7d67fb3 100644 --- a/tests/git_draft/drafter_test.py +++ b/tests/git_draft/drafter_test.py @@ -243,3 +243,17 @@ def test_history_table_active_draft(self) -> None: self._drafter.generate_draft("hello", FakeBot()) table = self._drafter.history_table() assert table + + def test_latest_draft_prompt(self) -> None: + bot = FakeBot() + + prompt1 = "First prompt" + self._drafter.generate_draft(prompt1, bot) + assert self._drafter.latest_draft_prompt() == prompt1 + + prompt2 = "Second prompt" + self._drafter.generate_draft(prompt2, bot) + assert self._drafter.latest_draft_prompt() == prompt2 + + def test_latest_draft_prompt_no_active_branch(self) -> None: + assert self._drafter.latest_draft_prompt() is None From 5738ff36151d70a969555192ee0807ca11082acf Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Sat, 8 Mar 2025 19:39:32 -0800 Subject: [PATCH 5/5] fixup! feat: support prompt editing --- src/git_draft/queries/get-latest-prompt.sql | 2 +- tests/git_draft/prompt_test.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/git_draft/queries/get-latest-prompt.sql b/src/git_draft/queries/get-latest-prompt.sql index 6b55e38..6a66082 100644 --- a/src/git_draft/queries/get-latest-prompt.sql +++ b/src/git_draft/queries/get-latest-prompt.sql @@ -3,4 +3,4 @@ select p.contents join branches as b on p.branch_suffix = b.suffix where b.repo_path = :repo_path and b.suffix = :branch_suffix order by p.id desc - limit 2; + limit 1; diff --git a/tests/git_draft/prompt_test.py b/tests/git_draft/prompt_test.py index 3be15cb..09f05cb 100644 --- a/tests/git_draft/prompt_test.py +++ b/tests/git_draft/prompt_test.py @@ -15,6 +15,11 @@ def test_ok(self) -> None: rendered = self._renderer.render(prompt) assert "foo" in rendered + def test_missing_variable(self) -> None: + prompt = sut.TemplatedPrompt.parse("add-test") + with pytest.raises(ValueError): + self._renderer.render(prompt) + class TestTemplate: @pytest.fixture(autouse=True)