@henryqw/pi-pr 2.1.2 → 3.0.1

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.
package/README.md CHANGED
@@ -8,9 +8,23 @@ See the current branch pull request in the Pi footer. Use `/pr` to run its next
8
8
  pi install npm:@henryqw/pi-pr
9
9
  ```
10
10
 
11
- Requires an authenticated GitHub CLI session (`gh auth login`) and a checkout on GitHub.com or GitHub Enterprise. The PR hostname selects its GitHub API host. It works in generic Pi sessions outside Herdr.
11
+ Requires an authenticated GitHub CLI session (`gh auth login`) and a checkout on GitHub.com or GitHub Enterprise. Run `gh auth status` to verify authentication.
12
12
 
13
- The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
13
+ ## Feedback snapshots
14
+
15
+ `pr-feedback.mjs fetch --out FILE` prints a compact feedback index. The index
16
+ includes IDs, kinds, states, authors, locations, and parent IDs as needed. It
17
+ does not print comment or review bodies.
18
+
19
+ The saved snapshot still contains the complete feedback. `fetch --json` also
20
+ keeps the complete JSON output. Read one item with
21
+ `pr-feedback.mjs show --snapshot FILE --id ID`.
22
+
23
+ `show` prints one JSON record with its exact stored body and fields. A thread
24
+ record includes child IDs without child bodies. A nested comment includes only
25
+ its parent thread's ID, state, and location. Missing, unknown, duplicate, or
26
+ ambiguous IDs fail. `show` does not run Git, call GitHub, use the network, or
27
+ write files.
14
28
 
15
29
  ## Works with
16
30
 
@@ -20,10 +34,11 @@ The comment sweep resolves its bundled helper and references from the installed
20
34
 
21
35
  ## Use
22
36
 
23
- Run `/pr` without arguments in a GitHub checkout. It reads the current branch pull request and local state, then runs one route.
37
+ Run `/pr` without arguments in a GitHub checkout. It reads the current branch pull request and local state, then runs one route. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
24
38
 
25
39
  | Surface | Type | Purpose |
26
40
  | --- | --- | --- |
41
+ | `/pr` | command | Run the current pull request's next safe route. |
27
42
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
28
43
  | Widget | ui | Show one actionable icon-prefixed `Run /pr to …` hint. |
29
44
 
@@ -35,7 +50,7 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
35
50
 
36
51
  ![Flowchart showing /pr reading fresh GitHub and local state, choosing the first matching condition, and stopping after one route](./docs/pr-routing.svg)
37
52
 
38
- ## Routes
53
+ ### Routes
39
54
 
40
55
  | Current condition | `/pr` route |
41
56
  | --- | --- |
@@ -62,7 +77,7 @@ Current-branch discovery matches the exact push repository and ref. It finds a f
62
77
 
63
78
  A no-action state includes a draft, merged or closed pull request, running CI, pending review, or blocked merge policy. It also includes a mutating workflow whose tree is dirty or whose local HEAD differs from the PR head.
64
79
 
65
- ## Route priority
80
+ ### Route priority
66
81
 
67
82
  A missing pull request uses creation. For an existing pull request, the first matching condition wins:
68
83
 
@@ -75,7 +90,9 @@ A missing pull request uses creation. For an existing pull request, the first ma
75
90
 
76
91
  Ordinary conversation comments do not trigger a route or block a merge. Changes requested and unresolved review threads can select the package comment sweep.
77
92
 
78
- ## Refresh
93
+ The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
94
+
95
+ ### Refresh
79
96
 
80
97
  The footer and widget load at session start. They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. They also poll every 30 seconds. Polling updates presentation only and may be stale.
81
98
 
@@ -83,7 +100,7 @@ The create widget stays hidden until the local branch has a commit beyond its cr
83
100
 
84
101
  Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
85
102
 
86
- ## Safety limits
103
+ ## Limits and recovery
87
104
 
88
105
  - `/pr` takes no arguments and does not open a browser.
89
106
  - It does not run `/done` or `/sweep`.
@@ -95,6 +95,7 @@ export function formatPrFooter(display: PrDisplay, theme: PrTheme): string | und
95
95
 
96
96
  export function formatPrWidget(display: PrDisplay, theme?: PrTheme, width?: number): string[] | undefined {
97
97
  if (display.widget === undefined) return undefined;
98
+ if (width !== undefined && width <= 0) return [];
98
99
  const color = display.footer?.color ?? "accent";
99
100
  const icon = color === "error" ? "✗" : color === "warning" ? "!" : color === "success" ? "✓" : "●";
100
101
  const line = `${theme ? theme.fg(color, icon) : icon} ${display.widget}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.1.2",
