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.
Files changed (35) hide show
  1. saga_cli-0.3.1/.claude/settings.local.json +8 -0
  2. {saga_cli-0.3.0 → saga_cli-0.3.1}/PKG-INFO +11 -12
  3. {saga_cli-0.3.0 → saga_cli-0.3.1}/README.md +10 -11
  4. {saga_cli-0.3.0 → saga_cli-0.3.1}/docs/example.html +125 -36
  5. {saga_cli-0.3.0 → saga_cli-0.3.1}/pyproject.toml +1 -1
  6. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/base.css +6 -0
  7. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/saga.css +5 -0
  8. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/saga.js +80 -18
  9. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/cli.py +37 -1
  10. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/comments.py +81 -36
  11. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/render.py +7 -4
  12. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_cli.py +34 -2
  13. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_comments.py +53 -1
  14. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_render.py +13 -1
  15. {saga_cli-0.3.0 → saga_cli-0.3.1}/uv.lock +1 -1
  16. saga_cli-0.3.0/skills/read_saga_comments/SKILL.md +0 -62
  17. {saga_cli-0.3.0 → saga_cli-0.3.1}/.github/CODEOWNERS +0 -0
  18. {saga_cli-0.3.0 → saga_cli-0.3.1}/.github/workflows/ci.yml +0 -0
  19. {saga_cli-0.3.0 → saga_cli-0.3.1}/.gitignore +0 -0
  20. {saga_cli-0.3.0 → saga_cli-0.3.1}/CONTRIBUTING.md +0 -0
  21. {saga_cli-0.3.0 → saga_cli-0.3.1}/LICENSE +0 -0
  22. {saga_cli-0.3.0 → saga_cli-0.3.1}/RELEASING.md +0 -0
  23. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/__init__.py +0 -0
  24. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/__main__.py +0 -0
  25. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/assets/tokens.css +0 -0
  26. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/diff.py +0 -0
  27. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/generate.py +0 -0
  28. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/model.py +0 -0
  29. {saga_cli-0.3.0 → saga_cli-0.3.1}/saga/prompts/saga.md +0 -0
  30. {saga_cli-0.3.0 → saga_cli-0.3.1}/skills/generate_saga/SKILL.md +0 -0
  31. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/cassettes/generate_anthropic.yaml +0 -0
  32. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/conftest.py +0 -0
  33. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_diff.py +0 -0
  34. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_generate.py +0 -0
  35. {saga_cli-0.3.0 → saga_cli-0.3.1}/tests/test_model.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run *)",
