Skip to content

Commit 65b3518

Browse files
authored
feat: support interactive feedback (#97)
1 parent 3ba6d1c commit 65b3518

30 files changed

+799
-524
lines changed

docs/git-draft.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Otherwise if no template is specified and stdin is a TTY, `$EDITOR` will be open
8989
--quit::
9090
Go back to the draft's origin branch, keeping the working directory's current state.
9191
This will delete the draft branch and its upstream.
92-
Generated commits and the draft branch's final state remain available via `ref/drafts`.
92+
Generated commits and the draft branch's final state remain available via `refs/drafts`.
9393

9494
-T::
9595
--templates::

poetry.lock

Lines changed: 93 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ requires-python = ">=3.12"
99
dependencies = [
1010
"docopt-ng (>=0.9,<0.10)",
1111
"jinja2 (>=3.1.5,<4)",
12+
"msgspec (>=0.19.0,<0.20.0)",
1213
"prettytable (>=3.15.1,<4)",
1314
"xdg-base-dirs (>=6.0.2,<7)",
1415
"yaspin (>=3.1.0,<4)",
@@ -39,7 +40,7 @@ python = ">=3.12,<4"
3940

4041
[tool.poetry.group.dev.dependencies]
4142
coverage = "^7.4.4"
42-
mypy = "^1.2.1"
43+
mypy = "^1.18.1"
4344
poethepoet = "^0.25.0"
4445
pytest = "^8.2.0"
4546
pytest-asyncio = "^1.0.0"
@@ -88,6 +89,7 @@ show_missing = true
8889

8990
[tool.mypy]
9091
disable_error_code = "import-untyped"
92+
enable_error_code = "exhaustive-match"
9193

9294
[tool.pytest.ini_options]
9395
log_level = "DEBUG"

src/git_draft/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
import logging
44

5-
from .bots import Action, Bot, Goal
6-
from .toolbox import Toolbox
5+
from .bots import ActionSummary, Bot, Goal, UserFeedback, Worktree
76

87

98
__all__ = [
10-
"Action",
9+
"ActionSummary",
1110
"Bot",
1211
"Goal",
13-
"Toolbox",
12+
"UserFeedback",
13+
"Worktree",
1414
]
1515

1616
logging.getLogger(__name__).addHandler(logging.NullHandler())

src/git_draft/__main__.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,11 @@
1111
import sys
1212

1313
from .bots import load_bot
14-
from .common import (
15-
PROGRAM,
16-
Config,
17-
Progress,
18-
UnreachableError,
19-
ensure_state_home,
20-
)
14+
from .common import PROGRAM, Config, UnreachableError, ensure_state_home
2115
from .drafter import Drafter, DraftMergeStrategy
2216
from .editor import open_editor
2317
from .git import Repo
18+
from .progress import Progress
2419
from .prompt import (
2520
PromptMetadata,
2621
TemplatedPrompt,
@@ -41,6 +36,11 @@ def new_parser() -> optparse.OptionParser:
4136

4237
parser.disable_interspersed_args()
4338

39+
parser.add_option(
40+
"--batch",
41+
help="disable interactive feedback",
42+
action="store_true",
43+
)
4444
parser.add_option(
4545
"--log-path",
4646
help="show log path and exit",
@@ -167,7 +167,11 @@ async def run() -> None: # noqa: PLR0912 PLR0915
167167
datefmt="%m-%d %H:%M",
168168
)
169169

170-
progress = Progress.dynamic() if sys.stdin.isatty() else Progress.static()
170+
progress = (
171+
Progress.dynamic()
172+
if sys.stdin.isatty() and not opts.batch
173+
else Progress.static()
174+
)
171175
repo = Repo.enclosing(Path(opts.root) if opts.root else Path.cwd())
172176
drafter = Drafter.create(repo, Store.persistent(), progress)
173177
match getattr(opts, "command", "new"):
@@ -200,10 +204,10 @@ async def run() -> None: # noqa: PLR0912 PLR0915
200204

201205
accept = Accept(opts.accept or 0)
202206
await drafter.generate_draft(
203-
prompt,
204-
bot,
205-
prompt_transform=open_editor if editable else None,
207+
prompt=prompt,
208+
bot=bot,
206209
merge_strategy=accept.merge_strategy(),
210+
prompt_transform=open_editor if editable else None,
207211
)
208212
if accept == Accept.MERGE_THEN_QUIT:
209213
# TODO: Refuse to quit on pending question?
@@ -235,7 +239,8 @@ def main() -> None:
235239
asyncio.run(run())
236240
except Exception as err:
237241
_logger.exception("Program failed.")
238-
print(f"Error: {err}", file=sys.stderr)
242+
message = str(err) or "See logs for more information"
243+
print(f"Error: {message}", file=sys.stderr)
239244
sys.exit(1)
240245

241246

src/git_draft/bots/__init__.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
1-
"""Bot interfaces and built-in implementations
2-
3-
* https://aider.chat/docs/leaderboards/
4-
"""
1+
"""Bot interfaces and built-in implementations"""
52

63
import importlib
74
import os
85
import sys
96

107
from ..common import BotConfig, reindent
11-
from ..toolbox import Toolbox
12-
from .common import Action, Bot, Goal
8+
from .common import ActionSummary, Bot, Goal, UserFeedback, Worktree
139

1410

1511
__all__ = [
16-
"Action",
12+
"ActionSummary",
1713
"Bot",
1814
"Goal",
19-
"Toolbox",
15+
"UserFeedback",
16+
"Worktree",
2017
]
2118

2219

src/git_draft/bots/common.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Sequence
6+
import contextlib
57
import dataclasses
6-
from pathlib import Path
8+
from pathlib import Path, PurePosixPath
9+
from typing import Protocol
710

811
from ..common import ensure_state_home, qualified_class_name
9-
from ..toolbox import Toolbox
1012

1113

1214
@dataclasses.dataclass(frozen=True)
@@ -17,8 +19,51 @@ class Goal:
1719
# TODO: Add timeout.
1820

1921

22+
class Worktree(Protocol):
23+
"""File operations
24+
25+
Implementations may not be thread-safe. Concurrent operations should be
26+
serialized by the caller.
27+
"""
28+
29+
def list_files(self) -> Sequence[PurePosixPath]:
30+
"""List all files"""
31+
32+
def read_file(self, path: PurePosixPath) -> str | None:
33+
"""Get a file's contents"""
34+
35+
def write_file(self, path: PurePosixPath, contents: str) -> None:
36+
"""Update a file's contents"""
37+
38+
def delete_file(self, path: PurePosixPath) -> None:
39+
"""Remove a file"""
40+
41+
def rename_file(
42+
self, src_path: PurePosixPath, dst_path: PurePosixPath
43+
) -> None:
44+
"""Move a file"""
45+
46+
def edit_files(self) -> contextlib.AbstractContextManager[Path]:
47+
"""Return path to a temporary folder with editable copies of all files
48+
49+
Any updates are synced back to the work tree when the context exits.
50+
Other operations should not be performed concurrently as they may be
51+
stale or lost.
52+
"""
53+
54+
55+
class UserFeedback(Protocol):
56+
"""User interactions"""
57+
58+
def notify(self, update: str) -> None:
59+
"""Report progress to the user"""
60+
61+
def ask(self, question: str) -> str:
62+
"""Request additional information from the user"""
63+
64+
2065
@dataclasses.dataclass
21-
class Action:
66+
class ActionSummary:
2267
"""End-of-action statistics
2368
2469
This dataclass is not frozen to allow bot implementors to populate its
@@ -28,7 +73,6 @@ class Action:
2873
title: str | None = None
2974
request_count: int | None = None
3075
token_count: int | None = None
31-
question: str | None = None
3276

3377
def increment_request_count(self, n: int = 1, init: bool = False) -> None:
3478
self._increment("request_count", n, init)
@@ -66,6 +110,8 @@ def state_folder_path(cls, ensure_exists: bool = False) -> Path:
66110
path.mkdir(parents=True, exist_ok=True)
67111
return path
68112

69-
async def act(self, goal: Goal, toolbox: Toolbox) -> Action:
70-
"""Runs the bot, striving to achieve the goal with the given toolbox"""
113+
async def act(
114+
self, goal: Goal, tree: Worktree, feedback: UserFeedback
115+
) -> ActionSummary:
116+
"""Runs the bot, striving to achieve the goal"""
71117
raise NotImplementedError()

0 commit comments

Comments
 (0)