pr-shepherd 0.54.1 → 0.55.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 (114) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/bin/checks/unreported-required.d.mts +29 -0
  4. package/bin/checks/unreported-required.mjs +52 -0
  5. package/bin/classify/apply.d.mts +2 -0
  6. package/bin/classify/apply.mjs +31 -31
  7. package/bin/classify/rule-action.d.mts +8 -0
  8. package/bin/classify/rule-action.mjs +29 -0
  9. package/bin/classify/types.d.mts +1 -1
  10. package/bin/cli/help-command-pages.d.mts +2 -2
  11. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  12. package/bin/cli/help-iterate-poll-pages.mjs +6 -5
  13. package/bin/cli/help-top-page.d.mts +1 -1
  14. package/bin/cli/help-top-page.mjs +2 -2
  15. package/bin/cli/help.d.mts +3 -3
  16. package/bin/cli/iterate-formatter.mjs +29 -9
  17. package/bin/cli/iterate-instructions.mjs +6 -0
  18. package/bin/cli/iterate-lean.mjs +15 -0
  19. package/bin/cli/poll-summary-emitter.mjs +4 -1
  20. package/bin/cli/poll-summary-formatter.mjs +10 -3
  21. package/bin/cli/stack-overview.d.mts +48 -0
  22. package/bin/cli/stack-overview.mjs +138 -0
  23. package/bin/commands/check-fingerprint.mjs +2 -1
  24. package/bin/commands/check-status.d.mts +1 -1
  25. package/bin/commands/check-status.mjs +8 -3
  26. package/bin/commands/check-unreported.d.mts +26 -0
  27. package/bin/commands/check-unreported.mjs +73 -0
  28. package/bin/commands/check.mjs +44 -38
  29. package/bin/commands/iterate/base.mjs +9 -0
  30. package/bin/commands/iterate/escalate.mjs +4 -0
  31. package/bin/commands/iterate/index.mjs +69 -3
  32. package/bin/commands/iterate/merge-state.mjs +12 -12
  33. package/bin/commands/iterate/merge.d.mts +12 -2
  34. package/bin/commands/iterate/merge.mjs +32 -2
  35. package/bin/commands/iterate/unreported-required.d.mts +18 -0
  36. package/bin/commands/iterate/unreported-required.mjs +122 -0
  37. package/bin/commands/poll-summary.mjs +1 -0
  38. package/bin/commands/poll.mjs +9 -2
  39. package/bin/commands/ready-mergeability.d.mts +1 -1
  40. package/bin/commands/ready-mergeability.mjs +2 -2
  41. package/bin/commands/rule-auto-resolve-format.d.mts +22 -0
  42. package/bin/commands/rule-auto-resolve-format.mjs +151 -0
  43. package/bin/commands/rule-auto-resolve.d.mts +21 -0
  44. package/bin/commands/rule-auto-resolve.mjs +113 -0
  45. package/bin/commands/stack-drain.d.mts +0 -5
  46. package/bin/commands/stack-drain.mjs +26 -16
  47. package/bin/commands/stack-merge-flag.d.mts +7 -0
  48. package/bin/commands/stack-merge-flag.mjs +6 -0
  49. package/bin/config/load.d.mts +3 -0
  50. package/bin/config/load.mjs +9 -2
  51. package/bin/config/merge-method.d.mts +27 -0
  52. package/bin/config/merge-method.mjs +56 -0
  53. package/bin/exit-codes.d.mts +1 -1
  54. package/bin/exit-codes.mjs +2 -1
  55. package/bin/github/batch-parse-suites.d.mts +3 -0
  56. package/bin/github/batch-parse-suites.mjs +15 -0
  57. package/bin/github/batch-parsers.d.mts +1 -1
  58. package/bin/github/batch-parsers.mjs +7 -0
  59. package/bin/github/batch-raw-types.d.mts +5 -0
  60. package/bin/github/batch.d.mts +3 -0
  61. package/bin/github/batch.mjs +9 -1
  62. package/bin/github/errors.d.mts +6 -0
  63. package/bin/github/errors.mjs +23 -5
  64. package/bin/github/fingerprint-fields.d.mts +1 -0
  65. package/bin/github/fingerprint-fields.mjs +1 -1
  66. package/bin/github/gql/batch-pr-page.gql +2 -0
  67. package/bin/github/gql/batch-pr.gql +5 -0
  68. package/bin/github/gql/commit-check-suites.gql +1 -0
  69. package/bin/github/gql/poll-stack-summary.gql +6 -0
  70. package/bin/github/gql/poll-summary-annotation-probe.gql +32 -0
  71. package/bin/github/gql/poll-summary-check-contexts.gql +2 -3
  72. package/bin/github/gql/poll-summary-fragment.gql +16 -0
  73. package/bin/github/gql/pr-merge-policy.gql +1 -44
  74. package/bin/github/gql/ref-rules-query.gql +18 -0
  75. package/bin/github/gql/ref-rules.gql +46 -0
  76. package/bin/github/graphql-internal-retry.d.mts +1 -1
  77. package/bin/github/graphql-internal-retry.mjs +4 -3
  78. package/bin/github/merge-queue-checks.mjs +7 -3
  79. package/bin/github/merge-target-rules.d.mts +20 -0
  80. package/bin/github/merge-target-rules.mjs +54 -0
  81. package/bin/github/poll-summary-annotation-probe.d.mts +19 -0
  82. package/bin/github/poll-summary-annotation-probe.mjs +88 -0
  83. package/bin/github/poll-summary-fingerprint.mjs +1 -1
  84. package/bin/github/poll-summary-projector.d.mts +1 -1
  85. package/bin/github/poll-summary-projector.mjs +19 -12
  86. package/bin/github/poll-summary-queue-removal.mjs +7 -3
  87. package/bin/github/poll-summary-raw.d.mts +16 -0
  88. package/bin/github/poll-summary-readiness.mjs +1 -0
  89. package/bin/github/poll-summary-route.mjs +5 -0
  90. package/bin/github/poll-summary-unreported.d.mts +6 -0
  91. package/bin/github/poll-summary-unreported.mjs +51 -0
  92. package/bin/github/poll-summary.d.mts +1 -0
  93. package/bin/github/poll-summary.mjs +28 -3
  94. package/bin/github/queries.d.mts +7 -0
  95. package/bin/github/queries.mjs +9 -2
  96. package/bin/github/queue-removal-freshness.d.mts +20 -5
  97. package/bin/github/queue-removal-freshness.mjs +37 -10
  98. package/bin/github/stack-read.d.mts +4 -0
  99. package/bin/github/stack-read.mjs +13 -1
  100. package/bin/mcp/server.mjs +6 -1
  101. package/bin/state/ci-retrigger.d.mts +25 -0
  102. package/bin/state/ci-retrigger.mjs +48 -0
  103. package/bin/types/escalate.d.mts +1 -1
  104. package/bin/types/github.d.mts +11 -0
  105. package/bin/types/iterate.d.mts +14 -3
  106. package/bin/types/poll-summary.d.mts +13 -0
  107. package/bin/types/report.d.mts +32 -1
  108. package/package.json +1 -1
  109. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  110. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  111. package/plugins/pr-shepherd/.mcp.json +1 -1
  112. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +2 -2
  113. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  114. package/src/classify/types.mts +1 -1
