pr-shepherd 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/checks/classify.d.mts +3 -1
  3. package/bin/checks/classify.mjs +8 -1
  4. package/bin/cli/poll-summary-formatter.mjs +3 -0
  5. package/bin/cli/rule-auto-resolve-lines.d.mts +3 -0
  6. package/bin/cli/rule-auto-resolve-lines.mjs +12 -0
  7. package/bin/cli/stack-layer-view.d.mts +2 -1
  8. package/bin/cli/stack-overview.mjs +3 -0
  9. package/bin/cli/triage-formatter.mjs +5 -1
  10. package/bin/commands/check-activity.d.mts +8 -0
  11. package/bin/commands/check-activity.mjs +26 -0
  12. package/bin/commands/check-annotations.mjs +2 -1
  13. package/bin/commands/check-status.d.mts +6 -1
  14. package/bin/commands/check-status.mjs +9 -4
  15. package/bin/commands/check-wait-detectors.mjs +11 -0
  16. package/bin/commands/check.mjs +10 -3
  17. package/bin/commands/iterate/escalate.mjs +4 -2
  18. package/bin/commands/poll-summary-explicit-instructions.d.mts +1 -1
  19. package/bin/commands/poll-summary-explicit-instructions.mjs +9 -7
  20. package/bin/commands/poll-summary-instructions.d.mts +2 -2
  21. package/bin/commands/poll-summary-instructions.mjs +16 -15
  22. package/bin/commands/poll-summary-signature.d.mts +6 -0
  23. package/bin/commands/poll-summary-signature.mjs +19 -2
  24. package/bin/commands/poll-summary.mjs +22 -14
  25. package/bin/commands/ready-delay.d.mts +1 -0
  26. package/bin/commands/ready-delay.mjs +8 -3
  27. package/bin/commands/ready-mergeability.d.mts +1 -1
  28. package/bin/commands/ready-mergeability.mjs +2 -2
  29. package/bin/commands/rule-auto-resolve-format.d.mts +7 -0
  30. package/bin/commands/rule-auto-resolve-format.mjs +11 -4
  31. package/bin/commands/rule-auto-resolve.d.mts +14 -2
  32. package/bin/commands/stack-drain.d.mts +1 -1
  33. package/bin/commands/stack-drain.mjs +3 -3
  34. package/bin/commands/stack-work.d.mts +4 -1
  35. package/bin/commands/stack-work.mjs +10 -2
  36. package/bin/commands/tick.mjs +1 -0
  37. package/bin/comments/thread-visibility.d.mts +6 -0
  38. package/bin/comments/thread-visibility.mjs +30 -15
  39. package/bin/github/batch-parse-suites.d.mts +14 -0
  40. package/bin/github/batch-parse-suites.mjs +4 -1
  41. package/bin/github/batch-parser-helpers.mjs +5 -0
  42. package/bin/github/batch-raw-types.d.mts +1 -0
  43. package/bin/github/check-annotation-cache.d.mts +5 -0
  44. package/bin/github/check-annotation-cache.mjs +6 -3
  45. package/bin/github/fingerprint.mjs +4 -0
  46. package/bin/github/github-fetch.d.mts +6 -2
  47. package/bin/github/github-fetch.mjs +37 -2
  48. package/bin/github/github-timeouts.d.mts +8 -0
  49. package/bin/github/github-timeouts.mjs +8 -0
  50. package/bin/github/gql/batch-pr-page.gql +1 -0
  51. package/bin/github/gql/batch-pr.gql +1 -0
  52. package/bin/github/gql/commit-check-contexts.gql +1 -0
  53. package/bin/github/gql/poll-summary-check-contexts.gql +1 -0
  54. package/bin/github/http-utils.mjs +3 -0
  55. package/bin/github/poll-summary-annotation-probe.d.mts +5 -0
  56. package/bin/github/poll-summary-annotation-probe.mjs +17 -3
  57. package/bin/github/poll-summary-annotations.d.mts +14 -0
  58. package/bin/github/poll-summary-annotations.mjs +75 -0
  59. package/bin/github/poll-summary-blocking-reviewer.d.mts +2 -0
  60. package/bin/github/poll-summary-blocking-reviewer.mjs +9 -0
  61. package/bin/github/poll-summary-checks.d.mts +1 -0
  62. package/bin/github/poll-summary-checks.mjs +21 -2
  63. package/bin/github/poll-summary-command.d.mts +7 -1
  64. package/bin/github/poll-summary-command.mjs +29 -0
  65. package/bin/github/poll-summary-fingerprint.d.mts +7 -0
  66. package/bin/github/poll-summary-fingerprint.mjs +7 -0
  67. package/bin/github/poll-summary-projector.d.mts +3 -1
  68. package/bin/github/poll-summary-projector.mjs +15 -25
  69. package/bin/github/poll-summary-queue-removal.d.mts +2 -0
  70. package/bin/github/poll-summary-queue-removal.mjs +11 -0
  71. package/bin/github/poll-summary-raw.d.mts +1 -0
  72. package/bin/github/poll-summary-readiness.mjs +3 -0
  73. package/bin/github/poll-summary-review-parity.d.mts +24 -0
  74. package/bin/github/poll-summary-review-parity.mjs +82 -0
  75. package/bin/github/poll-summary-review.d.mts +11 -2
  76. package/bin/github/poll-summary-review.mjs +87 -59
  77. package/bin/github/poll-summary-route.mjs +2 -0
  78. package/bin/github/poll-summary-row-error.d.mts +16 -0
  79. package/bin/github/poll-summary-row-error.mjs +74 -0
  80. package/bin/github/poll-summary-rule-auto-resolve.d.mts +32 -0
  81. package/bin/github/poll-summary-rule-auto-resolve.mjs +119 -0
  82. package/bin/github/poll-summary-rule-handoff.d.mts +7 -0
  83. package/bin/github/poll-summary-rule-handoff.mjs +20 -0
  84. package/bin/github/poll-summary-rule-items.d.mts +20 -0
  85. package/bin/github/poll-summary-rule-items.mjs +28 -0
  86. package/bin/github/poll-summary-unreported.d.mts +7 -0
  87. package/bin/github/poll-summary-unreported.mjs +16 -0
  88. package/bin/github/poll-summary.mjs +15 -7
  89. package/bin/github/rest-check-read.mjs +3 -1
  90. package/bin/github/rest-reader-core.mjs +13 -2
  91. package/bin/github/rest-summary-read.mjs +12 -1
  92. package/bin/github/rest-text.mjs +3 -2
  93. package/bin/github/stack-handoff-page.mjs +1 -0
  94. package/bin/reporters/agent.mjs +2 -0
  95. package/bin/types/github.d.mts +8 -0
  96. package/bin/types/poll-summary.d.mts +23 -0
  97. package/bin/types/report.d.mts +5 -1
  98. package/package.json +2 -2
  99. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  100. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  101. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  102. package/plugins/pr-shepherd/.mcp.json +1 -1
  103. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -0
