saga-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- saga/__init__.py +0 -0
- saga/__main__.py +4 -0
- saga/assets/base.css +41 -0
- saga/assets/saga.css +224 -0
- saga/assets/saga.js +459 -0
- saga/assets/tokens.css +141 -0
- saga/cli.py +100 -0
- saga/comments.py +258 -0
- saga/diff.py +79 -0
- saga/generate.py +182 -0
- saga/model.py +253 -0
- saga/prompts/saga.md +50 -0
- saga/render.py +144 -0
- saga_cli-0.1.0.dist-info/METADATA +158 -0
- saga_cli-0.1.0.dist-info/RECORD +18 -0
- saga_cli-0.1.0.dist-info/WHEEL +4 -0
- saga_cli-0.1.0.dist-info/entry_points.txt +2 -0
- saga_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
saga/model.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Data model for PR sagas — the chapter-based review of one change set.
|
|
2
|
+
|
|
3
|
+
A saga partitions a change set's whole diff into ordered *chapters* that
|
|
4
|
+
tell a coherent story. This module is the pure, side-effect-light core:
|
|
5
|
+
|
|
6
|
+
* ``parse_hunks`` splits a unified diff into individually addressable ``Hunk``s
|
|
7
|
+
(stable ``h0, h1, …`` ids in diff order), and ``reconstruct_diff`` rebuilds a
|
|
8
|
+
valid unified-diff string for any subset of them — so the browser can render a
|
|
9
|
+
single chapter through diff2html with the *same* file/line anchors the full
|
|
10
|
+
diff uses.
|
|
11
|
+
* ``Chapter``/``Saga`` are the persisted shape (``to_dict``/``from_dict``).
|
|
12
|
+
* ``validate_coverage`` enforces the hard invariant: every hunk belongs to at
|
|
13
|
+
least one chapter. It raises ``SagaError`` loudly on any gap.
|
|
14
|
+
|
|
15
|
+
Stdlib only — this is the standalone core and has no external dependencies.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
CONFIDENCE_LEVELS = ("high", "medium", "low")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SagaError(Exception):
|
|
27
|
+
"""Raised when a saga is malformed or fails the coverage invariant.
|
|
28
|
+
|
|
29
|
+
Carries a human-readable message surfaced to the reviewer as a non-blocking
|
|
30
|
+
notice before falling back to the standard diff.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Hunk parsing / diff reconstruction
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
_DIFF_GIT_RE = re.compile(r"^diff --git ", re.MULTILINE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Hunk:
|
|
43
|
+
"""One ``@@`` hunk of a unified diff, addressable by a stable id.
|
|
44
|
+
|
|
45
|
+
``preamble`` is the file-level header (``diff --git`` … through the ``+++``
|
|
46
|
+
line) shared by every hunk of that file; ``body`` is the ``@@`` line plus its
|
|
47
|
+
context/change lines. Reconstructing a file emits the preamble once followed
|
|
48
|
+
by each of its hunks' bodies — a valid unified diff diff2html can render.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
file_path: str
|
|
53
|
+
preamble: str
|
|
54
|
+
body: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _file_path_from_preamble(preamble: str) -> str:
|
|
58
|
+
"""The new-side path diff2html shows in its file header (``+++ b/<path>``).
|
|
59
|
+
|
|
60
|
+
Falls back to the ``diff --git b/<path>`` side for pure renames/deletes with
|
|
61
|
+
no ``+++`` line.
|
|
62
|
+
"""
|
|
63
|
+
for line in preamble.splitlines():
|
|
64
|
+
if line.startswith("+++ "):
|
|
65
|
+
target = line[4:].strip()
|
|
66
|
+
if target and target != "/dev/null":
|
|
67
|
+
return target[2:] if target.startswith(("a/", "b/")) else target
|
|
68
|
+
m = re.search(r"^diff --git a/.* b/(.*)$", preamble, re.MULTILINE)
|
|
69
|
+
return m.group(1).strip() if m else ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_hunks(diff_text: str) -> list[Hunk]:
|
|
73
|
+
"""Split a unified diff into ``Hunk``s with stable ``h0, h1, …`` ids.
|
|
74
|
+
|
|
75
|
+
Files with no ``@@`` hunk (pure renames, mode-only changes) contribute no
|
|
76
|
+
hunks — there is nothing line-addressable to review.
|
|
77
|
+
"""
|
|
78
|
+
hunks: list[Hunk] = []
|
|
79
|
+
starts = [m.start() for m in _DIFF_GIT_RE.finditer(diff_text)]
|
|
80
|
+
for i, start in enumerate(starts):
|
|
81
|
+
end = starts[i + 1] if i + 1 < len(starts) else len(diff_text)
|
|
82
|
+
block = diff_text[start:end]
|
|
83
|
+
at_idx = block.find("\n@@")
|
|
84
|
+
if at_idx == -1:
|
|
85
|
+
continue # no hunks in this file block
|
|
86
|
+
preamble = block[: at_idx + 1] # keep the trailing newline
|
|
87
|
+
rest = block[at_idx + 1 :]
|
|
88
|
+
file_path = _file_path_from_preamble(preamble)
|
|
89
|
+
# Each hunk runs from an "@@" line to the next "@@" (or end of block).
|
|
90
|
+
for m in re.finditer(r"(?m)^@@ ", rest):
|
|
91
|
+
h_start = m.start()
|
|
92
|
+
nxt = rest.find("\n@@ ", h_start + 1)
|
|
93
|
+
h_end = nxt + 1 if nxt != -1 else len(rest)
|
|
94
|
+
hunks.append(
|
|
95
|
+
Hunk(
|
|
96
|
+
id=f"h{len(hunks)}",
|
|
97
|
+
file_path=file_path,
|
|
98
|
+
preamble=preamble,
|
|
99
|
+
body=rest[h_start:h_end],
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return hunks
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def reconstruct_diff(hunks: list[Hunk]) -> str:
|
|
106
|
+
"""Rebuild a valid unified diff from an ordered subset of hunks.
|
|
107
|
+
|
|
108
|
+
Hunks are grouped by file (first-seen order preserved); each file's preamble
|
|
109
|
+
is emitted once, then its selected hunk bodies in the given order. The result
|
|
110
|
+
renders through diff2html exactly like the full view.
|
|
111
|
+
"""
|
|
112
|
+
out: list[str] = []
|
|
113
|
+
seen_preamble: set[str] = set()
|
|
114
|
+
for hunk in hunks:
|
|
115
|
+
if hunk.preamble not in seen_preamble:
|
|
116
|
+
out.append(hunk.preamble)
|
|
117
|
+
seen_preamble.add(hunk.preamble)
|
|
118
|
+
out.append(hunk.body)
|
|
119
|
+
return "".join(out)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Chapter / Saga model
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Chapter:
|
|
129
|
+
"""One ordered chapter of a saga.
|
|
130
|
+
|
|
131
|
+
``hunks`` lists the hunk ids this chapter covers. ``deviation`` is set (to the
|
|
132
|
+
agent's explanation) only when the implementation diverged from the stated
|
|
133
|
+
intent; ``qa`` carries an optional ``{"status": …, "note": str}`` receipt.
|
|
134
|
+
Both are ``None`` when no intent context was supplied.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
id: str
|
|
138
|
+
title: str
|
|
139
|
+
summary: str
|
|
140
|
+
narration: str
|
|
141
|
+
hunks: list[str] = field(default_factory=list)
|
|
142
|
+
plan_step: str | None = None
|
|
143
|
+
confidence: str = "medium"
|
|
144
|
+
deviation: str | None = None
|
|
145
|
+
qa: dict | None = None
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_dict(cls, d: dict) -> Chapter:
|
|
149
|
+
confidence = d.get("confidence", "medium")
|
|
150
|
+
if confidence not in CONFIDENCE_LEVELS:
|
|
151
|
+
confidence = "medium"
|
|
152
|
+
return cls(
|
|
153
|
+
id=d["id"],
|
|
154
|
+
title=d.get("title", ""),
|
|
155
|
+
summary=d.get("summary", ""),
|
|
156
|
+
narration=d.get("narration", ""),
|
|
157
|
+
hunks=list(d.get("hunks", [])),
|
|
158
|
+
plan_step=d.get("plan_step"),
|
|
159
|
+
confidence=confidence,
|
|
160
|
+
deviation=d.get("deviation") or None,
|
|
161
|
+
qa=d.get("qa"),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> dict:
|
|
165
|
+
return {
|
|
166
|
+
"id": self.id,
|
|
167
|
+
"title": self.title,
|
|
168
|
+
"summary": self.summary,
|
|
169
|
+
"narration": self.narration,
|
|
170
|
+
"hunks": self.hunks,
|
|
171
|
+
"plan_step": self.plan_step,
|
|
172
|
+
"confidence": self.confidence,
|
|
173
|
+
"deviation": self.deviation,
|
|
174
|
+
"qa": self.qa,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class Saga:
|
|
180
|
+
"""A change set's saga: ordered chapters + diff context."""
|
|
181
|
+
|
|
182
|
+
branch: str
|
|
183
|
+
base: str
|
|
184
|
+
commit_sha: str
|
|
185
|
+
generated_at: str = ""
|
|
186
|
+
chapters: list[Chapter] = field(default_factory=list)
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_dict(cls, d: dict) -> Saga:
|
|
190
|
+
return cls(
|
|
191
|
+
branch=d.get("branch", ""),
|
|
192
|
+
base=d.get("base", ""),
|
|
193
|
+
commit_sha=d.get("commit_sha", ""),
|
|
194
|
+
generated_at=d.get("generated_at", ""),
|
|
195
|
+
chapters=[Chapter.from_dict(c) for c in d.get("chapters", [])],
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def to_dict(self) -> dict:
|
|
199
|
+
return {
|
|
200
|
+
"branch": self.branch,
|
|
201
|
+
"base": self.base,
|
|
202
|
+
"commit_sha": self.commit_sha,
|
|
203
|
+
"generated_at": self.generated_at,
|
|
204
|
+
"chapters": [c.to_dict() for c in self.chapters],
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
def verdict(self, *, qa_state: str) -> dict:
|
|
208
|
+
"""The top-line summary rendered above the table of contents.
|
|
209
|
+
|
|
210
|
+
Computed here (not trusted from the LLM) so the counts always match the
|
|
211
|
+
chapters actually present.
|
|
212
|
+
"""
|
|
213
|
+
return {
|
|
214
|
+
"chapters": len(self.chapters),
|
|
215
|
+
"deviations": sum(1 for c in self.chapters if c.deviation),
|
|
216
|
+
"low_confidence": sum(1 for c in self.chapters if c.confidence == "low"),
|
|
217
|
+
"qa": qa_state,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Validation
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def validate_coverage(chapters: list[Chapter], hunks: list[Hunk]) -> None:
|
|
227
|
+
"""Enforce the hard invariant: every hunk belongs to at least one chapter.
|
|
228
|
+
|
|
229
|
+
Raises ``SagaError`` if any chapter references an unknown hunk id or
|
|
230
|
+
if any hunk is left unassigned. Chapters overlapping (a hunk in two chapters)
|
|
231
|
+
is allowed — coverage, not partition, is the contract.
|
|
232
|
+
"""
|
|
233
|
+
if not chapters:
|
|
234
|
+
raise SagaError("Saga has no chapters.")
|
|
235
|
+
|
|
236
|
+
all_ids = {h.id for h in hunks}
|
|
237
|
+
referenced: set[str] = set()
|
|
238
|
+
for ch in chapters:
|
|
239
|
+
for hid in ch.hunks:
|
|
240
|
+
referenced.add(hid)
|
|
241
|
+
|
|
242
|
+
unknown = referenced - all_ids
|
|
243
|
+
if unknown:
|
|
244
|
+
raise SagaError(
|
|
245
|
+
f"Chapters reference unknown hunks: {', '.join(sorted(unknown))}."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
unassigned = all_ids - referenced
|
|
249
|
+
if unassigned:
|
|
250
|
+
raise SagaError(
|
|
251
|
+
f"{len(unassigned)} hunk(s) not assigned to any chapter: "
|
|
252
|
+
f"{', '.join(sorted(unassigned))}."
|
|
253
|
+
)
|
saga/prompts/saga.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
You are giving a reviewer a guided tour of a code change — a chapter-by-chapter walkthrough that makes a large diff easy to review without losing focus.
|
|
2
|
+
|
|
3
|
+
You are handed:
|
|
4
|
+
|
|
5
|
+
1. **The full diff**, split into labeled hunks. Every hunk has a stable id like `h0`, `h1`, … shown as `### HUNK h3 — path/to/file (new lines 40-55)`.
|
|
6
|
+
2. **The commit messages** on this change, for context on intent and sequencing.
|
|
7
|
+
3. **Optional intent** — a plan, spec, or description of what the change set out to do. This may be absent; if so, infer intent from the diff and commits.
|
|
8
|
+
|
|
9
|
+
Your job: partition the diff into an **ordered list of chapters** that tell one coherent story, and explain each in your own voice.
|
|
10
|
+
|
|
11
|
+
## Rules
|
|
12
|
+
|
|
13
|
+
- **Cover every hunk.** Every hunk id must appear in at least one chapter. This is mandatory — the walkthrough is rejected if any hunk is left out. A hunk may appear in more than one chapter only if it genuinely belongs to both.
|
|
14
|
+
- **Order matters.** Sequence chapters so a reviewer who reads top-to-bottom builds understanding naturally — usually setup/foundation first, then the core change, then wiring and tests.
|
|
15
|
+
- **Group by idea, not by file.** A chapter can interleave hunks from several files when they serve one idea.
|
|
16
|
+
- **Zoom out when it helps.** If a chapter is about how the change fits the wider codebase, say so in the narration.
|
|
17
|
+
- **Zoom in on the hard parts.** For complex or non-obvious changes, explain why it was needed and why it was done this way rather than an alternative.
|
|
18
|
+
|
|
19
|
+
## What each chapter needs
|
|
20
|
+
|
|
21
|
+
- `title` — a short, concrete title.
|
|
22
|
+
- `summary` — one line for the table of contents (≤ 12 words).
|
|
23
|
+
- `narration` — **2–4 short sentences, plain language.** The intent, the key decision, and the rationale. The reviewer does not want a wall of text — be brief and concrete. Markdown is allowed but keep it light.
|
|
24
|
+
- `hunks` — the list of hunk ids in this chapter, in reading order.
|
|
25
|
+
- `plan_step` — when an intent document was supplied, the step/section this chapter maps to (e.g. `"PR 1: …"`); otherwise `null`.
|
|
26
|
+
- `confidence` — `"high"`, `"medium"`, or `"low"`. Use `"low"` honestly for anything that deserves close review.
|
|
27
|
+
- `deviation` — `null` unless an intent was supplied _and_ the implementation diverged from it; then a short sentence explaining how and why. Be honest — deviations are highlighted for the reviewer.
|
|
28
|
+
- `qa` — `null`, or `{"status": "green", "note": "…"}` when tests cover this chapter's changes, or `{"status": "none", "note": "…"}` to flag a chapter with no test coverage worth noting.
|
|
29
|
+
|
|
30
|
+
## Output
|
|
31
|
+
|
|
32
|
+
Return **only** a single JSON object, no prose before or after, no code fences:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
{
|
|
36
|
+
"chapters": [
|
|
37
|
+
{
|
|
38
|
+
"id": "ch1",
|
|
39
|
+
"title": "…",
|
|
40
|
+
"summary": "…",
|
|
41
|
+
"narration": "…",
|
|
42
|
+
"hunks": ["h0", "h1"],
|
|
43
|
+
"plan_step": null,
|
|
44
|
+
"confidence": "high",
|
|
45
|
+
"deviation": null,
|
|
46
|
+
"qa": {"status": "green", "note": "Covered by existing tests."}
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
saga/render.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Render a ``Saga`` into one self-contained static HTML file.
|
|
2
|
+
|
|
3
|
+
Everything is inlined — the design tokens, diff2html's CSS/JS (which bundles
|
|
4
|
+
highlight.js), marked, our saga JS, and the saga data itself — so
|
|
5
|
+
the output is a single file that opens offline and can be emailed, committed, or
|
|
6
|
+
dropped on any static host. The vendored CDN bundles are fetched once and cached
|
|
7
|
+
under ``.assets-cache/`` next to the package.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.request import urlopen
|
|
16
|
+
|
|
17
|
+
from .diff import compute_diff
|
|
18
|
+
from .model import Saga, parse_hunks, reconstruct_diff
|
|
19
|
+
|
|
20
|
+
_ASSETS = Path(__file__).resolve().parent / "assets"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cache_dir() -> Path:
|
|
24
|
+
"""User-level cache for the vendored CDN bundles.
|
|
25
|
+
|
|
26
|
+
Uses ``$XDG_CACHE_HOME`` when set, else ``~/.cache`` — never the install dir,
|
|
27
|
+
which may be read-only under site-packages.
|
|
28
|
+
"""
|
|
29
|
+
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
30
|
+
return Path(base) / "saga"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_CACHE = _cache_dir()
|
|
34
|
+
|
|
35
|
+
_CDN = {
|
|
36
|
+
"diff2html.min.css": "https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css",
|
|
37
|
+
"diff2html-ui.min.js": "https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js",
|
|
38
|
+
"marked.min.js": "https://cdn.jsdelivr.net/npm/marked/marked.min.js",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _vendored(name: str) -> str:
|
|
43
|
+
"""Return a cached CDN bundle, downloading it once on first use."""
|
|
44
|
+
cached = _CACHE / name
|
|
45
|
+
if not cached.exists():
|
|
46
|
+
_CACHE.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
cached.write_bytes(urlopen(_CDN[name]).read()) # noqa: S310
|
|
48
|
+
return cached.read_text()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _asset(name: str) -> str:
|
|
52
|
+
return (_ASSETS / name).read_text()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_payload(repo_root: Path, saga: Saga, *, qa_state: str = "n/a") -> dict:
|
|
56
|
+
"""Attach each chapter's reconstructed diff to the saga for the client.
|
|
57
|
+
|
|
58
|
+
The hunk map is recomputed from the live diff of the saga's own
|
|
59
|
+
base...head, so every stored hunk id resolves to its current diff text.
|
|
60
|
+
"""
|
|
61
|
+
diff = compute_diff(repo_root, saga.base, saga.branch)
|
|
62
|
+
hmap = {h.id: h for h in parse_hunks(diff.diff_text)}
|
|
63
|
+
chapters = []
|
|
64
|
+
for ch in saga.chapters:
|
|
65
|
+
d = ch.to_dict()
|
|
66
|
+
d["diff"] = reconstruct_diff([hmap[h] for h in ch.hunks if h in hmap])
|
|
67
|
+
chapters.append(d)
|
|
68
|
+
return {
|
|
69
|
+
"branch": saga.branch,
|
|
70
|
+
"base": saga.base,
|
|
71
|
+
"verdict": saga.verdict(qa_state=qa_state),
|
|
72
|
+
"chapters": chapters,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _json_for_script(payload: dict) -> str:
|
|
77
|
+
"""JSON-encode *payload* safe to inline in a ``<script>`` tag.
|
|
78
|
+
|
|
79
|
+
Escaping ``<`` to ``\\u003c`` means diff content containing ``</script>`` or
|
|
80
|
+
``<!--`` cannot break out of the tag.
|
|
81
|
+
"""
|
|
82
|
+
return json.dumps(payload, ensure_ascii=False).replace("<", "\\u003c")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render(repo_root: Path, saga: Saga, *, qa_state: str = "n/a") -> str:
|
|
86
|
+
"""Build the complete self-contained HTML document for *saga*."""
|
|
87
|
+
payload = build_payload(repo_root, saga, qa_state=qa_state)
|
|
88
|
+
title = f"Saga · {saga.branch}"
|
|
89
|
+
styles = "\n".join(
|
|
90
|
+
[
|
|
91
|
+
_asset("tokens.css"),
|
|
92
|
+
_vendored("diff2html.min.css"),
|
|
93
|
+
_asset("base.css"),
|
|
94
|
+
_asset("saga.css"),
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
scripts = "\n".join(
|
|
98
|
+
[
|
|
99
|
+
_vendored("diff2html-ui.min.js"),
|
|
100
|
+
_vendored("marked.min.js"),
|
|
101
|
+
f"window.__sagaData = {_json_for_script(payload)};",
|
|
102
|
+
_asset("saga.js"),
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
return f"""<!DOCTYPE html>
|
|
106
|
+
<html lang="en">
|
|
107
|
+
<head>
|
|
108
|
+
<meta charset="utf-8">
|
|
109
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
110
|
+
<title>{title}</title>
|
|
111
|
+
<script>
|
|
112
|
+
// Apply the saved theme before first paint so there is no flash.
|
|
113
|
+
try {{
|
|
114
|
+
var t = localStorage.getItem('saga-theme');
|
|
115
|
+
if (t === 'light' || t === 'dark') {{
|
|
116
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
117
|
+
}}
|
|
118
|
+
}} catch (e) {{}}
|
|
119
|
+
</script>
|
|
120
|
+
<style>
|
|
121
|
+
{styles}
|
|
122
|
+
</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<div class="saga-rail" id="saga-rail"></div>
|
|
126
|
+
<div class="header">
|
|
127
|
+
<button id="saga-theme" class="saga-theme-toggle" type="button"
|
|
128
|
+
aria-label="Toggle light or dark theme" title="Toggle theme"></button>
|
|
129
|
+
<nav class="saga-crumbs">
|
|
130
|
+
<span class="saga-crumb-current">Saga</span>
|
|
131
|
+
<span class="saga-crumb-sep">›</span>
|
|
132
|
+
<span class="mono">{saga.base}...{saga.branch}</span>
|
|
133
|
+
</nav>
|
|
134
|
+
<h1>Saga</h1>
|
|
135
|
+
<div id="saga-verdict" class="saga-verdict"></div>
|
|
136
|
+
</div>
|
|
137
|
+
<div id="saga-notice"></div>
|
|
138
|
+
<div id="saga-toc" class="saga-toc"></div>
|
|
139
|
+
<div id="saga-reader" class="saga-reader" hidden></div>
|
|
140
|
+
<script>
|
|
141
|
+
{scripts}
|
|
142
|
+
</script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>"""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saga-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate a self-contained static HTML saga of a git diff — a chapter-by-chapter guided tour of a change.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JakeBeresford/saga
|
|
6
|
+
Project-URL: Repository, https://github.com/JakeBeresford/saga
|
|
7
|
+
Project-URL: Issues, https://github.com/JakeBeresford/saga/issues
|
|
8
|
+
Author: Jake Beresford
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: code-review,diff,documentation,git,html,llm
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: anthropic
|
|
23
|
+
Requires-Dist: instructor
|
|
24
|
+
Requires-Dist: openai
|
|
25
|
+
Requires-Dist: typer>=0.26.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Saga
|
|
29
|
+
|
|
30
|
+
Generate a **chapter-by-chapter guided tour** of a code change as a single,
|
|
31
|
+
self-contained static HTML page, a _saga_ of your diff. It partitions a diff into
|
|
32
|
+
ordered chapters that tell one coherent story, each with a plain-language narration
|
|
33
|
+
and just the hunks that belong to it. Large PRs become easy to review without
|
|
34
|
+
losing the thread.
|
|
35
|
+
|
|
36
|
+
The output is one HTML file with everything inlined (diff2html, syntax highlighting,
|
|
37
|
+
the data). Open it offline, email it, commit it, or drop it on any static host.
|
|
38
|
+
|
|
39
|
+
[**See an example saga**](https://jakeberesford.github.io/saga/example.html).
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Python 3.11+
|
|
44
|
+
- `git`
|
|
45
|
+
- An API key for your chosen provider, in the standard environment variable:
|
|
46
|
+
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY`
|
|
47
|
+
|
|
48
|
+
Generation is one structured LLM call made through [`instructor`](https://python.useinstructor.com).
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Install once and the `saga` command is available from any repo:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv tool install saga-cli # recommended
|
|
56
|
+
# or
|
|
57
|
+
pipx install saga-cli
|
|
58
|
+
# or, into the current environment
|
|
59
|
+
pip install saga-cli
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Installing from a local checkout instead? Point the installer at this directory,
|
|
63
|
+
e.g. `uv tool install /path/to/saga`. To upgrade: `uv tool install --force saga-cli`.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
From inside the repo you want to review:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
saga --base main --head my-feature -o saga.html --open
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run it from anywhere with `--repo`:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
saga --repo ~/src/some-project --base main --head my-feature -o out.html
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Flag | Default | Meaning |
|
|
80
|
+
| ---------------------- | --------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `--base` | `main` | Base ref to diff against |
|
|
82
|
+
| `--head` | current branch | Head ref to walk through |
|
|
83
|
+
| `--intent PATH` | — | Optional plan/spec describing the change's intent, for plan-aware narration and deviation flagging |
|
|
84
|
+
| `--model` | `anthropic/claude-opus-4-8` | `provider/model` string (see [Providers](#providers)); also `$SAGA_MODEL` |
|
|
85
|
+
| `-o, --output` | `saga.html` | Output file |
|
|
86
|
+
| `--repo` | cwd | A path inside the target git repo |
|
|
87
|
+
| `--open` / `--no-open` | on | Open the result in a browser (on by default; `--no-open` to disable) |
|
|
88
|
+
|
|
89
|
+
## Providers
|
|
90
|
+
|
|
91
|
+
The model is a single `provider/model` string, dispatched through `instructor`.
|
|
92
|
+
Choose it with `--model` or the `SAGA_MODEL` environment variable, and set
|
|
93
|
+
the matching API key:
|
|
94
|
+
|
|
95
|
+
| Provider | `--model` example | API key env var |
|
|
96
|
+
| ---------- | ---------------------------------------- | -------------------- |
|
|
97
|
+
| Anthropic | `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` |
|
|
98
|
+
| OpenAI | `openai/gpt-4o` | `OPENAI_API_KEY` |
|
|
99
|
+
| OpenRouter | `openrouter/anthropic/claude-3.5-sonnet` | `OPENROUTER_API_KEY` |
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
export SAGA_MODEL=openai/gpt-4o
|
|
103
|
+
export OPENAI_API_KEY=sk-…
|
|
104
|
+
saga --base main --head my-feature -o saga.html
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## As a Claude Code skill
|
|
108
|
+
|
|
109
|
+
The `skills/` directory contains two Claude Code skills. To install both:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
cp -R "$(pwd)/skills" ~/.claude/skills/saga
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **`saga`** — say **"/saga"** (or "give me a walkthrough of this branch") to generate a
|
|
116
|
+
saga. It resolves the base/head refs and runs the tool for you.
|
|
117
|
+
- **`saga-comments`** — say **"/saga-comments"** (or "address the saga comments") to read an
|
|
118
|
+
exported `saga.comments.json` and act on the reviewer's feedback in code.
|
|
119
|
+
|
|
120
|
+
## Reviewing: comments
|
|
121
|
+
|
|
122
|
+
The saga page is also a lightweight review surface. Open `saga.html` and leave three
|
|
123
|
+
kinds of comments — **inline** (click a line's number in any chapter's diff), **per-file**
|
|
124
|
+
(the "💬 File comment" control in each file header), and one **overall** review comment
|
|
125
|
+
(the box at the top of the Chapters list). Comments are drafted in your browser's
|
|
126
|
+
`localStorage`, so they survive a reload.
|
|
127
|
+
|
|
128
|
+
When you're done, click **Export comments** to download a `saga.comments.json` sidecar next
|
|
129
|
+
to the HTML. (Export is disabled in the [hosted example](https://jakeberesford.github.io/saga/example.html)
|
|
130
|
+
so it never writes a file — every other part of the review UX works.) Two commands consume it:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
# Post everything as a single PENDING review on the PR (you submit it on GitHub).
|
|
134
|
+
saga comments push --comments saga.comments.json
|
|
135
|
+
|
|
136
|
+
# Emit the comments as JSON on stdout — for a coding agent to act on.
|
|
137
|
+
saga comments read --comments saga.comments.json
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`push` uses the `gh` CLI: it finds the open PR for the sidecar's branch and creates one
|
|
141
|
+
pending review (inline → line comments, per-file → a note anchored to the file's first
|
|
142
|
+
changed line, overall → the review body). Nothing is submitted until you review and submit
|
|
143
|
+
it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
|
|
144
|
+
|
|
145
|
+
## How it works
|
|
146
|
+
|
|
147
|
+
1. `diff.py` computes `git diff base...head` (no checkout) and the commit list.
|
|
148
|
+
2. `model.py` splits the diff into stable-id hunks (`h0, h1, …`).
|
|
149
|
+
3. `generate.py` sends the labeled diff + commits (+ optional intent) to the chosen
|
|
150
|
+
model via `instructor`, which returns chapters as schema-validated JSON. Coverage
|
|
151
|
+
is **re-validated in code** — every hunk must belong to a chapter or generation fails.
|
|
152
|
+
4. `render.py` reconstructs each chapter's diff and inlines everything into one
|
|
153
|
+
self-contained HTML file.
|
|
154
|
+
|
|
155
|
+
## Not included (yet)
|
|
156
|
+
|
|
157
|
+
- Support for Local LLMs via ollama / LM Studio
|
|
158
|
+
- A GitHub Action to auto-generate saga on PRs.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
saga/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
saga/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
|
|
3
|
+
saga/cli.py,sha256=ICexToy2Tl-Ja4-Jc9mTQz-R5YwLP1PUyDmbyF2Xpnc,3328
|
|
4
|
+
saga/comments.py,sha256=FrsUpyYTB8lkO0RiXq-0_TPdb1NI_FpJeylG3jqkrk0,8924
|
|
5
|
+
saga/diff.py,sha256=FG0fo_394cQerfEgah45qj1Xvbd83ekfMVDRAS-0cEc,2508
|
|
6
|
+
saga/generate.py,sha256=KvNRbCoIfp8WbLmFlAWNbGZ5T9worFSFmgRv_rwXxQU,5714
|
|
7
|
+
saga/model.py,sha256=tyF-dwIWsPiTVwXbEMggHdrpDX9ViiPqgcPi2_q8sek,8784
|
|
8
|
+
saga/render.py,sha256=NLG-wVp2VmgHnK5KHgSfss4XWupdrwckdwrAZx_pluA,4588
|
|
9
|
+
saga/assets/base.css,sha256=Yv8XCzevdQwRKpivTXcB8SxD6WKP2Iyhd9SqgHyICUE,2453
|
|
10
|
+
saga/assets/saga.css,sha256=XWSsrhjVejY0yXKF2qBDNGbhiKEhD1iXnTn4Klnzfno,9482
|
|
11
|
+
saga/assets/saga.js,sha256=4CLnx7D6Dyqe4penPch5ccdIul4xf5W5bM6Bz7yXSGU,17658
|
|
12
|
+
saga/assets/tokens.css,sha256=H34kFxqonmbAnd4ibyUQj2ECfd1Xdu6JbTugwDej8Zw,5978
|
|
13
|
+
saga/prompts/saga.md,sha256=tzFW4DI0kAaESWgrTvXqyg7DbRTBZq7Ljb0dqsjN7eg,2982
|
|
14
|
+
saga_cli-0.1.0.dist-info/METADATA,sha256=eVOOlEh18K4uo08tufmwvbFeTunQ1i4wbC7EJB89-LI,7211
|
|
15
|
+
saga_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
16
|
+
saga_cli-0.1.0.dist-info/entry_points.txt,sha256=THvRLIVVZE2hhRHNRbrv7pdZ64afQfO_jYhR7bkkavY,38
|
|
17
|
+
saga_cli-0.1.0.dist-info/licenses/LICENSE,sha256=pARUeoUO0rxQdvv3Xz-VYrogjxss-G7JU3epAQn27AQ,1071
|
|
18
|
+
saga_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jake Beresford
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|