saga-cli 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (36) hide show
  1. saga_cli-0.2.0/.github/CODEOWNERS +3 -0
  2. {saga_cli-0.1.0 → saga_cli-0.2.0}/PKG-INFO +53 -4
  3. {saga_cli-0.1.0 → saga_cli-0.2.0}/README.md +52 -3
  4. {saga_cli-0.1.0 → saga_cli-0.2.0}/pyproject.toml +1 -1
  5. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/cli.py +8 -2
  6. saga_cli-0.2.0/saga/generate.py +301 -0
  7. saga_cli-0.2.0/tests/test_generate.py +319 -0
  8. saga_cli-0.1.0/saga/generate.py +0 -182
  9. saga_cli-0.1.0/tests/test_generate.py +0 -153
  10. {saga_cli-0.1.0 → saga_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  11. {saga_cli-0.1.0 → saga_cli-0.2.0}/.gitignore +0 -0
  12. {saga_cli-0.1.0 → saga_cli-0.2.0}/CONTRIBUTING.md +0 -0
  13. {saga_cli-0.1.0 → saga_cli-0.2.0}/LICENSE +0 -0
  14. {saga_cli-0.1.0 → saga_cli-0.2.0}/RELEASING.md +0 -0
  15. {saga_cli-0.1.0 → saga_cli-0.2.0}/docs/example.html +0 -0
  16. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/__init__.py +0 -0
  17. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/__main__.py +0 -0
  18. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/base.css +0 -0
  19. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/saga.css +0 -0
  20. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/saga.js +0 -0
  21. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/tokens.css +0 -0
  22. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/comments.py +0 -0
  23. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/diff.py +0 -0
  24. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/model.py +0 -0
  25. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/prompts/saga.md +0 -0
  26. {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/render.py +0 -0
  27. {saga_cli-0.1.0 → saga_cli-0.2.0}/skills/generate_saga/SKILL.md +0 -0
  28. {saga_cli-0.1.0 → saga_cli-0.2.0}/skills/read_saga_comments/SKILL.md +0 -0
  29. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/cassettes/generate_anthropic.yaml +0 -0
  30. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/conftest.py +0 -0
  31. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_cli.py +0 -0
  32. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_comments.py +0 -0
  33. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_diff.py +0 -0
  34. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_model.py +0 -0
  35. {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_render.py +0 -0
  36. {saga_cli-0.1.0 → saga_cli-0.2.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ # Every change requires review from the maintainer.
2
+ # See https://docs.github.com/articles/about-code-owners
3
+ * @JakeBeresford
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: saga-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Generate a self-contained static HTML saga of a git diff — a chapter-by-chapter guided tour of a change.
5
5
  Project-URL: Homepage, https://github.com/JakeBeresford/saga
6
6
  Project-URL: Repository, https://github.com/JakeBeresford/saga
@@ -43,7 +43,8 @@ the data). Open it offline, email it, commit it, or drop it on any static host.
43
43
  - Python 3.11+
44
44
  - `git`
45
45
  - An API key for your chosen provider, in the standard environment variable:
46
- `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY`
46
+ `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY` — or a running
47
+ local server (Ollama / LM Studio) and no key, with a `local/` model
47
48
 
48
49
  Generation is one structured LLM call made through [`instructor`](https://python.useinstructor.com).
49
50
 
@@ -64,7 +65,13 @@ e.g. `uv tool install /path/to/saga`. To upgrade: `uv tool install --force saga-
64
65
 
65
66
  ## Usage
66
67
 
67
- From inside the repo you want to review:
68
+ From inside the repo, on the branch you want to review:
69
+
70
+ ```sh
71
+ saga
72
+ ```
73
+
74
+ From inside the repo, reviewing a different branch:
68
75
 
69
76
  ```sh
70
77
  saga --base main --head my-feature -o saga.html --open
@@ -97,6 +104,8 @@ the matching API key:
97
104
  | Anthropic | `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` |
98
105
  | OpenAI | `openai/gpt-4o` | `OPENAI_API_KEY` |
99
106
  | OpenRouter | `openrouter/anthropic/claude-3.5-sonnet` | `OPENROUTER_API_KEY` |
107
+ | Local | `local/qwen2.5-coder:14b` | none |
108
+ | Claude CLI | `claude-cli` or `claude-cli/sonnet` | none (Claude Code login) |
100
109
 
101
110
  ```sh
102
111
  export SAGA_MODEL=openai/gpt-4o
@@ -104,6 +113,47 @@ export OPENAI_API_KEY=sk-…
104
113
  saga --base main --head my-feature -o saga.html
105
114
  ```
106
115
 
116
+ ### Local LLMs (Ollama / LM Studio)
117
+
118
+ A `local/` model runs against any OpenAI-compatible local server, with no API
119
+ key. Pull a capable coder model first (`ollama pull qwen2.5-coder:14b`), then:
120
+
121
+ ```sh
122
+ saga --model local/qwen2.5-coder:14b --base main --head my-feature
123
+ ```
124
+
125
+ `local/` defaults to Ollama's endpoint (`http://localhost:11434/v1`). Point it
126
+ at another server — e.g. LM Studio — with `SAGA_LOCAL_BASE_URL`:
127
+
128
+ ```sh
129
+ export SAGA_LOCAL_BASE_URL=http://localhost:1234/v1 # LM Studio
130
+ saga --model local/your-loaded-model --base main --head my-feature
131
+ ```
132
+
133
+ Two caveats: saga requires schema-valid JSON output, so use an instruction-tuned
134
+ model that follows JSON prompting reliably; and the full diff plus a 16k output
135
+ budget can exceed a small model's context window — prefer larger-context models
136
+ and expect weaker narration than a frontier hosted model.
137
+
138
+ ### Claude Code CLI (no API key)
139
+
140
+ If you don't have an Anthropic API key but you are logged into the
141
+ [Claude Code](https://claude.com/claude-code) CLI — for example with a Claude
142
+ Pro/Max subscription — the `claude-cli` model routes generation through
143
+ `claude -p` instead of the API, reusing that login:
144
+
145
+ ```sh
146
+ saga --model claude-cli --base main --head my-feature
147
+ saga --model claude-cli/sonnet --base main --head my-feature # pin a model
148
+ ```
149
+
150
+ This shells out to the `claude` binary (which must be on your `PATH` and logged
151
+ in), constrains its output to saga's schema via `--json-schema`, and runs it as
152
+ a plain transform with no tools. `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`
153
+ are dropped for the subprocess so it uses your Claude Code login rather than
154
+ silently billing an API key. It is slower than a direct API call (Claude Code
155
+ boots an agent per run) and subject to your subscription's usage limits.
156
+
107
157
  ## As a Claude Code skill
108
158
 
109
159
  The `skills/` directory contains two Claude Code skills. To install both:
@@ -154,5 +204,4 @@ it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
154
204
 
155
205
  ## Not included (yet)
156
206
 
157
- - Support for Local LLMs via ollama / LM Studio
158
207
  - A GitHub Action to auto-generate saga on PRs.
@@ -16,7 +16,8 @@ the data). Open it offline, email it, commit it, or drop it on any static host.
16
16
  - Python 3.11+
17
17
  - `git`
18
18
  - An API key for your chosen provider, in the standard environment variable:
19
- `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY`
19
+ `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY` — or a running
20
+ local server (Ollama / LM Studio) and no key, with a `local/` model
20
21
 
21
22
  Generation is one structured LLM call made through [`instructor`](https://python.useinstructor.com).
22
23
 
@@ -37,7 +38,13 @@ e.g. `uv tool install /path/to/saga`. To upgrade: `uv tool install --force saga-
37
38
 
38
39
  ## Usage
39
40
 
40
- From inside the repo you want to review:
41
+ From inside the repo, on the branch you want to review:
42
+
43
+ ```sh
44
+ saga
45
+ ```
46
+
47
+ From inside the repo, reviewing a different branch:
41
48
 
42
49
  ```sh
43
50
  saga --base main --head my-feature -o saga.html --open
@@ -70,6 +77,8 @@ the matching API key:
70
77
  | Anthropic | `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` |
71
78
  | OpenAI | `openai/gpt-4o` | `OPENAI_API_KEY` |
72
79
  | OpenRouter | `openrouter/anthropic/claude-3.5-sonnet` | `OPENROUTER_API_KEY` |
80
+ | Local | `local/qwen2.5-coder:14b` | none |
81
+ | Claude CLI | `claude-cli` or `claude-cli/sonnet` | none (Claude Code login) |
73
82
 
74
83
  ```sh
75
84
  export SAGA_MODEL=openai/gpt-4o
@@ -77,6 +86,47 @@ export OPENAI_API_KEY=sk-…
77
86
  saga --base main --head my-feature -o saga.html
78
87
  ```
79
88
 
89
+ ### Local LLMs (Ollama / LM Studio)
90
+
91
+ A `local/` model runs against any OpenAI-compatible local server, with no API
92
+ key. Pull a capable coder model first (`ollama pull qwen2.5-coder:14b`), then:
93
+
94
+ ```sh
95
+ saga --model local/qwen2.5-coder:14b --base main --head my-feature
96
+ ```
97
+
98
+ `local/` defaults to Ollama's endpoint (`http://localhost:11434/v1`). Point it
99
+ at another server — e.g. LM Studio — with `SAGA_LOCAL_BASE_URL`:
100
+
101
+ ```sh
102
+ export SAGA_LOCAL_BASE_URL=http://localhost:1234/v1 # LM Studio
103
+ saga --model local/your-loaded-model --base main --head my-feature
104
+ ```
105
+
106
+ Two caveats: saga requires schema-valid JSON output, so use an instruction-tuned
107
+ model that follows JSON prompting reliably; and the full diff plus a 16k output
108
+ budget can exceed a small model's context window — prefer larger-context models
109
+ and expect weaker narration than a frontier hosted model.
110
+
111
+ ### Claude Code CLI (no API key)
112
+
113
+ If you don't have an Anthropic API key but you are logged into the
114
+ [Claude Code](https://claude.com/claude-code) CLI — for example with a Claude
115
+ Pro/Max subscription — the `claude-cli` model routes generation through
116
+ `claude -p` instead of the API, reusing that login:
117
+
118
+ ```sh
119
+ saga --model claude-cli --base main --head my-feature
120
+ saga --model claude-cli/sonnet --base main --head my-feature # pin a model
121
+ ```
122
+
123
+ This shells out to the `claude` binary (which must be on your `PATH` and logged
124
+ in), constrains its output to saga's schema via `--json-schema`, and runs it as
125
+ a plain transform with no tools. `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN`
126
+ are dropped for the subprocess so it uses your Claude Code login rather than
127
+ silently billing an API key. It is slower than a direct API call (Claude Code
128
+ boots an agent per run) and subject to your subscription's usage limits.
129
+
80
130
  ## As a Claude Code skill
81
131
 
82
132
  The `skills/` directory contains two Claude Code skills. To install both:
@@ -127,5 +177,4 @@ it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
127
177
 
128
178
  ## Not included (yet)
129
179
 
130
- - Support for Local LLMs via ollama / LM Studio
131
180
  - A GitHub Action to auto-generate saga on PRs.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "saga-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Generate a self-contained static HTML saga of a git diff — a chapter-by-chapter guided tour of a change."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,7 +2,10 @@
2
2
 
3
3
  Installs a ``saga`` command usable from any repo. Needs Python 3, git, and
4
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:
5
+ ``OPENROUTER_API_KEY``) or, with a ``local/`` model, a running Ollama or
6
+ LM Studio server and no key, or the ``claude-cli`` model, a logged-in Claude
7
+ Code CLI (e.g. a Claude subscription, no key). Run from inside the repo you
8
+ want to review:
6
9
 
7
10
  saga --base main --head my-feature -o saga.html --open
8
11
 
@@ -45,7 +48,10 @@ def main(
45
48
  envvar="SAGA_MODEL",
46
49
  help=(
47
50
  "provider/model to use, e.g. anthropic/claude-opus-4-8, openai/gpt-4o, "
48
- "openrouter/anthropic/claude-3.5-sonnet "
51
+ "openrouter/anthropic/claude-3.5-sonnet, local/qwen2.5-coder:14b, "
52
+ "claude-cli or claude-cli/sonnet "
53
+ "(local/ targets Ollama or LM Studio; see $SAGA_LOCAL_BASE_URL. "
54
+ "claude-cli routes through your logged-in Claude Code CLI) "
49
55
  "(default: $SAGA_MODEL or anthropic/claude-opus-4-8)"
50
56
  ),
51
57
  ),
@@ -0,0 +1,301 @@
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
+ The ``local/`` prefix targets any OpenAI-compatible local server — Ollama or
15
+ LM Studio — with no API key. It defaults to Ollama's endpoint; point it at a
16
+ different server (e.g. LM Studio on port 1234) with ``$SAGA_LOCAL_BASE_URL``.
17
+
18
+ The ``claude-cli`` provider shells out to a local Claude Code CLI (``claude -p``)
19
+ instead of the Anthropic SDK, so it uses whatever the user's Claude Code is
20
+ logged in with — including a Claude Pro/Max subscription, with no API key. The
21
+ saga schema is enforced via ``claude``'s ``--json-schema`` flag.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import subprocess
29
+ from pathlib import Path
30
+ from typing import Literal
31
+
32
+ import instructor
33
+ from pydantic import BaseModel, Field
34
+
35
+ from .diff import DiffResult, compute_diff, rev_parse
36
+ from .model import (
37
+ Chapter,
38
+ Hunk,
39
+ Saga,
40
+ SagaError,
41
+ parse_hunks,
42
+ validate_coverage,
43
+ )
44
+
45
+ _MAX_TOKENS = 16000
46
+ _OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
47
+ # Ollama's OpenAI-compatible endpoint; override with $SAGA_LOCAL_BASE_URL (e.g.
48
+ # http://localhost:1234/v1 for LM Studio).
49
+ _LOCAL_DEFAULT_BASE_URL = "http://localhost:11434/v1"
50
+ # Claude Code boot + generation for a full saga can take a while; be generous.
51
+ _CLAUDE_CLI_TIMEOUT = 300
52
+ _PROMPT_PATH = Path(__file__).resolve().parent / "prompts" / "saga.md"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # LLM response schema — what the model must return (validated by instructor).
57
+ # Mapped onto the persisted ``Chapter`` dataclass below so model.py's core,
58
+ # validate_coverage, and render.py stay untouched.
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ class _QAOut(BaseModel):
63
+ status: str
64
+ note: str = ""
65
+
66
+
67
+ class _ChapterOut(BaseModel):
68
+ id: str
69
+ title: str
70
+ summary: str
71
+ narration: str
72
+ hunks: list[str] = Field(default_factory=list)
73
+ plan_step: str | None = None
74
+ confidence: Literal["high", "medium", "low"] = "medium"
75
+ deviation: str | None = None
76
+ qa: _QAOut | None = None
77
+
78
+
79
+ class _SagaOut(BaseModel):
80
+ chapters: list[_ChapterOut]
81
+
82
+
83
+ def _to_chapter(c: _ChapterOut) -> Chapter:
84
+ return Chapter(
85
+ id=c.id,
86
+ title=c.title,
87
+ summary=c.summary,
88
+ narration=c.narration,
89
+ hunks=list(c.hunks),
90
+ plan_step=c.plan_step,
91
+ confidence=c.confidence,
92
+ deviation=c.deviation or None,
93
+ qa=c.qa.model_dump() if c.qa else None,
94
+ )
95
+
96
+
97
+ def labeled_diff(hunks: list[Hunk]) -> str:
98
+ """Render the diff for the prompt with each hunk headed by its stable id."""
99
+ out: list[str] = []
100
+ for h in hunks:
101
+ first = h.body.splitlines()[0] if h.body else ""
102
+ out.append(f"### HUNK {h.id} — {h.file_path} ({first.strip()})")
103
+ out.append(h.body.rstrip("\n"))
104
+ return "\n".join(out)
105
+
106
+
107
+ def _build_message(diff: DiffResult, hunks: list[Hunk], intent: str | None) -> str:
108
+ """Assemble the user message: intent (optional), commits, and labeled diff."""
109
+ commits = "\n".join(diff.commits) or "(no commit messages)"
110
+ intent_block = (
111
+ ["# Intent (what this change set out to do)", intent.strip(), ""]
112
+ if intent and intent.strip()
113
+ else []
114
+ )
115
+ return "\n".join(
116
+ [
117
+ *intent_block,
118
+ "# Commits",
119
+ commits,
120
+ "",
121
+ "# Full diff (every hunk must be assigned to a chapter)",
122
+ labeled_diff(hunks),
123
+ ]
124
+ )
125
+
126
+
127
+ def _build_client(model: str) -> instructor.Instructor:
128
+ """Build an instructor client for a ``provider/model`` string.
129
+
130
+ OpenRouter is OpenAI-compatible, so it needs an explicit base URL and its own
131
+ key; Anthropic and OpenAI resolve their keys from the SDK's standard env vars.
132
+ ``local`` targets an OpenAI-compatible local server (Ollama/LM Studio) via the
133
+ OpenAI SDK — no key, and forced JSON mode since local models' tool-calling is
134
+ unreliable and saga depends on schema-valid output.
135
+ """
136
+ provider = model.split("/", 1)[0]
137
+ try:
138
+ if provider == "openrouter":
139
+ key = os.environ.get("OPENROUTER_API_KEY")
140
+ if not key:
141
+ raise SagaError("OPENROUTER_API_KEY is not set.")
142
+ return instructor.from_provider(
143
+ model, base_url=_OPENROUTER_BASE_URL, api_key=key
144
+ )
145
+ if provider == "local":
146
+ model_name = model.split("/", 1)[1]
147
+ base_url = os.environ.get("SAGA_LOCAL_BASE_URL", _LOCAL_DEFAULT_BASE_URL)
148
+ return instructor.from_provider(
149
+ f"openai/{model_name}",
150
+ base_url=base_url,
151
+ api_key="local", # unused by the local server, but must be non-empty
152
+ mode=instructor.Mode.JSON,
153
+ )
154
+ return instructor.from_provider(model)
155
+ except SagaError:
156
+ raise
157
+ except Exception as e:
158
+ raise SagaError(f"Could not initialize model {model!r}: {e}") from e
159
+
160
+
161
+ def _generate_via_claude_cli(
162
+ model: str, system_prompt: str, user_message: str
163
+ ) -> _SagaOut:
164
+ """Generate a saga through a local Claude Code CLI (``claude -p``).
165
+
166
+ Unlike the ``anthropic/`` provider (which needs ``ANTHROPIC_API_KEY``), this
167
+ runs the ``claude`` binary and reuses its stored login, so a user with only a
168
+ Claude Pro/Max subscription can generate sagas. Output is constrained to the
169
+ saga schema with ``--json-schema`` and read back from the JSON envelope's
170
+ ``structured_output`` field. ``model`` is ``claude-cli`` (Claude Code's
171
+ default model) or ``claude-cli/<alias>`` (e.g. ``claude-cli/sonnet``).
172
+ """
173
+ parts = model.split("/", 1)
174
+ cmd = [
175
+ "claude",
176
+ "-p",
177
+ "--output-format",
178
+ "json",
179
+ # Replace Claude Code's default system prompt with saga's — a focused,
180
+ # tool-free transform rather than a coding agent.
181
+ "--system-prompt",
182
+ system_prompt,
183
+ "--allowedTools",
184
+ "", # no tools: prompt in, schema-valid JSON out
185
+ "--json-schema",
186
+ json.dumps(_SagaOut.model_json_schema()),
187
+ ]
188
+ if len(parts) == 2 and parts[1]:
189
+ cmd += ["--model", parts[1]]
190
+
191
+ # ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN outrank the Claude Code login, so a
192
+ # stray key would silently bill the API instead of the subscription this
193
+ # provider exists to use. Drop them for the subprocess.
194
+ env = {
195
+ k: v
196
+ for k, v in os.environ.items()
197
+ if k not in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
198
+ }
199
+
200
+ try:
201
+ proc = subprocess.run(
202
+ cmd,
203
+ input=user_message,
204
+ capture_output=True,
205
+ text=True,
206
+ env=env,
207
+ timeout=_CLAUDE_CLI_TIMEOUT,
208
+ )
209
+ except FileNotFoundError as e:
210
+ raise SagaError(
211
+ "claude CLI not found. Install Claude Code and log in "
212
+ "(https://claude.com/claude-code), or choose a different --model."
213
+ ) from e
214
+ except subprocess.TimeoutExpired as e:
215
+ raise SagaError(f"claude CLI timed out after {_CLAUDE_CLI_TIMEOUT}s.") from e
216
+
217
+ if proc.returncode != 0:
218
+ detail = (proc.stderr or proc.stdout).strip()
219
+ raise SagaError(f"claude CLI failed (exit {proc.returncode}): {detail}")
220
+
221
+ # stdout may carry a leading warning line before the JSON object; start at the
222
+ # first brace.
223
+ text = proc.stdout.strip()
224
+ start = text.find("{")
225
+ if start == -1:
226
+ raise SagaError(f"claude CLI returned no JSON:\n{text[:500]}")
227
+ try:
228
+ envelope = json.loads(text[start:])
229
+ except json.JSONDecodeError as e:
230
+ raise SagaError(f"could not parse claude CLI output: {e}") from e
231
+
232
+ if envelope.get("is_error"):
233
+ raise SagaError(f"claude CLI reported an error: {envelope.get('result')}")
234
+ structured = envelope.get("structured_output")
235
+ if structured is None:
236
+ raise SagaError(
237
+ "claude CLI returned no structured_output — the model did not "
238
+ "produce schema-valid output."
239
+ )
240
+ try:
241
+ return _SagaOut.model_validate(structured)
242
+ except Exception as e:
243
+ raise SagaError(f"claude CLI output did not match the saga schema: {e}") from e
244
+
245
+
246
+ def generate(
247
+ repo_root: Path,
248
+ base: str,
249
+ head: str,
250
+ *,
251
+ model: str,
252
+ intent: str | None = None,
253
+ ) -> Saga:
254
+ """Generate the saga for *base*...*head*.
255
+
256
+ Raises ``SagaError`` on an empty diff, a provider/model error, or a
257
+ coverage gap. The caller renders the error as a user-facing message.
258
+ """
259
+ diff = compute_diff(repo_root, base, head)
260
+ hunks = parse_hunks(diff.diff_text)
261
+ if not hunks:
262
+ raise SagaError("No reviewable hunks in this change set.")
263
+
264
+ provider = model.split("/", 1)[0]
265
+ system_prompt = _PROMPT_PATH.read_text()
266
+ user_message = _build_message(diff, hunks, intent)
267
+
268
+ if provider == "claude-cli":
269
+ result = _generate_via_claude_cli(model, system_prompt, user_message)
270
+ else:
271
+ client = _build_client(model)
272
+ kwargs: dict = {}
273
+ if provider == "openrouter":
274
+ # Ask OpenRouter to route only to providers that support the params we
275
+ # send (structured output), so schema enforcement is honored.
276
+ kwargs["extra_body"] = {"provider": {"require_parameters": True}}
277
+
278
+ try:
279
+ result: _SagaOut = client.create(
280
+ response_model=_SagaOut,
281
+ max_tokens=_MAX_TOKENS,
282
+ messages=[
283
+ {"role": "system", "content": system_prompt},
284
+ {"role": "user", "content": user_message},
285
+ ],
286
+ **kwargs,
287
+ )
288
+ except SagaError:
289
+ raise
290
+ except Exception as e:
291
+ raise SagaError(f"Saga generation failed: {e}") from e
292
+
293
+ chapters = [_to_chapter(c) for c in result.chapters]
294
+ validate_coverage(chapters, hunks)
295
+
296
+ return Saga(
297
+ branch=head,
298
+ base=base,
299
+ commit_sha=rev_parse(repo_root, head),
300
+ chapters=chapters,
301
+ )
@@ -0,0 +1,319 @@
1
+ """Tests for saga generation.
2
+
3
+ The one real LLM HTTP call is recorded to a VCR cassette (auth headers scrubbed)
4
+ and replayed offline. Everything around it — prompt assembly, client selection,
5
+ schema→dataclass mapping, and the empty-diff guard — is tested without network.
6
+
7
+ Re-record with a real key via: SAGA_RECORD=1 ANTHROPIC_API_KEY=... pytest
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from saga.generate import (
20
+ _build_client,
21
+ _build_message,
22
+ _ChapterOut,
23
+ _generate_via_claude_cli,
24
+ _QAOut,
25
+ _SagaOut,
26
+ _to_chapter,
27
+ generate,
28
+ labeled_diff,
29
+ )
30
+ from saga.model import SagaError, parse_hunks
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Prompt assembly (pure)
34
+ # ---------------------------------------------------------------------------
35
+
36
+ TWO_FILE_DIFF = (
37
+ "diff --git a/foo.py b/foo.py\n"
38
+ "--- a/foo.py\n+++ b/foo.py\n"
39
+ "@@ -1,2 +1,2 @@\n a\n-b\n+B\n"
40
+ )
41
+
42
+
43
+ def test_labeled_diff_heads_each_hunk_with_id():
44
+ hunks = parse_hunks(TWO_FILE_DIFF)
45
+ out = labeled_diff(hunks)
46
+ assert "### HUNK h0 — foo.py" in out
47
+ assert "@@ -1,2 +1,2 @@" in out
48
+
49
+
50
+ def test_build_message_includes_intent_commits_and_diff():
51
+ from saga.diff import DiffResult
52
+
53
+ diff = DiffResult(diff_text=TWO_FILE_DIFF, commits=["abc fix bug"], diffstat="")
54
+ hunks = parse_hunks(TWO_FILE_DIFF)
55
+ msg = _build_message(diff, hunks, intent="Fix the bug.")
56
+ assert "# Intent" in msg
57
+ assert "Fix the bug." in msg
58
+ assert "abc fix bug" in msg
59
+ assert "### HUNK h0" in msg
60
+
61
+
62
+ def test_build_message_omits_intent_block_when_absent():
63
+ from saga.diff import DiffResult
64
+
65
+ diff = DiffResult(diff_text=TWO_FILE_DIFF, commits=[], diffstat="")
66
+ hunks = parse_hunks(TWO_FILE_DIFF)
67
+ msg = _build_message(diff, hunks, intent=None)
68
+ assert "# Intent" not in msg
69
+ assert "(no commit messages)" in msg
70
+
71
+
72
+ def test_to_chapter_maps_schema_to_dataclass():
73
+ out = _ChapterOut(
74
+ id="c1",
75
+ title="T",
76
+ summary="S",
77
+ narration="N",
78
+ hunks=["h0"],
79
+ confidence="high",
80
+ deviation="",
81
+ qa=_QAOut(status="green", note="ok"),
82
+ )
83
+ ch = _to_chapter(out)
84
+ assert ch.id == "c1"
85
+ assert ch.confidence == "high"
86
+ # Empty-string deviation normalizes to None.
87
+ assert ch.deviation is None
88
+ assert ch.qa == {"status": "green", "note": "ok"}
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Client selection / error handling (no network)
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def test_build_client_openrouter_without_key_raises(monkeypatch):
97
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
98
+ with pytest.raises(SagaError, match="OPENROUTER_API_KEY"):
99
+ _build_client("openrouter/anthropic/claude-3.5-sonnet")
100
+
101
+
102
+ def test_build_client_unknown_provider_raises_sagaerror(monkeypatch):
103
+ with pytest.raises(SagaError, match="Could not initialize model"):
104
+ _build_client("bogusprovider/some-model")
105
+
106
+
107
+ def _capture_from_provider(monkeypatch) -> dict:
108
+ """Patch instructor.from_provider to record its args instead of building a
109
+ real client, so local-provider wiring can be checked without a server."""
110
+ import saga.generate as gen
111
+
112
+ captured: dict = {}
113
+
114
+ def fake(model, **kwargs):
115
+ captured["model"] = model
116
+ captured.update(kwargs)
117
+ return object()
118
+
119
+ monkeypatch.setattr(gen.instructor, "from_provider", fake)
120
+ return captured
121
+
122
+
123
+ def test_build_client_local_defaults_to_ollama(monkeypatch):
124
+ import saga.generate as gen
125
+
126
+ monkeypatch.delenv("SAGA_LOCAL_BASE_URL", raising=False)
127
+ captured = _capture_from_provider(monkeypatch)
128
+ _build_client("local/qwen2.5-coder:14b")
129
+ # local/ is dispatched through the OpenAI SDK at Ollama's endpoint, key unused.
130
+ assert captured["model"] == "openai/qwen2.5-coder:14b"
131
+ assert captured["base_url"] == "http://localhost:11434/v1"
132
+ assert captured["api_key"]
133
+ assert captured["mode"] == gen.instructor.Mode.JSON
134
+
135
+
136
+ def test_build_client_local_honors_base_url_override(monkeypatch):
137
+ # Point local/ at LM Studio (or any OpenAI-compatible server) via the env var.
138
+ monkeypatch.setenv("SAGA_LOCAL_BASE_URL", "http://localhost:1234/v1")
139
+ captured = _capture_from_provider(monkeypatch)
140
+ _build_client("local/lmstudio-community/Qwen2.5-Coder-GGUF")
141
+ assert captured["base_url"] == "http://localhost:1234/v1"
142
+ # A slash-bearing model id keeps its slashes after the local/ prefix is stripped.
143
+ assert captured["model"] == "openai/lmstudio-community/Qwen2.5-Coder-GGUF"
144
+
145
+
146
+ def test_generate_empty_diff_raises_before_any_network(empty_diff_repo: Path):
147
+ with pytest.raises(SagaError, match="No reviewable hunks"):
148
+ generate(empty_diff_repo, "main", "feature", model="anthropic/claude-opus-4-8")
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Full generation, replayed from a scrubbed cassette
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ def test_generate_full_saga_from_cassette(git_repo: Path, vcr_cassette, monkeypatch):
157
+ # Replay needs a syntactically-valid key for client construction; the real
158
+ # HTTP is served by the cassette. Don't clobber a real key while recording.
159
+ if not os.environ.get("ANTHROPIC_API_KEY"):
160
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-dummy")
161
+
162
+ with vcr_cassette.use_cassette("generate_anthropic.yaml"):
163
+ saga = generate(git_repo, "main", "feature", model="anthropic/claude-opus-4-8")
164
+
165
+ assert saga.base == "main"
166
+ assert saga.branch == "feature"
167
+ assert saga.commit_sha and len(saga.commit_sha) == 40
168
+ assert len(saga.chapters) >= 1
169
+ # generate() re-validates coverage internally, so reaching here means every
170
+ # hunk in the live diff was assigned. Confirm the chapters carry content.
171
+ assert all(c.id for c in saga.chapters)
172
+ assert any(c.hunks for c in saga.chapters)
173
+
174
+
175
+ def test_generate_wraps_provider_errors_as_sagaerror(git_repo: Path, monkeypatch):
176
+ """A failure from the LLM client is surfaced as a SagaError, not a raw
177
+ provider exception."""
178
+ import saga.generate as gen
179
+
180
+ class BoomClient:
181
+ def create(self, **kwargs):
182
+ raise RuntimeError("upstream 529 overloaded")
183
+
184
+ monkeypatch.setattr(gen, "_build_client", lambda model: BoomClient())
185
+ with pytest.raises(SagaError, match="Saga generation failed"):
186
+ generate(git_repo, "main", "feature", model="anthropic/claude-opus-4-8")
187
+
188
+
189
+ def test_cassette_is_scrubbed():
190
+ """Committed cassette must not carry a real API key."""
191
+ cassette = Path(__file__).parent / "cassettes" / "generate_anthropic.yaml"
192
+ if not cassette.exists():
193
+ pytest.skip("cassette not recorded yet")
194
+ text = cassette.read_text()
195
+ assert "sk-ant-" not in text
196
+ assert "REDACTED" in text
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # claude-cli provider (subprocess out to `claude -p`), no network / no binary
201
+ # ---------------------------------------------------------------------------
202
+
203
+ _ENVELOPE_CHAPTERS = {
204
+ "chapters": [
205
+ {"id": "c1", "title": "T", "summary": "S", "narration": "N", "hunks": ["h0"]}
206
+ ]
207
+ }
208
+
209
+
210
+ def _envelope(**fields) -> str:
211
+ """Serialize a `claude -p --output-format json` envelope for stubbed stdout."""
212
+ return json.dumps(fields)
213
+
214
+
215
+ def _fake_claude(monkeypatch, *, stdout: str, returncode: int = 0) -> dict:
216
+ """Patch subprocess.run to skip the real binary and record how it was called."""
217
+ import saga.generate as gen
218
+
219
+ captured: dict = {}
220
+
221
+ def fake_run(cmd, **kwargs):
222
+ captured["cmd"] = cmd
223
+ captured["kwargs"] = kwargs
224
+ return subprocess.CompletedProcess(cmd, returncode, stdout=stdout, stderr="")
225
+
226
+ monkeypatch.setattr(gen.subprocess, "run", fake_run)
227
+ return captured
228
+
229
+
230
+ def test_claude_cli_parses_structured_output(monkeypatch):
231
+ stdout = _envelope(is_error=False, structured_output=_ENVELOPE_CHAPTERS)
232
+ _fake_claude(monkeypatch, stdout=stdout)
233
+ result = _generate_via_claude_cli("claude-cli", "sys prompt", "the diff")
234
+ assert result.chapters[0].id == "c1"
235
+ assert result.chapters[0].hunks == ["h0"]
236
+
237
+
238
+ def test_claude_cli_command_pins_model_and_scrubs_key(monkeypatch):
239
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-should-be-dropped")
240
+ monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "tok-should-be-dropped")
241
+ captured = _fake_claude(
242
+ monkeypatch, stdout=_envelope(structured_output=_ENVELOPE_CHAPTERS)
243
+ )
244
+
245
+ _generate_via_claude_cli("claude-cli/sonnet", "sys prompt", "the diff")
246
+
247
+ cmd = captured["cmd"]
248
+ assert cmd[:2] == ["claude", "-p"]
249
+ assert "--json-schema" in cmd
250
+ # claude-cli/<alias> pins the model; bare claude-cli would omit --model.
251
+ assert cmd[cmd.index("--model") + 1] == "sonnet"
252
+ # The diff is piped on stdin, not passed as an argument.
253
+ assert captured["kwargs"]["input"] == "the diff"
254
+ # API-key env vars are dropped so the subprocess uses the Claude Code login.
255
+ env = captured["kwargs"]["env"]
256
+ assert "ANTHROPIC_API_KEY" not in env
257
+ assert "ANTHROPIC_AUTH_TOKEN" not in env
258
+
259
+
260
+ def test_claude_cli_bare_model_omits_model_flag(monkeypatch):
261
+ captured = _fake_claude(
262
+ monkeypatch, stdout=_envelope(structured_output=_ENVELOPE_CHAPTERS)
263
+ )
264
+ _generate_via_claude_cli("claude-cli", "sys prompt", "the diff")
265
+ assert "--model" not in captured["cmd"]
266
+
267
+
268
+ def test_claude_cli_tolerates_leading_warning_line(monkeypatch):
269
+ # Claude Code prints a stray warning before the JSON envelope on some setups.
270
+ stdout = "⚠ connectors disabled\n" + _envelope(structured_output=_ENVELOPE_CHAPTERS)
271
+ _fake_claude(monkeypatch, stdout=stdout)
272
+ result = _generate_via_claude_cli("claude-cli", "sys prompt", "the diff")
273
+ assert result.chapters[0].id == "c1"
274
+
275
+
276
+ def test_claude_cli_missing_binary_raises_sagaerror(monkeypatch):
277
+ import saga.generate as gen
278
+
279
+ def boom(cmd, **kwargs):
280
+ raise FileNotFoundError("claude")
281
+
282
+ monkeypatch.setattr(gen.subprocess, "run", boom)
283
+ with pytest.raises(SagaError, match="claude CLI not found"):
284
+ _generate_via_claude_cli("claude-cli", "sys prompt", "the diff")
285
+
286
+
287
+ def test_claude_cli_nonzero_exit_raises_sagaerror(monkeypatch):
288
+ _fake_claude(monkeypatch, stdout="", returncode=1)
289
+ with pytest.raises(SagaError, match="claude CLI failed"):
290
+ _generate_via_claude_cli("claude-cli", "sys prompt", "the diff")
291
+
292
+
293
+ def test_claude_cli_missing_structured_output_raises_sagaerror(monkeypatch):
294
+ _fake_claude(monkeypatch, stdout=_envelope(is_error=False, result="text only"))
295
+ with pytest.raises(SagaError, match="no structured_output"):
296
+ _generate_via_claude_cli("claude-cli", "sys prompt", "the diff")
297
+
298
+
299
+ def test_generate_dispatches_to_claude_cli(git_repo: Path, monkeypatch):
300
+ """A claude-cli model routes through the subprocess path, not instructor."""
301
+ import saga.generate as gen
302
+
303
+ called: dict = {}
304
+
305
+ def fake_cli(model, system_prompt, user_message):
306
+ called["model"] = model
307
+ return _SagaOut.model_validate(_ENVELOPE_CHAPTERS)
308
+
309
+ def no_client(model):
310
+ pytest.fail("should not build an instructor client")
311
+
312
+ monkeypatch.setattr(gen, "_generate_via_claude_cli", fake_cli)
313
+ monkeypatch.setattr(gen, "_build_client", no_client)
314
+ # Coverage against git_repo's real hunks isn't the point of this test — the
315
+ # stub chapters don't span them — so neutralize the check here.
316
+ monkeypatch.setattr(gen, "validate_coverage", lambda chapters, hunks: None)
317
+ saga = generate(git_repo, "main", "feature", model="claude-cli/sonnet")
318
+ assert called["model"] == "claude-cli/sonnet"
319
+ assert len(saga.chapters) >= 1
@@ -1,182 +0,0 @@
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
- )
@@ -1,153 +0,0 @@
1
- """Tests for saga generation.
2
-
3
- The one real LLM HTTP call is recorded to a VCR cassette (auth headers scrubbed)
4
- and replayed offline. Everything around it — prompt assembly, client selection,
5
- schema→dataclass mapping, and the empty-diff guard — is tested without network.
6
-
7
- Re-record with a real key via: SAGA_RECORD=1 ANTHROPIC_API_KEY=... pytest
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import os
13
- from pathlib import Path
14
-
15
- import pytest
16
-
17
- from saga.generate import (
18
- _build_client,
19
- _build_message,
20
- _ChapterOut,
21
- _QAOut,
22
- _to_chapter,
23
- generate,
24
- labeled_diff,
25
- )
26
- from saga.model import SagaError, parse_hunks
27
-
28
- # ---------------------------------------------------------------------------
29
- # Prompt assembly (pure)
30
- # ---------------------------------------------------------------------------
31
-
32
- TWO_FILE_DIFF = (
33
- "diff --git a/foo.py b/foo.py\n"
34
- "--- a/foo.py\n+++ b/foo.py\n"
35
- "@@ -1,2 +1,2 @@\n a\n-b\n+B\n"
36
- )
37
-
38
-
39
- def test_labeled_diff_heads_each_hunk_with_id():
40
- hunks = parse_hunks(TWO_FILE_DIFF)
41
- out = labeled_diff(hunks)
42
- assert "### HUNK h0 — foo.py" in out
43
- assert "@@ -1,2 +1,2 @@" in out
44
-
45
-
46
- def test_build_message_includes_intent_commits_and_diff():
47
- from saga.diff import DiffResult
48
-
49
- diff = DiffResult(diff_text=TWO_FILE_DIFF, commits=["abc fix bug"], diffstat="")
50
- hunks = parse_hunks(TWO_FILE_DIFF)
51
- msg = _build_message(diff, hunks, intent="Fix the bug.")
52
- assert "# Intent" in msg
53
- assert "Fix the bug." in msg
54
- assert "abc fix bug" in msg
55
- assert "### HUNK h0" in msg
56
-
57
-
58
- def test_build_message_omits_intent_block_when_absent():
59
- from saga.diff import DiffResult
60
-
61
- diff = DiffResult(diff_text=TWO_FILE_DIFF, commits=[], diffstat="")
62
- hunks = parse_hunks(TWO_FILE_DIFF)
63
- msg = _build_message(diff, hunks, intent=None)
64
- assert "# Intent" not in msg
65
- assert "(no commit messages)" in msg
66
-
67
-
68
- def test_to_chapter_maps_schema_to_dataclass():
69
- out = _ChapterOut(
70
- id="c1",
71
- title="T",
72
- summary="S",
73
- narration="N",
74
- hunks=["h0"],
75
- confidence="high",
76
- deviation="",
77
- qa=_QAOut(status="green", note="ok"),
78
- )
79
- ch = _to_chapter(out)
80
- assert ch.id == "c1"
81
- assert ch.confidence == "high"
82
- # Empty-string deviation normalizes to None.
83
- assert ch.deviation is None
84
- assert ch.qa == {"status": "green", "note": "ok"}
85
-
86
-
87
- # ---------------------------------------------------------------------------
88
- # Client selection / error handling (no network)
89
- # ---------------------------------------------------------------------------
90
-
91
-
92
- def test_build_client_openrouter_without_key_raises(monkeypatch):
93
- monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
94
- with pytest.raises(SagaError, match="OPENROUTER_API_KEY"):
95
- _build_client("openrouter/anthropic/claude-3.5-sonnet")
96
-
97
-
98
- def test_build_client_unknown_provider_raises_sagaerror(monkeypatch):
99
- with pytest.raises(SagaError, match="Could not initialize model"):
100
- _build_client("bogusprovider/some-model")
101
-
102
-
103
- def test_generate_empty_diff_raises_before_any_network(empty_diff_repo: Path):
104
- with pytest.raises(SagaError, match="No reviewable hunks"):
105
- generate(empty_diff_repo, "main", "feature", model="anthropic/claude-opus-4-8")
106
-
107
-
108
- # ---------------------------------------------------------------------------
109
- # Full generation, replayed from a scrubbed cassette
110
- # ---------------------------------------------------------------------------
111
-
112
-
113
- def test_generate_full_saga_from_cassette(git_repo: Path, vcr_cassette, monkeypatch):
114
- # Replay needs a syntactically-valid key for client construction; the real
115
- # HTTP is served by the cassette. Don't clobber a real key while recording.
116
- if not os.environ.get("ANTHROPIC_API_KEY"):
117
- monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-dummy")
118
-
119
- with vcr_cassette.use_cassette("generate_anthropic.yaml"):
120
- saga = generate(git_repo, "main", "feature", model="anthropic/claude-opus-4-8")
121
-
122
- assert saga.base == "main"
123
- assert saga.branch == "feature"
124
- assert saga.commit_sha and len(saga.commit_sha) == 40
125
- assert len(saga.chapters) >= 1
126
- # generate() re-validates coverage internally, so reaching here means every
127
- # hunk in the live diff was assigned. Confirm the chapters carry content.
128
- assert all(c.id for c in saga.chapters)
129
- assert any(c.hunks for c in saga.chapters)
130
-
131
-
132
- def test_generate_wraps_provider_errors_as_sagaerror(git_repo: Path, monkeypatch):
133
- """A failure from the LLM client is surfaced as a SagaError, not a raw
134
- provider exception."""
135
- import saga.generate as gen
136
-
137
- class BoomClient:
138
- def create(self, **kwargs):
139
- raise RuntimeError("upstream 529 overloaded")
140
-
141
- monkeypatch.setattr(gen, "_build_client", lambda model: BoomClient())
142
- with pytest.raises(SagaError, match="Saga generation failed"):
143
- generate(git_repo, "main", "feature", model="anthropic/claude-opus-4-8")
144
-
145
-
146
- def test_cassette_is_scrubbed():
147
- """Committed cassette must not carry a real API key."""
148
- cassette = Path(__file__).parent / "cassettes" / "generate_anthropic.yaml"
149
- if not cassette.exists():
150
- pytest.skip("cassette not recorded yet")
151
- text = cassette.read_text()
152
- assert "sk-ant-" not in text
153
- assert "REDACTED" in text
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes