pr-shepherd 1.0.0 → 1.1.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/bin/cli/poll-summary-formatter.mjs +3 -0
  3. package/bin/cli/rule-auto-resolve-lines.d.mts +3 -0
  4. package/bin/cli/rule-auto-resolve-lines.mjs +12 -0
  5. package/bin/cli/stack-layer-view.d.mts +2 -1
  6. package/bin/cli/stack-overview.mjs +3 -0
  7. package/bin/commands/check-activity.d.mts +8 -0
  8. package/bin/commands/check-activity.mjs +26 -0
  9. package/bin/commands/check-annotations.mjs +2 -1
  10. package/bin/commands/check-wait-detectors.mjs +11 -0
  11. package/bin/commands/check.mjs +6 -1
  12. package/bin/commands/poll-summary-signature.d.mts +6 -0
  13. package/bin/commands/poll-summary-signature.mjs +19 -2
  14. package/bin/commands/poll-summary.mjs +7 -0
  15. package/bin/commands/ready-delay.d.mts +1 -0
  16. package/bin/commands/ready-delay.mjs +8 -3
  17. package/bin/commands/rule-auto-resolve-format.d.mts +7 -0
  18. package/bin/commands/rule-auto-resolve-format.mjs +11 -4
  19. package/bin/commands/rule-auto-resolve.d.mts +14 -2
  20. package/bin/comments/thread-visibility.d.mts +6 -0
  21. package/bin/comments/thread-visibility.mjs +30 -15
  22. package/bin/github/batch-parse-suites.d.mts +14 -0
  23. package/bin/github/batch-parse-suites.mjs +4 -1
  24. package/bin/github/batch-parser-helpers.mjs +3 -0
  25. package/bin/github/check-annotation-cache.d.mts +5 -0
  26. package/bin/github/check-annotation-cache.mjs +6 -3
  27. package/bin/github/fingerprint.mjs +4 -0
  28. package/bin/github/poll-summary-annotation-probe.d.mts +5 -0
  29. package/bin/github/poll-summary-annotation-probe.mjs +17 -3
  30. package/bin/github/poll-summary-annotations.d.mts +14 -0
  31. package/bin/github/poll-summary-annotations.mjs +75 -0
  32. package/bin/github/poll-summary-blocking-reviewer.d.mts +2 -0
  33. package/bin/github/poll-summary-blocking-reviewer.mjs +9 -0
  34. package/bin/github/poll-summary-checks.d.mts +1 -0
  35. package/bin/github/poll-summary-checks.mjs +20 -2
  36. package/bin/github/poll-summary-fingerprint.d.mts +7 -0
  37. package/bin/github/poll-summary-fingerprint.mjs +7 -0
  38. package/bin/github/poll-summary-projector.d.mts +3 -1
  39. package/bin/github/poll-summary-projector.mjs +13 -24
  40. package/bin/github/poll-summary-queue-removal.d.mts +2 -0
  41. package/bin/github/poll-summary-queue-removal.mjs +11 -0
  42. package/bin/github/poll-summary-readiness.mjs +1 -0
  43. package/bin/github/poll-summary-review-parity.d.mts +24 -0
  44. package/bin/github/poll-summary-review-parity.mjs +82 -0
  45. package/bin/github/poll-summary-review.d.mts +11 -2
  46. package/bin/github/poll-summary-review.mjs +87 -59
  47. package/bin/github/poll-summary-row-error.d.mts +16 -0
  48. package/bin/github/poll-summary-row-error.mjs +74 -0
  49. package/bin/github/poll-summary-rule-auto-resolve.d.mts +32 -0
  50. package/bin/github/poll-summary-rule-auto-resolve.mjs +119 -0
  51. package/bin/github/poll-summary-rule-handoff.d.mts +7 -0
  52. package/bin/github/poll-summary-rule-handoff.mjs +20 -0
  53. package/bin/github/poll-summary-rule-items.d.mts +20 -0
  54. package/bin/github/poll-summary-rule-items.mjs +28 -0
  55. package/bin/github/poll-summary.mjs +15 -7
  56. package/bin/github/rest-reader-core.mjs +13 -2
  57. package/bin/github/rest-summary-read.mjs +12 -1
  58. package/bin/types/github.d.mts +2 -0
  59. package/bin/types/poll-summary.d.mts +23 -0
  60. package/package.json +2 -2
  61. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  62. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  64. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -3,6 +3,7 @@ import { withPollSummaryInstructions } from "../commands/poll-summary-instructio
3
3
  import { formatStackOverview, projectStackOverview } from "./stack-overview.mjs";
4
4
  import { formatTransportEvidence } from "./transport-formatter.mjs";
5
5
  import { formatNextCheckLines } from "./next-check-format.mjs";
