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
@@ -52,11 +52,25 @@ export function buildEscalateHumanMessage(escalate, pr) {
52
52
  lines.push(escalate.suggestion);
53
53
  const hasItems = escalate.unresolvedThreads.length > 0 ||
54
54
  escalate.changesRequestedReviews.length > 0 ||
55
- escalate.ambiguousComments.length > 0;
55
+ escalate.ambiguousComments.length > 0 ||
56
+ (escalate.stalledChecks?.length ?? 0) > 0;
56
57
  if (hasItems) {
57
58
  lines.push("");
58
59
  lines.push("## Items needing attention");
59
60
  lines.push("");
61
+ for (const c of escalate.stalledChecks ?? []) {
62
+ const target = c.runId
63
+ ? `run \`${c.runId}\``
64
+ : c.detailsUrl
65
+ ? `external \`${c.detailsUrl}\``
66
+ : "no run ID";
67
+ const ageMinutes = Math.floor(c.ageSeconds / 60);
68
+ lines.push(`- check \`${c.name}\` — ${c.status} ${c.source}, ${target}, waiting ${ageMinutes} minute${ageMinutes === 1 ? "" : "s"}`);
69
+ if (c.summary)
70
+ lines.push(` > ${c.summary}`);
71
+ }
72
+ if ((escalate.stalledChecks?.length ?? 0) > 0)
73
+ lines.push("");
60
74
  for (const t of escalate.unresolvedThreads) {
61
75
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
62
76
  lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
@@ -96,7 +110,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
96
110
  }
97
111
  export function buildEscalateSuggestion(triggers, detail) {
98
112
  if (triggers.includes("stall-timeout")) {
99
- const mins = detail ?? "30";
113
+ const mins = detail ?? "60";
100
114
  return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
101
115
  }
102
116
  if (triggers.includes("base-branch-unknown")) {
@@ -1,23 +1,35 @@
1
1
  /* eslint-disable max-lines */
2
- import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
2
+ import { readFixAttempts, writeFixAttempts, } from "../../state/fix-attempts.mjs";
3
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
4
+ import { hashBody, markSeen } from "../../state/seen-comments.mjs";
4
5
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
5
6
  import { buildResolveCommand } from "./classify.mjs";
6
7
  import { buildFixInstructions } from "./render.mjs";
7
8
  import { applyStallGuard } from "./stall.mjs";
8
9
  import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
10
+ import { annotationMarkerBody } from "../check-annotations.mjs";
11
+ import { threadTranscriptBody } from "../../threads/transcript.mjs";
12
+ function nextFixAttempts(stored, headSha, threads) {
13
+ const threadAttempts = stored ? { ...stored.threadAttempts } : {};
14
+ const threadBodyHashes = stored?.threadBodyHashes
15
+ ? { ...stored.threadBodyHashes }
16
+ : {};
17
+ for (const t of threads) {
18
+ const bodyHash = hashBody(threadTranscriptBody(t));
19
+ const previousHash = threadBodyHashes[t.id];
20
+ if (stored?.headSha === headSha && (previousHash === undefined || previousHash === bodyHash))
21
+ continue;
22
+ threadAttempts[t.id] = previousHash === bodyHash ? (threadAttempts[t.id] ?? 0) + 1 : 1;
23
+ threadBodyHashes[t.id] = bodyHash;
24
+ }
25
+ return { threadAttempts, threadBodyHashes };
26
+ }
9
27
  export async function handleFixCode(ctx) {
10
28
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
11
29
  const failingChecks = report.checks.failing;
12
30
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
13
- const isNewSha = stored?.headSha !== headSha;
14
- const currentAttempts = stored ? { ...stored.threadAttempts } : {};
15
- if (isNewSha) {
16
- for (const t of report.threads.actionable) {
17
- currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
18
- }
19
- }
20
- const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
31
+ const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
32
+ const escalateTriggers = checkEscalateTriggers(report.threads.actionable, threadAttempts);
21
33
  if (escalateTriggers.triggers.length > 0) {
22
34
  const escalateBase = {
23
35
  triggers: escalateTriggers.triggers,
@@ -36,7 +48,7 @@ export async function handleFixCode(ctx) {
36
48
  },
37
49
  };
38
50
  }
39
- await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts: currentAttempts });
51
+ await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
40
52
  let cancelled = [];
41
53
  if (!opts.noAutoCancelActionable) {
42
54
  const uniqueRunIds = [
@@ -65,12 +77,6 @@ export async function handleFixCode(ctx) {
65
77
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
66
78
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
67
79
  const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
68
- const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
69
- if (overlappingReviewIds.length > 0) {
70
- process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
71
- `review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
72
- `${overlappingReviewIds.join(", ")}\n`);
73
- }
74
80
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
75
81
  // would need the correct base to rebase safely. This is a conservative guard, not a
76
82
  // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
@@ -101,7 +107,7 @@ export async function handleFixCode(ctx) {
101
107
  const firstLookThreads = report.threads.firstLook;
102
108
  const firstLookComments = report.comments.firstLook;
103
109
  const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads);
104
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
110
+ const prospectiveResult = {
105
111
  ...base,
106
112
  baseBranch: baseLookup.branch,
107
113
  action: "fix_code",
@@ -122,5 +128,10 @@ export async function handleFixCode(ctx) {
122
128
  inProgressRunIds,
123
129
  },
124
130
  cancelled,
125
- }, report, reviewSummaryIds);
131
+ };
132
+ const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
133
+ if (result.action === "fix_code") {
134
+ await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
135
+ }
136
+ return result;
126
137
  }
@@ -53,6 +53,7 @@ export function buildRelevantChecks(report) {
53
53
  ...(c.jobName !== undefined && { jobName: c.jobName }),
54
54
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
55
55
  ...(c.summary !== undefined && { summary: c.summary }),
56
+ ...(c.annotations !== undefined && { annotations: c.annotations }),
56
57
  },
57
58
  ];
58
59
  });
@@ -30,6 +30,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
30
30
  actionableSections.push("`## Actionable comments`");
31
31
  if (checks.length > 0)
32
32
  actionableSections.push("`## Failing checks`");
33
+ if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
34
+ actionableSections.push("`## Check annotations`");
35
+ }
33
36
  if (changesRequestedReviews.length > 0)
34
37
  actionableSections.push("`## Changes-requested reviews`");
35
38
  const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
@@ -62,9 +65,12 @@ export function buildFixInstructions(threads, actionableComments, checks, change
62
65
  instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
63
66
  }
64
67
  if (resolutionOnlyThreads.length > 0) {
65
- instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
68
+ instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
66
69
  }
67
70
  instructions.push(...buildFailingCheckInstructions(checks));
71
+ if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
72
+ instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
73
+ }
68
74
  if (changesRequestedReviews.length > 0) {
69
75
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
70
76
  }
@@ -77,7 +83,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
77
83
  substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
78
84
  }
79
85
  if (resolveCommand.requiresDismissMessage) {
80
- substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
86
+ substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
81
87
  }
82
88
  const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
83
89
  instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
@@ -99,7 +105,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
99
105
  if (editedTotal > 0) {
100
106
  instructions.push(`Items marked \`[edited since first look]\`, items under \`## Review summaries (edited since first look)\`, and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
101
107
  }
102
- if (resolveCommand.hasMutations) {
108
+ if (resolveCommand.hasMutations || hasNonConflictHints || firstLookTotal > 0) {
103
109
  instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
104
110
  }
105
111
  instructions.push(FIX_INSTRUCTION_STOP);
@@ -1,5 +1,5 @@
1
1
  import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
- import { toAgentThread, toAgentComment } from "../../reporters/agent.mjs";
2
+ import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
4
4
  export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
5
5
  const checks = [
@@ -27,8 +27,31 @@ export function computeStallFingerprint(action, headSha, base, report, reviewSum
27
27
  });
28
28
  }
29
29
  export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
30
- const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
31
30
  const nowSeconds = Math.floor(Date.now() / 1000);
31
+ const stalledChecks = findCiStartStalledChecks(report.checks.inProgress, nowSeconds, {
32
+ stallTimeoutSeconds,
33
+ action: prospectiveResult.action,
34
+ });
35
+ if (stalledChecks.length > 0) {
36
+ const stalledMinutes = Math.floor(Math.max(...stalledChecks.map((c) => c.ageSeconds)) / 60);
37
+ const escalateBase = {
38
+ triggers: ["stall-timeout"],
39
+ unresolvedThreads: [],
40
+ ambiguousComments: [],
41
+ changesRequestedReviews: [],
42
+ stalledChecks,
43
+ suggestion: buildEscalateSuggestion(["stall-timeout"], String(stalledMinutes)),
44
+ };
45
+ return {
46
+ ...base,
47
+ action: "escalate",
48
+ escalate: {
49
+ ...escalateBase,
50
+ humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
51
+ },
52
+ };
53
+ }
54
+ const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
32
55
  const stored = await readStallState(stallKey);
33
56
  if (stored && stored.fingerprint === fingerprint) {
34
57
  const ageSeconds = nowSeconds - stored.firstSeenAt;
@@ -65,3 +88,21 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
65
88
  await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
66
89
  return prospectiveResult;
67
90
  }
91
+ function findCiStartStalledChecks(checks, nowSeconds, opts) {
92
+ if (opts.stallTimeoutSeconds <= 0 || opts.action !== "wait")
93
+ return [];
94
+ return checks
95
+ .filter((c) => isUnstartedCheck(c))
96
+ .map((c) => toAgentStalledCheck(c, nowSeconds))
97
+ .filter((c) => c.createdAtUnix !== undefined && c.ageSeconds >= opts.stallTimeoutSeconds);
98
+ }
99
+ function isUnstartedCheck(check) {
100
+ if (check.source === "status_context")
101
+ return true;
102
+ if (check.startedAtUnix !== undefined)
103
+ return false;
104
+ return (check.status === "PENDING" ||
105
+ check.status === "QUEUED" ||
106
+ check.status === "REQUESTED" ||
107
+ check.status === "WAITING");
108
+ }
@@ -103,7 +103,7 @@ function defaultConfig() {
103
103
  return {
104
104
  iterate: {
105
105
  fixAttemptsPerThread: 3,
106
- stallTimeoutMinutes: 30,
106
+ stallTimeoutMinutes: 60,
107
107
  minimizeApprovals: false,
108
108
  minimizeComments: "all",
109
109
  },
@@ -0,0 +1,18 @@
1
+ import { NOW } from "./iterate-test-support.mjs";
2
+ export function makeThread(overrides = {}) {
3
+ return {
4
+ id: "thread-1",
5
+ isResolved: false,
6
+ isOutdated: false,
7
+ isMinimized: false,
8
+ path: "src/foo.mts",
9
+ line: 10,
10
+ startLine: null,
11
+ author: "reviewer",
12
+ authorType: "Unknown",
13
+ body: "Fix this",
14
+ url: "",
15
+ createdAtUnix: NOW - 3600,
16
+ ...overrides,
17
+ };
18
+ }
@@ -23,6 +23,13 @@ vi.mock("../state/iterate-stall.mts", () => ({
23
23
  readStallState: vi.fn().mockResolvedValue(null),
24
24
  writeStallState: vi.fn().mockResolvedValue(undefined),
25
25
  }));
26
+ vi.mock("../state/seen-comments.mts", async (importOriginal) => {
27
+ const actual = await importOriginal();
28
+ return {
29
+ ...actual,
30
+ markSeen: vi.fn().mockResolvedValue(undefined),
31
+ };
32
+ });
26
33
  const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
27
34
  vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
28
35
  import { runIterate } from "./iterate/index.mjs";
@@ -30,12 +37,14 @@ import { runCheck } from "./check.mjs";
30
37
  import { updateReadyDelay } from "./ready-delay.mjs";
31
38
  import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
32
39
  import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
40
+ import { markSeen } from "../state/seen-comments.mjs";
33
41
  const mockRunCheck = vi.mocked(runCheck);
34
42
  const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
35
43
  const mockReadFixAttempts = vi.mocked(readFixAttempts);
36
44
  const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
37
45
  const mockReadStallState = vi.mocked(readStallState);
38
46
  const mockWriteStallState = vi.mocked(writeStallState);
47
+ const mockMarkSeen = vi.mocked(markSeen);
39
48
  function makeReport(overrides = {}) {
40
49
  return {
41
50
  pr: 42,
@@ -115,4 +124,4 @@ export function registerHooks() {
115
124
  vi.restoreAllMocks();
116
125
  });
117
126
  }
118
- export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
127
+ export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockMarkSeen, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
@@ -0,0 +1,220 @@
1
+ /* eslint-disable max-lines */
2
+ import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
3
+ import { paginateForward } from "../github/pagination.mjs";
4
+ import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
5
+ const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
+ repository(owner: $owner, name: $repo) {
7
+ pullRequest(number: $pr) {
8
+ id
9
+ number
10
+ files(first: 100, after: $filesCursor) {
11
+ pageInfo {
12
+ hasNextPage
13
+ endCursor
14
+ }
15
+ nodes {
16
+ path
17
+ viewerViewedState
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }`;
23
+ const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
24
+ const BULK_CHUNK_SIZE = 10;
25
+ export async function runMarkFilesAsViewed(opts) {
26
+ const repo = await getRepoInfo();
27
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
28
+ if (!prNumber)
29
+ throw new Error("No PR number provided and no current branch PR found");
30
+ const matchPatterns = opts.matchPatterns ?? [];
31
+ const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
32
+ const fetched = await fetchPullRequestFiles(prNumber, repo);
33
+ const selected = selectChangedFiles(fetched.files, {
34
+ files: opts.files,
35
+ tests: opts.tests === true,
36
+ matchPatterns,
37
+ matchRegexes,
38
+ });
39
+ const result = {
40
+ repo: `${repo.owner}/${repo.name}`,
41
+ prNumber,
42
+ pullRequestId: fetched.pullRequestId,
43
+ requestedPaths: opts.files,
44
+ testSelector: opts.tests === true,
45
+ matchPatterns,
46
+ matchedPaths: selected.matchedPaths,
47
+ markedPaths: [],
48
+ alreadyViewedPaths: selected.alreadyViewedPaths,
49
+ missingPaths: selected.missingPaths,
50
+ unmatchedSelectors: selected.unmatchedSelectors,
51
+ errors: [],
52
+ };
53
+ await bulkMarkFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
54
+ return result;
55
+ }
56
+ async function fetchPullRequestFiles(pr, repo) {
57
+ const first = await graphql(FILES_QUERY, {
58
+ owner: repo.owner,
59
+ repo: repo.name,
60
+ pr,
61
+ });
62
+ const raw = first.data.repository?.pullRequest;
63
+ if (!raw)
64
+ throw new Error(`PR #${pr} not found`);
65
+ let files = raw.files.nodes;
66
+ if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
67
+ const extra = await paginateForward(async (cursor) => {
68
+ const res = await graphql(FILES_QUERY, {
69
+ owner: repo.owner,
70
+ repo: repo.name,
71
+ pr,
72
+ ...(cursor ? { filesCursor: cursor } : {}),
73
+ });
74
+ const pr2 = res.data.repository?.pullRequest;
75
+ if (!pr2)
76
+ throw new Error(`PR #${pr} not found`);
77
+ return pr2.files;
78
+ }, raw.files.pageInfo.endCursor);
79
+ files = [...files, ...extra];
80
+ }
81
+ return { pullRequestId: raw.id, files };
82
+ }
83
+ function compilePattern(pattern) {
84
+ try {
85
+ return new RegExp(pattern, "i");
86
+ }
87
+ catch (e) {
88
+ const msg = e instanceof Error ? e.message : String(e);
89
+ throw new Error(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`);
90
+ }
91
+ }
92
+ function selectChangedFiles(changedFiles, opts) {
93
+ const byPath = new Map(changedFiles.map((f) => [f.path, f]));
94
+ const matched = new Set();
95
+ const missingPaths = [];
96
+ const unmatchedSelectors = [];
97
+ for (const path of opts.files) {
98
+ if (byPath.has(path))
99
+ matched.add(path);
100
+ else
101
+ missingPaths.push(path);
102
+ }
103
+ if (opts.tests) {
104
+ let matchedAny = false;
105
+ for (const file of changedFiles) {
106
+ if (TEST_FILE_RE.test(file.path)) {
107
+ matched.add(file.path);
108
+ matchedAny = true;
109
+ }
110
+ }
111
+ if (!matchedAny)
112
+ unmatchedSelectors.push("--tests");
113
+ }
114
+ for (let i = 0; i < opts.matchRegexes.length; i += 1) {
115
+ let matchedAny = false;
116
+ const regex = opts.matchRegexes[i];
117
+ for (const file of changedFiles) {
118
+ if (regex.test(file.path)) {
119
+ matched.add(file.path);
120
+ matchedAny = true;
121
+ }
122
+ }
123
+ if (!matchedAny)
124
+ unmatchedSelectors.push(`--match ${opts.matchPatterns[i]}`);
125
+ }
126
+ const matchedPaths = [...matched];
127
+ const alreadyViewedPaths = matchedPaths.filter((path) => byPath.get(path)?.viewerViewedState === "VIEWED");
128
+ const alreadyViewedSet = new Set(alreadyViewedPaths);
129
+ const pathsToMark = matchedPaths.filter((path) => !alreadyViewedSet.has(path));
130
+ return { matchedPaths, alreadyViewedPaths, missingPaths, unmatchedSelectors, pathsToMark };
131
+ }
132
+ function buildBulkMutation(paths) {
133
+ const ops = paths.map((path, i) => ` m${i}: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: ${JSON.stringify(path)} }) { pullRequest { id } }`);
134
+ return `mutation BulkMarkFilesAsViewed($pullRequestId: ID!) {\n${ops.join("\n")}\n}`;
135
+ }
136
+ async function bulkMarkFilesAsViewed(pullRequestId, paths, result) {
137
+ for (let i = 0; i < paths.length; i += BULK_CHUNK_SIZE) {
138
+ const chunk = paths.slice(i, i + BULK_CHUNK_SIZE);
139
+ // eslint-disable-next-line no-await-in-loop
140
+ const stopped = await bulkMarkFilesAsViewedChunk(pullRequestId, chunk, result, i + BULK_CHUNK_SIZE < paths.length);
141
+ if (stopped) {
142
+ const markedSet = new Set(result.markedPaths);
143
+ result.unmarkedPaths = paths.slice(i).filter((path) => !markedSet.has(path));
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendingAfter) {
149
+ if (paths.length === 0)
150
+ return false;
151
+ let data = {};
152
+ let graphQlErrors = [];
153
+ let suppressCurrentChunkErrors = false;
154
+ let rateLimitStop;
155
+ try {
156
+ const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
157
+ pullRequestId,
158
+ });
159
+ data = resp.data;
160
+ graphQlErrors = (resp.errors ?? []);
161
+ const messages = graphQlErrors.map((e) => e.message);
162
+ suppressCurrentChunkErrors = messages.some(isRateLimitMessage);
163
+ rateLimitStop = rateLimitFromGraphQlResult(messages, {
164
+ rateLimit: resp.rateLimit,
165
+ retryAfterSeconds: resp.retryAfterSeconds,
166
+ stopOnZeroRemaining: hasPendingAfter,
167
+ });
168
+ }
169
+ catch (err) {
170
+ const msg = err instanceof Error ? err.message : String(err);
171
+ const stop = rateLimitFromError(err, msg);
172
+ if (stop) {
173
+ result.errors.push(`rate limit: ${stop.message}`);
174
+ result.rateLimit = stop;
175
+ return true;
176
+ }
177
+ for (const path of paths)
178
+ result.errors.push(`${path}: ${msg}`);
179
+ return false;
180
+ }
181
+ const errorMessagesByAlias = mapAliasErrors(graphQlErrors);
182
+ for (let i = 0; i < paths.length; i += 1) {
183
+ const alias = `m${i}`;
184
+ const m = data[alias];
185
+ if (m?.pullRequest?.id === pullRequestId) {
186
+ result.markedPaths.push(paths[i]);
187
+ }
188
+ else if (!suppressCurrentChunkErrors) {
189
+ result.errors.push(`${paths[i]}: ${errorMessagesByAlias.get(alias) ?? "mark returned null"}`);
190
+ }
191
+ }
192
+ if (rateLimitStop) {
193
+ result.errors.push(`rate limit: ${rateLimitStop.message}`);
194
+ result.rateLimit = rateLimitStop;
195
+ return true;
196
+ }
197
+ return false;
198
+ }
199
+ function mapAliasErrors(errors) {
200
+ const out = new Map();
201
+ for (const error of errors) {
202
+ if (!Array.isArray(error.path))
203
+ continue;
204
+ const alias = error.path.find((part) => typeof part === "string" && isMarkAlias(part));
205
+ if (typeof alias === "string")
206
+ out.set(alias, error.message);
207
+ }
208
+ return out;
209
+ }
210
+ function isMarkAlias(value) {
211
+ if (!value.startsWith("m"))
212
+ return false;
213
+ if (value.length === 1)
214
+ return false;
215
+ for (const char of value.slice(1)) {
216
+ if (char < "0" || char > "9")
217
+ return false;
218
+ }
219
+ return true;
220
+ }
@@ -1,16 +1,53 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
+ import { fetchPrBatch } from "../github/batch.mjs";
4
+ import { isHumanAuthor } from "../comments/authors.mjs";
5
+ import { markSeen } from "../state/seen-comments.mjs";
6
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
7
  export async function runResolveMutate(opts) {
4
8
  const repo = await getRepoInfo();
5
9
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
6
10
  if (prNumber === null) {
7
11
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
8
12
  }
9
- return applyResolveOptions(prNumber, repo, {
10
- resolveThreadIds: opts.resolveThreadIds,
11
- minimizeCommentIds: opts.minimizeCommentIds,
12
- dismissReviewIds: opts.dismissReviewIds,
13
+ const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
14
+ const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
15
+ const humanThreadIds = new Set(data.reviewThreads.filter(isHumanAuthor).map((t) => t.id));
16
+ const humanCommentIds = new Set(data.comments.filter(isHumanAuthor).map((c) => c.id));
17
+ const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
18
+ .filter(isHumanAuthor)
19
+ .map((r) => r.id));
20
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
21
+ const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
22
+ const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
23
+ const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
24
+ const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
25
+ const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
26
+ const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id));
27
+ const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
28
+ const result = await applyResolveOptions(prNumber, repo, {
29
+ resolveThreadIds,
30
+ replyThreadIds,
31
+ minimizeCommentIds,
32
+ dismissReviewIds,
13
33
  dismissMessage: opts.dismissMessage,
14
34
  requireSha: opts.requireSha,
15
35
  });
36
+ if (skippedHumanResolves.length > 0)
37
+ result.skippedHumanResolves = skippedHumanResolves;
38
+ if (skippedHumanMinimizes.length > 0)
39
+ result.skippedHumanMinimizes = skippedHumanMinimizes;
40
+ if (skippedHumanDismissals.length > 0)
41
+ result.skippedHumanDismissals = skippedHumanDismissals;
42
+ if (skippedNonHumanReplies.length > 0)
43
+ result.skippedNonHumanReplies = skippedNonHumanReplies;
44
+ if (opts.dismissMessage) {
45
+ await Promise.all(result.repliedThreads.map((id) => {
46
+ const thread = threadById.get(id);
47
+ if (!thread)
48
+ return Promise.resolve();
49
+ return markSeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, threadTranscriptBody(thread, [opts.dismissMessage]));
50
+ }));
51
+ }
52
+ return result;
16
53
  }