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/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
+ )