6
+ import { formatRuleAutoResolveLines } from "./rule-auto-resolve-lines.mjs";
6
7
  export function formatPollSummaryResult(result) {
7
8
  if (result.selection.kind === "stack")
8
9
  return formatStackOverview(projectStackOverview(result));
@@ -69,12 +70,14 @@ function formatItem(item) {
69
70
  ...(review
70
71
  ? [` - review: ${formatCounts(review, review.incomplete ? ", incomplete" : "")}`]
71
72
  : []),
73
+ ...formatRuleAutoResolveLines(item.ruleAutoResolve),
72
74
  ...(item.queueRemoval
73
75
  ? [
74
76
  ` - queue removal: reason \`${item.queueRemoval.reason ?? "UNKNOWN"}\` · at \`${item.queueRemoval.createdAtUnix}\`${item.queueRemoval.actor ? ` · actor \`@${item.queueRemoval.actor}\`` : ""}${item.queueRemoval.beforeCommitOid ? ` · commit \`${item.queueRemoval.beforeCommitOid}\`` : ""}${item.queueRemoval.beforeCommitParentOids?.length ? ` · parents \`${item.queueRemoval.beforeCommitParentOids.join(",")}\`` : ""}`,
75
77
  ]
76
78
  : []),
77
79
  ` - reasons: ${item.reasons.map((reason) => `\`${reason}\``).join(", ")}`,
80
+ ...(item.error ? [` - error: ${item.error.replace(/\s*[\r\n]+\s*/g, " ")}`] : []),
78
81
  ...(item.transport ? [` - transport \`${item.transport}\``] : []),
79
82
  ...(item.transportUnavailable ?? []).map(({ field, reason }) => ` - unavailable \`${field}\`: ${reason}`),
80
83
  ...(item.pollCommand
@@ -0,0 +1,3 @@
1
+ import type { PollSummaryRuleAutoResolve } from "../types.mts";
2
+ /** Row lines for an aggregate row or stack layer: the one-PR `## Classification auto-resolve` content. */
3
+ export declare function formatRuleAutoResolveLines(event: PollSummaryRuleAutoResolve | undefined): string[];
@@ -0,0 +1,12 @@
1
+ /** Row lines for an aggregate row or stack layer: the one-PR `## Classification auto-resolve` content. */
2
+ export function formatRuleAutoResolveLines(event) {
3
+ if (!event)
4
+ return [];
5
+ const link = (kind, id, url) => ` - ${kind} \`${id}\`${url ? ` ${url}` : ""}`;
6
+ return [
7
+ ` - classification auto-resolve: ${event.summary}`,
8
+ ...(event.threads ?? []).map((thread) => link("thread", thread.id, thread.url)),
9
+ ...(event.minimized ?? []).map((item) => link(item.kind, item.id, item.url)),
10
+ ...(event.errors ?? []).map((error) => ` - ${error}`),
11
+ ];
12
+ }
@@ -1,4 +1,4 @@
1
- import type { PollSummaryItem } from "../types.mts";
1
+ import type { PollSummaryItem, PollSummaryRuleAutoResolve } from "../types.mts";
2
2
  /** One lean stack row shared by Markdown, JSON, and MCP. */
3
3
  export interface StackLayerView {
4
4
  transport?: "rest";
@@ -26,6 +26,7 @@ export interface StackLayerView {
26
26
  actionable?: number;
27
27
  checksIncomplete?: true;
28
28
  reviewIncomplete?: true;
29
+ ruleAutoResolve?: PollSummaryRuleAutoResolve;
29
30
  queueRemoval?: {
30
31
  reason: string | null;
31
32
  actor?: string;
@@ -3,6 +3,7 @@ import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
3
3
  import { formatTransportEvidence } from "./transport-formatter.mjs";
4
4
  import { formatNextCheckLines } from "./next-check-format.mjs";
5
5
  import { formatPrUrl } from "../pr-reference.mjs";
6
+ import { formatRuleAutoResolveLines } from "./rule-auto-resolve-lines.mjs";
6
7
  /** Lean stack payload shared by Markdown, JSON, and MCP. */
7
8
  export function projectStackOverview(result) {
8
9
  const selection = result.selection;
@@ -59,6 +60,7 @@ function projectLayer(item, stale) {
59
60
  (item.review?.actionable ?? 0) > 0 && { actionable: item.review?.actionable }),
60
61
  ...(item.checks?.incomplete && { checksIncomplete: true }),
61
62
  ...(item.review?.incomplete && { reviewIncomplete: true }),
63
+ ...(item.ruleAutoResolve && { ruleAutoResolve: item.ruleAutoResolve }),
62
64
  ...(removal && {
63
65
  queueRemoval: {
64
66
  reason: removal.reason,
@@ -156,6 +158,7 @@ function formatLayerLines(layer, index, overview) {
156
158
  return [
157
159
  `- ${layer.url === formatPrUrl(overview.repo, layer.pr) ? `PR #${layer.pr}: ${layer.title}` : `[PR #${layer.pr}: ${layer.title}](${layer.url})`}${facts.length > 0 ? ` — ${facts.join(" · ")}` : ""}`,
158
160
  ` - ${details.join(" · ")}`,
161
+ ...formatRuleAutoResolveLines(layer.ruleAutoResolve),
159
162
  ...(layer.transport ? [` - transport \`${layer.transport}\``] : []),
160
163
  ...(layer.transportUnavailable ?? []).map(({ field, reason }) => ` - unavailable \`${field}\`: ${reason}`),
161
164
  ];
@@ -0,0 +1,8 @@
1
+ import type { BatchPartition } from "../classify/apply.mts";
2
+ import type { PrActivitySummary } from "../types.mts";
3
+ /**
4
+ * Drop rule-suppressed items from the activity digest, matching what `check` hides from the
5
+ * agent. IDs whose automatic minimize/resolve was denied stay visible: `check` surfaces those
6
+ * once so the caller can act. `reviewRoundCount` is raw review history and stays unchanged.
7
+ */
8
+ export declare function filterSuppressedActivity(activity: PrActivitySummary | undefined, partition: BatchPartition, denied: Iterable<string>): PrActivitySummary | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Drop rule-suppressed items from the activity digest, matching what `check` hides from the
3
+ * agent. IDs whose automatic minimize/resolve was denied stay visible: `check` surfaces those
4
+ * once so the caller can act. `reviewRoundCount` is raw review history and stays unchanged.
5
+ */
6
+ export function filterSuppressedActivity(activity, partition, denied) {
7
+ if (!activity)
8
+ return activity;
9
+ const deniedIds = new Set(denied);
10
+ const hidden = (id, suppressed) => suppressed.has(id) && !deniedIds.has(id);
11
+ const reviewItemsSinceLatestCommit = activity.reviewItemsSinceLatestCommit.filter((item) => {
12
+ switch (item.kind) {
13
+ case "pr-comment":
14
+ return !hidden(item.id, partition.suppressedCommentIds);
15
+ case "review-thread-comment":
16
+ return !item.threadId || !hidden(item.threadId, partition.suppressedThreadIds);
17
+ case "review-summary":
18
+ return !hidden(item.id, partition.suppressedReviewSummaryIds);
19
+ case "changes-requested-review":
20
+ return !partition.suppressedChangesRequestedIds.has(item.id);
21
+ default:
22
+ return true;
23
+ }
24
+ });
25
+ return { ...activity, reviewItemsSinceLatestCommit };
26
+ }
@@ -42,7 +42,8 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber,
42
42
  ...buckets.filtered,
43
43
  ...buckets.ignored,
44
44
  ].filter(shouldFetchCheckAnnotations);
45
- const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber, cacheOpts);
45
+ const expectedCounts = new Map(candidates.flatMap((c) => c.id != null && c.annotationCount ? [[c.id, c.annotationCount]] : []));
46
+ const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber, cacheOpts && { ...cacheOpts, expectedCounts });
46
47
  const byId = new Map(annotated.flatMap((c) => (c.id != null ? [[c.id, c]] : [])));
47
48
  const apply = (list) => list.map((c) => {
48
49
  const next = (c.id == null ? undefined : byId.get(c.id)) ?? c;
@@ -30,6 +30,17 @@ function detectorsCover(stored) {
30
30
  * Read the PR's REST change detectors: the pull, the head's check runs, check suites and
31
31
  * statuses, the three feedback lists, and the base branch. Every read is conditional, so an
32
32
  * unchanged resource answers a free 304.
33
+ *
34
+ * The feedback lists are deliberately not rule-aware (#635 gap 7). An edited bot comment, even
35
+ * a rule-suppressed or minimized one, answers 200 and wakes the tick. The wake costs one
36
+ * charged list read plus one `BatchPr` first page (a complete fingerprint needs no more),
37
+ * the same point a fingerprint hit spends. Once the digest is re-recorded, a later eligible
38
+ * tick can replay for free again, but only if every detector answers 304 with that digest and
39
+ * the stored snapshot is within `poll.reconcile-seconds`; otherwise it goes to `BatchPr`. Being
40
+ * rule-aware would need the loaded rules, a persisted set of hidden comment ids (REST has no
41
+ * `isMinimized`), and a content digest in place of the ETag. That is more code, and more risk
42
+ * of sleeping through a real change (a rule-suppressed comment edited so it no longer matches,
43
+ * an edited minimized human comment, which `check` surfaces), than the one point it saves.
33
44
  */
34
45
  async function readDetectors(prNumber, repo, report) {
35
46
  const prefix = restRepoPath(repo);
@@ -33,6 +33,7 @@ import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers
33
33
  import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
34
34
  import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./iterate/thread-mutation-routing.mjs";
35
35
  import { loadClassifyRules } from "../classify/loader.mjs";
36
+ import { filterSuppressedActivity } from "./check-activity.mjs";
36
37
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
37
38
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
38
39
  import { getEffectiveCwd } from "../execution-context.mjs";
@@ -449,7 +450,11 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
449
450
  ...(batchData.allowedMergeMethods && {
450
451
  allowedMergeMethods: batchData.allowedMergeMethods,
451
452
  }),
452
- activity: batchData.activity,
453
+ activity: filterSuppressedActivity(batchData.activity, partition, [
454
+ ...deniedRuleAutoResolveCommentIds,
455
+ ...deniedRuleAutoResolveThreadIds,
456
+ ...deniedRuleAutoResolveReviewSummaryIds,
457
+ ]),
453
458
  ...(hasQueueState && {
454
459
  mergeQueue: {
455
460
  enabled: Boolean(batchData.isMergeQueueEnabled),
@@ -1,2 +1,8 @@
1
1
  import type { PollSummaryResult } from "../types.mts";
2
+ /**
3
+ * Decision-relevant state only. Raw review totals and the ignored, skipped, filtered, and
4
+ * superseded check counts move when rules suppress a bot notice or a bot reposts a status
5
+ * check, which would reprint quiet polls and keep an idle stack from reaching `stall-timeout`.
6
+ * Post-rule `actionable` and the check transitions that drive routing stay in.
7
+ */
2
8
  export declare function summaryStatusSignature(result: PollSummaryResult): string;
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Decision-relevant state only. Raw review totals and the ignored, skipped, filtered, and
3
+ * superseded check counts move when rules suppress a bot notice or a bot reposts a status
4
+ * check, which would reprint quiet polls and keep an idle stack from reaching `stall-timeout`.
5
+ * Post-rule `actionable` and the check transitions that drive routing stay in.
6
+ */
1
7
  export function summaryStatusSignature(result) {
2
8
  return JSON.stringify({
3
9
  nextAction: result.nextAction,
@@ -9,8 +15,19 @@ export function summaryStatusSignature(result) {
9
15
  mergeable: item.mergeable,
10
16
  mergeStateStatus: item.mergeStateStatus,
11
17
  reviewDecision: item.reviewDecision,
12
- checks: item.checks,
13
- review: item.review,
18
+ checks: item.checks && {
19
+ passing: item.checks.passing,
20
+ failing: item.checks.failing,
21
+ inProgress: item.checks.inProgress,
22
+ incomplete: item.checks.incomplete,
23
+ annotated: item.checks.annotated,
24
+ unreportedRequired: item.checks.unreportedRequired,
25
+ actionsWorkflowInProgress: item.checks.actionsWorkflowInProgress,
26
+ },
27
+ review: item.review && {
28
+ actionable: item.review.actionable,
29
+ incomplete: item.review.incomplete,
30
+ },
14
31
  })),
15
32
  });
16
33
  }
@@ -82,6 +82,7 @@ async function runAggregatePollCore(opts) {
82
82
  try {
83
83
  lastTick = await readPollSummaryTick(opts);
84
84
  last = lastTick.result;
85
+ writeAggregateRuleAutoResolve(last);
85
86
  rateLimitRetry.reset();
86
87
  }
87
88
  catch (error) {
@@ -192,6 +193,12 @@ async function runAggregatePollCore(opts) {
192
193
  await sleep(sleepMs);
193
194
  }
194
195
  }
196
+ /** An auto-resolve is a one-tick event; forward it so a swallowed tick still records it. */
197
+ function writeAggregateRuleAutoResolve(result) {
198
+ for (const item of result.prs)
199
+ if (item.ruleAutoResolve?.summary)
200
+ process.stderr.write(`#${item.pr} ${item.ruleAutoResolve.summary}\n`);
201
+ }
195
202
  function presentProbedCancel(result, merge) {
196
203
  // The stack planner already cancels an all-merged stack and escalates a
197
204
  // closed-unmerged layer. Do not rewrite that into a successful stop.
@@ -30,6 +30,7 @@ interface ReadyDelayState {
30
30
  export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options: {
31
31
  headSha: string;
32
32
  alreadyElapsed?: boolean;
33
+ summary?: boolean;
33
34
  }): Promise<ReadyDelayState>;
34
35
  /** Delete the ready-delay marker once an elapsed delay has been consumed. */
35
36
  export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
@@ -21,7 +21,7 @@ import { resolvePrStatePath } from "../state/base.mjs";
21
21
  * the loop and tells one-shot agents to stop.
22
22
  */
23
23
  export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options) {
24
- const markerPath = readySincePath(prNumber, owner, repo);
24
+ const markerPath = readySincePath(prNumber, owner, repo, options.summary);
25
25
  if (!isReady) {
26
26
  // Reset the timer.
27
27
  await safeUnlink(markerPath);
@@ -75,8 +75,13 @@ export async function readyDelayElapsed(prNumber, owner, repo, readyDelaySeconds
75
75
  // ---------------------------------------------------------------------------
76
76
  // Helpers
77
77
  // ---------------------------------------------------------------------------
78
- function readySincePath(pr, owner, repo) {
79
- return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
78
+ /**
79
+ * The one-PR session owns `ready-since.txt`. The aggregate summary's coarser
80
+ * view keeps its own `ready-since-summary.txt` so it can never reset or start
81
+ * the authoritative timer.
82
+ */
83
+ function readySincePath(pr, owner, repo, summary = false) {
84
+ return resolvePrStatePath({ owner, repo, pr }, summary ? "ready-since-summary.txt" : "ready-since.txt");
80
85
  }
81
86
  /** The marker's start time, or null when it is missing, malformed, or bound to another head. */
82
87
  async function readReadySince(path, headSha) {
@@ -3,6 +3,13 @@ export declare function uniqueReasons(reasons: readonly string[]): string[];
3
3
  export declare function decorateAutoResolveError(error: string, reasons: readonly string[]): string;
4
4
  export declare function reasonsForError(error: string, ids: readonly string[], ruleReasons: ReadonlyMap<string, readonly string[]>): string[];
5
5
  export declare function ruleAutoResolveFromReport(report: ShepherdReport): RuleAutoResolveReport | undefined;
6
+ /** The one report shape iterate and the aggregate summary both surface. */
7
+ export declare function buildRuleAutoResolveReport(input: {
8
+ threads: AutoResolvedThread[];
9
+ minimized: AutoMinimizedItem[];
10
+ errors: string[];
11
+ errorReasons: string[];
12
+ }): RuleAutoResolveReport | undefined;
6
13
  export declare function projectRuleAutoResolve(event: RuleAutoResolveReport): Record<string, unknown>;
7
14
  export declare function insertRuleAutoResolveSection(text: string, event: RuleAutoResolveReport | undefined): string;
8
15
  export declare function stripReplayedRuleAutoResolve(report: ShepherdReport): ShepherdReport;
@@ -57,9 +57,16 @@ function splitJoined(reason) {
57
57
  return reason.split("; ").filter(Boolean);
58
58
  }
59
59
  export function ruleAutoResolveFromReport(report) {
60
- const threads = report.threads.autoResolved;
61
- const minimized = report.comments.autoMinimized ?? [];
62
- const errors = report.threads.autoResolveErrors;
60
+ return buildRuleAutoResolveReport({
61
+ threads: report.threads.autoResolved,
62
+ minimized: report.comments.autoMinimized ?? [],
63
+ errors: report.threads.autoResolveErrors,
64
+ errorReasons: report.threads.autoResolveErrorReasons ?? [],
65
+ });
66
+ }
67
+ /** The one report shape iterate and the aggregate summary both surface. */
68
+ export function buildRuleAutoResolveReport(input) {
69
+ const { threads, minimized, errors } = input;
63
70
  if (threads.length === 0 && minimized.length === 0 && errors.length === 0)
64
71
  return undefined;
65
72
  const comments = minimized.filter((item) => item.kind === "pr-comment").length;
@@ -70,7 +77,7 @@ export function ruleAutoResolveFromReport(report) {
70
77
  ...threads.flatMap((thread) => splitJoined(thread.ruleReason)),
71
78
  ...minimized.flatMap((item) => splitJoined(item.ruleReason)),
72
79
  ])
73
- : uniqueReasons(report.threads.autoResolveErrorReasons ?? []);
80
+ : uniqueReasons(input.errorReasons);
74
81
  const body = ruleAutoResolveBody({ threads: threads.length, comments, reviewSummaries }) ??
75
82
  `auto-resolve failed for ${plural(errors.length, "mutation")}`;
76
83
  return {
@@ -1,5 +1,5 @@
1
1
  import type { BatchPartition } from "../classify/apply.mts";
2
- import type { AutoMinimizedItem, AutoResolvedThread, BatchPrData } from "../types.mts";
2
+ import type { AutoMinimizedItem, AutoResolvedThread, ReviewThread } from "../types.mts";
3
3
  export interface RuleAutoResolveApplied {
4
4
  threadIds: string[];
5
5
  commentIds: string[];
@@ -9,8 +9,20 @@ export interface RuleAutoResolveApplied {
9
9
  autoResolveErrors: string[];
10
10
  errorReasons: string[];
11
11
  }
12
+ interface MinimizableItem {
13
+ id: string;
14
+ url?: string;
15
+ viewerCanMinimize?: boolean;
16
+ }
17
+ /** The part of a PR read the mutations need. A full batch and a compact summary both satisfy it. */
18
+ export interface RuleAutoResolveBatch {
19
+ comments: readonly MinimizableItem[];
20
+ reviewSummaries: readonly MinimizableItem[];
21
+ reviewThreads: readonly ReviewThread[];
22
+ }
12
23
  export declare function applySuppressedRuleAutoResolve(input: {
13
24
  enabled: boolean;
14
25
  partition: BatchPartition;
15
- batch: BatchPrData;
26
+ batch: RuleAutoResolveBatch;
16
27
  }): Promise<RuleAutoResolveApplied>;
28
+ export {};
@@ -8,5 +8,11 @@ interface ThreadVisibility {
8
8
  firstLookThreads: FirstLookThread[];
9
9
  toMarkSeen: ReviewThread[];
10
10
  }
11
+ /**
12
+ * Whether an unresolved thread with a known outdated state keeps returning every tick once seen,
13
+ * given an authorized mutation. The compact summary calls this so its count cannot drift from
14
+ * `classifyThreadVisibility`.
15
+ */
16
+ export declare function unresolvedThreadRepeats(thread: ReviewThread, ended: boolean, botUsernames: NormalizedBotUsernames, policy: ResolveOtherHumanThreads): boolean;
11
17
  export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>, resolveOtherHumanThreads?: ResolveOtherHumanThreads): ThreadVisibility;
12
18
  export {};
@@ -3,6 +3,9 @@ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
3
  import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "./authors.mjs";
4
4
  import { threadEndedByShepherd } from "./marker.mjs";
5
5
  import { shouldResolveOtherHumanThread } from "./thread-resolve-policy.mjs";
6
+ function isOrdinaryHuman(thread, botUsernames) {
7
+ return isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
8
+ }
6
9
  function withEdited(thread, edited) {
7
10
  return edited ? { ...thread, edited: true } : thread;
8
11
  }
@@ -20,9 +23,33 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
20
23
  return null;
21
24
  return { ...visible, firstLookStatus };
22
25
  }
26
+ /** An unresolved, current thread whose author class makes it repeat while a mutation is authorized. */
27
+ function repeatsAsActive(thread, ended, botUsernames, policy) {
28
+ if (thread.isOutdated !== false || thread.isMinimized === true || ended)
29
+ return false;
30
+ return (isConfiguredBotAuthor(thread, botUsernames) ||
31
+ isViewerAuthoredHuman(thread, botUsernames) ||
32
+ (isOrdinaryHuman(thread, botUsernames) && policy === "always"));
33
+ }
34
+ /** An unresolved thread that is resolution-only work: outdated, minimized or ended by Shepherd. */
35
+ function isResolutionOnlyCandidate(thread, ended, botUsernames, policy) {
36
+ if (!ended)
37
+ return thread.isOutdated === true || thread.isMinimized === true;
38
+ if (!isOrdinaryHuman(thread, botUsernames))
39
+ return true;
40
+ return (isViewerAuthoredHuman(thread, botUsernames) || shouldResolveOtherHumanThread(thread, policy));
41
+ }
42
+ /**
43
+ * Whether an unresolved thread with a known outdated state keeps returning every tick once seen,
44
+ * given an authorized mutation. The compact summary calls this so its count cannot drift from
45
+ * `classifyThreadVisibility`.
46
+ */
47
+ export function unresolvedThreadRepeats(thread, ended, botUsernames, policy) {
48
+ return (repeatsAsActive(thread, ended, botUsernames, policy) ||
49
+ isResolutionOnlyCandidate(thread, ended, botUsernames, policy));
50
+ }
23
51
  export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set(), repeatableThreadIds, resolveOtherHumanThreads = "none") {
24
52
  const shouldRepeat = (thread) => repeatableThreadIds?.has(thread.id) ?? true;
25
- const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
26
53
  const unknownStatusThreads = threads.filter((thread) => thread.isResolved === undefined || thread.isOutdated === undefined);
27
54
  const unresolvedThreads = threads.filter((thread) => thread.isResolved === false && thread.isOutdated !== undefined);
28
55
  const activeThreads = unresolvedThreads
@@ -30,25 +57,13 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
30
57
  .flatMap((t) => {
31
58
  if (threadEndedByShepherd(t))
32
59
  return [];
33
- const repeatAuthor = isConfiguredBotAuthor(t, botUsernames) ||
34
- isViewerAuthoredHuman(t, botUsernames) ||
35
- (isOrdinaryHuman(t) && resolveOtherHumanThreads === "always");
36
- if (repeatAuthor && shouldRepeat(t))
60
+ if (repeatsAsActive(t, false, botUsernames, resolveOtherHumanThreads) && shouldRepeat(t))
37
61
  return [t];
38
62
  const visible = classifyVisibleThread(t, seenMap);
39
63
  return visible ? [visible] : [];
40
64
  });
41
65
  const resolutionOnlyThreads = unresolvedThreads
42
- .filter((t) => {
43
- const endedByShepherd = threadEndedByShepherd(t);
44
- if (endedByShepherd) {
45
- if (!isOrdinaryHuman(t))
46
- return true;
47
- return (isViewerAuthoredHuman(t, botUsernames) ||
48
- shouldResolveOtherHumanThread(t, resolveOtherHumanThreads));
49
- }
50
- return t.isOutdated || t.isMinimized;
51
- })
66
+ .filter((t) => isResolutionOnlyCandidate(t, threadEndedByShepherd(t), botUsernames, resolveOtherHumanThreads))
52
67
  .flatMap((t) => {
53
68
  const visible = classifyVisibleThread(t, seenMap);
54
69
  if (visible)
@@ -5,6 +5,20 @@ export declare function parseCheckSuitesComplete(raw: RawPr): boolean;
5
5
  /** True only when the head commit's suite page finished and listed nothing. */
6
6
  export declare function parseHeadCheckSuitesEmpty(raw: RawPr): boolean;
7
7
  export declare function parseSuiteStartupFailures(raw: RawPr): CheckRun[];
8
+ /** One failing check per STARTUP_FAILURE workflow-run suite; shared by the one-PR and summary reads. */
9
+ export declare function startupFailureChecksFromSuites(suites: {
10
+ nodes: Array<{
11
+ conclusion: string | null;
12
+ workflowRun: {
13
+ databaseId?: number | null;
14
+ event: string | null;
15
+ url?: string | null;
16
+ workflow?: {
17
+ name: string | null;
18
+ } | null;
19
+ } | null;
20
+ }>;
21
+ } | null | undefined): CheckRun[];
8
22
  /** `headWorkflowSuites`, omitted when the head has none. */
9
23
  export declare function workflowSuites(raw: RawPr): {
10
24
  headWorkflowSuites?: WorkflowSuiteSnapshot[];
@@ -22,7 +22,10 @@ function parseHeadWorkflowSuites(raw) {
22
22
  });
23
23
  }
24
24
  export function parseSuiteStartupFailures(raw) {
25
- const suites = raw.commits.nodes[0]?.commit.checkSuites;
25
+ return startupFailureChecksFromSuites(raw.commits.nodes[0]?.commit.checkSuites);
26
+ }
27
+ /** One failing check per STARTUP_FAILURE workflow-run suite; shared by the one-PR and summary reads. */
28
+ export function startupFailureChecksFromSuites(suites) {
26
29
  if (!suites)
27
30
  return [];
28
31
  return suites.nodes.flatMap((node) => {
@@ -48,6 +48,9 @@ export function mapCheckRunNode(node) {
48
48
  ...(completedAtUnix !== undefined && { completedAtUnix }),
49
49
  ...(updatedAtUnix !== undefined && { updatedAtUnix }),
50
50
  ...(summary !== undefined && { summary }),
51
+ ...((node.annotations?.totalCount ?? 0) > 0 && {
52
+ annotationCount: node.annotations.totalCount,
53
+ }),
51
54
  ...(((node.annotations?.totalCount ?? 0) > 0 || (node.annotations?.nodes.length ?? 0) > 0) && {
52
55
  hasAnnotations: true,
53
56
  }),
@@ -3,6 +3,11 @@ import type { CheckAnnotation } from "../types.mts";
3
3
  export interface AnnotationCacheOptions {
4
4
  stateKey: StateKey;
5
5
  headSha?: string;
6
+ /**
7
+ * Annotation total GitHub reports per check run right now. A cached list whose length
8
+ * differs is stale (a publisher added annotations to a completed run) and is not reused.
9
+ */
10
+ expectedCounts?: ReadonlyMap<string, number>;
6
11
  }
7
12
  export declare function readFreshCheckAnnotations(checkRunId: string, cacheOpts: AnnotationCacheOptions | undefined): Promise<CheckAnnotation[] | undefined>;
8
13
  export declare function storeCheckAnnotations(checkRunId: string, annotations: CheckAnnotation[], cacheOpts: AnnotationCacheOptions | undefined): Promise<void>;
@@ -12,9 +12,12 @@ export async function readFreshCheckAnnotations(checkRunId, cacheOpts) {
12
12
  if (!cacheOpts)
13
13
  return undefined;
14
14
  const cached = await loadDerived(cacheOpts.stateKey, cacheName(checkRunId));
15
- if (cached && Date.now() - cached.storedAt < ANNOTATION_CACHE_MAX_AGE_MS)
16
- return cached.value;
17
- return undefined;
15
+ if (!cached || Date.now() - cached.storedAt >= ANNOTATION_CACHE_MAX_AGE_MS)
16
+ return undefined;
17
+ const expected = cacheOpts.expectedCounts?.get(checkRunId);
18
+ if (expected !== undefined && cached.value.length !== expected)
19
+ return undefined;
20
+ return cached.value;
18
21
  }
19
22
  export async function storeCheckAnnotations(checkRunId, annotations, cacheOpts) {
20
23
  if (!cacheOpts)
@@ -12,6 +12,10 @@ export function fingerprintFromRaw(raw, viewerPermission = null, viewerLogin = n
12
12
  isMergeQueueEnabled: Boolean(raw.isMergeQueueEnabled),
13
13
  mergePolicy: mergePolicyFingerprint(raw),
14
14
  commentCount: raw.comments.totalCount ?? raw.comments.nodes.length,
15
+ // Raw `updatedAt` on purpose, even for bot or rule-suppressed comments (#635 gap 7). A miss
16
+ // continues the same BatchPr request into the full snapshot, so an edited bot comment costs
17
+ // no extra request over a hit, while excluding it would need rule evaluation here and would
18
+ // risk hiding an edit that un-suppresses the comment or edits a minimized human comment.
15
19
  commentRevisions: commentRevisions(raw.comments.nodes),
16
20
  threadCount: raw.reviewThreads.totalCount ?? raw.reviewThreads.nodes.length,
17
21
  reviewCount: raw.allReviews?.totalCount ?? 0,
@@ -19,3 +19,8 @@ export declare function hydrateReadyAnnotationProbe(pr: RawSummaryPr, repo: Repo
19
19
  actionable?: number;
20
20
  incomplete?: true;
21
21
  }): Promise<void>;
22
+ /**
23
+ * Load annotation totals once per summary row, from the same probe the READY receipt
24
+ * hash uses. False when a page could not be read, so no total is trustworthy.
25
+ */
26
+ export declare function hydrateAnnotationTotals(pr: RawSummaryPr, repo: RepoInfo): Promise<boolean>;
@@ -7,6 +7,7 @@ import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
7
7
  import { POLL_SUMMARY_ANNOTATION_PROBE_QUERY } from "./queries.mjs";
8
8
  const PROBE_UNAVAILABLE = Symbol.for("prShepherd.annotationProbeUnavailable");
9
9
  const COMPLETE_FROM_BATCH = new WeakSet();
10
+ const HYDRATED = new WeakSet();
10
11
  /** Annotation totals copied from the same full-snapshot request; no follow-up probe is needed. */
11
12
  export function markReadyAnnotationProbeComplete(pr) {
12
13
  COMPLETE_FROM_BATCH.add(pr);
@@ -36,9 +37,22 @@ export async function hydrateReadyAnnotationProbe(pr, repo, review) {
36
37
  const checks = summarizePollSummaryChecks(pr);
37
38
  if (!isCurrentSummaryReady(pr, checks, review))
38
39
  return;
39
- if (!(await hydrateCommitAnnotations(pr, repo))) {
40
- pr[PROBE_UNAVAILABLE] = true;
41
- }
40
+ await hydrateAnnotationTotals(pr, repo);
41
+ }
42
+ /**
43
+ * Load annotation totals once per summary row, from the same probe the READY receipt
44
+ * hash uses. False when a page could not be read, so no total is trustworthy.
45
+ */
46
+ export async function hydrateAnnotationTotals(pr, repo) {
47
+ if (COMPLETE_FROM_BATCH.has(pr))
48
+ return true;
49
+ if (HYDRATED.has(pr))
50
+ return !annotationProbeUnavailable(pr);
51
+ HYDRATED.add(pr);
52
+ if (await hydrateCommitAnnotations(pr, repo))
53
+ return true;
54
+ pr[PROBE_UNAVAILABLE] = true;
55
+ return false;
42
56
  }
43
57
  async function hydrateCommitAnnotations(pr, repo) {
44
58
  const commit = pr.commits.nodes[0]?.commit;
@@ -0,0 +1,14 @@
1
+ import type { PollSummaryChecks, PollSummaryItem } from "../types.mts";
2
+ import type { RepoInfo } from "./client.mts";
3
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
4
+ type Routed = Pick<PollSummaryItem, "action" | "reasons">;
5
+ /**
6
+ * The one-PR check treats unseen annotations on any non-SUCCESS check (failing, skipped,
7
+ * filtered, ignored or neutral) as actionable work. The compact summary only has annotation
8
+ * totals, and only for a row that otherwise claims readiness, so it loads them then and reads
9
+ * the annotations of just the non-SUCCESS checks that have any. A row with unseen annotations,
10
+ * or whose annotations cannot be read, goes to its one-PR `pollCommand` instead of
11
+ * claiming `appears-ready`, `cancel`, `mark_ready` or `merge`.
12
+ */
13
+ export declare function applyUnseenCheckAnnotations(raw: RawSummaryPr, repo: RepoInfo, routed: Routed, checks: PollSummaryChecks, seen: ReadonlyMap<string, unknown>, required: readonly string[]): Promise<Routed>;
14
+ export {};