@@ -0,0 +1,21 @@
1
+ import type { BatchPartition } from "../classify/apply.mts";
2
+ import type { AutoMinimizedItem, AutoResolvedThread, BatchPrData } from "../types.mts";
3
+ export interface RuleAutoResolveApplied {
4
+ threadIds: string[];
5
+ commentIds: string[];
6
+ reviewSummaryIds: string[];
7
+ autoResolved: AutoResolvedThread[];
8
+ autoMinimized: AutoMinimizedItem[];
9
+ autoResolveErrors: string[];
10
+ errorReasons: string[];
11
+ }
12
+ export declare function applySuppressedRuleAutoResolve(input: {
13
+ enabled: boolean;
14
+ partition: BatchPartition;
15
+ batch: BatchPrData;
16
+ prNumber: number;
17
+ repo: {
18
+ owner: string;
19
+ name: string;
20
+ };
21
+ }): Promise<RuleAutoResolveApplied>;
@@ -0,0 +1,113 @@
1
+ import { autoMinimizeComments, autoResolveThreads } from "../comments/resolve.mjs";
2
+ import { runJournal } from "./journal/index.mjs";
3
+ import { decorateAutoResolveError, formatRuleAutoResolveJournalItem, minimizedItem, reasonClause, reasonsForError, resolvedThread, ruleAutoResolveBody, uniqueReasons, } from "./rule-auto-resolve-format.mjs";
4
+ const EMPTY = {
5
+ threadIds: [],
6
+ commentIds: [],
7
+ reviewSummaryIds: [],
8
+ autoResolved: [],
9
+ autoMinimized: [],
10
+ autoResolveErrors: [],
11
+ errorReasons: [],
12
+ };
13
+ function idsIn(ids, confirmed) {
14
+ return ids.filter((id) => confirmed.has(id));
15
+ }
16
+ function decorateErrors(errors, ids, ruleReasons) {
17
+ return errors.map((error) => decorateAutoResolveError(error, reasonsForError(error, ids, ruleReasons)));
18
+ }
19
+ function successReasons(ids, ruleReasons) {
20
+ return uniqueReasons(ids.flatMap((id) => [...(ruleReasons.get(id) ?? [])]));
21
+ }
22
+ export async function applySuppressedRuleAutoResolve(input) {
23
+ if (!input.enabled) {
24
+ return {
25
+ ...EMPTY,
26
+ threadIds: input.partition.ruleAutoResolveThreadIds,
27
+ commentIds: input.partition.ruleAutoResolveCommentIds,
28
+ reviewSummaryIds: input.partition.ruleAutoResolveReviewSummaryIds,
29
+ };
30
+ }
31
+ const commentIds = input.partition.ruleAutoResolveCommentIds.filter((id) => input.partition.suppressedCommentIds.has(id));
32
+ const reviewSummaryIds = input.partition.ruleAutoResolveReviewSummaryIds.filter((id) => input.partition.suppressedReviewSummaryIds.has(id));
33
+ const threadIds = input.partition.ruleAutoResolveThreadIds.filter((id) => input.partition.suppressedThreadIds.has(id));
34
+ const minimizeIds = [...commentIds, ...reviewSummaryIds];
35
+ const [minimized, resolved] = await Promise.all([
36
+ minimizeIds.length > 0
37
+ ? autoMinimizeComments(minimizeIds)
38
+ : Promise.resolve({ minimized: [], errors: [] }),
39
+ threadIds.length > 0
40
+ ? autoResolveThreads(threadIds)
41
+ : Promise.resolve({ resolved: [], errors: [] }),
42
+ ]);
43
+ const minimizedSet = new Set(minimized.minimized);
44
+ const resolvedSet = new Set(resolved.resolved);
45
+ const resolvedIds = idsIn(threadIds, resolvedSet);
46
+ const minimizedCommentIds = idsIn(commentIds, minimizedSet);
47
+ const minimizedSummaryIds = idsIn(reviewSummaryIds, minimizedSet);
48
+ const threadsById = new Map(input.batch.reviewThreads.map((thread) => [thread.id, thread]));
49
+ const commentsById = new Map(input.batch.comments.map((comment) => [comment.id, comment]));
50
+ const summariesById = new Map(input.batch.reviewSummaries.map((review) => [review.id, review]));
51
+ const autoResolved = resolvedIds.flatMap((id) => {
52
+ const thread = threadsById.get(id);
53
+ return thread ? [resolvedThread(thread, input.partition.ruleReasons.get(id) ?? [])] : [];
54
+ });
55
+ const autoMinimized = [
56
+ ...minimizedCommentIds.map((id) => minimizedItem("pr-comment", id, commentsById.get(id)?.url, input.partition.ruleReasons.get(id) ?? [])),
57
+ ...minimizedSummaryIds.map((id) => minimizedItem("review-summary", id, summariesById.get(id)?.url, input.partition.ruleReasons.get(id) ?? [])),
58
+ ];
59
+ const autoResolveErrors = [
60
+ ...decorateErrors(minimized.errors, minimizeIds, input.partition.ruleReasons),
61
+ ...decorateErrors(resolved.errors, threadIds, input.partition.ruleReasons),
62
+ ];
63
+ const errorReasons = uniqueReasons([
64
+ ...minimized.errors.flatMap((error) => reasonsForError(error, minimizeIds, input.partition.ruleReasons)),
65
+ ...resolved.errors.flatMap((error) => reasonsForError(error, threadIds, input.partition.ruleReasons)),
66
+ ]);
67
+ const journalError = await journalSuccesses(input, autoResolved, autoMinimized);
68
+ if (journalError)
69
+ autoResolveErrors.push(journalError);
70
+ return {
71
+ threadIds: input.partition.ruleAutoResolveThreadIds.filter((id) => !resolvedSet.has(id)),
72
+ commentIds: input.partition.ruleAutoResolveCommentIds.filter((id) => !minimizedSet.has(id)),
73
+ reviewSummaryIds: input.partition.ruleAutoResolveReviewSummaryIds.filter((id) => !minimizedSet.has(id)),
74
+ autoResolved,
75
+ autoMinimized,
76
+ autoResolveErrors,
77
+ errorReasons,
78
+ };
79
+ }
80
+ async function journalSuccesses(input, autoResolved, autoMinimized) {
81
+ const body = ruleAutoResolveBody({
82
+ threads: autoResolved.length,
83
+ comments: autoMinimized.filter((item) => item.kind === "pr-comment").length,
84
+ reviewSummaries: autoMinimized.filter((item) => item.kind === "review-summary").length,
85
+ });
86
+ if (!body)
87
+ return undefined;
88
+ const ids = [...autoResolved.map((thread) => thread.id), ...autoMinimized.map((item) => item.id)];
89
+ const reasons = successReasons(ids, input.partition.ruleReasons);
90
+ const item = formatRuleAutoResolveJournalItem({
91
+ body,
92
+ reasons,
93
+ ...(input.batch.viewerLogin ? { viewerLogin: input.batch.viewerLogin } : {}),
94
+ urls: [
95
+ ...autoResolved.map((thread) => thread.url),
96
+ ...autoMinimized.map((item) => item.url ?? ""),
97
+ ],
98
+ });
99
+ try {
100
+ await runJournal({
101
+ prNumber: input.prNumber,
102
+ targetRepository: { owner: input.repo.owner, name: input.repo.name },
103
+ rawItem: item,
104
+ dryRun: false,
105
+ });
106
+ return undefined;
107
+ }
108
+ catch (err) {
109
+ const message = err instanceof Error ? err.message : String(err);
110
+ const clause = reasonClause(reasons);
111
+ return clause ? `journal: ${message} ${clause}` : `journal: ${message}`;
112
+ }
113
+ }
@@ -7,11 +7,6 @@ export interface StackPlan {
7
7
  idle?: PollSummaryItem[];
8
8
  instructions: string[];
9
9
  }
