pr-shepherd 0.21.0 → 0.23.0

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 (64) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +87 -124
  3. package/bin/checks/triage.mjs +1 -0
  4. package/bin/cli/args.mjs +2 -0
  5. package/bin/cli/fix-formatter.mjs +38 -9
  6. package/bin/cli/formatters.mjs +3 -45
  7. package/bin/cli/handlers.mjs +22 -1
  8. package/bin/cli/help-command-pages.mjs +27 -2
  9. package/bin/cli/help-top-page.mjs +2 -0
  10. package/bin/cli/iterate-lean.mjs +4 -0
  11. package/bin/cli/list-formatters.mjs +55 -4
  12. package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
  13. package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
  14. package/bin/cli/mutate-formatter.mjs +50 -0
  15. package/bin/cli-parser.mjs +11 -2
  16. package/bin/cli-parser.test-support.mjs +6 -1
  17. package/bin/commands/check-annotations.mjs +40 -0
  18. package/bin/commands/check.mjs +25 -57
  19. package/bin/commands/check.test-support.mjs +9 -2
  20. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  21. package/bin/commands/iterate/classify.mjs +25 -30
  22. package/bin/commands/iterate/escalate.mjs +16 -2
  23. package/bin/commands/iterate/fix-code.mjs +29 -18
  24. package/bin/commands/iterate/helpers.mjs +1 -0
  25. package/bin/commands/iterate/render.mjs +9 -3
  26. package/bin/commands/iterate/stall.mjs +43 -2
  27. package/bin/commands/iterate-test-support.mjs +1 -1
  28. package/bin/commands/iterate-thread-test-support.mjs +18 -0
  29. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +10 -1
  30. package/bin/commands/mark-files-as-viewed.mjs +220 -0
  31. package/bin/commands/resolve-mutate.mjs +41 -4
  32. package/bin/commands/resolve.mjs +16 -76
  33. package/bin/commands/resolve.test-support.mjs +2 -0
  34. package/bin/commands/shepherd-journal.mjs +1 -1
  35. package/bin/comments/authors.mjs +14 -0
  36. package/bin/comments/minimize-policy.mjs +6 -3
  37. package/bin/comments/pending-ops.mjs +6 -0
  38. package/bin/comments/resolve.mjs +28 -11
  39. package/bin/comments/resolve.test-support.mjs +6 -1
  40. package/bin/comments/review-visibility.mjs +14 -0
  41. package/bin/comments/thread-visibility.mjs +60 -0
  42. package/bin/comments/visible-comments.mjs +1 -1
  43. package/bin/config.json +1 -1
  44. package/bin/github/batch-parser-helpers.mjs +3 -4
  45. package/bin/github/batch-parsers.mjs +35 -8
  46. package/bin/github/batch.mjs +2 -0
  47. package/bin/github/check-annotations.mjs +75 -0
  48. package/bin/github/client.test-support.mjs +55 -0
  49. package/bin/github/gql/batch-pr.gql +15 -2
  50. package/bin/github/gql/check-run-annotations.gql +32 -0
  51. package/bin/github/gql/review-thread-comments.gql +27 -0
  52. package/bin/github/queries.mjs +4 -0
  53. package/bin/github/thread-comments.mjs +34 -0
  54. package/bin/reporters/agent.mjs +27 -0
  55. package/bin/state/seen-comments.test-support.mjs +19 -0
  56. package/bin/threads/transcript.mjs +31 -0
  57. package/bin/types/agent-thread.mjs +1 -0
  58. package/bin/types/check-annotations.mjs +1 -0
  59. package/bin/types/review-thread.mjs +1 -0
  60. package/bin/types.mjs +3 -0
  61. package/package.json +1 -1
  62. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
  64. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
@@ -1,12 +1,13 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { fetchPrBatch } from "../github/batch.mjs";
3
- import { getOutdatedThreads } from "../comments/outdated.mjs";
4
- import { autoResolveOutdated } from "../comments/resolve.mjs";
5
3
  import { loadConfig } from "../config/load.mjs";
6
4
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
7
5
  import { extractSuggestion } from "../suggestions/extract.mjs";
8
6
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
9
7
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
8
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
9
+ import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
10
+ import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
10
11
  export { runResolveMutate } from "./resolve-mutate.mjs";
