saga-cli 0.3.0__tar.gz → 0.3.1__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.3.1/.claude/settings.local.json +8 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/PKG-INFO +11 -12
- {saga_cli-0.3.0 → saga_cli-0.3.1}/README.md +10 -11
- {saga_cli-0.3.0 → saga_cli-0.3.1}/docs/example.html +125 -36
- {saga_cli-0.3.0 → saga_cli-0.3.1}/pyproject.toml +1 -1
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/base.css +6 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/saga.css +5 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/saga.js +80 -18
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/cli.py +37 -1
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/comments.py +81 -36
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/render.py +7 -4
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_cli.py +34 -2
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_comments.py +53 -1
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_render.py +13 -1
- {saga_cli-0.3.0 → saga_cli-0.3.1}/uv.lock +1 -1
- saga_cli-0.3.0/skills/read_saga_comments/SKILL.md +0 -62
- {saga_cli-0.3.0 → saga_cli-0.3.1}/.github/CODEOWNERS +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/.github/workflows/ci.yml +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/.gitignore +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/CONTRIBUTING.md +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/LICENSE +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/RELEASING.md +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/__init__.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/__main__.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/tokens.css +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/diff.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/generate.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/model.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/prompts/saga.md +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/skills/generate_saga/SKILL.md +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/cassettes/generate_anthropic.yaml +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/conftest.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_diff.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_generate.py +0 -0
- {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: saga-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -176,7 +176,7 @@ boots an agent per run) and subject to your subscription's usage limits.
|
|
|
176
176
|
|
|
177
177
|
## As a Claude Code skill
|
|
178
178
|
|
|
179
|
-
The `skills/` directory contains
|
|
179
|
+
The `skills/` directory contains a Claude Code skill. To install it:
|
|
180
180
|
|
|
181
181
|
```sh
|
|
182
182
|
cp -R "$(pwd)/skills" ~/.claude/skills/saga
|
|
@@ -184,8 +184,6 @@ cp -R "$(pwd)/skills" ~/.claude/skills/saga
|
|
|
184
184
|
|
|
185
185
|
- **`saga`** — say **"/saga"** (or "give me a walkthrough of this branch") to generate a
|
|
186
186
|
saga. It resolves the base/head refs and runs the tool for you.
|
|
187
|
-
- **`saga-comments`** — say **"/saga-comments"** (or "address the saga comments") to read an
|
|
188
|
-
exported `saga.comments.json` and act on the reviewer's feedback in code.
|
|
189
187
|
|
|
190
188
|
## Reviewing: comments
|
|
191
189
|
|
|
@@ -195,19 +193,20 @@ kinds of comments — **inline** (click a line's number in any chapter's diff),
|
|
|
195
193
|
(the box at the top of the Chapters list). Comments are drafted in your browser's
|
|
196
194
|
`localStorage`, so they survive a reload.
|
|
197
195
|
|
|
198
|
-
When you're done, click
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
When you're done, click one of the copy buttons. Each puts a ready-to-run command on your
|
|
197
|
+
clipboard with the comments encoded inline (`--data`), so there's no file to find on disk —
|
|
198
|
+
paste it wherever you need it:
|
|
201
199
|
|
|
202
200
|
```sh
|
|
203
|
-
#
|
|
204
|
-
saga comments push --
|
|
201
|
+
# "Copy for GitHub" → post everything as a single PENDING review (you submit it on GitHub).
|
|
202
|
+
saga comments push --data <base64>
|
|
205
203
|
|
|
206
|
-
#
|
|
207
|
-
saga comments read --
|
|
204
|
+
# "Copy for agent" → emit the comments as JSON on stdout for a coding agent to act on.
|
|
205
|
+
saga comments read --data <base64>
|
|
208
206
|
```
|
|
209
207
|
|
|
210
|
-
|
|
208
|
+
Prefer a file? Both commands also accept `--comments <path>` for a `saga.comments.json` you
|
|
209
|
+
write yourself (handy for scripting). `push` uses the `gh` CLI: it finds the open PR for the branch and creates one
|
|
211
210
|
pending review (inline → line comments, per-file → a note anchored to the file's first
|
|
212
211
|
changed line, overall → the review body). Nothing is submitted until you review and submit
|
|
213
212
|
it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
|
|
@@ -149,7 +149,7 @@ boots an agent per run) and subject to your subscription's usage limits.
|
|
|
149
149
|
|
|
150
150
|
## As a Claude Code skill
|
|
151
151
|
|
|
152
|
-
The `skills/` directory contains
|
|
152
|
+
The `skills/` directory contains a Claude Code skill. To install it:
|
|
153
153
|
|
|
154
154
|
```sh
|
|
155
155
|
cp -R "$(pwd)/skills" ~/.claude/skills/saga
|
|
@@ -157,8 +157,6 @@ cp -R "$(pwd)/skills" ~/.claude/skills/saga
|
|
|
157
157
|
|
|
158
158
|
- **`saga`** — say **"/saga"** (or "give me a walkthrough of this branch") to generate a
|
|
159
159
|
saga. It resolves the base/head refs and runs the tool for you.
|
|
160
|
-
- **`saga-comments`** — say **"/saga-comments"** (or "address the saga comments") to read an
|
|
161
|
-
exported `saga.comments.json` and act on the reviewer's feedback in code.
|
|
162
160
|
|
|
163
161
|
## Reviewing: comments
|
|
164
162
|
|
|
@@ -168,19 +166,20 @@ kinds of comments — **inline** (click a line's number in any chapter's diff),
|
|
|
168
166
|
(the box at the top of the Chapters list). Comments are drafted in your browser's
|
|
169
167
|
`localStorage`, so they survive a reload.
|
|
170
168
|
|
|
171
|
-
When you're done, click
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
When you're done, click one of the copy buttons. Each puts a ready-to-run command on your
|
|
170
|
+
clipboard with the comments encoded inline (`--data`), so there's no file to find on disk —
|
|
171
|
+
paste it wherever you need it:
|
|
174
172
|
|
|
175
173
|
```sh
|
|
176
|
-
#
|
|
177
|
-
saga comments push --
|
|
174
|
+
# "Copy for GitHub" → post everything as a single PENDING review (you submit it on GitHub).
|
|
175
|
+
saga comments push --data <base64>
|
|
178
176
|
|
|
179
|
-
#
|
|
180
|
-
saga comments read --
|
|
177
|
+
# "Copy for agent" → emit the comments as JSON on stdout for a coding agent to act on.
|
|
178
|
+
saga comments read --data <base64>
|
|
181
179
|
```
|
|
182
180
|
|
|
183
|
-
|
|
181
|
+
Prefer a file? Both commands also accept `--comments <path>` for a `saga.comments.json` you
|
|
182
|
+
write yourself (handy for scripting). `push` uses the `gh` CLI: it finds the open PR for the branch and creates one
|
|
184
183
|
pending review (inline → line comments, per-file → a note anchored to the file's first
|
|
185
184
|
changed line, overall → the review body). Nothing is submitted until you review and submit
|
|
186
185
|
it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
|
|
@@ -520,8 +520,8 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
520
520
|
// diff). This file renders it — a table of contents (the entry point) and a
|
|
521
521
|
// chapter reader — tracks per-chapter mark-as-read in localStorage, and lets a
|
|
522
522
|
// reviewer leave inline / per-file / overall comments (also drafted in
|
|
523
|
-
// localStorage) that
|
|
524
|
-
// server, no fetch.
|
|
523
|
+
// localStorage) that the Copy buttons hand off to the `saga comments` CLI as a
|
|
524
|
+
// base64 `--data` payload. No server, no fetch.
|
|
525
525
|
|
|
526
526
|
(function () {
|
|
527
527
|
const data = window.__sagaData || {chapters: [], verdict: null, branch: ''};
|
|
@@ -588,7 +588,7 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
588
588
|
localStorage.setItem(readKey(), JSON.stringify([...s]));
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
-
// --- comments (localStorage draft,
|
|
591
|
+
// --- comments (localStorage draft, copied to the CLI as --data) ----
|
|
592
592
|
|
|
593
593
|
let comments = {files: {}, overall: ''};
|
|
594
594
|
|
|
@@ -779,15 +779,64 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
779
779
|
});
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
782
|
+
// Assemble the comments payload — the same shape the CLI validates.
|
|
783
|
+
function buildPayload() {
|
|
784
|
+
const files = {};
|
|
785
|
+
Object.keys(comments.files).forEach((p) => {
|
|
786
|
+
const e = comments.files[p];
|
|
787
|
+
const inline = (e.inline || []).filter((c) => c.body && c.body.trim());
|
|
788
|
+
const fc = (e.file_comment || '').trim();
|
|
789
|
+
if (!inline.length && !fc) return;
|
|
790
|
+
const out = {};
|
|
791
|
+
if (fc) {
|
|
792
|
+
out.file_comment = fc;
|
|
793
|
+
if (e.file_anchor) out.file_anchor = e.file_anchor;
|
|
794
|
+
}
|
|
795
|
+
if (inline.length) out.inline = inline;
|
|
796
|
+
files[p] = out;
|
|
797
|
+
});
|
|
798
|
+
return {
|
|
799
|
+
branch: data.branch || '',
|
|
800
|
+
base: data.base || '',
|
|
801
|
+
overall: (comments.overall || '').trim(),
|
|
802
|
+
files: files,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// base64(UTF-8 JSON) — the payload rides inside the copied command as
|
|
807
|
+
// `--data`, so the reviewer never has to find a file on disk.
|
|
808
|
+
function encodePayload() {
|
|
809
|
+
const bytes = new TextEncoder().encode(JSON.stringify(buildPayload()));
|
|
810
|
+
let bin = '';
|
|
811
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
812
|
+
return btoa(bin);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function copyText(text) {
|
|
816
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
817
|
+
return navigator.clipboard.writeText(text);
|
|
818
|
+
}
|
|
819
|
+
// file:// fallback where the async clipboard API is unavailable.
|
|
820
|
+
const ta = document.createElement('textarea');
|
|
821
|
+
ta.value = text;
|
|
822
|
+
ta.style.position = 'fixed';
|
|
823
|
+
ta.style.opacity = '0';
|
|
824
|
+
document.body.appendChild(ta);
|
|
825
|
+
ta.select();
|
|
826
|
+
try { document.execCommand('copy'); } finally { ta.remove(); }
|
|
827
|
+
return Promise.resolve();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Copy a ready-to-run `saga comments <sub> --data …` command. The reviewer
|
|
831
|
+
// pastes it into their terminal (push) or coding agent (read).
|
|
832
|
+
function copyCommand(sub, btn) {
|
|
833
|
+
const cmd = 'saga comments ' + sub + ' --data ' + encodePayload();
|
|
834
|
+
copyText(cmd).then(() => {
|
|
835
|
+
const label = btn.dataset.label;
|
|
836
|
+
btn.textContent = 'Copied ✓';
|
|
837
|
+
btn.disabled = true;
|
|
838
|
+
setTimeout(() => { btn.textContent = label; btn.disabled = false; }, 1500);
|
|
839
|
+
});
|
|
791
840
|
}
|
|
792
841
|
|
|
793
842
|
// --- entry ----------------------------------------------------------
|
|
@@ -796,28 +845,65 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
796
845
|
chapters = data.chapters || [];
|
|
797
846
|
comments = loadComments();
|
|
798
847
|
initTheme();
|
|
799
|
-
|
|
848
|
+
renderHead();
|
|
849
|
+
renderVerdict(data.verdict, data.stats);
|
|
800
850
|
renderTOC();
|
|
801
851
|
}
|
|
802
852
|
|
|
853
|
+
// --- header (title, summary, provenance) ----------------------------
|
|
854
|
+
|
|
855
|
+
function renderHead() {
|
|
856
|
+
if (data.title) $('saga-title').textContent = data.title;
|
|
857
|
+
const sum = $('saga-summary');
|
|
858
|
+
if (sum && data.summary) { sum.textContent = data.summary; sum.hidden = false; }
|
|
859
|
+
const meta = $('saga-meta');
|
|
860
|
+
if (meta) {
|
|
861
|
+
const parts = [];
|
|
862
|
+
if (data.commit_sha) parts.push(data.commit_sha.slice(0, 7));
|
|
863
|
+
const rel = relativeTime(data.generated_at);
|
|
864
|
+
if (rel) parts.push('generated ' + rel);
|
|
865
|
+
meta.textContent = parts.join(' · ');
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function relativeTime(iso) {
|
|
870
|
+
if (!iso) return '';
|
|
871
|
+
const then = new Date(iso).getTime();
|
|
872
|
+
if (isNaN(then)) return '';
|
|
873
|
+
const secs = Math.round((Date.now() - then) / 1000);
|
|
874
|
+
if (secs < 60) return 'just now';
|
|
875
|
+
const units = [['year', 31536000], ['month', 2592000], ['week', 604800],
|
|
876
|
+
['day', 86400], ['hour', 3600], ['minute', 60]];
|
|
877
|
+
for (const [name, size] of units) {
|
|
878
|
+
const n = Math.floor(secs / size);
|
|
879
|
+
if (n >= 1) return n + ' ' + name + (n === 1 ? '' : 's') + ' ago';
|
|
880
|
+
}
|
|
881
|
+
return 'just now';
|
|
882
|
+
}
|
|
883
|
+
|
|
803
884
|
// --- verdict line --------------------------------------------------
|
|
804
885
|
|
|
805
|
-
function renderVerdict(v) {
|
|
886
|
+
function renderVerdict(v, stats) {
|
|
806
887
|
if (!v) return;
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
888
|
+
// Scope segments read neutral; attention flags echo the amber rail so a
|
|
889
|
+
// reviewer can spot what needs a look. All text is generated (no user
|
|
890
|
+
// content), so building innerHTML here is injection-safe.
|
|
891
|
+
const seg = [{ t: v.chapters + (v.chapters === 1 ? ' chapter' : ' chapters') }];
|
|
892
|
+
if (stats && stats.files) seg.push({ t: stats.files + (stats.files === 1 ? ' file' : ' files') });
|
|
893
|
+
if (stats && (stats.added || stats.removed)) {
|
|
894
|
+
seg.push({ html: '<span class="saga-add">+' + (stats.added || 0) + '</span> ' +
|
|
895
|
+
'<span class="saga-del">−' + (stats.removed || 0) + '</span>' });
|
|
896
|
+
}
|
|
897
|
+
if (v.deviations > 0) seg.push({ t: v.deviations + (v.deviations === 1 ? ' differs from plan' : ' differ from plan'), flag: true });
|
|
898
|
+
if (v.low_confidence > 0) seg.push({ t: v.low_confidence + (v.low_confidence === 1 ? ' needs a closer look' : ' need a closer look'), flag: true });
|
|
899
|
+
$('saga-verdict').innerHTML = seg
|
|
900
|
+
.map((s) => (s.html ? s.html : s.flag ? '<span class="saga-flag">' + s.t + '</span>' : s.t))
|
|
901
|
+
.join(' · ');
|
|
902
|
+
// Two-tier status rail: amber when anything is flagged, else green.
|
|
816
903
|
const rail = $('saga-rail');
|
|
817
904
|
if (rail) {
|
|
818
|
-
rail.classList.remove('saga-rail-ok', 'saga-rail-attn'
|
|
819
|
-
if (v.deviations > 0) rail.classList.add('saga-rail-
|
|
820
|
-
else if (v.low_confidence > 0 || v.qa === 'attention') rail.classList.add('saga-rail-attn');
|
|
905
|
+
rail.classList.remove('saga-rail-ok', 'saga-rail-attn');
|
|
906
|
+
if (v.deviations > 0 || v.low_confidence > 0) rail.classList.add('saga-rail-attn');
|
|
821
907
|
else rail.classList.add('saga-rail-ok');
|
|
822
908
|
}
|
|
823
909
|
}
|
|
@@ -827,9 +913,9 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
827
913
|
function badges(ch, read) {
|
|
828
914
|
const out = [];
|
|
829
915
|
if (ch.plan_step) out.push('<span class="saga-badge saga-plan">' + esc(ch.plan_step) + '</span>');
|
|
830
|
-
if (ch.deviation) out.push('<span class="saga-badge saga-dev"
|
|
831
|
-
if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">
|
|
832
|
-
if (ch.qa
|
|
916
|
+
if (ch.deviation) out.push('<span class="saga-badge saga-dev">Differs from plan</span>');
|
|
917
|
+
if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">Needs a closer look</span>');
|
|
918
|
+
if (ch.qa) out.push('<span class="saga-badge saga-qa">⚠ QA</span>');
|
|
833
919
|
if (read) out.push('<span class="saga-badge saga-read">✓ Read</span>');
|
|
834
920
|
return out.join('');
|
|
835
921
|
}
|
|
@@ -845,7 +931,8 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
845
931
|
'<textarea id="saga-overall" class="saga-cmt-input saga-overall" placeholder="Overall review comment…"></textarea>' +
|
|
846
932
|
'<div class="saga-review-actions">' +
|
|
847
933
|
'<span class="saga-cmt-count" id="saga-cmt-count"></span>' +
|
|
848
|
-
'<button class="saga-btn" id="saga-
|
|
934
|
+
'<button class="saga-btn" id="saga-copy-agent" data-label="Copy for agent">Copy for agent</button>' +
|
|
935
|
+
'<button class="saga-btn" id="saga-copy-gh" data-label="Copy for GitHub">Copy for GitHub</button>' +
|
|
849
936
|
'</div>' +
|
|
850
937
|
'</div>' +
|
|
851
938
|
'<h2 class="saga-toc-title">Chapters</h2>';
|
|
@@ -867,8 +954,10 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
867
954
|
overall.value = comments.overall || '';
|
|
868
955
|
overall.addEventListener('input', () => { comments.overall = overall.value; persist(); });
|
|
869
956
|
}
|
|
870
|
-
const
|
|
871
|
-
if (
|
|
957
|
+
const agentBtn = $('saga-copy-agent');
|
|
958
|
+
if (agentBtn) agentBtn.addEventListener('click', () => copyCommand('read', agentBtn));
|
|
959
|
+
const ghBtn = $('saga-copy-gh');
|
|
960
|
+
if (ghBtn) ghBtn.addEventListener('click', () => copyCommand('push', ghBtn));
|
|
872
961
|
updateCount();
|
|
873
962
|
}
|
|
874
963
|
|
|
@@ -883,13 +972,13 @@ window.__sagaData = {"branch": "tui-gui", "base": "main", "verdict": {"chapters"
|
|
|
883
972
|
reader.hidden = false;
|
|
884
973
|
|
|
885
974
|
const devBanner = ch.deviation
|
|
886
|
-
? '<div class="saga-deviation"><strong
|
|
975
|
+
? '<div class="saga-deviation"><strong>Differs from the plan.</strong> ' + esc(ch.deviation) + ' Worth confirming this was intentional.</div>'
|
|
887
976
|
: '';
|
|
888
977
|
const lowBanner = ch.confidence === 'low'
|
|
889
|
-
? '<div class="saga-lowconf">
|
|
978
|
+
? '<div class="saga-lowconf"><strong>Needs a closer look.</strong> The walkthrough is unsure here — read the diff rather than just trusting the summary.</div>'
|
|
890
979
|
: '';
|
|
891
|
-
const qaLine = ch.qa
|
|
892
|
-
? '<div class="saga-qanote"
|
|
980
|
+
const qaLine = ch.qa
|
|
981
|
+
? '<div class="saga-qanote">⚠ Manual QA: ' + esc(ch.qa) + '</div>'
|
|
893
982
|
: '';
|
|
894
983
|
|
|
895
984
|
// Class-based (not id-based) so the same nav can render at top and bottom.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "saga-cli"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
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"
|
|
@@ -17,6 +17,12 @@ body { font-family: var(--font-sans); background: var(--bg); color: var(--text);
|
|
|
17
17
|
.d2h-file-diff { background: var(--bg); }
|
|
18
18
|
.d2h-code-line-ctn, .d2h-code-line { background: var(--bg) !important; color: var(--text) !important; }
|
|
19
19
|
.d2h-code-linenumber { background: var(--surface) !important; color: var(--text-faint) !important; border-color: var(--line) !important; }
|
|
20
|
+
/* Wrap long lines instead of overflowing the file wrapper (its overflow:hidden
|
|
21
|
+
was clipping them). diff2html defaults to a fixed-width nowrap/pre line; here
|
|
22
|
+
the code line fills its cell and wraps, hang-indented past the
|
|
23
|
+
absolutely-positioned line-number gutter (~7.5em wide). */
|
|
24
|
+
.d2h-code-line { display: block; width: auto; padding: 0 1em 0 8em; white-space: normal; }
|
|
25
|
+
.d2h-code-line-ctn { display: inline; width: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
20
26
|
.d2h-del { background: var(--diff-del-bg) !important; }
|
|
21
27
|
.d2h-ins { background: var(--diff-add-bg) !important; }
|
|
22
28
|
.d2h-del .d2h-code-line-ctn { background: var(--diff-del-bg) !important; }
|
|
@@ -237,6 +237,11 @@
|
|
|
237
237
|
.saga-file-cmt-panel[hidden] { display: none; }
|
|
238
238
|
.saga-file-cmt-panel .saga-cmt-input { flex: 1 1 240px; min-height: 44px; }
|
|
239
239
|
|
|
240
|
+
/* The clickable file path in a diff header inherits diff2html's file-name colour;
|
|
241
|
+
only the hover underline distinguishes it, keeping the header uncluttered. */
|
|
242
|
+
.saga-file-link { color: inherit; text-decoration: none; }
|
|
243
|
+
.saga-file-link:hover { text-decoration: underline; }
|
|
244
|
+
|
|
240
245
|
/* --- narrow screens: tighten the reader gutter (the 1000px max-width already
|
|
241
246
|
goes fluid below that width) and shrink the chapter title. --- */
|
|
242
247
|
@media (max-width: 640px) {
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// diff). This file renders it — a table of contents (the entry point) and a
|
|
6
6
|
// chapter reader — tracks per-chapter mark-as-read in localStorage, and lets a
|
|
7
7
|
// reviewer leave inline / per-file / overall comments (also drafted in
|
|
8
|
-
// localStorage) that
|
|
9
|
-
// server, no fetch.
|
|
8
|
+
// localStorage) that the Copy buttons hand off to the `saga comments` CLI as a
|
|
9
|
+
// base64 `--data` payload. No server, no fetch.
|
|
10
10
|
|
|
11
11
|
(function () {
|
|
12
12
|
const data = window.__sagaData || {chapters: [], verdict: null, branch: ''};
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
localStorage.setItem(readKey(), JSON.stringify([...s]));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// --- comments (localStorage draft,
|
|
76
|
+
// --- comments (localStorage draft, copied to the CLI as --data) ----
|
|
77
77
|
|
|
78
78
|
let comments = {files: {}, overall: ''};
|
|
79
79
|
|
|
@@ -242,12 +242,44 @@
|
|
|
242
242
|
btn.addEventListener('click', () => { panel.hidden = !panel.hidden; });
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// Build a link for a diff file path — a local editor/file URL or a GitHub blob
|
|
246
|
+
// URL, per data.file_links — or null when there is nothing to link to.
|
|
247
|
+
function fileURL(path) {
|
|
248
|
+
const fl = data.file_links;
|
|
249
|
+
if (!fl) return null;
|
|
250
|
+
// diff2html shows renames as "old → new"; link the new path.
|
|
251
|
+
const rel = path.split(' → ').pop().trim();
|
|
252
|
+
if (fl.type === 'github') {
|
|
253
|
+
return fl.base + '/' + rel.split('/').map(encodeURIComponent).join('/');
|
|
254
|
+
}
|
|
255
|
+
const abs = fl.root.replace(/\/+$/, '') + '/' + rel;
|
|
256
|
+
return (fl.scheme === 'file' ? 'file://' : fl.scheme + '://file') + encodeURI(abs);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Turn a diff2html file name into a link that opens the file (editor/GitHub).
|
|
260
|
+
function linkifyFileName(fw, path) {
|
|
261
|
+
const url = fileURL(path);
|
|
262
|
+
if (!url) return;
|
|
263
|
+
const nameEl = fw.querySelector('.d2h-file-name');
|
|
264
|
+
if (!nameEl || nameEl.querySelector('a.saga-file-link')) return;
|
|
265
|
+
const a = document.createElement('a');
|
|
266
|
+
a.className = 'saga-file-link';
|
|
267
|
+
a.href = url;
|
|
268
|
+
a.target = '_blank';
|
|
269
|
+
a.rel = 'noopener';
|
|
270
|
+
a.title = 'Open ' + path;
|
|
271
|
+
a.textContent = nameEl.textContent;
|
|
272
|
+
nameEl.textContent = '';
|
|
273
|
+
nameEl.appendChild(a);
|
|
274
|
+
}
|
|
275
|
+
|
|
245
276
|
// After a chapter's diff is drawn, make its lines and files commentable.
|
|
246
277
|
function wireComments(container) {
|
|
247
278
|
container.querySelectorAll('.d2h-file-wrapper').forEach((fw) => {
|
|
248
279
|
const nameEl = fw.querySelector('.d2h-file-name');
|
|
249
280
|
const path = nameEl ? nameEl.textContent.trim() : '';
|
|
250
281
|
if (!path) return;
|
|
282
|
+
linkifyFileName(fw, path);
|
|
251
283
|
wireFileComment(fw, path);
|
|
252
284
|
fw.querySelectorAll('tr').forEach((tr) => {
|
|
253
285
|
const lnCell = tr.querySelector('td.d2h-code-linenumber');
|
|
@@ -264,7 +296,8 @@
|
|
|
264
296
|
});
|
|
265
297
|
}
|
|
266
298
|
|
|
267
|
-
|
|
299
|
+
// Assemble the comments payload — the same shape the CLI validates.
|
|
300
|
+
function buildPayload() {
|
|
268
301
|
const files = {};
|
|
269
302
|
Object.keys(comments.files).forEach((p) => {
|
|
270
303
|
const e = comments.files[p];
|
|
@@ -279,22 +312,48 @@
|
|
|
279
312
|
if (inline.length) out.inline = inline;
|
|
280
313
|
files[p] = out;
|
|
281
314
|
});
|
|
282
|
-
|
|
315
|
+
return {
|
|
283
316
|
branch: data.branch || '',
|
|
284
317
|
base: data.base || '',
|
|
285
|
-
generated_at: new Date().toISOString(),
|
|
286
318
|
overall: (comments.overall || '').trim(),
|
|
287
319
|
files: files,
|
|
288
320
|
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// base64(UTF-8 JSON) — the payload rides inside the copied command as
|
|
324
|
+
// `--data`, so the reviewer never has to find a file on disk.
|
|
325
|
+
function encodePayload() {
|
|
326
|
+
const bytes = new TextEncoder().encode(JSON.stringify(buildPayload()));
|
|
327
|
+
let bin = '';
|
|
328
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
329
|
+
return btoa(bin);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function copyText(text) {
|
|
333
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
334
|
+
return navigator.clipboard.writeText(text);
|
|
335
|
+
}
|
|
336
|
+
// file:// fallback where the async clipboard API is unavailable.
|
|
337
|
+
const ta = document.createElement('textarea');
|
|
338
|
+
ta.value = text;
|
|
339
|
+
ta.style.position = 'fixed';
|
|
340
|
+
ta.style.opacity = '0';
|
|
341
|
+
document.body.appendChild(ta);
|
|
342
|
+
ta.select();
|
|
343
|
+
try { document.execCommand('copy'); } finally { ta.remove(); }
|
|
344
|
+
return Promise.resolve();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Copy a ready-to-run `saga comments <sub> --data …` command. The reviewer
|
|
348
|
+
// pastes it into their terminal (push) or coding agent (read).
|
|
349
|
+
function copyCommand(sub, btn) {
|
|
350
|
+
const cmd = 'saga comments ' + sub + ' --data ' + encodePayload();
|
|
351
|
+
copyText(cmd).then(() => {
|
|
352
|
+
const label = btn.dataset.label;
|
|
353
|
+
btn.textContent = 'Copied ✓';
|
|
354
|
+
btn.disabled = true;
|
|
355
|
+
setTimeout(() => { btn.textContent = label; btn.disabled = false; }, 1500);
|
|
356
|
+
});
|
|
298
357
|
}
|
|
299
358
|
|
|
300
359
|
// --- entry ----------------------------------------------------------
|
|
@@ -389,7 +448,8 @@
|
|
|
389
448
|
'<textarea id="saga-overall" class="saga-cmt-input saga-overall" placeholder="Overall review comment…"></textarea>' +
|
|
390
449
|
'<div class="saga-review-actions">' +
|
|
391
450
|
'<span class="saga-cmt-count" id="saga-cmt-count"></span>' +
|
|
392
|
-
'<button class="saga-btn" id="saga-
|
|
451
|
+
'<button class="saga-btn" id="saga-copy-agent" data-label="Copy for agent">Copy for agent</button>' +
|
|
452
|
+
'<button class="saga-btn" id="saga-copy-gh" data-label="Copy for GitHub">Copy for GitHub</button>' +
|
|
393
453
|
'</div>' +
|
|
394
454
|
'</div>' +
|
|
395
455
|
'<h2 class="saga-toc-title">Chapters</h2>';
|
|
@@ -411,8 +471,10 @@
|
|
|
411
471
|
overall.value = comments.overall || '';
|
|
412
472
|
overall.addEventListener('input', () => { comments.overall = overall.value; persist(); });
|
|
413
473
|
}
|
|
414
|
-
const
|
|
415
|
-
if (
|
|
474
|
+
const agentBtn = $('saga-copy-agent');
|
|
475
|
+
if (agentBtn) agentBtn.addEventListener('click', () => copyCommand('read', agentBtn));
|
|
476
|
+
const ghBtn = $('saga-copy-gh');
|
|
477
|
+
if (ghBtn) ghBtn.addEventListener('click', () => copyCommand('push', ghBtn));
|
|
416
478
|
updateCount();
|
|
417
479
|
}
|
|
418
480
|
|
|
@@ -21,6 +21,7 @@ Pass --intent PATH to give the model a plan/spec for richer, plan-aware narratio
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
import os
|
|
24
25
|
import webbrowser
|
|
25
26
|
from importlib.metadata import version as package_version
|
|
26
27
|
from pathlib import Path
|
|
@@ -41,6 +42,29 @@ from .generate import generate
|
|
|
41
42
|
from .model import SagaError
|
|
42
43
|
from .render import render
|
|
43
44
|
|
|
45
|
+
# GUI editors that register a URL protocol, so a browser can open a file in
|
|
46
|
+
# them via <scheme>://file/<abs-path>. Keyed by the binary $EDITOR/$VISUAL names.
|
|
47
|
+
_EDITOR_SCHEMES = {
|
|
48
|
+
"code": "vscode",
|
|
49
|
+
"code-insiders": "vscode-insiders",
|
|
50
|
+
"codium": "vscodium",
|
|
51
|
+
"cursor": "cursor",
|
|
52
|
+
"windsurf": "windsurf",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _editor_scheme() -> str:
|
|
57
|
+
"""URL scheme for the diff's file-path links, inferred from ``$VISUAL``/``$EDITOR``.
|
|
58
|
+
|
|
59
|
+
Only editors that register a URL protocol can be opened from a browser, so an
|
|
60
|
+
unrecognised or terminal editor (vim, nano, …) falls back to ``file`` — the OS
|
|
61
|
+
default handler. ``$EDITOR`` may carry a path and flags (``/usr/bin/code -w``),
|
|
62
|
+
so we match on the bare binary name.
|
|
63
|
+
"""
|
|
64
|
+
raw = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
|
|
65
|
+
binary = Path(raw.split()[0]).name if raw.strip() else ""
|
|
66
|
+
return _EDITOR_SCHEMES.get(binary, "file")
|
|
67
|
+
|
|
44
68
|
|
|
45
69
|
class SagaGroup(TyperGroup):
|
|
46
70
|
"""Let the top-level command take an optional positional target (a PR URL)
|
|
@@ -151,6 +175,12 @@ def main(
|
|
|
151
175
|
pr = pr_diff(target)
|
|
152
176
|
diff = pr.diff
|
|
153
177
|
resolved_base, resolved_head, commit_sha = pr.base, pr.head, pr.head_sha
|
|
178
|
+
# No local checkout, so link file paths to the file on GitHub at the
|
|
179
|
+
# PR's head commit (repo URL is the PR URL minus its /pull/<n> tail).
|
|
180
|
+
file_links = {
|
|
181
|
+
"type": "github",
|
|
182
|
+
"base": f"{pr.url.split('/pull/')[0]}/blob/{pr.head_sha}",
|
|
183
|
+
}
|
|
154
184
|
else:
|
|
155
185
|
# Local mode: diff two refs straight from git.
|
|
156
186
|
repo_root = repo_root_from(repo)
|
|
@@ -161,6 +191,12 @@ def main(
|
|
|
161
191
|
resolved_head = current_branch(repo_root) if head == "HEAD" else head
|
|
162
192
|
diff = compute_diff(repo_root, resolved_base, resolved_head)
|
|
163
193
|
commit_sha = rev_parse(repo_root, resolved_head)
|
|
194
|
+
# Link file paths to the local file, opened in the reader's editor.
|
|
195
|
+
file_links = {
|
|
196
|
+
"type": "local",
|
|
197
|
+
"root": str(repo_root),
|
|
198
|
+
"scheme": _editor_scheme(),
|
|
199
|
+
}
|
|
164
200
|
|
|
165
201
|
typer.echo(f"Generating saga for {resolved_base}...{resolved_head} …", err=True)
|
|
166
202
|
saga = generate(
|
|
@@ -171,7 +207,7 @@ def main(
|
|
|
171
207
|
model=model,
|
|
172
208
|
intent=intent_text,
|
|
173
209
|
)
|
|
174
|
-
html = render(saga, diff)
|
|
210
|
+
html = render(saga, diff, file_links=file_links)
|
|
175
211
|
except SagaError as e:
|
|
176
212
|
typer.echo(f"error: {e}", err=True)
|
|
177
213
|
raise typer.Exit(1) from e
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
"""The ``saga comments`` subcommands:
|
|
1
|
+
"""The ``saga comments`` subcommands: comment IO, GitHub push, agent read.
|
|
2
2
|
|
|
3
|
-
A reviewer authors comments in the browser (see ``assets/saga.js``); the page
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A reviewer authors comments in the browser (see ``assets/saga.js``); the page's
|
|
4
|
+
"Copy" buttons put a ready-to-run command on the clipboard, with the comments
|
|
5
|
+
base64-encoded inline as ``--data`` — so nothing has to be found on disk. This
|
|
6
|
+
module consumes those comments two ways:
|
|
6
7
|
|
|
7
8
|
* ``push`` bundles every comment into a single **pending** PR review via the
|
|
8
9
|
``gh`` CLI (``event`` omitted ⇒ PENDING) — the reviewer submits it on GitHub.
|
|
9
10
|
* ``read`` emits a normalized JSON view on stdout for a coding agent.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
``
|
|
13
|
-
|
|
12
|
+
Both accept the base64 ``--data`` payload or, as a scripting escape hatch, a
|
|
13
|
+
``--comments`` file. The GitHub subprocess calls mirror ``diff._git`` — stdlib
|
|
14
|
+
+ the ``gh`` CLI only. ``build_review_payload`` is a pure function so the
|
|
15
|
+
payload shape is unit-tested without touching the network.
|
|
14
16
|
"""
|
|
15
17
|
|
|
16
18
|
from __future__ import annotations
|
|
17
19
|
|
|
20
|
+
import base64
|
|
21
|
+
import binascii
|
|
18
22
|
import json
|
|
19
23
|
import subprocess
|
|
20
24
|
import sys
|
|
@@ -30,35 +34,29 @@ _FILE_NOTE_PREFIX = "**File-level note:** "
|
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
# ---------------------------------------------------------------------------
|
|
33
|
-
#
|
|
37
|
+
# Comment IO
|
|
34
38
|
# ---------------------------------------------------------------------------
|
|
35
39
|
|
|
36
40
|
|
|
37
|
-
def
|
|
38
|
-
"""
|
|
41
|
+
def _validate(data: object) -> dict:
|
|
42
|
+
"""Validate a decoded comments object, whatever source it came from.
|
|
39
43
|
|
|
40
44
|
Raises ``SagaError`` with a reviewer-facing message on any malformed shape —
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
a non-object, a bad ``files`` map, or an inline comment lacking
|
|
46
|
+
``line``/``body``.
|
|
43
47
|
"""
|
|
44
|
-
try:
|
|
45
|
-
raw = Path(path).read_text()
|
|
46
|
-
except OSError as e:
|
|
47
|
-
raise SagaError(f"could not read comments file {path}: {e}") from e
|
|
48
|
-
try:
|
|
49
|
-
data = json.loads(raw)
|
|
50
|
-
except json.JSONDecodeError as e:
|
|
51
|
-
raise SagaError(f"comments file {path} is not valid JSON: {e}") from e
|
|
52
|
-
|
|
53
48
|
if not isinstance(data, dict):
|
|
54
|
-
raise SagaError("comments
|
|
49
|
+
raise SagaError("comments must be a JSON object.")
|
|
55
50
|
files = data.get("files", {})
|
|
56
51
|
if not isinstance(files, dict):
|
|
57
52
|
raise SagaError("'files' must be a JSON object keyed by file path.")
|
|
58
53
|
for fpath, entry in files.items():
|
|
59
54
|
if not isinstance(entry, dict):
|
|
60
55
|
raise SagaError(f"file entry for {fpath} must be an object.")
|
|
61
|
-
|
|
56
|
+
inline = entry.get("inline", [])
|
|
57
|
+
if not isinstance(inline, list):
|
|
58
|
+
raise SagaError(f"'inline' for {fpath} must be a list.")
|
|
59
|
+
for c in inline:
|
|
62
60
|
if not isinstance(c, dict) or "line" not in c or "body" not in c:
|
|
63
61
|
raise SagaError(
|
|
64
62
|
f"inline comment on {fpath} needs a 'line' and a 'body'."
|
|
@@ -66,6 +64,48 @@ def load_sidecar(path: Path) -> dict:
|
|
|
66
64
|
return data
|
|
67
65
|
|
|
68
66
|
|
|
67
|
+
def load_sidecar(path: Path) -> dict:
|
|
68
|
+
"""Read and validate a ``saga.comments.json`` file (the scripting fallback).
|
|
69
|
+
|
|
70
|
+
Callers handle a *missing* file themselves (it means "no comments yet"); an
|
|
71
|
+
unreadable or malformed file is a real error.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
raw = Path(path).read_text()
|
|
75
|
+
except OSError as e:
|
|
76
|
+
raise SagaError(f"could not read comments file {path}: {e}") from e
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(raw)
|
|
79
|
+
except json.JSONDecodeError as e:
|
|
80
|
+
raise SagaError(f"comments file {path} is not valid JSON: {e}") from e
|
|
81
|
+
return _validate(data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decode_payload(encoded: str) -> dict:
|
|
85
|
+
"""Decode+validate the base64 comments payload from the page's Copy button."""
|
|
86
|
+
try:
|
|
87
|
+
raw = base64.b64decode(encoded, validate=True).decode("utf-8")
|
|
88
|
+
except (binascii.Error, ValueError, UnicodeDecodeError) as e:
|
|
89
|
+
raise SagaError(f"comments payload is not valid base64: {e}") from e
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(raw)
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
raise SagaError(f"comments payload is not valid JSON: {e}") from e
|
|
94
|
+
return _validate(data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve(data: str | None, sidecar_path: Path) -> dict:
|
|
98
|
+
"""Resolve comments from the ``--data`` payload or a ``--comments`` file.
|
|
99
|
+
|
|
100
|
+
``--data`` is the primary path and wins when present. A *missing* file means
|
|
101
|
+
"no comments yet" (empty dict); a bad payload or malformed file is an error.
|
|
102
|
+
"""
|
|
103
|
+
if data is not None:
|
|
104
|
+
return decode_payload(data)
|
|
105
|
+
path = Path(sidecar_path)
|
|
106
|
+
return load_sidecar(path) if path.exists() else {}
|
|
107
|
+
|
|
108
|
+
|
|
69
109
|
# ---------------------------------------------------------------------------
|
|
70
110
|
# GitHub review payload (pure)
|
|
71
111
|
# ---------------------------------------------------------------------------
|
|
@@ -167,10 +207,11 @@ def _pr_info(repo_root: Path, branch: str) -> tuple[int, str]:
|
|
|
167
207
|
# ---------------------------------------------------------------------------
|
|
168
208
|
|
|
169
209
|
|
|
170
|
-
def push(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
210
|
+
def push(
|
|
211
|
+
sidecar_path: Path, repo_root: Path, *, data: str | None = None, web: bool = False
|
|
212
|
+
) -> int:
|
|
213
|
+
"""Post the reviewer's comments as a single pending review on the PR."""
|
|
214
|
+
sidecar = _resolve(data, sidecar_path)
|
|
174
215
|
|
|
175
216
|
payload = build_review_payload(sidecar)
|
|
176
217
|
if not payload:
|
|
@@ -207,15 +248,14 @@ def push(sidecar_path: Path, repo_root: Path, *, web: bool = False) -> int:
|
|
|
207
248
|
return 0
|
|
208
249
|
|
|
209
250
|
|
|
210
|
-
def read(sidecar_path: Path) -> int:
|
|
211
|
-
"""Print the
|
|
251
|
+
def read(sidecar_path: Path, *, data: str | None = None) -> int:
|
|
252
|
+
"""Print the reviewer's comments as normalized JSON on stdout.
|
|
212
253
|
|
|
213
|
-
This command feeds a coding agent, so
|
|
214
|
-
|
|
215
|
-
|
|
254
|
+
This command feeds a coding agent, so *no comments* is not an error —
|
|
255
|
+
an absent ``--data`` and a missing file both report a valid, empty JSON
|
|
256
|
+
document. A payload or file that exists but is malformed still errors.
|
|
216
257
|
"""
|
|
217
|
-
|
|
218
|
-
sidecar = load_sidecar(path) if path.exists() else {}
|
|
258
|
+
sidecar = _resolve(data, sidecar_path)
|
|
219
259
|
print(json.dumps(_normalize_for_agent(sidecar), indent=2, ensure_ascii=False))
|
|
220
260
|
return 0
|
|
221
261
|
|
|
@@ -226,8 +266,12 @@ comments_app = typer.Typer(
|
|
|
226
266
|
)
|
|
227
267
|
|
|
228
268
|
|
|
269
|
+
_DATA_HELP = "base64 comments payload from the saga page's Copy button"
|
|
270
|
+
|
|
271
|
+
|
|
229
272
|
@comments_app.command("push")
|
|
230
273
|
def push_cmd(
|
|
274
|
+
data: str = typer.Option(None, "--data", help=_DATA_HELP),
|
|
231
275
|
comments: Path = typer.Option(_DEFAULT_SIDECAR, "--comments"),
|
|
232
276
|
repo: Path = typer.Option(Path.cwd(), "--repo"),
|
|
233
277
|
web: bool = typer.Option(
|
|
@@ -240,7 +284,7 @@ def push_cmd(
|
|
|
240
284
|
typer.echo(f"error: {repo} is not inside a git repository.", err=True)
|
|
241
285
|
raise typer.Exit(1)
|
|
242
286
|
try:
|
|
243
|
-
push(comments, repo_root, web=web)
|
|
287
|
+
push(comments, repo_root, data=data, web=web)
|
|
244
288
|
except SagaError as e:
|
|
245
289
|
typer.echo(f"error: {e}", err=True)
|
|
246
290
|
raise typer.Exit(1) from e
|
|
@@ -248,11 +292,12 @@ def push_cmd(
|
|
|
248
292
|
|
|
249
293
|
@comments_app.command("read")
|
|
250
294
|
def read_cmd(
|
|
295
|
+
data: str = typer.Option(None, "--data", help=_DATA_HELP),
|
|
251
296
|
comments: Path = typer.Option(_DEFAULT_SIDECAR, "--comments"),
|
|
252
297
|
) -> None:
|
|
253
298
|
"""Print comments as JSON on stdout (for a coding agent)."""
|
|
254
299
|
try:
|
|
255
|
-
read(comments)
|
|
300
|
+
read(comments, data=data)
|
|
256
301
|
except SagaError as e:
|
|
257
302
|
typer.echo(f"error: {e}", err=True)
|
|
258
303
|
raise typer.Exit(1) from e
|
|
@@ -70,12 +70,14 @@ def _diffstat(diff_text: str) -> dict:
|
|
|
70
70
|
return {"files": files, "added": added, "removed": removed}
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
def build_payload(saga: Saga, diff: DiffResult) -> dict:
|
|
73
|
+
def build_payload(saga: Saga, diff: DiffResult, file_links: dict | None = None) -> dict:
|
|
74
74
|
"""Attach each chapter's reconstructed diff to the saga for the client.
|
|
75
75
|
|
|
76
76
|
The hunk map is built from the same *diff* generation used, so every stored
|
|
77
77
|
hunk id resolves to its diff text — whether the diff came from local git or
|
|
78
|
-
a fetched PR.
|
|
78
|
+
a fetched PR. *file_links* tells the client how to turn each diff file path
|
|
79
|
+
into a link (a local editor/file URL, or a GitHub blob URL); ``None`` leaves
|
|
80
|
+
the paths as plain text.
|
|
79
81
|
"""
|
|
80
82
|
hmap = {h.id: h for h in parse_hunks(diff.diff_text)}
|
|
81
83
|
chapters = []
|
|
@@ -92,6 +94,7 @@ def build_payload(saga: Saga, diff: DiffResult) -> dict:
|
|
|
92
94
|
"generated_at": saga.generated_at,
|
|
93
95
|
"verdict": saga.verdict(),
|
|
94
96
|
"stats": _diffstat(diff.diff_text),
|
|
97
|
+
"file_links": file_links,
|
|
95
98
|
"chapters": chapters,
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -105,9 +108,9 @@ def _json_for_script(payload: dict) -> str:
|
|
|
105
108
|
return json.dumps(payload, ensure_ascii=False).replace("<", "\\u003c")
|
|
106
109
|
|
|
107
110
|
|
|
108
|
-
def render(saga: Saga, diff: DiffResult) -> str:
|
|
111
|
+
def render(saga: Saga, diff: DiffResult, file_links: dict | None = None) -> str:
|
|
109
112
|
"""Build the complete self-contained HTML document for *saga*."""
|
|
110
|
-
payload = build_payload(saga, diff)
|
|
113
|
+
payload = build_payload(saga, diff, file_links)
|
|
111
114
|
title = f"{html.escape(saga.title) or 'Saga'} · {html.escape(saga.branch)}"
|
|
112
115
|
styles = "\n".join(
|
|
113
116
|
[
|
|
@@ -36,8 +36,8 @@ def stub_pipeline(monkeypatch):
|
|
|
36
36
|
chapters=[Chapter(id="c1", title="t", summary="s", narration="n")],
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
-
def fake_render(saga, diff):
|
|
40
|
-
calls["render"] = dict(saga=saga, diff=diff)
|
|
39
|
+
def fake_render(saga, diff, file_links=None):
|
|
40
|
+
calls["render"] = dict(saga=saga, diff=diff, file_links=file_links)
|
|
41
41
|
return "<html>saga</html>"
|
|
42
42
|
|
|
43
43
|
monkeypatch.setattr(cli, "generate", fake_generate)
|
|
@@ -70,6 +70,33 @@ def test_main_happy_path_writes_output(git_repo: Path, tmp_path: Path, stub_pipe
|
|
|
70
70
|
assert stub_pipeline["generate"]["intent"] is None
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_main_local_file_links_use_editor_scheme(
|
|
74
|
+
git_repo: Path, tmp_path, stub_pipeline, monkeypatch
|
|
75
|
+
):
|
|
76
|
+
"""A local saga links each path to the file, opened in the reader's editor."""
|
|
77
|
+
monkeypatch.setenv("EDITOR", "/usr/local/bin/code --wait")
|
|
78
|
+
monkeypatch.delenv("VISUAL", raising=False)
|
|
79
|
+
result = runner.invoke(
|
|
80
|
+
app, ["--repo", str(git_repo), "-o", str(tmp_path / "o.html"), "--no-open"]
|
|
81
|
+
)
|
|
82
|
+
assert result.exit_code == 0
|
|
83
|
+
fl = stub_pipeline["render"]["file_links"]
|
|
84
|
+
assert fl == {"type": "local", "root": str(git_repo), "scheme": "vscode"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_main_unknown_editor_falls_back_to_file_scheme(
|
|
88
|
+
git_repo: Path, tmp_path, stub_pipeline, monkeypatch
|
|
89
|
+
):
|
|
90
|
+
"""A terminal/unrecognised $EDITOR can't be opened from a browser, so file://."""
|
|
91
|
+
monkeypatch.setenv("EDITOR", "vim")
|
|
92
|
+
monkeypatch.delenv("VISUAL", raising=False)
|
|
93
|
+
result = runner.invoke(
|
|
94
|
+
app, ["--repo", str(git_repo), "-o", str(tmp_path / "o.html"), "--no-open"]
|
|
95
|
+
)
|
|
96
|
+
assert result.exit_code == 0
|
|
97
|
+
assert stub_pipeline["render"]["file_links"]["scheme"] == "file"
|
|
98
|
+
|
|
99
|
+
|
|
73
100
|
def test_main_resolves_head_from_current_branch(
|
|
74
101
|
git_repo: Path, tmp_path, stub_pipeline
|
|
75
102
|
):
|
|
@@ -209,6 +236,11 @@ def test_main_pr_url_uses_pr_metadata(tmp_path, stub_pipeline, monkeypatch):
|
|
|
209
236
|
assert g["commit_sha"] == "f" * 40
|
|
210
237
|
assert g["model"] == "openai/gpt-4o"
|
|
211
238
|
assert out.read_text() == "<html>saga</html>"
|
|
239
|
+
# No local checkout, so file paths link to the file on GitHub at the head sha.
|
|
240
|
+
assert stub_pipeline["render"]["file_links"] == {
|
|
241
|
+
"type": "github",
|
|
242
|
+
"base": "https://github.com/o/r/blob/" + "f" * 40,
|
|
243
|
+
}
|
|
212
244
|
|
|
213
245
|
|
|
214
246
|
def test_main_pr_error_maps_to_exit_1(tmp_path, monkeypatch):
|
|
@@ -2,13 +2,25 @@
|
|
|
2
2
|
GitHub review payload builder. The gh/subprocess paths are side-effectful and
|
|
3
3
|
are not unit tested (matching the repo's test-the-pure-core convention)."""
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import json
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
9
|
-
from saga.comments import
|
|
10
|
+
from saga.comments import (
|
|
11
|
+
build_review_payload,
|
|
12
|
+
decode_payload,
|
|
13
|
+
load_sidecar,
|
|
14
|
+
push,
|
|
15
|
+
read,
|
|
16
|
+
)
|
|
10
17
|
from saga.model import SagaError
|
|
11
18
|
|
|
19
|
+
|
|
20
|
+
def _encode(obj: dict) -> str:
|
|
21
|
+
return base64.b64encode(json.dumps(obj).encode()).decode()
|
|
22
|
+
|
|
23
|
+
|
|
12
24
|
SIDECAR = {
|
|
13
25
|
"branch": "feature",
|
|
14
26
|
"base": "main",
|
|
@@ -143,3 +155,43 @@ def test_load_sidecar_rejects_inline_missing_line(tmp_path):
|
|
|
143
155
|
p.write_text(json.dumps({"files": {"a.py": {"inline": [{"body": "x"}]}}}))
|
|
144
156
|
with pytest.raises(SagaError, match="needs a 'line' and a 'body'"):
|
|
145
157
|
load_sidecar(p)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- decode_payload (the --data path) -------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_decode_payload_round_trips_the_sidecar():
|
|
164
|
+
assert decode_payload(_encode(SIDECAR))["branch"] == "feature"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_decode_payload_validates_shape():
|
|
168
|
+
with pytest.raises(SagaError, match="needs a 'line' and a 'body'"):
|
|
169
|
+
decode_payload(_encode({"files": {"a.py": {"inline": [{"body": "x"}]}}}))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_decode_payload_rejects_non_base64():
|
|
173
|
+
with pytest.raises(SagaError, match="not valid base64"):
|
|
174
|
+
decode_payload("not base64!!")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_decode_payload_rejects_base64_of_non_json():
|
|
178
|
+
with pytest.raises(SagaError, match="not valid JSON"):
|
|
179
|
+
decode_payload(base64.b64encode(b"not json").decode())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_read_from_data_ignores_missing_file(tmp_path, capsys):
|
|
183
|
+
# --data wins and no file is touched.
|
|
184
|
+
rc = read(tmp_path / "does-not-exist.json", data=_encode(SIDECAR))
|
|
185
|
+
assert rc == 0
|
|
186
|
+
out = json.loads(capsys.readouterr().out)
|
|
187
|
+
assert out["overall"] == "Overall this is solid."
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_push_empty_data_reports_no_comments(tmp_path, capsys):
|
|
191
|
+
rc = push(
|
|
192
|
+
tmp_path / "unused.json",
|
|
193
|
+
tmp_path,
|
|
194
|
+
data=_encode({"branch": "x", "base": "main", "overall": "", "files": {}}),
|
|
195
|
+
)
|
|
196
|
+
assert rc == 0
|
|
197
|
+
assert "No comments to push" in capsys.readouterr().err
|
|
@@ -78,6 +78,18 @@ def test_render_uses_saga_title_in_document_and_head(git_repo: Path, stub_vendor
|
|
|
78
78
|
assert "<title>Add goal mailbox entry point · feature</title>" in html
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def test_build_payload_carries_file_links(git_repo: Path):
|
|
82
|
+
"""file_links travels to the client verbatim (defaulting to None when absent)."""
|
|
83
|
+
assert (
|
|
84
|
+
render_mod.build_payload(_saga_for(), _feature_diff(git_repo))["file_links"]
|
|
85
|
+
is None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
fl = {"type": "local", "root": "/repo", "scheme": "vscode"}
|
|
89
|
+
payload = render_mod.build_payload(_saga_for(), _feature_diff(git_repo), fl)
|
|
90
|
+
assert payload["file_links"] == fl
|
|
91
|
+
|
|
92
|
+
|
|
81
93
|
def test_build_payload_skips_unknown_hunk_ids(git_repo: Path):
|
|
82
94
|
"""A chapter referencing a hunk id absent from the live diff must not crash;
|
|
83
95
|
the unknown id is simply dropped from the reconstructed diff."""
|
|
@@ -109,7 +121,7 @@ def test_render_escapes_angle_brackets_in_payload(
|
|
|
109
121
|
"""A diff containing ``</script>`` must be escaped so it cannot break out of
|
|
110
122
|
the inlined ``<script>`` tag."""
|
|
111
123
|
|
|
112
|
-
def fake_payload(saga, diff):
|
|
124
|
+
def fake_payload(saga, diff, file_links=None):
|
|
113
125
|
return {
|
|
114
126
|
"branch": "b",
|
|
115
127
|
"base": "m",
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: saga-comments
|
|
3
|
-
description: Read a reviewer's saga comments (inline, per-file, and overall) and act on them in code. Use when the user asks you to address or apply saga review comments, says "read the saga comments" or "/saga-comments", or points at a saga.comments.json file.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Saga comments
|
|
7
|
-
|
|
8
|
-
A reviewer leaves comments on a saga (the HTML guided tour of a diff) in the browser and
|
|
9
|
-
exports them as a `saga.comments.json` sidecar. This skill reads those comments so you can
|
|
10
|
-
act on them. The CLI does the reading — your job is to run it, parse the JSON, and address
|
|
11
|
-
each comment in code.
|
|
12
|
-
|
|
13
|
-
## Steps
|
|
14
|
-
|
|
15
|
-
1. **Check the command is installed.** Run `saga --help`. If it is missing, tell the user
|
|
16
|
-
to install it once (see the tool's README — e.g. `uv tool install saga-cli`), then
|
|
17
|
-
continue.
|
|
18
|
-
|
|
19
|
-
2. **Locate the sidecar.** It defaults to `saga.comments.json` in the current directory. If
|
|
20
|
-
it lives elsewhere, pass `--comments <path>`. When the user names a saga HTML file, the
|
|
21
|
-
sidecar sits next to it (the reviewer's "Export comments" button downloads it).
|
|
22
|
-
|
|
23
|
-
3. **Read the comments** from inside the target repo:
|
|
24
|
-
|
|
25
|
-
```sh
|
|
26
|
-
saga comments read --comments saga.comments.json
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
The output is JSON:
|
|
30
|
-
|
|
31
|
-
```json
|
|
32
|
-
{
|
|
33
|
-
"branch": "my-feature",
|
|
34
|
-
"base": "main",
|
|
35
|
-
"overall": "overall review comment, or null",
|
|
36
|
-
"files": {
|
|
37
|
-
"<path>": {
|
|
38
|
-
"file_comment": "per-file note, or null",
|
|
39
|
-
"inline": [ { "line": 88, "side": "RIGHT", "body": "the note" } ]
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
`side` is `RIGHT` (a line in the new/head version of the file) or `LEFT` (a line in the
|
|
46
|
-
old/base version). An empty `files` map with a null `overall` means no comments have been
|
|
47
|
-
authored yet — tell the user and stop.
|
|
48
|
-
|
|
49
|
-
4. **Act on each comment** as review feedback to resolve:
|
|
50
|
-
- `overall` — the high-level ask; keep it in mind across every change.
|
|
51
|
-
- `file_comment` — a note scoped to the whole file at `<path>`.
|
|
52
|
-
- each `inline` — open `<path>` at `line` (use `side` to know which version the line
|
|
53
|
-
refers to) and address the note there.
|
|
54
|
-
|
|
55
|
-
5. **Report** what you changed for each comment, and verify (run the tests / relevant
|
|
56
|
-
command) before handing back.
|
|
57
|
-
|
|
58
|
-
## Notes
|
|
59
|
-
|
|
60
|
-
- This consumes the local sidecar only — it does **not** fetch from GitHub. Posting the
|
|
61
|
-
comments to a PR is a separate, user-driven action (`saga comments push`).
|
|
62
|
-
- If the sidecar exists but is malformed, relay the error (written to stderr).
|
|
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
|