10
- /**
11
- * Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
12
- * stack number first, but stack numbers come from the repository's issue and
13
- * pull request sequence, so a PR number never names a stack.
14
- */
15
10
  export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
16
11
  /**
17
12
  * A merge-requested, fully ready stack whose bottom open layer still targets
@@ -1,11 +1,6 @@
1
+ import { stackMergeFlag } from "./stack-merge-flag.mjs";
1
2
  import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
2
3
  import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
3
- /**
4
- * Highest open layer such that it and every open layer below it are ready to
5
- * merge together. `gh stack merge <PR>` lands that PR and every unmerged layer
6
- * below it. A merge queue accepts the same prefix and evaluates each layer
7
- * from the bottom; a failure ejects that layer and those above it.
8
- */
9
4
  function readyPrefixTop(result) {
10
5
  const layers = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
11
6
  if (layers.some((item) => item.state === "OPEN" && item.isInMergeQueue))
@@ -29,11 +24,6 @@ function readyPrefixTop(result) {
29
24
  }
30
25
  return top;
31
26
  }
32
- /**
33
- * Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
34
- * stack number first, but stack numbers come from the repository's issue and
35
- * pull request sequence, so a PR number never names a stack.
36
- */
37
27
  export function planPrefixDrain(result, mergeRequested) {
38
28
  if (!mergeRequested)
39
29
  return undefined;
@@ -49,8 +39,16 @@ export function planPrefixDrain(result, mergeRequested) {
49
39
  item.action !== "escalate" &&
50
40
  (!isStackLayerReady(item) || staleChildren.has(item.pr))), staleChildren);
