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/cli.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Generate a self-contained PR saga as a static HTML page.
|
|
2
|
+
|
|
3
|
+
Installs a ``saga`` command usable from any repo. Needs Python 3, git, and
|
|
4
|
+
an API key for the chosen provider (``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` /
|
|
5
|
+
``OPENROUTER_API_KEY``). Run from inside the repo you want to review:
|
|
6
|
+
|
|
7
|
+
saga --base main --head my-feature -o saga.html --open
|
|
8
|
+
|
|
9
|
+
Defaults: base=main, head=current branch (HEAD), output=saga.html,
|
|
10
|
+
model=anthropic/claude-opus-4-8 (override with --model or $SAGA_MODEL).
|
|
11
|
+
Pass --intent PATH to give the model a plan/spec for richer, plan-aware narration.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import webbrowser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from .comments import comments_app
|
|
22
|
+
from .diff import current_branch, repo_root_from
|
|
23
|
+
from .generate import generate
|
|
24
|
+
from .model import SagaError
|
|
25
|
+
from .render import render
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="saga",
|
|
29
|
+
help="Generate a self-contained PR saga as static HTML.",
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
app.add_typer(comments_app, name="comments")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback(invoke_without_command=True)
|
|
36
|
+
def main(
|
|
37
|
+
ctx: typer.Context,
|
|
38
|
+
base: str = typer.Option("main", help="base ref (default: main)"),
|
|
39
|
+
head: str = typer.Option("HEAD", help="head ref to walk through (default: HEAD)"),
|
|
40
|
+
intent: Path | None = typer.Option(
|
|
41
|
+
None, help="optional path to a plan/spec describing the change's intent"
|
|
42
|
+
),
|
|
43
|
+
model: str = typer.Option(
|
|
44
|
+
"anthropic/claude-opus-4-8",
|
|
45
|
+
envvar="SAGA_MODEL",
|
|
46
|
+
help=(
|
|
47
|
+
"provider/model to use, e.g. anthropic/claude-opus-4-8, openai/gpt-4o, "
|
|
48
|
+
"openrouter/anthropic/claude-3.5-sonnet "
|
|
49
|
+
"(default: $SAGA_MODEL or anthropic/claude-opus-4-8)"
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
output: Path = typer.Option(
|
|
53
|
+
Path("saga.html"),
|
|
54
|
+
"-o",
|
|
55
|
+
"--output",
|
|
56
|
+
help="output HTML path (default: saga.html)",
|
|
57
|
+
),
|
|
58
|
+
repo: Path = typer.Option(
|
|
59
|
+
Path.cwd(), "--repo", help="path inside the target git repo (default: cwd)"
|
|
60
|
+
),
|
|
61
|
+
open_browser: bool = typer.Option(
|
|
62
|
+
True,
|
|
63
|
+
"--open/--no-open",
|
|
64
|
+
help="open the result in a browser (default: on; use --no-open to disable)",
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Generate a self-contained PR saga as static HTML."""
|
|
68
|
+
if ctx.invoked_subcommand is not None:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
repo_root = repo_root_from(repo)
|
|
72
|
+
if repo_root is None:
|
|
73
|
+
typer.echo(f"error: {repo} is not inside a git repository.", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
intent_text = None
|
|
77
|
+
if intent is not None:
|
|
78
|
+
try:
|
|
79
|
+
intent_text = intent.read_text()
|
|
80
|
+
except OSError as e:
|
|
81
|
+
typer.echo(f"error: could not read intent file: {e}", err=True)
|
|
82
|
+
raise typer.Exit(1) from e
|
|
83
|
+
|
|
84
|
+
resolved_head = current_branch(repo_root) if head == "HEAD" else head
|
|
85
|
+
typer.echo(f"Generating saga for {base}...{resolved_head} …", err=True)
|
|
86
|
+
try:
|
|
87
|
+
saga = generate(repo_root, base, resolved_head, model=model, intent=intent_text)
|
|
88
|
+
html = render(repo_root, saga)
|
|
89
|
+
except SagaError as e:
|
|
90
|
+
typer.echo(f"error: {e}", err=True)
|
|
91
|
+
raise typer.Exit(1) from e
|
|
92
|
+
|
|
93
|
+
output.write_text(html)
|
|
94
|
+
typer.echo(f"Wrote {output} ({len(saga.chapters)} chapters).", err=True)
|
|
95
|
+
if open_browser:
|
|
96
|
+
webbrowser.open(output.resolve().as_uri())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
app()
|
saga/comments.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""The ``saga comments`` subcommands: sidecar IO, GitHub push, agent read.
|
|
2
|
+
|
|
3
|
+
A reviewer authors comments in the browser (see ``assets/saga.js``); the page
|
|
4
|
+
exports them as a sidecar ``saga.comments.json`` next to the saga HTML. This
|
|
5
|
+
module consumes that sidecar two ways:
|
|
6
|
+
|
|
7
|
+
* ``push`` bundles every comment into a single **pending** PR review via the
|
|
8
|
+
``gh`` CLI (``event`` omitted ⇒ PENDING) — the reviewer submits it on GitHub.
|
|
9
|
+
* ``read`` emits a normalized JSON view on stdout for a coding agent.
|
|
10
|
+
|
|
11
|
+
The GitHub subprocess calls mirror ``diff._git`` — stdlib + the ``gh`` CLI only.
|
|
12
|
+
``build_review_payload`` is a pure function so the payload shape is unit-tested
|
|
13
|
+
without touching the network.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from .diff import repo_root_from
|
|
26
|
+
from .model import SagaError
|
|
27
|
+
|
|
28
|
+
_DEFAULT_SIDECAR = Path("saga.comments.json")
|
|
29
|
+
_FILE_NOTE_PREFIX = "**File-level note:** "
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Sidecar IO
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_sidecar(path: Path) -> dict:
|
|
38
|
+
"""Read and validate a ``saga.comments.json`` sidecar.
|
|
39
|
+
|
|
40
|
+
Raises ``SagaError`` with a reviewer-facing message on any malformed shape —
|
|
41
|
+
unreadable file, bad JSON, or an inline comment lacking ``line``/``body``.
|
|
42
|
+
Callers handle a *missing* sidecar themselves (it means "no comments yet").
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
raw = Path(path).read_text()
|
|
46
|
+
except OSError as e:
|
|
47
|
+
raise SagaError(f"could not read comments file {path}: {e}") from e
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(raw)
|
|
50
|
+
except json.JSONDecodeError as e:
|
|
51
|
+
raise SagaError(f"comments file {path} is not valid JSON: {e}") from e
|
|
52
|
+
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise SagaError("comments file must be a JSON object.")
|
|
55
|
+
files = data.get("files", {})
|
|
56
|
+
if not isinstance(files, dict):
|
|
57
|
+
raise SagaError("'files' must be a JSON object keyed by file path.")
|
|
58
|
+
for fpath, entry in files.items():
|
|
59
|
+
if not isinstance(entry, dict):
|
|
60
|
+
raise SagaError(f"file entry for {fpath} must be an object.")
|
|
61
|
+
for c in entry.get("inline", []):
|
|
62
|
+
if not isinstance(c, dict) or "line" not in c or "body" not in c:
|
|
63
|
+
raise SagaError(
|
|
64
|
+
f"inline comment on {fpath} needs a 'line' and a 'body'."
|
|
65
|
+
)
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# GitHub review payload (pure)
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_review_payload(sidecar: dict) -> dict:
|
|
75
|
+
"""Assemble the body for GitHub's create-review endpoint from a sidecar.
|
|
76
|
+
|
|
77
|
+
Inline comments map straight to ``{path, line, side, body}``. Per-file
|
|
78
|
+
comments are anchored to the file's first changed line (``file_anchor``) and
|
|
79
|
+
prefixed — GitHub's batch review API has no file-level comment type, so this
|
|
80
|
+
keeps them inside the one pending review. The overall comment becomes the
|
|
81
|
+
review ``body``. ``event`` is deliberately absent so the review is PENDING.
|
|
82
|
+
"""
|
|
83
|
+
body = (sidecar.get("overall") or "").strip()
|
|
84
|
+
comments: list[dict] = []
|
|
85
|
+
for path, entry in sidecar.get("files", {}).items():
|
|
86
|
+
for c in entry.get("inline", []):
|
|
87
|
+
comments.append(
|
|
88
|
+
{
|
|
89
|
+
"path": path,
|
|
90
|
+
"line": c["line"],
|
|
91
|
+
"side": c.get("side", "RIGHT"),
|
|
92
|
+
"body": c["body"],
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
file_comment = (entry.get("file_comment") or "").strip()
|
|
96
|
+
if file_comment:
|
|
97
|
+
anchor = entry.get("file_anchor") or {}
|
|
98
|
+
comments.append(
|
|
99
|
+
{
|
|
100
|
+
"path": path,
|
|
101
|
+
"line": anchor.get("line", 1),
|
|
102
|
+
"side": anchor.get("side", "RIGHT"),
|
|
103
|
+
"body": _FILE_NOTE_PREFIX + file_comment,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
payload: dict = {}
|
|
108
|
+
if body:
|
|
109
|
+
payload["body"] = body
|
|
110
|
+
if comments:
|
|
111
|
+
payload["comments"] = comments
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_for_agent(sidecar: dict) -> dict:
|
|
116
|
+
"""A lean, stable view for a coding agent — drops GitHub-only anchors."""
|
|
117
|
+
files = {}
|
|
118
|
+
for path, entry in sidecar.get("files", {}).items():
|
|
119
|
+
files[path] = {
|
|
120
|
+
"file_comment": (entry.get("file_comment") or None),
|
|
121
|
+
"inline": [
|
|
122
|
+
{"line": c["line"], "side": c.get("side", "RIGHT"), "body": c["body"]}
|
|
123
|
+
for c in entry.get("inline", [])
|
|
124
|
+
],
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
"branch": sidecar.get("branch", ""),
|
|
128
|
+
"base": sidecar.get("base", ""),
|
|
129
|
+
"overall": (sidecar.get("overall") or None),
|
|
130
|
+
"files": files,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# gh CLI shell
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _gh(
|
|
140
|
+
repo_root: Path, *args: str, input: str | None = None
|
|
141
|
+
) -> subprocess.CompletedProcess[str]:
|
|
142
|
+
return subprocess.run(
|
|
143
|
+
["gh", *args],
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
cwd=repo_root,
|
|
147
|
+
input=input,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _pr_info(repo_root: Path, branch: str) -> tuple[int, str]:
|
|
152
|
+
"""Return ``(pr_number, pr_url)`` for the PR whose head is *branch*."""
|
|
153
|
+
result = _gh(repo_root, "pr", "view", branch, "--json", "number,url")
|
|
154
|
+
if result.returncode != 0:
|
|
155
|
+
raise SagaError(
|
|
156
|
+
f"could not find an open PR for branch '{branch}': {result.stderr.strip()}"
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(result.stdout)
|
|
160
|
+
return int(data["number"]), str(data["url"])
|
|
161
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
162
|
+
raise SagaError(f"unexpected gh pr view output: {e}") from e
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Commands
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def push(sidecar_path: Path, repo_root: Path, *, web: bool = False) -> int:
|
|
171
|
+
"""Post the sidecar's comments as a single pending review on the PR."""
|
|
172
|
+
path = Path(sidecar_path)
|
|
173
|
+
sidecar = load_sidecar(path) if path.exists() else {}
|
|
174
|
+
|
|
175
|
+
payload = build_review_payload(sidecar)
|
|
176
|
+
if not payload:
|
|
177
|
+
print("No comments to push.", file=sys.stderr)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
branch = sidecar.get("branch")
|
|
181
|
+
if not branch:
|
|
182
|
+
raise SagaError("comments file is missing 'branch'; cannot locate the PR.")
|
|
183
|
+
|
|
184
|
+
pr_number, pr_url = _pr_info(repo_root, branch)
|
|
185
|
+
result = _gh(
|
|
186
|
+
repo_root,
|
|
187
|
+
"api",
|
|
188
|
+
"--method",
|
|
189
|
+
"POST",
|
|
190
|
+
f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews",
|
|
191
|
+
"--input",
|
|
192
|
+
"-",
|
|
193
|
+
input=json.dumps(payload),
|
|
194
|
+
)
|
|
195
|
+
if result.returncode != 0:
|
|
196
|
+
raise SagaError(f"gh api failed: {result.stderr.strip()}")
|
|
197
|
+
|
|
198
|
+
n = len(payload.get("comments", []))
|
|
199
|
+
print(
|
|
200
|
+
f"Created a PENDING review on PR #{pr_number} "
|
|
201
|
+
f"({n} comment{'s' if n != 1 else ''}). Review and submit it on GitHub:\n"
|
|
202
|
+
f" {pr_url}/files",
|
|
203
|
+
file=sys.stderr,
|
|
204
|
+
)
|
|
205
|
+
if web:
|
|
206
|
+
_gh(repo_root, "pr", "view", branch, "--web")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def read(sidecar_path: Path) -> int:
|
|
211
|
+
"""Print the sidecar's comments as normalized JSON on stdout.
|
|
212
|
+
|
|
213
|
+
This command feeds a coding agent, so a missing sidecar is not an error —
|
|
214
|
+
"no comments authored yet" is reported as a valid, empty JSON document. A
|
|
215
|
+
sidecar that exists but is malformed still errors (that is a real problem).
|
|
216
|
+
"""
|
|
217
|
+
path = Path(sidecar_path)
|
|
218
|
+
sidecar = load_sidecar(path) if path.exists() else {}
|
|
219
|
+
print(json.dumps(_normalize_for_agent(sidecar), indent=2, ensure_ascii=False))
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
comments_app = typer.Typer(
|
|
224
|
+
help="Push saga review comments to GitHub, or read them as JSON.",
|
|
225
|
+
no_args_is_help=True,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@comments_app.command("push")
|
|
230
|
+
def push_cmd(
|
|
231
|
+
comments: Path = typer.Option(_DEFAULT_SIDECAR, "--comments"),
|
|
232
|
+
repo: Path = typer.Option(Path.cwd(), "--repo"),
|
|
233
|
+
web: bool = typer.Option(
|
|
234
|
+
False, "--web", help="open the PR in a browser after pushing"
|
|
235
|
+
),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Post comments to the PR as a pending GitHub review."""
|
|
238
|
+
repo_root = repo_root_from(repo)
|
|
239
|
+
if repo_root is None:
|
|
240
|
+
typer.echo(f"error: {repo} is not inside a git repository.", err=True)
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
try:
|
|
243
|
+
push(comments, repo_root, web=web)
|
|
244
|
+
except SagaError as e:
|
|
245
|
+
typer.echo(f"error: {e}", err=True)
|
|
246
|
+
raise typer.Exit(1) from e
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@comments_app.command("read")
|
|
250
|
+
def read_cmd(
|
|
251
|
+
comments: Path = typer.Option(_DEFAULT_SIDECAR, "--comments"),
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Print comments as JSON on stdout (for a coding agent)."""
|
|
254
|
+
try:
|
|
255
|
+
read(comments)
|
|
256
|
+
except SagaError as e:
|
|
257
|
+
typer.echo(f"error: {e}", err=True)
|
|
258
|
+
raise typer.Exit(1) from e
|
saga/diff.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Git-sourced diff computation for a saga.
|
|
2
|
+
|
|
3
|
+
Computes the diff of a head ref against a base ref purely from git — no
|
|
4
|
+
checkout, no working-tree changes. Stdlib + the ``git`` CLI only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DiffResult:
|
|
16
|
+
"""The computed diff of a ref against a base: text, commit list, diffstat."""
|
|
17
|
+
|
|
18
|
+
diff_text: str
|
|
19
|
+
commits: list[str]
|
|
20
|
+
diffstat: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
["git", *args],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
cwd=repo_root,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compute_diff(repo_root: Path, base: str, ref: str) -> DiffResult:
|
|
33
|
+
"""Compute the diff of *ref* against *base* purely from refs (no checkout).
|
|
34
|
+
|
|
35
|
+
``git diff base...ref`` is a symmetric-difference diff, so it never touches
|
|
36
|
+
the working tree and works for any local ref, not just the checked-out
|
|
37
|
+
branch.
|
|
38
|
+
"""
|
|
39
|
+
diff_result = _git(repo_root, "diff", f"{base}...{ref}")
|
|
40
|
+
if diff_result.returncode != 0:
|
|
41
|
+
raise RuntimeError(f"git diff failed: {diff_result.stderr.strip()}")
|
|
42
|
+
|
|
43
|
+
log_result = _git(repo_root, "log", "--oneline", f"{base}..{ref}")
|
|
44
|
+
commits = (
|
|
45
|
+
log_result.stdout.strip().splitlines() if log_result.returncode == 0 else []
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
stat_result = _git(repo_root, "diff", "--stat", f"{base}...{ref}")
|
|
49
|
+
diffstat = stat_result.stdout.strip() if stat_result.returncode == 0 else ""
|
|
50
|
+
|
|
51
|
+
return DiffResult(
|
|
52
|
+
diff_text=diff_result.stdout,
|
|
53
|
+
commits=commits,
|
|
54
|
+
diffstat=diffstat,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rev_parse(repo_root: Path, ref: str) -> str:
|
|
59
|
+
"""Return the full SHA *ref* resolves to, or ``""`` if it does not resolve."""
|
|
60
|
+
result = _git(repo_root, "rev-parse", ref)
|
|
61
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def current_branch(repo_root: Path) -> str:
|
|
65
|
+
"""The checked-out branch name, or ``"HEAD"`` when detached."""
|
|
66
|
+
result = _git(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
67
|
+
name = result.stdout.strip() if result.returncode == 0 else ""
|
|
68
|
+
return name or "HEAD"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def repo_root_from(path: Path) -> Path | None:
|
|
72
|
+
"""The git top-level containing *path*, or ``None`` if not in a repo."""
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
cwd=path,
|
|
78
|
+
)
|
|
79
|
+
return Path(result.stdout.strip()) if result.returncode == 0 else None
|
saga/generate.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Generate a saga for one change set via a single structured LLM call.
|
|
2
|
+
|
|
3
|
+
The call takes the full diff (every hunk labeled by a stable id), the commit
|
|
4
|
+
messages, and an optional intent document, and returns strict, schema-validated
|
|
5
|
+
JSON. Coverage is re-validated in code before trusting it — nothing the model
|
|
6
|
+
returns is assumed complete.
|
|
7
|
+
|
|
8
|
+
Provider-agnostic: the model is chosen by a ``provider/model`` string (e.g.
|
|
9
|
+
``anthropic/claude-opus-4-8``, ``openai/gpt-4o``,
|
|
10
|
+
``openrouter/anthropic/claude-3.5-sonnet``) and dispatched through ``instructor``.
|
|
11
|
+
The API key is read from the provider's standard environment variable
|
|
12
|
+
(``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` / ``OPENROUTER_API_KEY``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
import instructor
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from .diff import DiffResult, compute_diff, rev_parse
|
|
25
|
+
from .model import (
|
|
26
|
+
Chapter,
|
|
27
|
+
Hunk,
|
|
28
|
+
Saga,
|
|
29
|
+
SagaError,
|
|
30
|
+
parse_hunks,
|
|
31
|
+
validate_coverage,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_MAX_TOKENS = 16000
|
|
35
|
+
_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
36
|
+
_PROMPT_PATH = Path(__file__).resolve().parent / "prompts" / "saga.md"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# LLM response schema — what the model must return (validated by instructor).
|
|
41
|
+
# Mapped onto the persisted ``Chapter`` dataclass below so model.py's core,
|
|
42
|
+
# validate_coverage, and render.py stay untouched.
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _QAOut(BaseModel):
|
|
47
|
+
status: str
|
|
48
|
+
note: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _ChapterOut(BaseModel):
|
|
52
|
+
id: str
|
|
53
|
+
title: str
|
|
54
|
+
summary: str
|
|
55
|
+
narration: str
|
|
56
|
+
hunks: list[str] = Field(default_factory=list)
|
|
57
|
+
plan_step: str | None = None
|
|
58
|
+
confidence: Literal["high", "medium", "low"] = "medium"
|
|
59
|
+
deviation: str | None = None
|
|
60
|
+
qa: _QAOut | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _SagaOut(BaseModel):
|
|
64
|
+
chapters: list[_ChapterOut]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _to_chapter(c: _ChapterOut) -> Chapter:
|
|
68
|
+
return Chapter(
|
|
69
|
+
id=c.id,
|
|
70
|
+
title=c.title,
|
|
71
|
+
summary=c.summary,
|
|
72
|
+
narration=c.narration,
|
|
73
|
+
hunks=list(c.hunks),
|
|
74
|
+
plan_step=c.plan_step,
|
|
75
|
+
confidence=c.confidence,
|
|
76
|
+
deviation=c.deviation or None,
|
|
77
|
+
qa=c.qa.model_dump() if c.qa else None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def labeled_diff(hunks: list[Hunk]) -> str:
|
|
82
|
+
"""Render the diff for the prompt with each hunk headed by its stable id."""
|
|
83
|
+
out: list[str] = []
|
|
84
|
+
for h in hunks:
|
|
85
|
+
first = h.body.splitlines()[0] if h.body else ""
|
|
86
|
+
out.append(f"### HUNK {h.id} — {h.file_path} ({first.strip()})")
|
|
87
|
+
out.append(h.body.rstrip("\n"))
|
|
88
|
+
return "\n".join(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_message(diff: DiffResult, hunks: list[Hunk], intent: str | None) -> str:
|
|
92
|
+
"""Assemble the user message: intent (optional), commits, and labeled diff."""
|
|
93
|
+
commits = "\n".join(diff.commits) or "(no commit messages)"
|
|
94
|
+
intent_block = (
|
|
95
|
+
["# Intent (what this change set out to do)", intent.strip(), ""]
|
|
96
|
+
if intent and intent.strip()
|
|
97
|
+
else []
|
|
98
|
+
)
|
|
99
|
+
return "\n".join(
|
|
100
|
+
[
|
|
101
|
+
*intent_block,
|
|
102
|
+
"# Commits",
|
|
103
|
+
commits,
|
|
104
|
+
"",
|
|
105
|
+
"# Full diff (every hunk must be assigned to a chapter)",
|
|
106
|
+
labeled_diff(hunks),
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_client(model: str) -> instructor.Instructor:
|
|
112
|
+
"""Build an instructor client for a ``provider/model`` string.
|
|
113
|
+
|
|
114
|
+
OpenRouter is OpenAI-compatible, so it needs an explicit base URL and its own
|
|
115
|
+
key; Anthropic and OpenAI resolve their keys from the SDK's standard env vars.
|
|
116
|
+
"""
|
|
117
|
+
provider = model.split("/", 1)[0]
|
|
118
|
+
try:
|
|
119
|
+
if provider == "openrouter":
|
|
120
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
121
|
+
if not key:
|
|
122
|
+
raise SagaError("OPENROUTER_API_KEY is not set.")
|
|
123
|
+
return instructor.from_provider(
|
|
124
|
+
model, base_url=_OPENROUTER_BASE_URL, api_key=key
|
|
125
|
+
)
|
|
126
|
+
return instructor.from_provider(model)
|
|
127
|
+
except SagaError:
|
|
128
|
+
raise
|
|
129
|
+
except Exception as e:
|
|
130
|
+
raise SagaError(f"Could not initialize model {model!r}: {e}") from e
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def generate(
|
|
134
|
+
repo_root: Path,
|
|
135
|
+
base: str,
|
|
136
|
+
head: str,
|
|
137
|
+
*,
|
|
138
|
+
model: str,
|
|
139
|
+
intent: str | None = None,
|
|
140
|
+
) -> Saga:
|
|
141
|
+
"""Generate the saga for *base*...*head*.
|
|
142
|
+
|
|
143
|
+
Raises ``SagaError`` on an empty diff, a provider/model error, or a
|
|
144
|
+
coverage gap. The caller renders the error as a user-facing message.
|
|
145
|
+
"""
|
|
146
|
+
diff = compute_diff(repo_root, base, head)
|
|
147
|
+
hunks = parse_hunks(diff.diff_text)
|
|
148
|
+
if not hunks:
|
|
149
|
+
raise SagaError("No reviewable hunks in this change set.")
|
|
150
|
+
|
|
151
|
+
client = _build_client(model)
|
|
152
|
+
system_prompt = _PROMPT_PATH.read_text()
|
|
153
|
+
kwargs: dict = {}
|
|
154
|
+
if model.split("/", 1)[0] == "openrouter":
|
|
155
|
+
# Ask OpenRouter to route only to providers that support the params we
|
|
156
|
+
# send (structured output), so schema enforcement is honored.
|
|
157
|
+
kwargs["extra_body"] = {"provider": {"require_parameters": True}}
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
result: _SagaOut = client.create(
|
|
161
|
+
response_model=_SagaOut,
|
|
162
|
+
max_tokens=_MAX_TOKENS,
|
|
163
|
+
messages=[
|
|
164
|
+
{"role": "system", "content": system_prompt},
|
|
165
|
+
{"role": "user", "content": _build_message(diff, hunks, intent)},
|
|
166
|
+
],
|
|
167
|
+
**kwargs,
|
|
168
|
+
)
|
|
169
|
+
except SagaError:
|
|
170
|
+
raise
|
|
171
|
+
except Exception as e:
|
|
172
|
+
raise SagaError(f"Saga generation failed: {e}") from e
|
|
173
|
+
|
|
174
|
+
chapters = [_to_chapter(c) for c in result.chapters]
|
|
175
|
+
validate_coverage(chapters, hunks)
|
|
176
|
+
|
|
177
|
+
return Saga(
|
|
178
|
+
branch=head,
|
|
179
|
+
base=base,
|
|
180
|
+
commit_sha=rev_parse(repo_root, head),
|
|
181
|
+
chapters=chapters,
|
|
182
|
+
)
|