11
12
  export async function runResolveFetch(opts) {
12
13
  const repo = await getRepoInfo();
@@ -16,39 +17,9 @@ export async function runResolveFetch(opts) {
16
17
  }
17
18
  const { data } = await fetchPrBatch(prNumber, repo);
18
19
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
19
- const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
20
- const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
21
- const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
22
- const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
23
20
  const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
24
21
  const seenMap = await loadSeenMap(stateKey);
25
- const unseenOutdated = [];
26
- const editedOutdated = [];
27
- for (const t of outdatedCandidates) {
28
- const cls = classifyItem(t.id, t.body, seenMap);
29
- if (cls === "new")
30
- unseenOutdated.push(t);
31
- else if (cls === "edited")
32
- editedOutdated.push(t);
33
- }
34
- const unseenResolved = [];
35
- const editedResolved = [];
36
- for (const t of resolvedCandidates) {
37
- const cls = classifyItem(t.id, t.body, seenMap);
38
- if (cls === "new")
39
- unseenResolved.push(t);
40
- else if (cls === "edited")
41
- editedResolved.push(t);
42
- }
43
- const unseenMinimizedThreads = [];
44
- const editedMinimizedThreads = [];
45
- for (const t of minimizedThreadCandidates) {
46
- const cls = classifyItem(t.id, t.body, seenMap);
47
- if (cls === "new")
48
- unseenMinimizedThreads.push(t);
49
- else if (cls === "edited")
50
- editedMinimizedThreads.push(t);
51
- }
22
+ const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap);
52
23
  const unseenMinimizedComments = [];
53
24
  const editedMinimizedComments = [];
54
25
  for (const c of minimizedCommentCandidates) {
@@ -58,52 +29,19 @@ export async function runResolveFetch(opts) {
58
29
  else if (cls === "edited")
59
30
  editedMinimizedComments.push(c);
60
31
  }
61
- const outdated = getOutdatedThreads(unresolvedThreads);
62
- const autoResolvedIds = new Set();
63
- if (outdated.length > 0) {
64
- const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
65
- for (const id of resolvedIds)
66
- autoResolvedIds.add(id);
67
- if (errors.length > 0) {
68
- process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
69
- }
70
- }
71
- const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
72
- const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
73
32
  const cfg = loadConfig();
74
33
  const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
75
- const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
34
+ const changesRequestedReviewVisibility = classifyReviewsForDisplay(data.changesRequestedReviews, seenMap);
35
+ const reviewSummaryVisibility = cfg.resolve.fetchReviewSummaries
36
+ ? classifyReviewsForDisplay(data.reviewSummaries, seenMap)
37
+ : { visible: [], toMarkSeen: [] };
38
+ const actionableThreads = threadVisibility.activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
76
39
  const thread = rest;
77
40
  const suggestion = extractSuggestion(rest);
78
41
  if (suggestion)
79
42
  thread.suggestion = suggestion;
80
43
  return thread;
81
44
  });