3
+ "version": "3.0.1",
4
4
  "description": "Run /pr to automatically choose the current pull request's next safe lifecycle step: create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,15 +28,23 @@ Run standalone; never invoke, defer to, or modify Shipyard.
28
28
  ```
29
29
 
30
30
  Target verification requires authenticated `gh`, an open PR, exact local/PR
31
- head, and one matching configured push target. Inspect base, full diff, and raw
32
- snapshot. Maintain recovery reference's one-line checkpoint after each
33
- phase.
34
- 2. Initial fetch prints all conversation comments, review bodies, unresolved
35
- current/outdated threads, replies, and status. Later fetches into a copied
36
- snapshot print additions, edits, and state changes only; raw JSON remains complete.
31
+ head, and one matching configured push target. Inspect the base and full
32
+ diff. Maintain the recovery reference's one-line checkpoint after each phase.
33
+
34
+ 2. Initial fetch prints a compact index of every feedback ID. Later fetches into
35
+ a copied snapshot print compact indexes of additions, edits, and state changes.
36
+ The snapshot remains complete. For each non-obvious item, inspect only that item:
37
+
38
+ ```bash
39
+ node "<skill>/scripts/pr-feedback.mjs" show --snapshot "$SNAPSHOT" --id "$ID"
40
+ ```
41
+
42
+ Always inspect every unresolved feedback item before triage. Never
43
+ read or print the whole raw snapshot when `show` provides the bounded lookup.
37
44
  Follow [Thread triage](<skill>/references/thread-triage.md>) and ledger every item as
38
- `actionable`, `non-actionable`, or `blocked`, with evidence, smallest fix,
39
- and regression. Never reconstruct GraphQL pagination.
45
+ `actionable`, `non-actionable`, or `blocked`, with terse evidence, smallest
46
+ fix, and regression. Never reconstruct GraphQL pagination.
47
+
40
48
  3. Verify feedback against code, callers, tests, and PR intent. For SDK/framework
41
49
  claims, inspect installed types and runtime caller; missing public capability
42
50
  is `blocked`, never a private-API or cast workaround. Invocation authorizes
@@ -57,6 +65,7 @@ Run standalone; never invoke, defer to, or modify Shipyard.
57
65
  ```bash
58
66
  EXPECTED_HEAD=$(git rev-parse --verify 'HEAD^{commit}')
59
67
  ```
68
+
60
69
  6. Copy baseline before final fetch:
61
70
 
62
71
  ```bash
@@ -65,15 +74,18 @@ Run standalone; never invoke, defer to, or modify Shipyard.
65
74
  node "<skill>/scripts/pr-feedback.mjs" fetch --pr "$PR" --out "$FINAL_SNAPSHOT"
66
75
  ```
67
76
 
68
- Assess delta, then resolve all addressed IDs in one command, repeating flag:
77
+ Assess the compact delta. Use `show --snapshot "$FINAL_SNAPSHOT" --id "$ID"`
78
+ for each non-obvious delta item and every unresolved human feedback item.
79
+ Then resolve all addressed IDs in one command, repeating the flag:
69
80
  `node "<skill>/scripts/pr-feedback.mjs" resolve --pr "$PR" --expected-head
70
- "$EXPECTED_HEAD" --thread "$ID1" --thread "$ID2"`. Before each resolution,
81
+ "$EXPECTED_HEAD" --thread "$ID1" --thread "$ID2"`. Before each resolution,
71
82
  the helper re-resolves the checkout's configured push target and exact open PR.
72
83
  Never resolve non-actionable or blocked threads. Re-fetch once into `FINAL_SNAPSHOT`, assess
73
84
  late delta, batch any newly addressed IDs, then run