51
41
  const span = open[0]?.pr === top.pr ? "that layer alone" : `PR #${top.pr} and every unmerged layer below it`;
42
+ const method = stackMergeFlag(result.allowedMergeMethods);
43
+ if ("unavailable" in method) {
44
+ return {
45
+ action: "escalate",
46
+ stackMergeable: false,
47
+ instructions: [`1. ${method.unavailable}`],
48
+ };
49
+ }
52
50
  const instructions = [
53
- `1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes --squash\` to merge ${span}. When the base uses a merge queue, the same command queues that prefix together and GitHub evaluates each layer from the bottom; a failure ejects that layer and the layers above it. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
51
+ `1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes ${method.flag}\` to merge ${span}. When the base uses a merge queue, the same command queues that prefix together and GitHub evaluates each layer from the bottom; a failure ejects that layer and the layers above it. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
54
52
  ];
55
53
  appendAutonomousInstructions(instructions, above.sessions);
56
54
  appendMarkReadyInstructions(instructions, above.markReady);
@@ -90,17 +88,29 @@ export function idleWaitPlan(idle) {
90
88
  ],
91
89
  };
92
90
  }
91
+ function sessionInstruction(item) {
92
+ if (!item.owned)
93
+ return ` PR #${item.pr} is not owned. Do not run a session for it.`;
94
+ const removal = item.queueRemoval
95
+ ? ` PR #${item.pr} was removed from the merge queue (\`${item.queueRemoval.reason ?? "unknown reason"}\`${item.queueRemoval.actor ? ` by @${item.queueRemoval.actor}` : ""}).`
96
+ : "";
97
+ if (!item.pollCommand) {
98
+ return ` PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`;
99
+ }
100
+ const queueNote = item.queueRemoval
101
+ ? " That session fixes failing queue CI, or escalates when the removal has no concrete fix."
102
+ : "";
103
+ return `${removal} Run \`${item.pollCommand}\` for PR #${item.pr}.${queueNote}`;
104
+ }
93
105
  export function describeIdleLayers(idle) {
94
106
  return idle.map((item) => `PR #${item.pr} (${item.reasons.join(", ")})`).join("; ");
95
107
  }