82
- const firstLookThreads = [
83
- ...unseenOutdated.map((t) => ({
84
- ...t,
85
- firstLookStatus: "outdated",
86
- autoResolved: autoResolvedIds.has(t.id),
87
- })),
88
- ...editedOutdated.map((t) => ({
89
- ...t,
90
- firstLookStatus: "outdated",
91
- autoResolved: autoResolvedIds.has(t.id),
92
- edited: true,
93
- })),
94
- ...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
95
- ...editedResolved.map((t) => ({
96
- ...t,
97
- firstLookStatus: "resolved",
98
- edited: true,
99
- })),
100
- ...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
101
- ...editedMinimizedThreads.map((t) => ({
102
- ...t,
103
- firstLookStatus: "minimized",
104
- edited: true,
105
- })),
106
- ];
107
45
  const firstLookComments = [
108
46
  ...unseenMinimizedComments.map((c) => ({ ...c, firstLookStatus: "minimized" })),
109
47
  ...editedMinimizedComments.map((c) => ({
@@ -114,19 +52,21 @@ export async function runResolveFetch(opts) {
114
52
  ];
115
53
  // Mark new and edited items as seen (best-effort — markSeen never throws).
116
54
  await Promise.allSettled([
117
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
55
+ ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
118
56
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
119
57
  ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
58
+ ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
59
+ ...reviewSummaryVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
120
60
  ]);
121
61
  const result = {
122
62
  prNumber,
123
63
  actionableThreads,
124
- resolutionOnlyThreads,
125
- firstLookThreads,
64
+ resolutionOnlyThreads: threadVisibility.resolutionOnlyThreads,
65
+ firstLookThreads: threadVisibility.firstLookThreads,
126
66
  actionableComments: visibleCommentClassification.actionable,
127
67
  firstLookComments,
128
- changesRequestedReviews: data.changesRequestedReviews,
129
- reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
68
+ changesRequestedReviews: changesRequestedReviewVisibility.visible,
69
+ reviewSummaries: reviewSummaryVisibility.visible,
130
70
  commitSuggestionsEnabled: cfg.actions.commitSuggestions,
131
71
  };
132
72
  return { ...result, instructions: buildFetchInstructions(prNumber, result) };
@@ -102,8 +102,10 @@ function makeComment(overrides = {}) {
102
102
  export function registerHooks() {
103
103
  beforeEach(() => {
104
104
  vi.clearAllMocks();
105
+ mockFetchPrBatch.mockResolvedValue({ data: makeBatchData() });
105
106
  mockAutoResolveOutdated.mockResolvedValue({ resolved: [], errors: [] });
106
107
  mockApplyResolveOptions.mockResolvedValue({
108
+ repliedThreads: [],
107
109
  resolvedThreads: [],
108
110
  minimizedComments: [],
109
111
  dismissedReviews: [],
@@ -1,7 +1,7 @@
1
1
  export const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
2
2
  export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
3
3
  export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
4
- export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Any IDs eligible for minimization are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
4
+ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Eligible non-human IDs, when present, are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
5
5
  export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
6
6
  return [
7
7
  `For any large decisions or rejections you made this iteration, add or update a \`${SHEPHERD_JOURNAL_SECTION}\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision.`,
@@ -0,0 +1,14 @@
1
+ export function isBotLogin(login) {
2
+ return (login ?? "").toLowerCase().includes("[bot]");
3
+ }
4
+ export function normalizeAuthorType(typeName, login) {
5
+ if (isBotLogin(login))
6
+ return "Bot";
7
+ if (typeName === "User" || typeName === "Bot")
8
+ return typeName;
9
+ return "Unknown";
10
+ }
11
+ export function isHumanAuthor(author) {
12
+ const login = author.author ?? author.login ?? "";
13
+ return author.authorType === "User" && !isBotLogin(login);
14
+ }
@@ -1,12 +1,15 @@
1
- export function shouldMinimizeAuthor(authorType, policy) {
1
+ import { isHumanAuthor } from "./authors.mjs";
2
+ export function shouldMinimizeAuthor(authorType, policy, author) {
3
+ if (isHumanAuthor({ author, authorType }))
4
+ return false;
2
5
  switch (policy) {
3
6
  case undefined:
4
7
  case "all":
5
- return true;
8
+ return authorType !== "User";
6
9
  case "bots":
7
10
  return authorType === "Bot";
8
11
  case "users":
9
- return authorType === "User";
12
+ return false;
10
13
  case "none":
11
14
  return false;
12
15
  default:
@@ -1,7 +1,11 @@
1
1
  export function setPendingOps(result, ops) {
2
2
  const resolved = new Set(result.resolvedThreads);
3
+ const replied = new Set(result.repliedThreads);
3
4
  const minimized = new Set(result.minimizedComments);
4
5
  const dismissed = new Set(result.dismissedReviews);
6
+ const unrepliedThreads = ops
7
+ .filter((op) => op.kind === "p" && !replied.has(op.id))
8
+ .map((op) => op.id);
5
9
  const unresolvedThreads = ops
6
10
  .filter((op) => op.kind === "r" && !resolved.has(op.id))
7
11
  .map((op) => op.id);
@@ -11,6 +15,8 @@ export function setPendingOps(result, ops) {
11
15
  const undismissedReviews = ops
12
16
  .filter((op) => op.kind === "d" && !dismissed.has(op.id))
13
17
  .map((op) => op.id);
18
+ if (unrepliedThreads.length > 0)
19
+ result.unrepliedThreads = unrepliedThreads;
14
20
  if (unresolvedThreads.length > 0)
15
21
  result.unresolvedThreads = unresolvedThreads;
16
22
  if (unminimizedComments.length > 0)
@@ -25,12 +25,14 @@ function dismissReviewNonDismissibleMessage(id) {
25
25
  }
26
26
  export async function applyResolveOptions(pr, repo, opts) {
27
27
  const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
28
+ const replyThreadIds = dedupeIds(opts.replyThreadIds ?? []);
28
29
  const minimizeCommentIds = opts.minimizeCommentIds ?? [];
29
30
  const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
30
31
  const minimizeCommentIdSet = new Set(minimizeCommentIds);
31
32
  const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
32
33
  const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
33
34
  const result = {
35
+ repliedThreads: [],
34
36
  resolvedThreads: [],
35
37
  minimizedComments: [],
36
38
  dismissedReviews: [],
@@ -42,31 +44,35 @@ export async function applyResolveOptions(pr, repo, opts) {
42
44
  result.skippedDismissals.push(id);
43
45
  }
44
46
  }
45
- if (filteredDismissReviewIds.length > 0 && !opts.dismissMessage) {
46
- throw new Error("--message is required when dismissing reviews");
47
+ if ((filteredDismissReviewIds.length > 0 || replyThreadIds.length > 0) && !opts.dismissMessage) {
48
+ throw new Error("--message is required when replying to threads or dismissing reviews");
47
49
  }
48
50
  if (opts.requireSha) {
49
51
  // Verify GitHub received the commit before resolving — prevents auto-merge
50
52
  // before reviewers see the fix.
51
53
  await waitForSha(pr, repo, opts.requireSha);
52
54
  }
53
- await bulkApply(resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
55
+ await bulkApply(replyThreadIds, resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
54
56
  return result;
55
57
  }
56
58
  export async function autoResolveOutdated(threadIds) {
57
59
  const result = {
60
+ repliedThreads: [],
58
61
  resolvedThreads: [],
59
62
  minimizedComments: [],
60
63
  dismissedReviews: [],
61
64
  errors: [],
62
65
  };
63
- await bulkApply(threadIds, [], [], "", result);
66
+ await bulkApply([], threadIds, [], [], "", result);
64
67
  return { resolved: result.resolvedThreads, errors: result.errors };
65
68
  }
66
69
  // Keep mutation batches small so rate-limit stops leave a precise pending list.
67
70
  const BULK_CHUNK_SIZE = 10;
68
- function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
71
+ function buildBulkMutation(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage) {
69
72
  const ops = [];
73
+ for (let i = 0; i < replyIds.length; i++) {
74
+ ops.push(` p${i}: addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: ${JSON.stringify(replyIds[i])}, body: ${JSON.stringify(dismissMessage)} }) { comment { id } }`);
75
+ }
70
76
  for (let i = 0; i < resolveIds.length; i++) {
71
77
  ops.push(` r${i}: resolveReviewThread(input: { threadId: ${JSON.stringify(resolveIds[i])} }) { thread { isResolved } }`);
72
78
  }
@@ -78,8 +84,9 @@ function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage)
78
84
  }
79
85
  return `mutation BulkApply {\n${ops.join("\n")}\n}`;
80
86
  }
81
- async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
87
+ async function bulkApply(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
82
88
  const allOps = [
89
+ ...replyIds.map((id) => ({ kind: "p", id })),
83
90
  ...resolveIds.map((id) => ({ kind: "r", id })),
84
91
  ...minimizeIds.map((id) => ({ kind: "m", id })),
85
92
  ...dismissIds.map((id) => ({ kind: "d", id })),
@@ -87,17 +94,15 @@ async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, re
87
94
  for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
88
95
  const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
89
96
  // eslint-disable-next-line no-await-in-loop
90
- const stopped = await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result, i + BULK_CHUNK_SIZE < allOps.length);
97
+ const stopped = await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "p").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result, i + BULK_CHUNK_SIZE < allOps.length);
91
98
  if (stopped) {
92
99
  setPendingOps(result, allOps.slice(i));
93
100
  return;
94
101
  }
95
102
  }
96
103
  }
97
- async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result, hasPendingAfter) {
98
- if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
99
- return false;
100
- const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
104
+ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dismissMessage, result, hasPendingAfter) {
105
+ const doc = buildBulkMutation(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage);
101
106
  let data = {};
102
107
  let graphQlErrors = [];
103
108
  let rateLimitStop;
@@ -122,6 +127,8 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
122
127
  result.rateLimit = stop;
123
128
  return true;
124
129
  }
130
+ for (const id of replyIds)
131
+ result.errors.push(`${id}: ${msg}`);
125
132
  for (const id of resolveIds)
126
133
  result.errors.push(`${id}: ${msg}`);
127
134
  for (const id of minimizeIds)
@@ -130,6 +137,16 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
130
137
  result.errors.push(`${id}: ${msg}`);
131
138
  return false;
132
139
  }
140
+ for (let i = 0; i < replyIds.length; i++) {
141
+ const id = replyIds[i];
142
+ if (id === undefined)
143
+ continue;
144
+ const p = data[`p${i}`];
145
+ if (p?.comment?.id)
146
+ result.repliedThreads.push(id);
147
+ else if (!suppressCurrentChunkErrors)
148
+ result.errors.push(`${id}: reply returned null or comment not created`);
149
+ }
133
150
  for (let i = 0; i < resolveIds.length; i++) {
134
151
  const r = data[`r${i}`];
135
152
  if (r?.thread?.isResolved === true)
@@ -15,9 +15,14 @@ const REPO = { owner: "owner", name: "repo" };
15
15
  function makeBulkResponse(doc) {
16
16
  const str = typeof doc === "string" ? doc : "";
17
17
  const data = {};
18
- for (const [, alias] of str.matchAll(/^\s+([a-z]\d+):/gm)) {
18
+ for (const match of str.matchAll(/^\s+([a-z]\d+):/gm)) {
19
+ const alias = match[1];
20
+ if (alias === undefined)
21
+ continue;
19
22
  if (alias.startsWith("r"))
20
23
  data[alias] = { thread: { isResolved: true } };
24
+ else if (alias.startsWith("p"))
25
+ data[alias] = { comment: { id: `${alias}-comment` } };
21
26
  else if (alias.startsWith("m"))
22
27
  data[alias] = { minimizedComment: { isMinimized: true } };
23
28
  else if (alias.startsWith("d"))
@@ -0,0 +1,14 @@
1
+ import { classifyItem } from "../state/seen-comments.mjs";
2
+ export function classifyReviewsForDisplay(reviews, seenMap) {
3
+ const visible = [];
4
+ const toMarkSeen = [];
5
+ for (const review of reviews) {
6
+ const cls = classifyItem(review.id, review.body, seenMap);
7
+ if (cls === "unchanged")
8
+ continue;
9
+ const rendered = cls === "edited" ? { ...review, edited: true } : review;
10
+ visible.push(rendered);
11
+ toMarkSeen.push(review);
12
+ }
13
+ return { visible, toMarkSeen };
14
+ }
@@ -0,0 +1,60 @@
1
+ import { classifyItem } from "../state/seen-comments.mjs";
2
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
+ function withEdited(thread, edited) {
4
+ return edited ? { ...thread, edited: true } : thread;
5
+ }
6
+ function classifyVisibleThread(thread, seenMap) {
7
+ const cls = classifyItem(thread.id, threadTranscriptBody(thread), seenMap);
8
+ if (cls === "unchanged")
9
+ return null;
10
+ return withEdited(thread, cls === "edited");
11
+ }
12
+ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
13
+ const visible = classifyVisibleThread(thread, seenMap);
14
+ if (visible === null)
15
+ return null;
16
+ return { ...visible, firstLookStatus };
17
+ }
18
+ export function classifyThreadVisibility(threads, seenMap) {
19
+ const unresolvedThreads = threads.filter((t) => !t.isResolved);
20
+ const activeThreads = unresolvedThreads
21
+ .filter((t) => !t.isOutdated && !t.isMinimized)
22
+ .flatMap((t) => {
23
+ const visible = classifyVisibleThread(t, seenMap);
24
+ return visible ? [visible] : [];
25
+ });
26
+ const resolutionOnlyThreads = unresolvedThreads
27
+ .filter((t) => t.isOutdated || t.isMinimized)
28
+ .flatMap((t) => {
29
+ const visible = classifyVisibleThread(t, seenMap);
30
+ return visible ? [visible] : [];
31
+ });
32
+ const firstLookThreads = [
33
+ ...threads.flatMap((t) => {
34
+ if (!t.isOutdated)
35
+ return [];
36
+ const visible = classifyFirstLookThread(t, seenMap, "outdated");
37
+ return visible ? [visible] : [];
38
+ }),
39
+ ...threads.flatMap((t) => {
40
+ if (!t.isResolved || t.isOutdated)
41
+ return [];
42
+ const visible = classifyFirstLookThread(t, seenMap, "resolved");
43
+ return visible ? [visible] : [];
44
+ }),
45
+ ...threads.flatMap((t) => {
46
+ if (!t.isMinimized || t.isResolved || t.isOutdated)
47
+ return [];
48
+ const visible = classifyFirstLookThread(t, seenMap, "minimized");
49
+ return visible ? [visible] : [];
50
+ }),
51
+ ];
52
+ const seenMarkerIds = new Set();
53
+ const toMarkSeen = [...activeThreads, ...resolutionOnlyThreads, ...firstLookThreads].filter((thread) => {
54
+ if (seenMarkerIds.has(thread.id))
55
+ return false;
56
+ seenMarkerIds.add(thread.id);
57
+ return true;
58
+ });
59
+ return { activeThreads, resolutionOnlyThreads, firstLookThreads, toMarkSeen };
60
+ }
@@ -5,7 +5,7 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments) {
5
5
  const minimizeIds = [];
6
6
  const toMarkSeen = [];
7
7
  for (const c of comments.filter((comment) => !comment.isMinimized)) {
8
- if (shouldMinimizeAuthor(c.authorType, minimizeComments)) {
8
+ if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author)) {
9
9
  actionable.push(c);
10
10
  minimizeIds.push(c.id);
11
11
  continue;
package/bin/config.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "iterate": {
3
3
  "fixAttemptsPerThread": 3,
4
- "stallTimeoutMinutes": 30,
4
+ "stallTimeoutMinutes": 60,
5
5
  "minimizeApprovals": false,
6
6
  "minimizeComments": "all"
7
7
  },
@@ -1,7 +1,6 @@
1
- export function mapAuthorType(typeName) {
2
- if (typeName === "User" || typeName === "Bot")
3
- return typeName;
4
- return "Unknown";
1
+ import { normalizeAuthorType } from "../comments/authors.mjs";
2
+ export function mapAuthorType(typeName, login) {
3
+ return normalizeAuthorType(typeName, login);
5
4
  }
6
5
  export function parseCreatedAt(iso) {
7
6
  const ms = new Date(iso).getTime();
@@ -10,26 +10,36 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
10
10
  }));
11
11
  const reviewThreads = rawThreadPages.map((t) => {
12
12
  const comment = t.comments.nodes[0];
13
+ const comments = t.comments.nodes.map((c) => ({
14
+ id: c.id,
15
+ isMinimized: c.isMinimized,
16
+ author: c.author?.login ?? "unknown",
17
+ authorType: mapAuthorType(c.author?.__typename, c.author?.login),
18
+ body: c.body,
19
+ url: c.url,
20
+ createdAtUnix: parseCreatedAt(c.createdAt),
21
+ }));
13
22
  return {
14
23
  id: t.id,
15
24
  isResolved: t.isResolved,
16
25
  isOutdated: t.isOutdated,
17
26
  isMinimized: comment?.isMinimized ?? false,
18
- path: comment?.path ?? null,
19
- line: comment?.line ?? null,
20
- startLine: comment?.startLine ?? null,
27
+ path: t.path ?? comment?.path ?? null,
28
+ line: t.line ?? comment?.line ?? null,
29
+ startLine: t.startLine ?? comment?.startLine ?? null,
21
30
  author: comment?.author?.login ?? "unknown",
22
- authorType: mapAuthorType(comment?.author?.__typename),
31
+ authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
23
32
  body: comment?.body ?? "",
24
33
  url: comment?.url ?? "",
25
34
  createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
35
+ comments,
26
36
  };
27
37
  });
28
38
  const comments = rawCommentNodes.map((c) => ({
29
39
  id: c.id,
30
40
  isMinimized: c.isMinimized,
31
41
  author: c.author?.login ?? "unknown",
32
- authorType: mapAuthorType(c.author?.__typename),
42
+ authorType: mapAuthorType(c.author?.__typename, c.author?.login),
33
43
  body: c.body,
34
44
  url: c.url,
35
45
  createdAtUnix: parseCreatedAt(c.createdAt),
@@ -37,7 +47,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
37
47
  const changesRequestedReviews = rawReviewNodes.map((r) => ({
38
48
  id: r.id,
39
49
  author: r.author?.login ?? "unknown",
40
- authorType: mapAuthorType(r.author?.__typename),
50
+ authorType: mapAuthorType(r.author?.__typename, r.author?.login),
41
51
  body: r.body,
42
52
  }));
43
53
  const reviewSummaries = rawReviewSummaryNodes
@@ -45,7 +55,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
45
55
  .map((r) => ({
46
56
  id: r.id,
47
57
  author: r.author?.login ?? "unknown",
48
- authorType: mapAuthorType(r.author?.__typename),
58
+ authorType: mapAuthorType(r.author?.__typename, r.author?.login),
49
59
  body: r.body,
50
60
  }));
51
61
  // APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
@@ -56,7 +66,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
56
66
  .map((r) => ({
57
67
  id: r.id,
58
68
  author: r.author?.login ?? "unknown",
59
- authorType: mapAuthorType(r.author?.__typename),
69
+ authorType: mapAuthorType(r.author?.__typename, r.author?.login),
60
70
  body: r.body,
61
71
  }));
62
72
  const checks = rawCheckNodes.flatMap((node) => {
@@ -64,14 +74,28 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
64
74
  const event = node.checkSuite?.workflowRun?.event ?? null;
65
75
  const runId = extractRunId(node.detailsUrl);
66
76
  const summary = extractCheckRunSummary(node.title, node.summary);
77
+ const rawCreatedAt = node.checkSuite
78
+ ? (node.checkSuite.workflowRun?.createdAt ?? node.checkSuite.createdAt)
79
+ : undefined;
80
+ const rawUpdatedAt = node.checkSuite
81
+ ? (node.checkSuite.workflowRun?.updatedAt ?? node.checkSuite.updatedAt)
82
+ : undefined;
83
+ const createdAtUnix = rawCreatedAt ? parseCreatedAt(rawCreatedAt) : undefined;
84
+ const startedAtUnix = node.startedAt ? parseCreatedAt(node.startedAt) : undefined;
85
+ const updatedAtUnix = rawUpdatedAt ? parseCreatedAt(rawUpdatedAt) : undefined;
67
86
  return [
68
87
  {
88
+ id: node.id,
69
89
  name: node.name,
70
90
  status: node.status,
71
91
  conclusion: node.conclusion,
92
+ source: "check_run",
72
93
  detailsUrl: node.detailsUrl ?? "",
73
94
  event,
74
95
  runId,
96
+ ...(createdAtUnix !== undefined && { createdAtUnix }),
97
+ ...(startedAtUnix !== undefined && { startedAtUnix }),
98
+ ...(updatedAtUnix !== undefined && { updatedAtUnix }),
75
99
  ...(summary !== undefined && { summary }),
76
100
  },
77
101
  ];
@@ -79,14 +103,17 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
79
103
  if (node.__typename === "StatusContext") {
80
104
  const { status, conclusion } = mapStatusContextState(node.state);
81
105
  const summary = node.description?.trim() || undefined;
106
+ const createdAtUnix = node.createdAt ? parseCreatedAt(node.createdAt) : undefined;
82
107
  return [
83
108
  {
84
109
  name: node.context,
85
110
  status,
86
111
  conclusion,
112
+ source: "status_context",
87
113
  detailsUrl: node.targetUrl ?? "",
88
114
  event: null,
89
115
  runId: null,
116
+ ...(createdAtUnix !== undefined && { createdAtUnix }),
90
117
  ...(summary !== undefined && { summary }),
91
118
  },
92
119
  ];
@@ -1,5 +1,6 @@
1
1
  import { graphql, graphqlWithRateLimit } from "./client.mjs";
2
2
  import { paginateForward, paginateBackward } from "./pagination.mjs";
3
+ import { hydrateThreadCommentPages } from "./thread-comments.mjs";
3
4
  import { BATCH_PR_QUERY } from "./queries.mjs";
4
5
  import { parseRawPr } from "./batch-parsers.mjs";
5
6
  /**
@@ -36,6 +37,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
36
37
  // extra contains pages before the first page.
37
38
  rawThreadPages = [...extra, ...rawThreadPages];
38
39
  }
40
+ rawThreadPages = await hydrateThreadCommentPages(rawThreadPages);
39
41
  // Paginate comments backward if the first page is incomplete.
40
42
  let rawCommentNodes = raw.comments.nodes;
41
43
  if (raw.comments.pageInfo.hasPreviousPage && raw.comments.pageInfo.startCursor) {