74
85
  `node "<skill>/scripts/pr-feedback.mjs" checks --pr "$PR" --expected-head
75
- "$EXPECTED_HEAD"` once. Read-only
86
+ "$EXPECTED_HEAD"` once. Read-only
76
87
  transient retries are allowed; mutation retries and polling are not. Do not
77
88
  reply unless explicitly requested.
89
+
78
90
  7. Report `PR | actioned | resolved IDs | skipped IDs | pending IDs | checks |
79
- commits | push | blockers`.
91
+ commits | push | blockers`.
@@ -12,8 +12,9 @@ line under `## PR comment sweep`; never stage it:
12
12
  ```
13
13
 
14
14
  Use phases `triage`, `editing`, `validated`, `committed`, `pushed`, or `resolved`.
15
- Update after each phase; keep repository-relative owned paths, thread verdicts in
16
- `ledger`, exact validation results, commit SHA, push state, and resolved IDs.
15
+ Update after each phase. Keep ledger evidence and validation results terse. Edit
16
+ only fields changed by that phase; do not replace the whole JSON line. Keep
17
+ repository-relative owned paths, commit SHA, push state, and resolved IDs.
17
18
 
18
19
  Resume only when workflow and PR match, snapshot exists and names that PR, local
19
20
  `HEAD` equals `head`, and tracked dirty paths equal `owned`. Skip completed
@@ -630,31 +630,35 @@ function printPrStatus(metadata, log = console.log) {
630
630
  }
631
631
 
632
632
  function printComments(label, comments, log) {
633
- log(`${label}=comment\tauthor\tbody`);
633
+ log(`${label}=kind\tid\tauthor`);
634
634
  for (const comment of comments) {
635
635
  if (!isRecord(comment)) throw new FeedbackError("invalid conversation comment");
636
- log([requiredText(comment.id, "conversation comment ID"), login(comment.author), JSON.stringify(text(comment.body, "conversation comment body"))].join("\t"));
636
+ text(comment.body, "conversation comment body");
637
+ log(["conversation_comment", requiredText(comment.id, "conversation comment ID"), login(comment.author)].join("\t"));
637
638
  }
638
639
  }
639
640
 
640
641
  function printReviews(label, reviews, log) {
641
- log(`${label}=review\tstate\tauthor\tbody`);
642
+ log(`${label}=kind\tid\tstate\tauthor`);
642
643
  for (const review of reviews) {
643
644
  if (!isRecord(review)) throw new FeedbackError("invalid review");
644
- log([requiredText(review.id, "review ID"), requiredText(review.state, "review state"), login(review.author), JSON.stringify(text(review.body, "review body"))].join("\t"));
645
+ text(review.body, "review body");
646
+ log(["review", requiredText(review.id, "review ID"), requiredText(review.state, "review state"), login(review.author)].join("\t"));
645
647
  }
646
648
  }
647
649
 
648
- function printThreads(label, threads, log) {
649
- log(`${label}=thread\tstate\tlocation\tcomment\tauthor\tbody`);
650
+ function printThreads(label, threads, log, commentsOnly = false) {
651
+ log(`${label}=kind\tid\tstate\tauthor\tlocation\tparent`);
650
652
  for (const thread of threads) {
653
+ if (!isRecord(thread)) throw new FeedbackError("invalid review thread");
651
654
  const threadId = requiredText(thread.id, "review thread ID");
652
- const prefix = [threadId, threadState(thread), location(thread)];
653
- const comments = list(thread.comments, `replies for ${threadId}`);
654
- if (!comments.length) log([...prefix, "-", "-", "-"].join("\t"));
655
- for (const comment of comments) {
655
+ const state = threadState(thread);
656
+ const threadLocation = location(thread);
657
+ if (!commentsOnly) log(["thread", threadId, state, "-", threadLocation, "-"].join("\t"));
658
+ for (const comment of list(thread.comments, `replies for ${threadId}`)) {
656
659
  if (!isRecord(comment)) throw new FeedbackError(`invalid comment in ${threadId}`);
657
- log([...prefix, requiredText(comment.id, "comment ID"), login(comment.author), JSON.stringify(text(comment.body, "comment body"))].join("\t"));
660
+ text(comment.body, "comment body");
661
+ log(["thread_comment", requiredText(comment.id, "comment ID"), state, login(comment.author), threadLocation, threadId].join("\t"));
658
662
  }
659
663
  }
660
664
  }
@@ -664,6 +668,74 @@ function threadState(thread) {
664
668
  return bool(thread.isOutdated, "review thread isOutdated") ? "outdated" : "current";
665
669
  }
666
670
 
671
+ function feedbackEntries(data) {
672
+ const entries = [];
673
+ for (const node of list(data.conversationComments, "conversation comments")) {
674
+ if (!isRecord(node)) throw new FeedbackError("invalid conversation comment");
675
+ text(node.body, "conversation comment body");
676
+ login(node.author);
677
+ entries.push({ id: requiredText(node.id, "conversation comment ID"), kind: "conversation_comment", node });
678
+ }
679
+ for (const node of list(data.reviews, "reviews")) {
680
+ if (!isRecord(node)) throw new FeedbackError("invalid review");
681
+ requiredText(node.state, "review state");
682
+ text(node.body, "review body");
683
+ login(node.author);
684
+ entries.push({ id: requiredText(node.id, "review ID"), kind: "review", node });
685
+ }
686
+ for (const thread of list(data.reviewThreads, "review threads")) {
687
+ if (!isRecord(thread)) throw new FeedbackError("invalid review thread");
688
+ const threadId = requiredText(thread.id, "review thread ID");
689
+ threadState(thread);
690
+ location(thread);
691
+ entries.push({ id: threadId, kind: "thread", node: thread });
692
+ for (const node of list(thread.comments, `replies for ${threadId}`)) {
693
+ if (!isRecord(node)) throw new FeedbackError(`invalid comment in ${threadId}`);
694
+ text(node.body, "comment body");
695
+ login(node.author);
696
+ entries.push({ id: requiredText(node.id, "comment ID"), kind: "thread_comment", node, thread });
697
+ }
698
+ }
699
+
700
+ const byId = new Map();
701
+ for (const entry of entries) {
702
+ const matches = byId.get(entry.id) ?? [];
703
+ matches.push(entry);
704
+ byId.set(entry.id, matches);
705
+ }
706
+ for (const [id, matches] of byId) {
707
+ if (matches.length < 2) continue;
708
+ if (new Set(matches.map(({ kind }) => kind)).size > 1) throw new FeedbackError(`ambiguous feedback ID: ${id}`);
709
+ throw new FeedbackError(`duplicate feedback ID: ${id}`);
710
+ }
711
+ return byId;
712
+ }
713
+
714
+ function showFeedback(options, log = console.log) {
715
+ const data = snapshot(resolve(options.snapshot));
716
+ const matches = feedbackEntries(data).get(options.id);
717
+ if (!matches) throw new FeedbackError(`feedback ID not found: ${options.id}`);
718
+ const [{ kind, node, thread }] = matches;
719
+ let record;
720
+ if (kind === "thread") {
721
+ const { comments, ...metadata } = node;
722
+ record = { ...metadata, kind, childIds: nodeIds(comments, `replies for ${node.id}`) };
723
+ } else if (kind === "thread_comment") {
724
+ record = {
725
+ ...node,
726
+ kind,
727
+ parentThread: {
728
+ id: requiredText(thread.id, "review thread ID"),
729
+ state: threadState(thread),
730
+ location: location(thread),
731
+ },
732
+ };
733
+ } else {
734
+ record = { ...node, kind };
735
+ }
736
+ log(JSON.stringify(record));
737
+ }
738
+
667
739
  function printFeedbackDelta(data, previous, log) {
668
740
  const oldComments = new Map(previous.conversationComments.map((comment) => [requiredText(comment.id, "previous conversation comment ID"), comment]));
669
741
  const oldReviews = new Map(previous.reviews.map((review) => [requiredText(review.id, "previous review ID"), review]));
@@ -689,7 +761,7 @@ function printFeedbackDelta(data, previous, log) {
689
761
  if (updated.length) updatedReplies.push({ ...thread, comments: updated });
690
762
  const before = threadState(oldThread);
691
763
  const after = threadState(thread);
692
- if (before !== after) stateChanges.push([thread.id, before, after]);
764
+ if (before !== after) stateChanges.push([thread, before, after]);
693
765
  }
694
766
 
695
767
  if (newComments.length) printComments("new_conversation_comments", newComments, log);
@@ -697,17 +769,19 @@ function printFeedbackDelta(data, previous, log) {
697
769
  if (newReviews.length) printReviews("new_reviews", newReviews, log);
698
770
  if (updatedReviews.length) printReviews("updated_reviews", updatedReviews, log);
699
771
  if (newThreads.length) printThreads("new_threads", newThreads, log);
700
- if (newReplies.length) printThreads("new_replies", newReplies, log);
701
- if (updatedReplies.length) printThreads("updated_replies", updatedReplies, log);
772
+ if (newReplies.length) printThreads("new_replies", newReplies, log, true);
773
+ if (updatedReplies.length) printThreads("updated_replies", updatedReplies, log, true);
702
774
  if (stateChanges.length) {
703
- log("thread_state_changes=thread\tbefore\tafter");
704
- for (const change of stateChanges) log(change.join("\t"));
775
+ log("thread_state_changes=kind\tid\tbefore\tafter\tlocation");
776
+ for (const [thread, before, after] of stateChanges) {
777
+ log(["thread", requiredText(thread.id, "review thread ID"), before, after, location(thread)].join("\t"));
778
+ }
705
779
  }
706
780
  if (![newComments, updatedComments, newReviews, updatedReviews, newThreads, newReplies, updatedReplies, stateChanges].some((items) => items.length)) log("feedback_delta=none");
707
781
  }
708
782
 
709
783
  function printFetchSummary(data, previous, out, log = console.log) {
710
- const { resolved, outdated, openCurrent } = threadBuckets(data.reviewThreads);
784
+ const { outdated, openCurrent } = threadBuckets(data.reviewThreads);
711
785
  log(`snapshot=${out}`);
712
786
  log(`counts comments=${data.conversationComments.length} reviews=${data.reviews.length} threads=${data.reviewThreads.length} open_current=${openCurrent.length} outdated=${outdated.length}`);
713
787
  printPrStatus(data.pullRequest, log);
@@ -717,9 +791,7 @@ function printFetchSummary(data, previous, out, log = console.log) {
717
791
  }
718
792
  printComments("conversation_comments", data.conversationComments, log);
719
793
  printReviews("reviews", data.reviews, log);
720
- printThreads("open_current_threads", openCurrent, log);
721
- printThreads("outdated_threads", outdated, log);
722
- log(`resolved_thread_ids=${nodeIds(resolved, "resolved review thread").join(",") || "-"}`);
794
+ printThreads("review_threads", data.reviewThreads, log);
723
795
  }
724
796
 
725
797
  function writeSnapshot(path, data) {
@@ -1365,6 +1437,7 @@ exit 1
1365
1437
  { id: "resolved", isResolved: false, isOutdated: false, path: "src/c.js", line: 5, comments: [] },
1366
1438
  ],
1367
1439
  };
1440
+ const botBody = "<details>verbose bot HTML</details>";
1368
1441
  const feedback = {
1369
1442
  pullRequest: {
1370
1443
  mergeStateStatus: "BLOCKED",
@@ -1376,7 +1449,7 @@ exit 1
1376
1449
  ],
1377
1450
  },
1378
1451
  conversationComments: [{ ...previousFeedback.conversationComments[0], body: "edited conversation" }, { id: "conversation-2", author: { login: "octocat" }, body: "new conversation" }],
1379
- reviews: [{ ...previousFeedback.reviews[0], state: "DISMISSED", body: "edited review" }, { id: "review-2", state: "COMMENTED", author: { login: "reviewer" }, body: "new review" }],
1452
+ reviews: [{ ...previousFeedback.reviews[0], state: "DISMISSED", body: "edited review" }, { id: "review-2", state: "COMMENTED", author: { login: "review-bot[bot]" }, body: botBody }],
1380
1453
  reviewThreads: [
1381
1454
  { ...previousFeedback.reviewThreads[0], comments: [{ ...previousFeedback.reviewThreads[0].comments[0], body: "edited reply" }, { id: "new-reply", author: { login: "reviewer" }, body: "new reply" }] },
1382
1455
  { ...previousFeedback.reviewThreads[1], isOutdated: true },
@@ -1395,8 +1468,8 @@ exit 1
1395
1468
  comments: [],
1396
1469
  }], (line) => maliciousPathLines.push(line));
1397
1470
  assert.deepEqual(maliciousPathLines, [
1398
- "threads=thread\tstate\tlocation\tcomment\tauthor\tbody",
1399
- "thread\tcurrent\tsrc/forged\\nrow\\t\\u001b[31m\\u009b31m.js:7\t-\t-\t-",
1471
+ "threads=kind\tid\tstate\tauthor\tlocation\tparent",
1472
+ "thread\tthread\tcurrent\t-\tsrc/forged\\nrow\\t\\u001b[31m\\u009b31m.js:7\t-",
1400
1473
  ]);
1401
1474
 
1402
1475
  assert.equal(checkBucket({ conclusion: "STALE" }), "failing");
@@ -1405,27 +1478,95 @@ exit 1
1405
1478
  assert.throws(() => checkBucket({ status: "IN_PROGRESS", conclusion: "SUCCESS" }), /invalid PR status check/);
1406
1479
  assert.throws(() => checkBucket({ status: "", state: "", conclusion: "" }), /invalid PR status check/);
1407
1480
  assert.deepEqual(checkSummary(feedback.pullRequest), { passing: 1, pending: 1, failing: 2 });
1481
+ const bodies = ["edited conversation", "edited review", "edited reply", "new conversation", botBody, "new reply", "new thread"];
1408
1482
  const initialLines = [];
1409
1483
  printFetchSummary(feedback, null, "/tmp/snapshot", (line) => initialLines.push(line));
1410
- for (const body of ["edited conversation", "edited review", "edited reply", "new conversation", "new review", "new reply", "new thread"]) {
1411
- assert(initialLines.some((line) => line.includes(body)));
1412
- }
1413
-
1414
- const savedFeedback = { ...feedback, pullRequest: { ...feedback.pullRequest, url: "https://github.com/owner/repo/pull/1" } };
1484
+ assert.deepEqual(initialLines.slice(0, 3), [
1485
+ "snapshot=/tmp/snapshot",
1486
+ "counts comments=2 reviews=2 threads=4 open_current=2 outdated=1",
1487
+ "merge_state=BLOCKED checks_total=4 passing=1 pending=1 failing=2",
1488
+ ]);
1489
+ const initial = initialLines.join("\n");
1490
+ for (const body of bodies) assert(!initial.includes(body));
1491
+ for (const id of [
1492
+ "conversation-1", "conversation-2", "review-1", "review-2", "current", "current-comment", "new-reply",
1493
+ "outdated", "resolved", "new-thread", "new-thread-comment",
1494
+ ]) assert(initial.includes(id), id);
1495
+ assert(initial.includes("thread_comment\tnew-reply\tcurrent\treviewer\tsrc/a.js:3\tcurrent"));
1496
+ assert(initial.includes("thread\tresolved\tresolved\t-\tsrc/c.js:5\t-"));
1497
+
1498
+ const savedFeedback = {
1499
+ ...feedback,
1500
+ pullRequest: { ...feedback.pullRequest, url: "https://github.com/owner/repo/pull/1" },
1501
+ openCurrentThreads: threadBuckets(feedback.reviewThreads).openCurrent,
1502
+ };
1415
1503
  const savedPrevious = { ...previousFeedback, pullRequest: savedFeedback.pullRequest };
1416
1504
  const deltaSnapshot = join(temporary, "delta.json");
1417
1505
  writeSnapshot(deltaSnapshot, savedPrevious);
1418
1506
  const deltaLines = [];
1419
1507
  printFetchSummary(feedback, previousSnapshot(deltaSnapshot), deltaSnapshot, (line) => deltaLines.push(line));
1420
1508
  writeSnapshot(deltaSnapshot, savedFeedback);
1421
- assert.equal(snapshot(deltaSnapshot).reviewThreads.length, feedback.reviewThreads.length);
1509
+ assert.deepEqual(snapshot(deltaSnapshot), savedFeedback);
1422
1510
  const delta = deltaLines.join("\n");
1511
+ assert(delta.includes(`snapshot=${deltaSnapshot}`));
1512
+ assert(delta.includes("counts comments=2 reviews=2 threads=4 open_current=2 outdated=1"));
1423
1513
  assert(delta.includes("merge_state=BLOCKED checks_total=4 passing=1 pending=1 failing=2"));
1424
- for (const body of ["edited conversation", "edited review", "edited reply", "new conversation", "new review", "new reply", "new thread"]) assert(delta.includes(body));
1425
- for (const old of ["old conversation", "old review", "old reply"]) assert(!delta.includes(old));
1426
- assert(delta.includes("new-thread\tcurrent"));
1427
- assert(delta.includes("outdated\tcurrent\toutdated"));
1428
- assert(delta.includes("resolved\tcurrent\tresolved"));
1514
+ for (const body of [...bodies, "old conversation", "old review", "old reply"]) assert(!delta.includes(body));
1515
+ for (const id of ["conversation-1", "conversation-2", "review-1", "review-2", "current-comment", "new-reply", "new-thread", "new-thread-comment"]) {
1516
+ assert(delta.includes(id), id);
1517
+ }
1518
+ assert(delta.includes("new_replies=kind\tid\tstate\tauthor\tlocation\tparent"));
1519
+ assert(delta.includes("thread_comment\tnew-reply\tcurrent\treviewer\tsrc/a.js:3\tcurrent"));
1520
+ assert(!delta.includes("new_replies=kind\tid\tstate\tauthor\tlocation\tparent\nthread\tcurrent"));
1521
+ assert(delta.includes("thread\toutdated\tcurrent\toutdated\tsrc/b.js:4"));
1522
+ assert(delta.includes("thread\tresolved\tcurrent\tresolved\tsrc/c.js:5"));
1523
+
1524
+ const show = (id) => {
1525
+ const lines = [];
1526
+ showFeedback({ snapshot: deltaSnapshot, id }, (line) => lines.push(line));
1527
+ assert.equal(lines.length, 1);
1528
+ return { raw: lines[0], record: JSON.parse(lines[0]) };
1529
+ };
1530
+ const shownComment = show("conversation-2");
1531
+ assert.deepEqual(shownComment.record, { ...feedback.conversationComments[1], kind: "conversation_comment" });
1532
+ const shownReview = show("review-2");
1533
+ assert.deepEqual(shownReview.record, { ...feedback.reviews[1], kind: "review" });
1534
+ const shownReply = show("current-comment");
1535
+ assert.deepEqual(shownReply.record, {
1536
+ ...feedback.reviewThreads[0].comments[0],
1537
+ kind: "thread_comment",
1538
+ parentThread: { id: "current", state: "current", location: "src/a.js:3" },
1539
+ });
1540
+ const { comments: _comments, ...currentThread } = feedback.reviewThreads[0];
1541
+ const shownThread = show("current");
1542
+ assert.deepEqual(shownThread.record, { ...currentThread, kind: "thread", childIds: ["current-comment", "new-reply"] });
1543
+ for (const [shown, selectedBody] of [
1544
+ [shownComment.raw, "new conversation"],
1545
+ [shownReview.raw, botBody],
1546
+ [shownReply.raw, "edited reply"],
1547
+ [shownThread.raw, null],
1548
+ ]) {
1549
+ for (const body of bodies) assert.equal(shown.includes(body), body === selectedBody, `${body} in ${shown}`);
1550
+ }
1551
+
1552
+ assert.throws(() => showFeedback({ snapshot: deltaSnapshot, id: "unknown" }, () => {}), /feedback ID not found/);
1553
+ const invalidSnapshot = join(temporary, "invalid.json");
1554
+ writeSnapshot(invalidSnapshot, { ...savedFeedback, conversationComments: [{ author: null, body: "invalid" }] });
1555
+ assert.throws(() => showFeedback({ snapshot: invalidSnapshot, id: "anything" }, () => {}), /conversation comment ID must be a string/);
1556
+ const duplicateSnapshot = join(temporary, "duplicate.json");
1557
+ writeSnapshot(duplicateSnapshot, { ...savedFeedback, conversationComments: [feedback.conversationComments[0], feedback.conversationComments[0]] });
1558
+ assert.throws(() => showFeedback({ snapshot: duplicateSnapshot, id: "conversation-1" }, () => {}), /duplicate feedback ID/);
1559
+ const ambiguousSnapshot = join(temporary, "ambiguous.json");
1560
+ writeSnapshot(ambiguousSnapshot, {
1561
+ ...savedFeedback,
1562
+ conversationComments: [{ ...feedback.conversationComments[0], id: "review-1" }],
1563
+ });
1564
+ assert.throws(() => showFeedback({ snapshot: ambiguousSnapshot, id: "review-1" }, () => {}), /ambiguous feedback ID/);
1565
+ assert.throws(() => parse("show", ["--snapshot", deltaSnapshot]), /show needs --id/);
1566
+ assert.throws(() => parse("show", ["--snapshot", deltaSnapshot, "--id", ""]), /--id must not be empty/);
1567
+ assert.throws(() => parse("show", ["--snapshot", deltaSnapshot, "--id", "current", "--unknown"]), /unsupported --unknown/);
1568
+ assert.throws(() => parse("show", ["--snapshot", deltaSnapshot, "--id", "current", "--id", "current"]), /--id was supplied twice/);
1569
+
1429
1570
  const unchangedLines = [];
1430
1571
  printFetchSummary(feedback, savedFeedback, deltaSnapshot, (line) => unchangedLines.push(line));
1431
1572
  assert(unchangedLines.includes("feedback_delta=none"));
@@ -1587,6 +1728,7 @@ function usage() {
1587
1728
  ${command} fetch [--pr PR] (--out FILE | --json)
1588
1729
  ${command} target [--pr PR]
1589
1730
  ${command} checks [--pr PR] --expected-head SHA
1731
+ ${command} show --snapshot FILE --id ID
1590
1732
  ${command} snapshot-url --snapshot FILE
1591
1733
  ${command} push --snapshot FILE
1592
1734
  ${command} resolve [--pr PR] --expected-head SHA --thread ID [--thread ID ...]
@@ -1598,6 +1740,7 @@ function parse(command, args) {
1598
1740
  fetch: new Set(["--pr", "--out", "--json"]),
1599
1741
  target: new Set(["--pr"]),
1600
1742
  checks: new Set(["--pr", "--expected-head"]),
1743
+ show: new Set(["--snapshot", "--id"]),
1601
1744
  "snapshot-url": new Set(["--snapshot"]),
1602
1745
  push: new Set(["--snapshot"]),
1603
1746
  resolve: new Set(["--pr", "--expected-head", "--thread"]),
@@ -1626,9 +1769,11 @@ function parse(command, args) {
1626
1769
  validatePrRef(values.pr);
1627
1770
  if (command === "fetch" && values.out === undefined && !values.json) throw new UsageError("fetch needs --out or --json");
1628
1771
  if (command === "fetch" && values.out !== undefined && values.json) throw new UsageError("fetch accepts --out or --json, not both");
1629
- if ((command === "push" || command === "snapshot-url") && values.snapshot === undefined) {
1772
+ if (["push", "show", "snapshot-url"].includes(command) && values.snapshot === undefined) {
1630
1773
  throw new UsageError(`${command} needs --snapshot`);
1631
1774
  }
1775
+ if (command === "show" && values.id === undefined) throw new UsageError("show needs --id");
1776
+ if (command === "show" && !values.id) throw new UsageError("--id must not be empty");
1632
1777
  if (command === "checks" && values.expectedHead === undefined) throw new UsageError("checks needs --expected-head");
1633
1778
  if ((command === "checks" || command === "resolve") && values.expectedHead !== undefined) {
1634
1779
  if (!OID.test(values.expectedHead)) throw new UsageError("--expected-head must be a full OID");
@@ -1657,6 +1802,7 @@ function main(argv) {
1657
1802
  if (command === "fetch") fetchFeedback(options);
1658
1803
  if (command === "target") verifyTarget(options);
1659
1804
  if (command === "checks") checkPr(options);
1805
+ if (command === "show") showFeedback(options);
1660
1806
  if (command === "snapshot-url") printSnapshotPrUrl(options);
1661
1807
  if (command === "push") pushHead(options);
1662
1808
  if (command === "resolve") resolveFeedback(options);