96
108
  export function appendAutonomousInstructions(instructions, candidates) {
97
109
  if (candidates.length === 0)
98
110
  return;
99
- instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
111
+ instructions.push(`${instructions.length + 1}. Start or delegate one-PR sessions only for rows marked \`owned\`. Leave every other author's layer untouched. Owned layers can proceed concurrently.`);
100
112
  for (const item of candidates) {
101
- instructions.push(item.pollCommand
102
- ? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
103
- : `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
113
+ instructions.push(`${instructions.length + 1}.${sessionInstruction(item)}`);
104
114
  }
105
115
  }
106
116
  export function findHumanHandoffs(result) {
@@ -0,0 +1,7 @@
1
+ import { type MergeMethod } from "../config/merge-method.mts";
2
+ /** Stack commands prefer squash unless config or the repository selects another method. */
3
+ export declare function stackMergeFlag(allowed: readonly MergeMethod[] | undefined): {
4
+ flag: string;
5
+ } | {
6
+ unavailable: string;
7
+ };
@@ -0,0 +1,6 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ import { configuredMergeMethod, mergeMethodFlag, } from "../config/merge-method.mjs";
3
+ /** Stack commands prefer squash unless config or the repository selects another method. */
4
+ export function stackMergeFlag(allowed) {
5
+ return mergeMethodFlag(allowed, "squash", configuredMergeMethod(loadConfig().merge ?? {}));
6
+ }
@@ -1,3 +1,4 @@
1
+ import { type MergeMethod } from "./merge-method.mts";
1
2
  declare const MINIMIZE_COMMENTS_POLICIES: readonly ["all", "bots", "users", "none"];
2
3
  export type MinimizeCommentsPolicy = (typeof MINIMIZE_COMMENTS_POLICIES)[number];
3
4
  declare const RESOLVE_OTHER_HUMAN_THREADS: readonly ["none", "outdated", "always"];
@@ -70,6 +71,8 @@ export interface PrShepherdConfig {
70
71
  blockingReviewerLogins: string[];
71
72
  };
72
73
  merge?: {
74
+ /** Preferred merge method. Used only when the repository allows it. */
75
+ method?: MergeMethod;
73
76
  /** Options added to ordinary `gh pr merge` commands. Queue commands never use these. */
74
77
  commandArgs: string[];
75
78
  };
@@ -6,6 +6,7 @@ import { parse } from "yaml";
6
6
  import builtins from "../config.json" with { type: "json" };
7
7
  import { getEffectiveCwd } from "../execution-context.mjs";
8
8
  import { findMergeStrategies } from "./merge-command-args.mjs";
9
+ import { parseMergeMethod } from "./merge-method.mjs";
9
10
  const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
10
11
  const RESOLVE_OTHER_HUMAN_THREADS = ["none", "outdated", "always"];
11
12
  const RC_FILENAME = ".pr-shepherdrc.yml";
@@ -143,7 +144,7 @@ function parseMergeCommandArgs(value) {
143
144
  if (strategies.length > 1) {
144
145
  throw new Error(`Invalid config: merge.commandArgs includes multiple merge strategies: ${strategies.join(", ")}`);
145
146
  }
146
- return strategies.length === 0 ? [...value, "--merge"] : [...value];
147
+ return [...value];
147
148
  }
148
149
  function parsePollConfig(value) {
149
150
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -279,7 +280,7 @@ const KNOWN_NESTED_KEYS = {
279
280
  resolve: new Set(["shaPoll"]),
280
281
  checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
281
282
  mergeStatus: new Set(["blockingReviewerLogins"]),
282
- merge: new Set(["commandArgs"]),
283
+ merge: new Set(["commandArgs", "method"]),
283
284
  actions: new Set([
284
285
  "autoMinimizeSuppressed",
285
286
  "autoMarkReady",
@@ -321,6 +322,12 @@ function parseConfig(value, normalizeMergeArgs = true) {
321
322
  config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
322
323
  if (config.merge && normalizeMergeArgs) {
323
324
  config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
325
+ if (config.merge.method !== undefined)
326
+ config.merge.method = parseMergeMethod(config.merge.method);
327
+ const fromArgs = findMergeStrategies(config.merge.commandArgs)[0];
328
+ if (config.merge.method && fromArgs && fromArgs !== config.merge.method) {
329
+ throw new Error(`Invalid config: merge.method ${config.merge.method} conflicts with merge.commandArgs strategy ${fromArgs}`);
330
+ }
324
331
  }
325
332
  config.poll = parsePollConfig(config.poll);
326
333
  config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings, config.poll.intervalSeconds);
@@ -0,0 +1,27 @@
1
+ export type MergeMethod = "merge" | "squash" | "rebase";
2
+ export interface RepositoryMergeSettings {
3
+ mergeCommitAllowed?: boolean;
4
+ squashMergeAllowed?: boolean;
5
+ rebaseMergeAllowed?: boolean;
6
+ }
7
+ /** `undefined` when the query did not select repository merge settings. */
8
+ export declare function readAllowedMergeMethods(repository: RepositoryMergeSettings | null | undefined): MergeMethod[] | undefined;
9
+ export declare function parseMergeMethod(value: unknown): MergeMethod;
10
+ export declare function configuredMergeMethod(merge: {
11
+ method?: MergeMethod;
12
+ commandArgs?: string[];
13
+ }): MergeMethod | null;
14
+ export declare function chooseMergeMethod(input: {
15
+ allowed: readonly MergeMethod[] | undefined;
16
+ configured: MergeMethod | null;
17
+ fallback: MergeMethod;
18
+ }): {
19
+ method: MergeMethod;
20
+ } | {
21
+ unavailable: string;
22
+ };
23
+ export declare function mergeMethodFlag(allowed: readonly MergeMethod[] | undefined, fallback: MergeMethod, configured: MergeMethod | null): {
24
+ flag: string;
25
+ } | {
26
+ unavailable: string;
27
+ };
@@ -0,0 +1,56 @@
1
+ import { findMergeStrategies } from "./merge-command-args.mjs";
2
+ const MERGE_METHODS = new Set(["merge", "squash", "rebase"]);
3
+ const METHOD_ORDER = ["merge", "squash", "rebase"];
4
+ /** `undefined` when the query did not select repository merge settings. */
5
+ export function readAllowedMergeMethods(repository) {
6
+ if (!repository ||
7
+ (repository.mergeCommitAllowed === undefined &&
8
+ repository.squashMergeAllowed === undefined &&
9
+ repository.rebaseMergeAllowed === undefined)) {
10
+ return undefined;
11
+ }
12
+ const allowed = [];
13
+ if (repository.mergeCommitAllowed)
14
+ allowed.push("merge");
15
+ if (repository.squashMergeAllowed)
16
+ allowed.push("squash");
17
+ if (repository.rebaseMergeAllowed)
18
+ allowed.push("rebase");
19
+ return allowed;
20
+ }
21
+ export function parseMergeMethod(value) {
22
+ if (typeof value === "string" && MERGE_METHODS.has(value)) {
23
+ return value;
24
+ }
25
+ throw new Error("Invalid config: merge.method must be merge, squash, or rebase");
26
+ }
27
+ export function configuredMergeMethod(merge) {
28
+ const fromArgs = findMergeStrategies(merge.commandArgs ?? [])[0];
29
+ return merge.method ?? fromArgs ?? null;
30
+ }
31
+ export function chooseMergeMethod(input) {
32
+ if (input.allowed === undefined)
33
+ return { method: input.configured ?? input.fallback };
34
+ if (input.configured) {
35
+ if (input.allowed.includes(input.configured))
36
+ return { method: input.configured };
37
+ const allowedText = input.allowed.length > 0 ? input.allowed.join(", ") : "none";
38
+ return {
39
+ unavailable: `Configured merge method \`${input.configured}\` is not allowed by this repository. Allowed methods: ${allowedText}.`,
40
+ };
41
+ }
42
+ if (input.allowed.includes(input.fallback))
43
+ return { method: input.fallback };
44
+ const method = METHOD_ORDER.find((item) => input.allowed?.includes(item));
45
+ if (method)
46
+ return { method };
47
+ return {
48
+ unavailable: "This repository allows no merge method (merge commits, squash, and rebase are all disabled).",
49
+ };
50
+ }
51
+ export function mergeMethodFlag(allowed, fallback, configured) {
52
+ const decision = chooseMergeMethod({ allowed, configured, fallback });
53
+ if ("unavailable" in decision)
54
+ return decision;
55
+ return { flag: `--${decision.method}` };
56
+ }
@@ -13,7 +13,7 @@ import type { IterateResult } from "./types.mts";
13
13
  export declare const EXIT: Readonly<{
14
14
  /** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
15
15
  readonly OK: 0;
16
- /** Nothing to do yet; CI still in progress. */
16
+ /** Nothing to do yet, or a clean PR whose ready-delay is still counting. */
17
17
  readonly WAIT: 10;
18
18
  /** Draft PR converted to ready for review. */
19
19
  readonly MARK_READY: 11;
@@ -12,7 +12,7 @@
12
12
  export const EXIT = Object.freeze({
13
13
  /** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
14
14
  OK: 0,
15
- /** Nothing to do yet; CI still in progress. */
15
+ /** Nothing to do yet, or a clean PR whose ready-delay is still counting. */
16
16
  WAIT: 10,
17
17
  /** Draft PR converted to ready for review. */
18
18
  MARK_READY: 11,
@@ -62,6 +62,7 @@ export function iterateResultToExitCode(result) {
62
62
  case "cancel":
63
63
  return CANCEL_REASON_EXIT_CODE[result.reason];
64
64
  case "wait":
65
+ case "ready":
65
66
  return EXIT.WAIT;
66
67
  case "mark_ready":
67
68
  return EXIT.MARK_READY;
@@ -1,6 +1,9 @@
1
+ import type { WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
1
2
  import type { CheckRun } from "../types.mts";
2
3
  import type { RawPr } from "./batch-raw-types.mts";
3
4
  export declare function parseCheckSuitesComplete(raw: RawPr): boolean;
4
5
  /** True only when the head commit's suite page finished and listed nothing. */
5
6
  export declare function parseHeadCheckSuitesEmpty(raw: RawPr): boolean;
7
+ /** Actions suites on the head. Third-party suites with no workflow run are omitted. */
8
+ export declare function parseHeadWorkflowSuites(raw: RawPr): WorkflowSuiteSnapshot[];
6
9
  export declare function parseSuiteStartupFailures(raw: RawPr): CheckRun[];
@@ -6,6 +6,21 @@ export function parseHeadCheckSuitesEmpty(raw) {
6
6
  const suites = raw.commits.nodes[0]?.commit.checkSuites;
7
7
  return suites?.pageInfo.hasNextPage === false && suites.nodes.length === 0;
8
8
  }
9
+ /** Actions suites on the head. Third-party suites with no workflow run are omitted. */
10
+ export function parseHeadWorkflowSuites(raw) {
11
+ const nodes = raw.commits.nodes[0]?.commit.checkSuites?.nodes ?? [];
12
+ return nodes.flatMap((node) => {
13
+ if (!node.workflowRun)
14
+ return [];
15
+ return [
16
+ {
17
+ status: node.status ?? null,
18
+ conclusion: node.conclusion,
19
+ workflowRun: { event: node.workflowRun.event },
20
+ },
21
+ ];
22
+ });
23
+ }
9
24
  export function parseSuiteStartupFailures(raw) {
10
25
  const suites = raw.commits.nodes[0]?.commit.checkSuites;
11
26
  if (!suites)
@@ -1,3 +1,3 @@
1
1
  import type { BatchPrData } from "../types.mts";
2
2
  import type { RawBatchResponse, RawPr, RawThread, RawComment, RawReview, RawReviewSummary, RawContextNode } from "./batch-raw-types.mts";
3
- export declare function parseRawPr(raw: RawPr, rawThreadPages: RawThread[], rawCommentNodes: RawComment[], rawReviewNodes: RawReview[], rawReviewSummaryNodes: RawReviewSummary[], rawApprovedReviewNodes: RawReviewSummary[], rawCheckNodes: RawContextNode[], repository?: Pick<NonNullable<RawBatchResponse["repository"]>, "viewerPermission" | "viewerCanAdminister">): BatchPrData;
3
+ export declare function parseRawPr(raw: RawPr, rawThreadPages: RawThread[], rawCommentNodes: RawComment[], rawReviewNodes: RawReview[], rawReviewSummaryNodes: RawReviewSummary[], rawApprovedReviewNodes: RawReviewSummary[], rawCheckNodes: RawContextNode[], repository?: Pick<NonNullable<RawBatchResponse["repository"]>, "viewerPermission" | "viewerCanAdminister" | "mergeCommitAllowed" | "squashMergeAllowed" | "rebaseMergeAllowed">): BatchPrData;
@@ -1,8 +1,10 @@
1
1
  import { mapAuthorType, parseCreatedAt, latestApprovedLogins, isReviewStale, } from "./batch-parser-helpers.mjs";
2
2
  import { parseCheckNodes } from "./batch-parse-checks.mjs";
3
+ import { headPushUnixFromCheckNodes } from "./queue-removal-freshness.mjs";
3
4
  import { requireContextNodes } from "./batch-response.mjs";
4
5
  import { buildPrActivitySummary } from "./activity.mjs";
5
6
  import { parseBranchProtection } from "./branch-protection.mjs";
7
+ import { readAllowedMergeMethods } from "../config/merge-method.mjs";
6
8
  import { parseAutoMergeRequest, parseBranchRules, parseLatestMergeQueueRemoval, parseMergeQueueEntry, parseStack, } from "./batch-parsers-rules.mjs";
7
9
  function parseReviewNode(r) {
8
10
  const base = {
@@ -13,6 +15,7 @@ function parseReviewNode(r) {
13
15
  viewerCanMinimize: "viewerCanMinimize" in r && r.viewerCanMinimize === true,
14
16
  body: r.body,
15
17
  createdAtUnix: r.createdAt ? parseCreatedAt(r.createdAt) : 0,
18
+ ...("url" in r && r.url ? { url: r.url } : {}),
16
19
  };
17
20
  if ("commit" in r && r.commit?.oid) {
18
21
  base.commitOid = r.commit.oid;
@@ -100,9 +103,11 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
100
103
  const mergeQueueChecks = parseCheckNodes(queueHead?.statusCheckRollup
101
104
  ? requireContextNodes(queueHead.statusCheckRollup.contexts.nodes)
102
105
  : undefined, queueHead?.oid);
106
+ const headPushedAtUnix = headPushUnixFromCheckNodes(rawCheckNodes);
103
107
  const removedMergeQueueChecks = parseCheckNodes(removedQueueHead?.statusCheckRollup
104
108
  ? requireContextNodes(removedQueueHead.statusCheckRollup.contexts.nodes)
105
109
  : undefined, removedQueueHead?.oid);
110
+ const allowedMergeMethods = readAllowedMergeMethods(repository);
106
111
  return {
107
112
  nodeId: raw.id,
108
113
  number: raw.number,
@@ -112,6 +117,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
112
117
  mergeStateStatus: raw.mergeStateStatus,
113
118
  reviewDecision: (raw.reviewDecision ?? null),
114
119
  headRefOid: raw.headRefOid,
120
+ ...(headPushedAtUnix !== undefined && { headPushedAtUnix }),
115
121
  headRefName: raw.headRefName,
116
122
  headRepoWithOwner: raw.headRepository?.nameWithOwner ?? null,
117
123
  viewerAuthorization: {
@@ -135,6 +141,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
135
141
  approvedReviews,
136
142
  checks,
137
143
  branchProtection: parseBranchProtection(raw),
144
+ ...(allowedMergeMethods && { allowedMergeMethods }),
138
145
  branchRules: parseBranchRules(raw.baseRef),
139
146
  isInMergeQueue: raw.isInMergeQueue ?? false,
140
147
  isMergeQueueEnabled: raw.isMergeQueueEnabled ?? false,
@@ -7,6 +7,9 @@ export interface RawBatchResponse {
7
7
  repository: {
8
8
  viewerPermission: string | null;
9
9
  viewerCanAdminister: boolean;
10
+ mergeCommitAllowed?: boolean;
11
+ squashMergeAllowed?: boolean;
12
+ rebaseMergeAllowed?: boolean;
10
13
  pullRequest: RawPr | null;
11
14
  } | null;
12
15
  }
@@ -118,6 +121,7 @@ interface RawCheckSuites {
118
121
  };
119
122
  nodes: Array<{
120
123
  id?: string;
124
+ status?: string | null;
121
125
  conclusion: string | null;
122
126
  workflowRun: {
123
127
  databaseId: number | null;
@@ -208,6 +212,7 @@ export interface RawReviewSummary {
208
212
  author: RawAuthor | null;
209
213
  body: string;
210
214
  createdAt?: string;
215
+ url?: string;
211
216
  }
212
217
  export type RawContextNode = {
213
218
  __typename: "CheckRun";
@@ -1,4 +1,5 @@
1
1
  import { type RateLimitInfo, type RepoInfo } from "./client.mts";
2
+ import type { WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
2
3
  import type { BatchPrData } from "../types.mts";
3
4
  import { type PrFingerprint } from "./fingerprint.mts";
4
5
  interface BatchResult {
@@ -9,6 +10,8 @@ interface BatchResult {
9
10
  checkSuitesComplete?: boolean;
10
11
  /** True when that complete page listed no check suites on the head commit. */
11
12
  headCheckSuitesEmpty?: true;
13
+ /** Actions workflow suites on the head, excluding apps that have no workflow run. */
14
+ headWorkflowSuites?: WorkflowSuiteSnapshot[];
12
15
  }
13
16
  interface FetchPrBatchOptions {
14
17
  /**
@@ -2,7 +2,7 @@ import { graphqlWithRateLimit } from "./client.mjs";
2
2
  import { hydrateThreadCommentPages } from "./thread-comments.mjs";
3
3
  import { BATCH_PR_QUERY } from "./queries.mjs";
4
4
  import { parseRawPr } from "./batch-parsers.mjs";
5
- import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
5
+ import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseHeadWorkflowSuites, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
6
6
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
7
7
  import { paginateBatchConnections } from "./batch-page.mjs";
8
8
  import { requireRawPr } from "./batch-response.mjs";
@@ -22,6 +22,9 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
22
22
  const paged = await paginateBatchConnections(pr, repo, raw, opts, result.rateLimit);
23
23
  const rawThreadPages = await hydrateThreadCommentPages(paged.threads);
24
24
  const data = parseRawPr(raw, rawThreadPages, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks, result.data.repository);
25
+ const viewerLogin = result.data.viewer?.login;
26
+ if (viewerLogin)
27
+ data.viewerLogin = viewerLogin;
25
28
  data.checks = mergeStartupFailureChecks(data.checks, parseSuiteStartupFailures(raw));
26
29
  return {
27
30
  data,
@@ -29,5 +32,10 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
29
32
  rateLimit: paged.rateLimit ?? result.rateLimit,
30
33
  ...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
31
34
  ...(parseHeadCheckSuitesEmpty(raw) && { headCheckSuitesEmpty: true }),
35
+ ...workflowSuites(raw),
32
36
  };
33
37
  }
38
+ function workflowSuites(raw) {
39
+ const suites = parseHeadWorkflowSuites(raw);
40
+ return suites.length > 0 ? { headWorkflowSuites: suites } : {};
41
+ }
@@ -10,6 +10,12 @@ export interface GitHubGraphQlError {
10
10
  }
11
11
  /** GitHub GraphQL engine crash: HTTP 200, `data: null`, INTERNAL type/code or message. */
12
12
  export declare function isRetryableGraphQlInternal(graphqlErrors?: GitHubGraphQlError[]): boolean;
13
+ /**
14
+ * GitHub refused the query for size or load (`Resource limits for this query
15
+ * exceeded`). The same document can succeed on a later attempt, and a smaller
16
+ * page can succeed when the full one cannot.
17
+ */
18
+ export declare function isRetryableGraphQlResourceLimit(graphqlErrors?: GitHubGraphQlError[]): boolean;
13
19
  /** HTTP 200 GraphQL response whose `repository` field resolved to null. */
14
20
  export declare function missingRepositoryError(repo: {
15
21
  owner: string;
@@ -6,8 +6,9 @@ import { EXIT, ShepherdError } from "../exit-codes.mjs";
6
6
  // GraphQL error messages themselves.
7
7
  const GRAPHQL_PERMISSION_ERROR = /resource not accessible/i;
8
8
  const GRAPHQL_INTERNAL_MESSAGE = /something went wrong while executing your query/i;
9
- function isInternalToken(value) {
10
- return typeof value === "string" && value.toUpperCase() === "INTERNAL";
9
+ const GRAPHQL_RESOURCE_LIMIT_MESSAGE = /resource limits for this query exceeded/i;
10
+ function isToken(value, token) {
11
+ return typeof value === "string" && value.toUpperCase() === token;
11
12
  }
12
13
  function extensionsCode(extensions) {
13
14
  if (typeof extensions !== "object" || extensions === null || Array.isArray(extensions)) {
@@ -23,13 +24,29 @@ export function isRetryableGraphQlInternal(graphqlErrors) {
23
24
  if (!graphqlErrors?.length)
24
25
  return false;
25
26
  return graphqlErrors.some((error) => {
26
- if (isInternalToken(error.type))
27
+ if (isToken(error.type, "INTERNAL"))
27
28
  return true;
28
- if (isInternalToken(extensionsCode(error.extensions)))
29
+ if (isToken(extensionsCode(error.extensions), "INTERNAL"))
29
30
  return true;
30
31
  return GRAPHQL_INTERNAL_MESSAGE.test(error.message);
31
32
  });
32
33
  }
34
+ /**
35
+ * GitHub refused the query for size or load (`Resource limits for this query
36
+ * exceeded`). The same document can succeed on a later attempt, and a smaller
37
+ * page can succeed when the full one cannot.
38
+ */
39
+ export function isRetryableGraphQlResourceLimit(graphqlErrors) {
40
+ if (!graphqlErrors?.length)
41
+ return false;
42
+ return graphqlErrors.some((error) => {
43
+ if (isToken(error.type, "RESOURCE_LIMITS_EXCEEDED"))
44
+ return true;
45
+ if (isToken(extensionsCode(error.extensions), "RESOURCE_LIMITS_EXCEEDED"))
46
+ return true;
47
+ return GRAPHQL_RESOURCE_LIMIT_MESSAGE.test(error.message);
48
+ });
49
+ }
33
50
  function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
34
51
  // Retry signals take priority over everything else: GitHub's secondary rate limit
35
52
  // returns 403 with a Retry-After header, which is a transient throttle — not the
@@ -40,7 +57,8 @@ function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
40
57
  status >= 500 ||
41
58
  retryAfterSeconds !== undefined ||
42
59
  rateLimitExhausted ||
43
- isRetryableGraphQlInternal(graphqlErrors)) {
60
+ isRetryableGraphQlInternal(graphqlErrors) ||
61
+ isRetryableGraphQlResourceLimit(graphqlErrors)) {
44
62
  return EXIT.TEMPFAIL;
45
63
  }
46
64
  if (status === 401 || status === 403 || hasPermissionError(graphqlErrors))
@@ -5,6 +5,7 @@ export interface FingerprintSuites {
5
5
  };
6
6
  nodes?: Array<{
7
7
  id?: string;
8
+ status?: string | null;
8
9
  conclusion: string | null;
9
10
  workflowRun?: {
10
11
  databaseId: number | null;