Skip to content

Commit 014c428

Browse files
authored
feat: add list events command (#101)
1 parent fc33886 commit 014c428

File tree

17 files changed

+320
-125
lines changed

17 files changed

+320
-125
lines changed

docs/git-draft.1.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ IMPORTANT: `git-draft` is WIP.
2121
git draft [options] [--new] [--accept... | --no-accept] [--bot BOT]
2222
[--edit] [TEMPLATE [VARIABLE...] | -]
2323
git draft [options] --quit
24+
git draft [options] --events [REF]
2425
git draft [options] --templates [--json | [--edit] TEMPLATE]
2526

2627

@@ -123,7 +124,7 @@ o draft! sync(prompt)
123124
o <some commit> (main, draft/123)
124125
----
125126

126-
If merging is enabled, it have both the LLM-generated changes and manual edits as parents.
127+
If merging is enabled, the merge commit will have both the LLM-generated changes and manual edits as parents.
127128

128129
[source]
129130
----

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ lines-after-imports = 2
133133
[tool.ruff.lint.pydocstyle]
134134
convention = "google"
135135

136+
[tool.ruff.lint.pylint]
137+
max-returns = 20
138+
136139
[tool.ruff.lint.per-file-ignores]
137140
"__main__.py" = ["T20"]
138141
"tests/**" = ["ANN", "D", "SLF"]

src/git_draft/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def callback(
7272

7373
add_command("new", help="create a new draft from a prompt")
7474
add_command("quit", help="return to original branch")
75+
add_command("events", help="list events")
7576
add_command("templates", short="T", help="show template information")
7677

7778
parser.add_option(
@@ -214,6 +215,10 @@ async def run() -> None: # noqa: PLR0912 PLR0915
214215
drafter.quit_folio()
215216
case "quit":
216217
drafter.quit_folio()
218+
case "events":
219+
draft_id = args[0] if args else None
220+
for elem in drafter.list_draft_events(draft_id):
221+
print(elem)
217222
case "templates":
218223
if args:
219224
name = args[0]

src/git_draft/common.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from collections.abc import Mapping, Sequence
66
import dataclasses
7-
import datetime
7+
from datetime import datetime
88
import itertools
99
import logging
1010
import os
@@ -103,13 +103,22 @@ def reindent(s: str, prefix: str = "", width: int = 0) -> str:
103103
)
104104

105105

106+
def tagged(text: str, /, **kwargs) -> str:
107+
if kwargs:
108+
tags = [
109+
f"{key}={val}" for key, val in kwargs.items() if val is not None
110+
]
111+
text = f"{text} [{', '.join(tags)}]" if tags else text
112+
return reindent(text)
113+
114+
106115
def qualified_class_name(cls: type) -> str:
107116
name = cls.__qualname__
108117
return f"{cls.__module__}.{name}" if cls.__module__ else name
109118

110119

111-
def now() -> datetime.datetime:
112-
return datetime.datetime.now().astimezone()
120+
def now() -> datetime:
121+
return datetime.now().astimezone()
113122

114123

115124
class Table:

src/git_draft/drafter.py

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,27 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Callable
5+
from collections.abc import Callable, Sequence
66
import dataclasses
7-
from datetime import timedelta
7+
from datetime import datetime, timedelta
88
import logging
99
import re
1010
import textwrap
1111
import time
1212
from typing import Literal
1313

1414
from .bots import ActionSummary, Bot, Goal
15-
from .common import qualified_class_name, reindent
15+
from .common import (
16+
UnreachableError,
17+
now,
18+
qualified_class_name,
19+
reindent,
20+
tagged,
21+
)
1622
from .events import (
1723
Event,
1824
EventConsumer,
25+
event_decoders,
1926
event_encoder,
2027
feedback_events,
2128
worktree_events,
@@ -46,8 +53,17 @@ def ref(self) -> str:
4653
return _draft_ref(self.folio.id, self.seqno)
4754

4855

56+
_DRAFT_REF_PREFIX = "refs/drafts/"
57+
58+
4959
def _draft_ref(folio_id: int, suffix: int | str) -> str:
50-
return f"refs/drafts/{folio_id}/{suffix}"
60+
return f"{_DRAFT_REF_PREFIX}{folio_id}/{suffix}"
61+
62+
63+
def _parse_draft_ref(ref: str) -> tuple[int, int | None]:
64+
ref = ref.removeprefix(_DRAFT_REF_PREFIX)
65+
parts = ref.split("/")
66+
return int(parts[0]), int(parts[1]) if len(parts) > 1 else None
5167

5268

5369
_FOLIO_BRANCH_NAMESPACE = "draft"
@@ -70,7 +86,7 @@ def upstream_branch_name(self) -> str:
7086
return self.branch_name() + _FOLIO_UPSTREAM_BRANCH_SUFFIX
7187

7288

73-
def _active_folio(repo: Repo) -> Folio | None:
89+
def _maybe_active_folio(repo: Repo) -> Folio | None:
7490
active_branch = repo.active_branch()
7591
if not active_branch:
7692
return None
@@ -80,6 +96,13 @@ def _active_folio(repo: Repo) -> Folio | None:
8096
return Folio(int(match[1]))
8197

8298

99+
def _active_folio(repo: Repo) -> Folio:
100+
folio = _maybe_active_folio(repo)
101+
if not folio:
102+
raise RuntimeError("Not currently on a draft branch")
103+
return folio
104+
105+
83106
#: Select ort strategies.
84107
DraftMergeStrategy = Literal[
85108
"ours",
@@ -133,7 +156,7 @@ async def generate_draft(
133156
)
134157

135158
# Ensure that we are in a folio.
136-
folio = _active_folio(self._repo)
159+
folio = _maybe_active_folio(self._repo)
137160
if not folio:
138161
folio = self._create_folio()
139162
with self._store.cursor() as cursor:
@@ -149,7 +172,7 @@ async def generate_draft(
149172
# Run the bot to generate the change.
150173
event_recorder = _EventRecorder(self._progress)
151174
with self._progress.spinner("Running bot...") as spinner:
152-
feedback = spinner.feedback()
175+
feedback = spinner.feedback(event_recorder)
153176
change = await self._generate_change(
154177
bot,
155178
Goal(prompt_contents),
@@ -206,11 +229,11 @@ async def generate_draft(
206229
[
207230
{
208231
"prompt_id": prompt_id,
209-
"occurred_at": e.at,
232+
"occurred_at": dt,
210233
"class": e.__class__.__name__,
211234
"data": encoder.encode(e),
212235
}
213-
for e in event_recorder.events
236+
for (dt, e) in event_recorder.events()
214237
],
215238
)
216239
spinner.update("Created draft commit.", ref=draft.ref)
@@ -244,9 +267,6 @@ async def generate_draft(
244267

245268
def quit_folio(self) -> None:
246269
folio = _active_folio(self._repo)
247-
if not folio:
248-
raise RuntimeError("Not currently on a draft branch")
249-
250270
with self._store.cursor() as cursor:
251271
rows = cursor.execute(sql("get-folio-by-id"), {"id": folio.id})
252272
if not rows:
@@ -404,7 +424,7 @@ def _commit_tree(
404424

405425
def latest_draft_prompt(self) -> str | None:
406426
"""Returns the latest prompt for the current draft"""
407-
folio = _active_folio(self._repo)
427+
folio = _maybe_active_folio(self._repo)
408428
if not folio:
409429
return None
410430
with self._store.cursor() as cursor:
@@ -422,6 +442,27 @@ def latest_draft_prompt(self) -> str | None:
422442
prompt = "\n\n".join([prompt, reindent(question, prefix="> ")])
423443
return prompt
424444

445+
def list_draft_events(self, draft_ref: str | None = None) -> Sequence[str]:
446+
if draft_ref:
447+
folio_id, seqno = _parse_draft_ref(draft_ref)
448+
else:
449+
folio = _active_folio(self._repo)
450+
folio_id = folio.id
451+
seqno = None
452+
elems = []
453+
with self._store.cursor() as cursor:
454+
rows = cursor.execute(
455+
sql("list-action-events"),
456+
{"folio_id": folio_id, "seqno": seqno},
457+
)
458+
decoders = event_decoders()
459+
for row in rows:
460+
occurred_at, class_name, data = row
461+
event = decoders[class_name].decode(data)
462+
description = _format_event(event)
463+
elems.append(f"{occurred_at}\t{class_name}\t{description}")
464+
return elems
465+
425466

426467
@dataclasses.dataclass(frozen=True)
427468
class _Change:
@@ -442,34 +483,50 @@ class _EventRecorder(EventConsumer):
442483
"""
443484

444485
def __init__(self, progress: Progress) -> None:
445-
self.events = list[Event]()
486+
self._events = list[tuple[datetime, Event]]()
446487
self._progress = progress
447488

489+
def events(self) -> Sequence[tuple[datetime, Event]]:
490+
return sorted(list(self._events))
491+
448492
def on_event(self, event: Event) -> None:
449-
self.events.append(event)
450-
match event:
451-
case worktree_events.ListFiles(_, paths):
452-
self._progress.report("Listed files.", count=len(paths))
453-
case worktree_events.ReadFile(_, path, contents):
454-
size = -1 if contents is None else len(contents)
455-
self._progress.report(f"Read {path}.", length=size)
456-
case worktree_events.WriteFile(_, path, contents):
457-
size = len(contents)
458-
self._progress.report(f"Wrote {path}.", length=size)
459-
case worktree_events.DeleteFile(_, path):
460-
self._progress.report(f"Deleted {path}.")
461-
case worktree_events.RenameFile(_, src_path, dst_path):
462-
self._progress.report(f"Renamed {src_path} to {dst_path}.")
463-
case worktree_events.StartEditingFiles(_):
464-
self._progress.report("Started editing files...")
465-
case worktree_events.StopEditingFiles(_):
466-
self._progress.report("Stopped editing files.")
467-
case (
468-
feedback_events.NotifyUser(_, _)
469-
| feedback_events.RequestUserGuidance(_, _)
470-
| feedback_events.ReceiveUserGuidance(_, _)
471-
):
472-
pass
493+
self._events.append((now(), event))
494+
if formatted := _format_internal_event(event):
495+
self._progress.report(formatted)
496+
497+
498+
def _format_internal_event(event: Event) -> str:
499+
match event:
500+
case worktree_events.ListFiles(path_count):
501+
return f"Listed {path_count} files."
502+
case worktree_events.ReadFile(path, char_count):
503+
return tagged(f"Read {path}.", length=char_count)
504+
case worktree_events.WriteFile(path, char_count):
505+
return tagged(f"Wrote {path}.", length=char_count)
506+
case worktree_events.DeleteFile(path):
507+
return f"Deleted {path}."
508+
case worktree_events.RenameFile(src_path, dst_path):
509+
return f"Renamed {src_path} to {dst_path}."
510+
case worktree_events.StartEditingFiles():
511+
return "Started editing files..."
512+
case worktree_events.StopEditingFiles():
513+
return "Stopped editing files."
514+
case _:
515+
return ""
516+
517+
518+
def _format_event(event: Event) -> str:
519+
if formatted := _format_internal_event(event):
520+
return formatted
521+
match event:
522+
case feedback_events.NotifyUser(update):
523+
return update
524+
case feedback_events.RequestUserGuidance(question):
525+
return question
526+
case feedback_events.ReceiveUserGuidance(answer):
527+
return answer
528+
case _:
529+
raise UnreachableError()
473530

474531

475532
def _default_title(prompt: str) -> str:

src/git_draft/events/__init__.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
"""Event package"""
1+
"""Event definitions and (de)serializers"""
22

3+
import collections
4+
from collections.abc import Mapping
35
from pathlib import PurePosixPath
46
from typing import Any, Protocol
57

68
import msgspec
79

810
from . import feedback_events, worktree_events
9-
from .common import events
11+
from .common import all_events
1012

1113

1214
__all__ = [
1315
"Event",
1416
"EventConsumer",
15-
"event_decoder",
17+
"event_decoders",
1618
"event_encoder",
17-
"events",
1819
"feedback_events",
1920
"worktree_events",
2021
]
@@ -42,6 +43,7 @@ def on_event(self, event: Event) -> None:
4243

4344

4445
def event_encoder() -> msgspec.json.Encoder:
46+
"""Returns a JSON encoder for event instances"""
4547
return msgspec.json.Encoder(enc_hook=_enc_hook)
4648

4749

@@ -50,14 +52,15 @@ def _enc_hook(obj: Any) -> Any:
5052
return str(obj)
5153

5254

53-
def event_decoder() -> msgspec.json.Decoder:
54-
"""Returns a decoder for event instances
55+
def event_decoders() -> Mapping[str, msgspec.json.Decoder]:
56+
"""Returns JSON decoders for event instances, keyed by event class name"""
57+
return _Decoders()
5558

56-
It should be used as follows to get typed values:
5759

58-
decoder.decode(data, type=events[class_name])
59-
"""
60-
return msgspec.json.Decoder(dec_hook=_dec_hook)
60+
class _Decoders(collections.defaultdict[str, msgspec.json.Decoder]):
61+
def __missing__(self, key: str) -> msgspec.json.Decoder:
62+
event_class = getattr(all_events, key)
63+
return msgspec.json.Decoder(dec_hook=_dec_hook, type=event_class)
6164

6265

6366
def _dec_hook(tp: type, obj: Any) -> Any:

src/git_draft/events/common.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
"""Common event utilities"""
22

3-
import datetime
43
import types
5-
from typing import Any
4+
from typing import Any, dataclass_transform
65

76
import msgspec
87

98

10-
events = types.SimpleNamespace()
9+
all_events = types.SimpleNamespace()
1110

1211

12+
# https://discuss.python.org/t/cannot-inherit-non-frozen-dataclass-from-a-frozen-one/79273
13+
@dataclass_transform(field_specifiers=(msgspec.field,), frozen_default=True)
1314
class EventStruct(msgspec.Struct, frozen=True):
1415
"""Base immutable structure for all event types"""
1516

16-
at: datetime.datetime
17-
1817
def __init_subclass__(cls, *args: Any, **kwargs) -> None:
1918
super().__init_subclass__(*args, **kwargs)
20-
setattr(events, cls.__name__, cls)
19+
setattr(all_events, cls.__name__, cls)

0 commit comments

Comments
 (0)