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.
- saga_cli-0.2.0/.github/CODEOWNERS +3 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/PKG-INFO +53 -4
- {saga_cli-0.1.0 → saga_cli-0.2.0}/README.md +52 -3
- {saga_cli-0.1.0 → saga_cli-0.2.0}/pyproject.toml +1 -1
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/cli.py +8 -2
- saga_cli-0.2.0/saga/generate.py +301 -0
- saga_cli-0.2.0/tests/test_generate.py +319 -0
- saga_cli-0.1.0/saga/generate.py +0 -182
- saga_cli-0.1.0/tests/test_generate.py +0 -153
- {saga_cli-0.1.0 → saga_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/.gitignore +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/LICENSE +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/RELEASING.md +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/docs/example.html +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/__init__.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/__main__.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/base.css +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/saga.css +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/saga.js +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/assets/tokens.css +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/comments.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/diff.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/model.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/prompts/saga.md +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/saga/render.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/skills/generate_saga/SKILL.md +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/skills/read_saga_comments/SKILL.md +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/cassettes/generate_anthropic.yaml +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/conftest.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_cli.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_comments.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_diff.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_model.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/tests/test_render.py +0 -0
- {saga_cli-0.1.0 → saga_cli-0.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: saga-cli
|
|
3
|
-
Version: 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.
|
|
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``)
|
|
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
|
saga_cli-0.1.0/saga/generate.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|