5
+ "Bash(.venv/bin/python -m pytest tests/test_comments.py -q)"
6
+ ]
7
+ }
8
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: saga-cli
3
- Version: 0.3.0
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 two Claude Code skills. To install both:
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 **Export comments** to download a `saga.comments.json` sidecar next
199
- to the HTML. (Export is disabled in the [hosted example](https://jakeberesford.github.io/saga/example.html)
200
- so it never writes a file — every other part of the review UX works.) Two commands consume it:
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
- # Post everything as a single PENDING review on the PR (you submit it on GitHub).
204
- saga comments push --comments saga.comments.json
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
- # Emit the comments as JSON on stdout for a coding agent to act on.
207
- saga comments read --comments saga.comments.json
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
- `push` uses the `gh` CLI: it finds the open PR for the sidecar's branch and creates one
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 two Claude Code skills. To install both:
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 **Export comments** to download a `saga.comments.json` sidecar next
172
- to the HTML. (Export is disabled in the [hosted example](https://jakeberesford.github.io/saga/example.html)
173
- so it never writes a file — every other part of the review UX works.) Two commands consume it:
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
- # Post everything as a single PENDING review on the PR (you submit it on GitHub).
177
- saga comments push --comments saga.comments.json
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
- # Emit the comments as JSON on stdout for a coding agent to act on.
180
- saga comments read --comments saga.comments.json
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
- `push` uses the `gh` CLI: it finds the open PR for the sidecar's branch and creates one
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 Export downloads as a saga.comments.json sidecar. No
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, exported as a sidecar) ----------
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
- function exportComments() {
783
- // Hosted demo: export is disabled so the example never writes a
784
- // saga.comments.json sidecar. The button still confirms visually.
785
- const btn = $('saga-export');
786
- if (!btn) return;
787
- const label = btn.textContent;
788
- btn.textContent = 'Exported ';
789
- btn.disabled = true;
790
- setTimeout(() => { btn.textContent = label; btn.disabled = false; }, 1500);
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
- renderVerdict(data.verdict);
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
- const parts = [
808
- v.chapters + (v.chapters === 1 ? ' chapter' : ' chapters'),
809
- v.deviations + (v.deviations === 1 ? ' deviation' : ' deviations'),
810
- v.low_confidence + ' low-confidence',
811
- ];
812
- if (v.qa && v.qa !== 'n/a') parts.push('QA ' + v.qa);
813
- $('saga-verdict').textContent = parts.join(' · ');
814
- // Shift the top status rail to the loudest state present:
815
- // deviation (red) > attention (amber) > clear (green).
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', 'saga-rail-dev');
819
- if (v.deviations > 0) rail.classList.add('saga-rail-dev');
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">⚠ Deviation</span>');
831
- if (ch.confidence === 'low') out.push('<span class="saga-badge saga-low">Low confidence</span>');
832
- if (ch.qa && ch.qa.status === 'green') out.push('<span class="saga-badge saga-qa">✓ QA</span>');
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-export">Export comments</button>' +
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 exp = $('saga-export');
871
- if (exp) exp.addEventListener('click', exportComments);
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>⚠ Deviation from plan.</strong> ' + esc(ch.deviation) + '</div>'
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">Low confidencethis chapter needs close review.</div>'
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 && ch.qa.note
892
- ? '<div class="saga-qanote">' + (ch.qa.status === 'green' ? '✓ ' : '') + esc(ch.qa.note) + '</div>'
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.0"
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 Export downloads as a saga.comments.json sidecar. No
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, exported as a sidecar) ----------
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
- function exportComments() {
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
- const sidecar = {
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
- const blob = new Blob([JSON.stringify(sidecar, null, 2)], {type: 'application/json'});
290
- const url = URL.createObjectURL(blob);
291
- const a = document.createElement('a');
292
- a.href = url;
293
- a.download = 'saga.comments.json';
294
- document.body.appendChild(a);
295
- a.click();
296
- a.remove();
297
- URL.revokeObjectURL(url);
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-export">Export comments</button>' +
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 exp = $('saga-export');
415
- if (exp) exp.addEventListener('click', exportComments);
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: sidecar IO, GitHub push, agent read.
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
- exports them as a sidecar ``saga.comments.json`` next to the saga HTML. This
5
- module consumes that sidecar two ways:
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
- The GitHub subprocess calls mirror ``diff._git`` stdlib + the ``gh`` CLI only.
12
- ``build_review_payload`` is a pure function so the payload shape is unit-tested
13
- without touching the network.
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
- # Sidecar IO
37
+ # Comment IO
34
38
  # ---------------------------------------------------------------------------
35
39
 
36
40
 
37
- def load_sidecar(path: Path) -> dict:
38
- """Read and validate a ``saga.comments.json`` sidecar.
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
- unreadable file, bad JSON, or an inline comment lacking ``line``/``body``.
42
- Callers handle a *missing* sidecar themselves (it means "no comments yet").
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 file must be a JSON object.")
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
- for c in entry.get("inline", []):
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(sidecar_path: Path, repo_root: Path, *, web: bool = False) -> int:
171
- """Post the sidecar's comments as a single pending review on the PR."""
172
- path = Path(sidecar_path)
173
- sidecar = load_sidecar(path) if path.exists() else {}
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 sidecar's comments as normalized JSON on stdout.
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 a missing sidecar is not an error —
214
- "no comments authored yet" is reported as a valid, empty JSON document. A
215
- sidecar that exists but is malformed still errors (that is a real problem).
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
- path = Path(sidecar_path)
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 build_review_payload, load_sidecar, push, read
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",
@@ -1207,7 +1207,7 @@ wheels = [
1207
1207
 
1208
1208
  [[package]]
1209
1209
  name = "saga-cli"
1210
- version = "0.2.0"
1210
+ version = "0.3.1"
1211
1211
  source = { editable = "." }
1212
1212
  dependencies = [
1213
1213
  { name = "anthropic" },
@@ -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