@@ -1,15 +1,24 @@
1
- import { normalizeBotUsernames } from "../comments/authors.mjs";
2
- import { applyRules } from "../classify/apply.mjs";
3
- import { normalizeAuthorType } from "../comments/authors.mjs";
1
+ import { isConfiguredBotAuthor, isHumanAuthor, normalizeAuthorType, normalizeBotUsernames, } from "../comments/authors.mjs";
4
2
  import { loadClassifyRules } from "../classify/loader.mjs";
5
3
  import { loadConfig } from "../config/load.mjs";
6
4
  import { getEffectiveCwd } from "../execution-context.mjs";
7
- import { classifyItem } from "../state/seen-comments.mjs";
8
- import { mutationWasDenied } from "../state/seen-comments.mjs";
9
- import { canGenerateGithubMutation } from "./mutation-policy.mjs";
5
+ import { classifyItem, mutationWasDenied } from "../state/seen-comments.mjs";
10
6
  import { threadEndedByShepherd } from "../comments/marker.mjs";
11
- const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
12
- export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister) {
7
+ import { threadTranscriptBodies } from "../threads/transcript.mjs";
8
+ import { canGenerateGithubMutation } from "./mutation-policy.mjs";
9
+ import { applySummaryRuleAutoResolve, } from "./poll-summary-rule-auto-resolve.mjs";
10
+ import { countCommentWork, repeatingThreadIds } from "./poll-summary-review-parity.mjs";
11
+ import { ruleHides, summaryRuleAction } from "./poll-summary-rule-items.mjs";
12
+ const authorOf = (author) => ({
13
+ login: author?.login ?? "",
14
+ authorType: normalizeAuthorType(author?.__typename, author?.login),
15
+ });
16
+ /**
17
+ * Count the review work a compact row routes to the full check. With `autoResolveKey` and
18
+ * `actions.auto-minimize-suppressed` on, rule-driven auto-resolve runs first, exactly as in the
19
+ * one-PR path, so a handled bot notice never sends the row to triage.
20
+ */
21
+ export async function evaluatePollSummaryReview(raw, seen, viewerCanAdminister, autoResolveKey) {
13
22
  const config = loadConfig();
14
23
  const bots = normalizeBotUsernames(config["bot-usernames"]);
15
24
  const rules = await loadClassifyRules(getEffectiveCwd(), config.standardRules);
@@ -17,18 +26,54 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
17
26
  let incomplete = raw.comments.pageInfo.hasPreviousPage ||
18
27
  raw.reviews.pageInfo.hasPreviousPage ||
19
28
  raw.reviewThreads.pageInfo.hasPreviousPage;
20
- for (const comment of raw.comments.nodes) {
21
- if (!isSuppressed(rules, "pr-comment", comment, comment.id, undefined, comment.body, comment.isMinimized) &&
22
- // An edited PR comment is shown once by iterate but never blocks readiness.
23
- classifyItem(comment.id, comment.body, seen) === "new")
24
- actionable += 1;
25
- }
29
+ // A pre-minimized comment is a first-look acknowledgement, never readiness work.
30
+ const actions = {
31
+ comments: new Map(),
32
+ summaries: new Map(),
33
+ threads: new Map(),
34
+ };
35
+ for (const comment of raw.comments.nodes)
36
+ if (!comment.isMinimized)
37
+ actions.comments.set(comment.id, summaryRuleAction(rules, "pr-comment", comment));
38
+ for (const review of raw.reviews.nodes)
39
+ if (review.state === "COMMENTED" && review.body.trim() !== "" && !review.isMinimized)
40
+ actions.summaries.set(review.id, summaryRuleAction(rules, "review-summary", review));
41
+ const threadView = raw.reviewThreads.nodes.map((thread) => {
42
+ const truncated = thread.comments.pageInfo.hasPreviousPage;
43
+ if (truncated)
44
+ incomplete = true;
45
+ const root = thread.comments.nodes[0];
46
+ const transcript = threadTranscriptBodies(thread.comments.nodes.map((c) => c.body));
47
+ // The window's last comment is the thread's last only when nothing lies beyond it.
48
+ const ended = thread.isResolved === true &&
49
+ !truncated &&
50
+ threadEndedByShepherd({ body: root?.body ?? "", comments: thread.comments.nodes });
51
+ if (!ended)
52
+ actions.threads.set(thread.id, summaryRuleAction(rules, "review-thread", root, thread.id, thread.path ?? root?.path ?? null, root?.body ?? ""));
53
+ return { thread, truncated, root, transcript, ended };
54
+ });
55
+ const handled = autoResolveKey && config.actions["auto-minimize-suppressed"]
56
+ ? await applySummaryRuleAutoResolve({ raw, actions, seen, key: autoResolveKey })
57
+ : { confirmed: new Set(), unconfirmed: new Set() };
58
+ const { confirmed, unconfirmed } = handled;
59
+ actionable += countCommentWork(raw.comments.nodes.filter((comment) => {
60
+ const action = actions.comments.get(comment.id);
61
+ return action !== undefined && !ruleHides(action, "pr-comment", confirmed.has(comment.id));
62
+ }), unconfirmed, seen, config.iterate["minimize-comments"], bots);
26
63
  const latestStateByAuthor = new Map((raw.latestReviews?.nodes ?? []).flatMap((review) => review.author ? [[review.author.login.toLowerCase(), review.state]] : []));
27
64
  for (const review of raw.reviews.nodes) {
28
65
  if (review.state === "COMMENTED") {
29
- if (review.body.trim() !== "" &&
66
+ const action = actions.summaries.get(review.id);
67
+ if (!action || ruleHides(action, "review-summary", confirmed.has(review.id)))
68
+ continue;
69
+ if (unconfirmed.has(review.id) || classifyItem(review.id, review.body, seen) !== "unchanged")
70
+ actionable += 1;
71
+ continue;
72
+ }
73
+ // An approval is surfaced once, as work, when iterate.minimize-approvals is on.
74
+ if (review.state === "APPROVED") {
75
+ if (config.iterate["minimize-approvals"] &&
30
76
  !review.isMinimized &&
31
- !isSuppressed(rules, "review-summary", review) &&
32
77
  classifyItem(review.id, review.body, seen) !== "unchanged")
33
78
  actionable += 1;
34
79
  continue;
@@ -38,63 +83,46 @@ export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister)
38
83
  const login = review.author?.login.toLowerCase();
39
84
  if (login && latestStateByAuthor.get(login) === "APPROVED")
40
85
  continue;
41
- if (isSuppressed(rules, "changes-requested", review))
86
+ if (ruleHides(summaryRuleAction(rules, "changes-requested", review), "changes-requested", false))
42
87
  continue;
43
- const isBot = review.author?.__typename === "Bot" || bots.has(review.author?.login.toLowerCase() ?? "");
44
- if ((isBot &&
45
- canGenerateGithubMutation(viewerCanAdminister, "dismiss") &&
46
- !mutationWasDenied(review.id, review.body, seen)) ||
88
+ const author = authorOf(review.author);
89
+ const isBot = !isHumanAuthor(author) || isConfiguredBotAuthor(author, bots);
90
+ // The full check counts a human changes-requested review whether or not it was seen.
91
+ if (!isBot ||
92
+ (canGenerateGithubMutation(viewerCanAdminister, "dismiss") &&
93
+ !mutationWasDenied(review.id, review.body, seen)) ||
47
94
  classifyItem(review.id, review.body, seen) !== "unchanged")
48
95
  actionable += 1;
49
96
  }
50
- for (const thread of raw.reviewThreads.nodes) {
51
- // The window holds the first comments. Past it, a new reply leaves the window
52
- // unchanged, so a long thread cannot prove it was seen.
53
- const truncated = thread.comments.pageInfo.hasPreviousPage;
54
- if (truncated)
55
- incomplete = true;
56
- const root = thread.comments.nodes[0];
57
- const transcript = thread.comments.nodes
58
- .map((comment) => comment.body)
59
- .join(THREAD_COMMENT_SEPARATOR);
60
- const completeResolved = thread.isResolved === true && !truncated;
61
- if (completeResolved &&
62
- threadEndedByShepherd({ body: root?.body ?? "", comments: thread.comments.nodes }))
97
+ const repeating = repeatingThreadIds(threadView, seen, bots, config.iterate["resolve-other-human-threads"]);
98
+ for (const { thread, truncated, transcript, ended } of threadView) {
99
+ const action = actions.threads.get(thread.id);
100
+ if (ended || !action)
63
101
  continue;
64
- if (isSuppressed(rules, "review-thread", root, thread.id, thread.path ?? root?.path ?? null, root?.body ?? "", completeResolved))
102
+ // Rules read the root, so a resolved or outdated thread satisfies its auto-resolve match.
103
+ const satisfied = confirmed.has(thread.id) || thread.isResolved === true || thread.isOutdated === true;
104
+ if (ruleHides(action, "review-thread", satisfied))
65
105
  continue;
66
- const repeatable = !mutationWasDenied(thread.id, transcript, seen) &&
67
- (root?.viewerDidAuthor === true ||
68
- root?.author?.__typename === "Bot" ||
69
- bots.has(root?.author?.login.toLowerCase() ?? ""));
70
- const unseen = truncated || classifyItem(thread.id, transcript, seen) !== "unchanged";
71
- if ((thread.isResolved === false && (repeatable || unseen)) ||
106
+ if (unconfirmed.has(thread.id)) {
107
+ actionable += 1;
108
+ continue;
109
+ }
110
+ // The marker hashes the full transcript, which a window never reproduces. A long resolved
111
+ // thread is seen once any marker exists; any other long thread cannot prove it was seen.
112
+ const unseen = truncated
113
+ ? !(thread.isResolved === true && seen.has(thread.id))
114
+ : classifyItem(thread.id, transcript, seen) !== "unchanged";
115
+ if ((thread.isResolved === false && (repeating.has(thread.id) || unseen)) ||
72
116
  (thread.isResolved === undefined && unseen) ||
73
117
  ((thread.isResolved || thread.isOutdated) && unseen))
74
118
  actionable += 1;
75
119
  }
76
- return {
120
+ const review = {
77
121
  ...(raw.comments.totalCount > 0 && { comments: raw.comments.totalCount }),
78
122
  ...(raw.reviews.totalCount > 0 && { reviews: raw.reviews.totalCount }),
79
123
  ...(raw.reviewThreads.totalCount > 0 && { threads: raw.reviewThreads.totalCount }),
80
124
  ...(actionable > 0 && { actionable }),
81
125
  ...(incomplete && { incomplete: true }),
82
126
  };
83
- }
84
- function isSuppressed(rules, kind, raw, id = raw?.id ?? "unknown", path, body = raw?.body ?? "", autoResolveSatisfied = false) {
85
- const item = {
86
- kind,
87
- id,
88
- author: raw?.author?.login ?? "unknown",
89
- authorType: normalizeAuthorType(raw?.author?.__typename, raw?.author?.login),
90
- ...(raw?.authorAssociation !== undefined && { authorAssociation: raw.authorAssociation }),
91
- body,
92
- ...(kind === "review-thread" ? { url: raw?.url ?? "" } : raw?.url ? { url: raw.url } : {}),
93
- ...(kind === "review-thread" && { path }),
94
- };
95
- const action = applyRules(rules, item);
96
- // An auto-resolve match is hidden only once the full check minimizes or resolves it, or
97
- // surfaces a denied mutation once; until then it stays actionable so that check runs.
98
- return (action.suppress === true &&
99
- (action.autoResolve !== true || kind === "changes-requested" || autoResolveSatisfied));
127
+ return { review, ...(handled.report && { ruleAutoResolve: handled.report }) };
100
128
  }
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config/load.mjs";
2
+ import { hasNoRelevantChecks } from "./poll-summary-unreported.mjs";
2
3
  import { isKnownMergeStateStatus, transportReadinessGaps } from "./transport-evidence.mjs";
3
4
  import { canGenerateGithubMutation } from "./mutation-policy.mjs";
4
5
  import { getGithubTransport, isCcrTransport } from "./transport.mjs";
@@ -25,6 +26,7 @@ export function routePollSummary(raw, checks, review, opts) {
25
26
  return { action: "triage", reasons: ["unreported-required-checks"] };
26
27
  }
27
28
  if ((checks.inProgress ?? 0) > 0 ||
29
+ (checks.actionsWorkflowInProgress === true && hasNoRelevantChecks(checks)) ||
28
30
  !isKnownMergeStateStatus(raw.mergeStateStatus) ||
29
31
  raw.mergeable === "UNKNOWN" ||
30
32
  raw.mergeStateStatus === "UNKNOWN")
@@ -0,0 +1,16 @@
1
+ import type { PollSummaryCommandOptions, PollSummaryItem } from "../types.mts";
2
+ import type { RepoInfo } from "./client.mts";
3
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
4
+ /** `failures` maps a PR to the isolatable error that stopped its chunk-time hydration. */
5
+ export interface ExplicitChunk {
6
+ prs: RawSummaryPr[];
7
+ failures?: Map<number, unknown>;
8
+ }
9
+ /** Hydrate each PR's checks and mergeability; one PR's unreadable checks must not fail its siblings. */
10
+ export declare function hydrateRowsIsolated(raws: RawSummaryPr[], repo: RepoInfo): Promise<Map<number, unknown>>;
11
+ /**
12
+ * Summarize each PR, turning an isolatable failure into a row. `failures` holds
13
+ * PRs whose chunk-time hydration already failed. A fatal error, or no readable
14
+ * row at all, rejects the tick with the first such error.
15
+ */
16
+ export declare function summarizeRowsIsolated(raws: RawSummaryPr[], failures: Map<number, unknown>, repo: RepoInfo, opts: PollSummaryCommandOptions, summarize: (raw: RawSummaryPr) => Promise<PollSummaryItem>): Promise<PollSummaryItem[]>;
@@ -0,0 +1,74 @@
1
+ import { GitHubRequestError } from "./errors.mjs";
2
+ import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
3
+ import { refreshUnknownSummaryMergeability } from "./poll-summary-mergeability.mjs";
4
+ import { pollCommandFields } from "./poll-summary-command.mjs";
5
+ import { normalizePollSummaryState } from "./poll-summary-route.mjs";
6
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
7
+ import { isGithubReadFallbackError } from "./transport.mjs";
8
+ import { formatPrUrl } from "../pr-reference.mjs";
9
+ /**
10
+ * Whether one PR's failed read becomes a row. Throttles, credential failures, and
11
+ * errors that switch transport describe the whole tick, so they still reject it.
12
+ */
13
+ function isIsolatableRowError(error) {
14
+ if (isGithubReadFallbackError(error))
15
+ return false;
16
+ if (!(error instanceof GitHubRequestError))
17
+ return true;
18
+ return error.status !== 401 && rateLimitKind(error) === null;
19
+ }
20
+ /** Hydrate each PR's checks and mergeability; one PR's unreadable checks must not fail its siblings. */
21
+ export async function hydrateRowsIsolated(raws, repo) {
22
+ const failures = new Map();
23
+ for (const raw of raws) {
24
+ try {
25
+ await hydratePollSummaryChecks(raw, repo);
26
+ await refreshUnknownSummaryMergeability(raw, repo);
27
+ }
28
+ catch (error) {
29
+ if (!isIsolatableRowError(error))
30
+ throw error;
31
+ failures.set(raw.number, error);
32
+ }
33
+ }
34
+ return failures;
35
+ }
36
+ /** A non-terminal row whose `pollCommand` runs the authoritative one-PR session. */
37
+ function readFailureRow(raw, repo, opts, error) {
38
+ const repoName = `${repo.owner}/${repo.name}`;
39
+ return {
40
+ pr: raw.number,
41
+ repo: repoName,
42
+ ...(raw.transport && { transport: raw.transport }),
43
+ title: raw.title,
44
+ url: raw.url || formatPrUrl(repoName, raw.number),
45
+ action: "triage",
46
+ reasons: ["summary-read-failed"],
47
+ error: error instanceof Error ? error.message : String(error),
48
+ state: normalizePollSummaryState(raw.state),
49
+ mergeable: raw.mergeable,
50
+ mergeStateStatus: raw.mergeStateStatus,
51
+ headRefName: raw.headRefName,
52
+ headRefOid: raw.headRefOid,
53
+ baseRefName: raw.baseRefName,
54
+ ...(raw.isDraft && { isDraft: true }),
55
+ ...pollCommandFields(repoName, raw.number, raw.isDraft, opts),
56
+ };
57
+ }
58
+ /**
59
+ * Summarize each PR, turning an isolatable failure into a row. `failures` holds
60
+ * PRs whose chunk-time hydration already failed. A fatal error, or no readable
61
+ * row at all, rejects the tick with the first such error.
62
+ */
63
+ export async function summarizeRowsIsolated(raws, failures, repo, opts, summarize) {
64
+ const settled = await Promise.allSettled(raws.map((raw) => failures.has(raw.number) ? Promise.reject(failures.get(raw.number)) : summarize(raw)));
65
+ const rejected = settled.filter((entry) => entry.status === "rejected");
66
+ const fatal = rejected.find((entry) => !isIsolatableRowError(entry.reason));
67
+ if (fatal)
68
+ throw fatal.reason;
69
+ if (rejected.length === settled.length)
70
+ throw rejected[0].reason;
71
+ return settled.map((entry, index) => entry.status === "fulfilled"
72
+ ? entry.value
73
+ : readFailureRow(raws[index], repo, opts, entry.reason));
74
+ }
@@ -0,0 +1,32 @@
1
+ import type { ClassifyAction } from "../classify/types.mts";
2
+ import { type SeenMarker } from "../state/seen-comments.mts";
3
+ import type { PollSummaryRuleAutoResolve } from "../types.mts";
4
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
5
+ export interface SummaryStateKey {
6
+ owner: string;
7
+ repo: string;
8
+ pr: number;
9
+ }
10
+ export interface SummaryRuleActions {
11
+ comments: ReadonlyMap<string, ClassifyAction>;
12
+ summaries: ReadonlyMap<string, ClassifyAction>;
13
+ threads: ReadonlyMap<string, ClassifyAction>;
14
+ }
15
+ export interface SummaryAutoResolve {
16
+ /** Items whose mutation GitHub confirmed this tick. */
17
+ confirmed: ReadonlySet<string>;
18
+ /** Items Shepherd may mutate whose mutation was not confirmed; the full check still has work. */
19
+ unconfirmed: ReadonlySet<string>;
20
+ report?: PollSummaryRuleAutoResolve;
21
+ }
22
+ /**
23
+ * Apply the rule-driven auto-resolve the one-PR check performs: minimize matching PR comments and
24
+ * COMMENTED review summaries, resolve matching unresolved threads. Capability, denial, and marker
25
+ * handling mirror `check.mts`; a denied item is left to the normal first-look gate.
26
+ */
27
+ export declare function applySummaryRuleAutoResolve(input: {
28
+ raw: RawSummaryPr;
29
+ actions: SummaryRuleActions;
30
+ seen: Map<string, SeenMarker>;
31
+ key: SummaryStateKey;
32
+ }): Promise<SummaryAutoResolve>;
@@ -0,0 +1,119 @@
1
+ import { normalizeAuthorType } from "../comments/authors.mjs";
2
+ import { buildRuleAutoResolveReport } from "../commands/rule-auto-resolve-format.mjs";
3
+ import { applySuppressedRuleAutoResolve } from "../commands/rule-auto-resolve.mjs";
4
+ import { markSeen, mutationWasDenied } from "../state/seen-comments.mjs";
5
+ import { threadTranscriptBodies } from "../threads/transcript.mjs";
6
+ import { canGenerateGithubMutation } from "./mutation-policy.mjs";
7
+ const NONE = { confirmed: new Set(), unconfirmed: new Set() };
8
+ function matchesAutoResolve(action) {
9
+ return action?.suppress === true && action.autoResolve === true;
10
+ }
11
+ function toReviewThread(thread) {
12
+ const root = thread.comments.nodes[0];
13
+ return {
14
+ id: thread.id,
15
+ ...(thread.isResolved !== undefined && { isResolved: thread.isResolved }),
16
+ ...(thread.isOutdated !== undefined && { isOutdated: thread.isOutdated }),
17
+ ...(thread.viewerCanResolve !== undefined && { viewerCanResolve: thread.viewerCanResolve }),
18
+ path: thread.path ?? root?.path ?? null,
19
+ line: thread.line ?? null,
20
+ startLine: thread.startLine ?? null,
21
+ author: root?.author?.login ?? "unknown",
22
+ authorType: normalizeAuthorType(root?.author?.__typename, root?.author?.login),
23
+ body: root?.body ?? "",
24
+ url: root?.url ?? "",
25
+ };
26
+ }
27
+ /** The transcript the full check hashes is complete only when the window holds every comment. */
28
+ function threadDenied(thread, seen) {
29
+ const transcript = threadTranscriptBodies(thread.comments.nodes.map((comment) => comment.body));
30
+ return thread.comments.pageInfo.hasPreviousPage
31
+ ? seen.get(thread.id)?.deniedMutationBodyHash !== undefined
32
+ : mutationWasDenied(thread.id, transcript, seen);
33
+ }
34
+ /** Patch the read to match GitHub so a stack handoff does not replay the mutation. */
35
+ function patchRaw(raw, confirmed) {
36
+ for (const comment of [...raw.comments.nodes, ...raw.reviews.nodes])
37
+ if (confirmed.has(comment.id))
38
+ comment.isMinimized = true;
39
+ for (const thread of raw.reviewThreads.nodes)
40
+ if (confirmed.has(thread.id))
41
+ thread.isResolved = true;
42
+ }
43
+ /**
44
+ * Apply the rule-driven auto-resolve the one-PR check performs: minimize matching PR comments and
45
+ * COMMENTED review summaries, resolve matching unresolved threads. Capability, denial, and marker
46
+ * handling mirror `check.mts`; a denied item is left to the normal first-look gate.
47
+ */
48
+ export async function applySummaryRuleAutoResolve(input) {
49
+ const { raw, actions, seen, key } = input;
50
+ const comments = raw.comments.nodes.filter((comment) => !comment.isMinimized &&
51
+ matchesAutoResolve(actions.comments.get(comment.id)) &&
52
+ !mutationWasDenied(comment.id, comment.body, seen) &&
53
+ canGenerateGithubMutation(comment.viewerCanMinimize, "minimize"));
54
+ const summaries = raw.reviews.nodes.filter((review) => !review.isMinimized &&
55
+ matchesAutoResolve(actions.summaries.get(review.id)) &&
56
+ !mutationWasDenied(review.id, review.body, seen) &&
57
+ canGenerateGithubMutation(review.viewerCanMinimize, "minimize"));
58
+ const threads = raw.reviewThreads.nodes.filter((thread) => thread.isResolved === false &&
59
+ thread.isOutdated !== true &&
60
+ matchesAutoResolve(actions.threads.get(thread.id)) &&
61
+ !threadDenied(thread, seen) &&
62
+ canGenerateGithubMutation(thread.viewerCanResolve, "resolve"));
63
+ if (comments.length + summaries.length + threads.length === 0)
64
+ return NONE;
65
+ // The full check marks every non-denied suppressed item before it mutates, whatever the outcome.
66
+ await Promise.allSettled([
67
+ ...[...comments, ...summaries].map((item) => markSeen(key, item.id, item.body)),
68
+ ...threads
69
+ .filter((thread) => !thread.comments.pageInfo.hasPreviousPage)
70
+ .map((thread) => markSeen(key, thread.id, threadTranscriptBodies(thread.comments.nodes.map((comment) => comment.body)))),
71
+ ]);
72
+ const reviewThreads = threads.map(toReviewThread);
73
+ const reasonOf = (id, map) => {
74
+ const reason = map.get(id)?.reason;
75
+ return [id, reason ? [reason] : []];
76
+ };
77
+ const ruleReasons = new Map([
78
+ ...comments.map((item) => reasonOf(item.id, actions.comments)),
79
+ ...summaries.map((item) => reasonOf(item.id, actions.summaries)),
80
+ ...threads.map((item) => reasonOf(item.id, actions.threads)),
81
+ ]);
82
+ const partition = {
83
+ suppressedCommentIds: new Set(comments.map((item) => item.id)),
84
+ suppressedThreadIds: new Set(threads.map((item) => item.id)),
85
+ suppressedReviewSummaryIds: new Set(summaries.map((item) => item.id)),
86
+ suppressedChangesRequestedIds: new Set(),
87
+ ruleAutoResolveCommentIds: comments.map((item) => item.id),
88
+ ruleAutoResolveThreadIds: threads.map((item) => item.id),
89
+ ruleAutoResolveReviewSummaryIds: summaries.map((item) => item.id),
90
+ ruleReasons,
91
+ };
92
+ const applied = await applySuppressedRuleAutoResolve({
93
+ enabled: true,
94
+ partition,
95
+ batch: { comments, reviewSummaries: summaries, reviewThreads },
96
+ });
97
+ const confirmed = new Set([
98
+ ...applied.autoMinimized.map((item) => item.id),
99
+ ...applied.autoResolved.map((thread) => thread.id),
100
+ ]);
101
+ patchRaw(raw, confirmed);
102
+ const full = buildRuleAutoResolveReport({
103
+ threads: applied.autoResolved,
104
+ minimized: applied.autoMinimized,
105
+ errors: applied.autoResolveErrors,
106
+ errorReasons: applied.errorReasons,
107
+ });
108
+ const report = full && {
109
+ ...full,
110
+ ...(full.threads && {
111
+ threads: full.threads.map((thread) => ({
112
+ id: thread.id,
113
+ ...(thread.url && { url: thread.url }),
114
+ })),
115
+ }),
116
+ };
117
+ const unconfirmed = [...ruleReasons.keys()].filter((id) => !confirmed.has(id));
118
+ return { confirmed, unconfirmed: new Set(unconfirmed), ...(report && { report }) };
119
+ }
@@ -0,0 +1,7 @@
1
+ import type { PollSummaryItem } from "../types.mts";
2
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /**
4
+ * A stack handoff clones the read before rows are evaluated. Reflect the rule-driven mutations the
5
+ * rows confirmed so the one-PR session that consumes the handoff does not repeat them.
6
+ */
7
+ export declare function patchHandoffLayers(layers: RawSummaryPr[], items: PollSummaryItem[]): void;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A stack handoff clones the read before rows are evaluated. Reflect the rule-driven mutations the
3
+ * rows confirmed so the one-PR session that consumes the handoff does not repeat them.
4
+ */
5
+ export function patchHandoffLayers(layers, items) {
6
+ for (const item of items) {
7
+ const report = item.ruleAutoResolve;
8
+ const layer = layers.find((candidate) => candidate.number === item.pr);
9
+ if (!report || !layer)
10
+ continue;
11
+ const minimized = new Set(report.minimized?.map((entry) => entry.id));
12
+ const resolved = new Set(report.threads?.map((thread) => thread.id));
13
+ for (const node of [...layer.comments.nodes, ...layer.reviews.nodes])
14
+ if (minimized.has(node.id))
15
+ node.isMinimized = true;
16
+ for (const thread of layer.reviewThreads.nodes)
17
+ if (resolved.has(thread.id))
18
+ thread.isResolved = true;
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ import { applyRules } from "../classify/apply.mts";
2
+ import type { ClassifyAction, ClassifyItem } from "../classify/types.mts";
3
+ import type { RawAuthor } from "./poll-summary-raw.mts";
4
+ type Rules = Parameters<typeof applyRules>[0];
5
+ export interface RuleSource {
6
+ id: string;
7
+ body: string;
8
+ author: RawAuthor | null;
9
+ authorAssociation?: string;
10
+ url?: string;
11
+ path?: string | null;
12
+ }
13
+ /** The rule action for one compact item, built like the full check builds its rule items. */
14
+ export declare function summaryRuleAction(rules: Rules, kind: ClassifyItem["kind"], raw: RuleSource | undefined, id?: string, path?: string | null, body?: string): ClassifyAction;
15
+ /**
16
+ * An auto-resolve match is hidden only once its mutation is confirmed or the item is already
17
+ * resolved; until then it stays visible so the full check can run or surface it.
18
+ */
19
+ export declare function ruleHides(action: ClassifyAction, kind: ClassifyItem["kind"], autoResolveSatisfied: boolean): boolean;
20
+ export {};
@@ -0,0 +1,28 @@
1
+ import { applyRules } from "../classify/apply.mjs";
2
+ import { normalizeAuthorType } from "../comments/authors.mjs";
3
+ /** The rule action for one compact item, built like the full check builds its rule items. */
4
+ export function summaryRuleAction(rules, kind, raw, id = raw?.id ?? "unknown", path, body = raw?.body ?? "") {
5
+ const item = {
6
+ kind,
7
+ id,
8
+ author: raw?.author?.login ?? "unknown",
9
+ authorType: normalizeAuthorType(raw?.author?.__typename, raw?.author?.login),
10
+ ...(raw?.authorAssociation !== undefined && { authorAssociation: raw.authorAssociation }),
11
+ body,
12
+ // A changes-requested rule item carries no URL, as in the full check.
13
+ ...(kind === "review-thread" ? { url: raw?.url ?? "" } : {}),
14
+ ...(kind !== "review-thread" && kind !== "changes-requested" && raw?.url
15
+ ? { url: raw.url }
16
+ : {}),
17
+ ...(kind === "review-thread" && { path }),
18
+ };
19
+ return applyRules(rules, item);
20
+ }
21
+ /**
22
+ * An auto-resolve match is hidden only once its mutation is confirmed or the item is already
23
+ * resolved; until then it stays visible so the full check can run or surface it.
24
+ */
25
+ export function ruleHides(action, kind, autoResolveSatisfied) {
26
+ return (action.suppress === true &&
27
+ (action.autoResolve !== true || kind === "changes-requested" || autoResolveSatisfied));
28
+ }
@@ -7,3 +7,10 @@ export declare function trunkRequiredContexts(ordered: readonly RawSummaryPr[],
7
7
  export declare function summaryRequiredContexts(raw: RawSummaryPr, mergeTargetContexts: readonly string[] | undefined): string[];
8
8
  /** Record missing required contexts. A running Actions workflow does not clear the names. */
9
9
  export declare function applyUnreportedRequiredChecks(raw: RawSummaryPr, checks: PollSummaryChecks, required: readonly string[]): void;
10
+ /** No relevant check yet. A skipped, filtered, ignored or superseded check is not relevant. */
11
+ export declare function hasNoRelevantChecks(checks: PollSummaryChecks): boolean;
12
+ /**
13
+ * Flag a running Actions workflow suite when the head has no relevant check yet. Suites exist
14
+ * before their check runs, so CLEAN with no checks is not proof that CI passed.
15
+ */
16
+ export declare function applyPendingWorkflowSuites(raw: RawSummaryPr, checks: PollSummaryChecks): void;
@@ -37,6 +37,22 @@ export function applyUnreportedRequiredChecks(raw, checks, required) {
37
37
  checks.actionsWorkflowInProgress = true;
38
38
  }
39
39
  }
40
+ /** No relevant check yet. A skipped, filtered, ignored or superseded check is not relevant. */
41
+ export function hasNoRelevantChecks(checks) {
42
+ return (checks.passing ?? 0) + (checks.failing ?? 0) + (checks.inProgress ?? 0) === 0;
43
+ }
44
+ /**
45
+ * Flag a running Actions workflow suite when the head has no relevant check yet. Suites exist
46
+ * before their check runs, so CLEAN with no checks is not proof that CI passed.
47
+ */
48
+ export function applyPendingWorkflowSuites(raw, checks) {
49
+ if (checks.actionsWorkflowInProgress === true || !hasNoRelevantChecks(checks))
50
+ return;
51
+ const suites = raw.commits.nodes[0]?.commit.checkSuites?.nodes ?? [];
52
+ if (actionsWorkflowInProgress(suites, new Set(loadConfig().checks["ci-trigger-events"]))) {
53
+ checks.actionsWorkflowInProgress = true;
54
+ }
55
+ }
40
56
  function reportedNames(raw) {
41
57
  const names = new Set();
42
58
  const rollups = [
@@ -8,11 +8,13 @@ import { missingRepositoryError } from "./errors.mjs";
8
8
  import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
9
9
  import { refreshUnknownSummaryMergeability } from "./poll-summary-mergeability.mjs";
10
10
  import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
11
+ import { hydrateRowsIsolated, summarizeRowsIsolated, } from "./poll-summary-row-error.mjs";
11
12
  import { trunkRequiredContexts } from "./poll-summary-unreported.mjs";
12
13
  import { loadStackSizeHint, storeStackSizeHint } from "./stack-size-hint.mjs";
13
14
  import { POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
14
15
  import { stackAncestryGaps } from "./stack-read.mjs";
15
16
  import { readGraphqlStackSummary } from "./poll-summary-stack-page.mjs";
17
+ import { patchHandoffLayers } from "./poll-summary-rule-handoff.mjs";
16
18
  import { stackHandoffSource } from "./stack-handoff.mjs";
17
19
  const MAX_EXPLICIT_PRS_PER_QUERY = 50;
18
20
  async function fetchSelectedPollSummary(opts, repo) {
@@ -23,21 +25,27 @@ async function fetchSelectedPollSummary(opts, repo) {
23
25
  throw new ShepherdError("Aggregate poll requires at least two PRs or a stack PR", EXIT.USAGE);
24
26
  }
25
27
  const raw = [];
28
+ const failures = new Map();
26
29
  let viewerCanAdminister;
27
30
  for (let offset = 0; offset < requested.length; offset += MAX_EXPLICIT_PRS_PER_QUERY) {
28
31
  const chunk = requested.slice(offset, offset + MAX_EXPLICIT_PRS_PER_QUERY);
29
32
  const fetched = await fetchExplicitChunk(chunk, repo);
30
33
  viewerCanAdminister = fetched.viewerCanAdminister;
31
34
  raw.push(...fetched.prs);
35
+ for (const [pr, error] of fetched.failures ?? [])
36
+ failures.set(pr, error);
32
37
  }
33
38
  return {
34
39
  selection: { kind: "prs", requested },
35
- prs: await Promise.all(raw.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
40
+ // One explicit PR's failed read becomes a row; stack selections stay fail-closed.
41
+ prs: await summarizeRowsIsolated(raw, failures, repo, opts, (pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, undefined, null, true)),
36
42
  };
37
43
  }
38
44
  /** Fresh, read-only snapshot used to bind a one-PR READY receipt to stack routing. */
39
45
  export async function fetchRawSummaryPr(pr, repo) {
40
46
  const fetched = await fetchExplicitChunk([pr], repo);
47
+ if (fetched.failures?.has(pr))
48
+ throw fetched.failures.get(pr);
41
49
  return fetched.prs[0];
42
50
  }
43
51
  async function fetchGraphqlExplicitChunk(prs, repo) {
@@ -60,11 +68,8 @@ async function fetchGraphqlExplicitChunk(prs, repo) {
60
68
  throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
61
69
  return raw;
62
70
  });
63
- for (const raw of rawPrs) {
64
- await hydratePollSummaryChecks(raw, repo);
65
- await refreshUnknownSummaryMergeability(raw, repo);
66
- }
67
- return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
71
+ const failures = await hydrateRowsIsolated(rawPrs, repo);
72
+ return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister, failures };
68
73
  }
69
74
  async function fetchExplicitChunk(prs, repo) {
70
75
  return githubOperation("PollSummary", () => runWithGithubTransport("graphql", () => fetchGraphqlExplicitChunk(prs, repo)), async () => ({
@@ -101,9 +106,12 @@ async function fetchStackSummary(opts, repo) {
101
106
  const handoffSource = getGithubTransport() === "rest" ? undefined : stackHandoffSource(read);
102
107
  const stackAncestry = stackAncestryGaps(ordered);
103
108
  const required = await trunkRequiredContexts(ordered, repo);
109
+ const prs = await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, required, viewerLogin, true)));
110
+ if (handoffSource)
111
+ patchHandoffLayers(handoffSource.layers, prs);
104
112
  return {
105
113
  selection: { kind: "stack", anchor, stackNumber, stackSize },
106
- prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, required, viewerLogin))),
114
+ prs,
107
115
  ...(stackAncestry.length > 0 && { stackAncestry }),
108
116
  ...(allowedMergeMethods && { allowedMergeMethods }),
109
117
  ...(handoffSource && { handoffSource }),