pr-shepherd 0.54.0 → 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 (126) 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-branch-segment.d.mts +3 -0
  17. package/bin/cli/iterate-branch-segment.mjs +13 -0
  18. package/bin/cli/iterate-formatter.mjs +34 -22
  19. package/bin/cli/iterate-instructions.mjs +6 -0
  20. package/bin/cli/iterate-lean.mjs +16 -0
  21. package/bin/cli/poll-summary-emitter.mjs +4 -1
  22. package/bin/cli/poll-summary-formatter.mjs +10 -3
  23. package/bin/cli/stack-overview.d.mts +48 -0
  24. package/bin/cli/stack-overview.mjs +138 -0
  25. package/bin/commands/check-fingerprint.mjs +2 -1
  26. package/bin/commands/check-status.d.mts +1 -1
  27. package/bin/commands/check-status.mjs +8 -3
  28. package/bin/commands/check-unreported.d.mts +26 -0
  29. package/bin/commands/check-unreported.mjs +73 -0
  30. package/bin/commands/check.mjs +45 -38
  31. package/bin/commands/iterate/base.mjs +9 -0
  32. package/bin/commands/iterate/conflicting-head-ci.d.mts +18 -0
  33. package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
  34. package/bin/commands/iterate/escalate.mjs +4 -0
  35. package/bin/commands/iterate/fix-code.mjs +30 -1
  36. package/bin/commands/iterate/index.mjs +69 -3
  37. package/bin/commands/iterate/merge-state.mjs +12 -12
  38. package/bin/commands/iterate/merge.d.mts +12 -2
  39. package/bin/commands/iterate/merge.mjs +32 -2
  40. package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
  41. package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
  42. package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
  43. package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
  44. package/bin/commands/iterate/unreported-required.d.mts +18 -0
  45. package/bin/commands/iterate/unreported-required.mjs +122 -0
  46. package/bin/commands/poll-summary.mjs +1 -0
  47. package/bin/commands/poll.mjs +9 -2
  48. package/bin/commands/ready-mergeability.d.mts +1 -1
  49. package/bin/commands/ready-mergeability.mjs +2 -2
  50. package/bin/commands/rule-auto-resolve-format.d.mts +22 -0
  51. package/bin/commands/rule-auto-resolve-format.mjs +151 -0
  52. package/bin/commands/rule-auto-resolve.d.mts +21 -0
  53. package/bin/commands/rule-auto-resolve.mjs +113 -0
  54. package/bin/commands/stack-drain.d.mts +0 -5
  55. package/bin/commands/stack-drain.mjs +26 -16
  56. package/bin/commands/stack-merge-flag.d.mts +7 -0
  57. package/bin/commands/stack-merge-flag.mjs +6 -0
  58. package/bin/config/load.d.mts +3 -0
  59. package/bin/config/load.mjs +9 -2
  60. package/bin/config/merge-method.d.mts +27 -0
  61. package/bin/config/merge-method.mjs +56 -0
  62. package/bin/exit-codes.d.mts +1 -1
  63. package/bin/exit-codes.mjs +2 -1
  64. package/bin/github/batch-parse-suites.d.mts +5 -0
  65. package/bin/github/batch-parse-suites.mjs +20 -0
  66. package/bin/github/batch-parsers.d.mts +1 -1
  67. package/bin/github/batch-parsers.mjs +7 -0
  68. package/bin/github/batch-raw-types.d.mts +5 -0
  69. package/bin/github/batch.d.mts +5 -0
  70. package/bin/github/batch.mjs +10 -1
  71. package/bin/github/errors.d.mts +6 -0
  72. package/bin/github/errors.mjs +23 -5
  73. package/bin/github/fingerprint-fields.d.mts +1 -0
  74. package/bin/github/fingerprint-fields.mjs +1 -1
  75. package/bin/github/gql/batch-pr-page.gql +2 -0
  76. package/bin/github/gql/batch-pr.gql +5 -0
  77. package/bin/github/gql/commit-check-suites.gql +1 -0
  78. package/bin/github/gql/poll-stack-summary.gql +6 -0
  79. package/bin/github/gql/poll-summary-annotation-probe.gql +32 -0
  80. package/bin/github/gql/poll-summary-check-contexts.gql +2 -3
  81. package/bin/github/gql/poll-summary-fragment.gql +16 -0
  82. package/bin/github/gql/pr-merge-policy.gql +1 -44
  83. package/bin/github/gql/ref-rules-query.gql +18 -0
  84. package/bin/github/gql/ref-rules.gql +46 -0
  85. package/bin/github/gql/upper-layer-conflict-target.gql +36 -0
  86. package/bin/github/graphql-internal-retry.d.mts +1 -1
  87. package/bin/github/graphql-internal-retry.mjs +4 -3
  88. package/bin/github/merge-queue-checks.mjs +7 -3
  89. package/bin/github/merge-target-rules.d.mts +20 -0
  90. package/bin/github/merge-target-rules.mjs +54 -0
  91. package/bin/github/poll-summary-annotation-probe.d.mts +19 -0
  92. package/bin/github/poll-summary-annotation-probe.mjs +88 -0
  93. package/bin/github/poll-summary-fingerprint.mjs +1 -1
  94. package/bin/github/poll-summary-projector.d.mts +1 -1
  95. package/bin/github/poll-summary-projector.mjs +19 -12
  96. package/bin/github/poll-summary-queue-removal.mjs +7 -3
  97. package/bin/github/poll-summary-raw.d.mts +16 -0
  98. package/bin/github/poll-summary-readiness.mjs +1 -0
  99. package/bin/github/poll-summary-route.mjs +5 -0
  100. package/bin/github/poll-summary-unreported.d.mts +6 -0
  101. package/bin/github/poll-summary-unreported.mjs +51 -0
  102. package/bin/github/poll-summary.d.mts +1 -0
  103. package/bin/github/poll-summary.mjs +28 -3
  104. package/bin/github/queries.d.mts +12 -0
  105. package/bin/github/queries.mjs +14 -2
  106. package/bin/github/queue-removal-freshness.d.mts +20 -5
  107. package/bin/github/queue-removal-freshness.mjs +37 -10
  108. package/bin/github/stack-read.d.mts +4 -0
  109. package/bin/github/stack-read.mjs +13 -1
  110. package/bin/mcp/server.mjs +6 -1
  111. package/bin/state/ci-retrigger.d.mts +25 -0
  112. package/bin/state/ci-retrigger.mjs +48 -0
  113. package/bin/state/conflicting-head-seen.d.mts +13 -0
  114. package/bin/state/conflicting-head-seen.mjs +44 -0
  115. package/bin/types/escalate.d.mts +1 -1
  116. package/bin/types/github.d.mts +11 -0
  117. package/bin/types/iterate.d.mts +19 -3
  118. package/bin/types/poll-summary.d.mts +13 -0
  119. package/bin/types/report.d.mts +34 -1
  120. package/package.json +1 -1
  121. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  122. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  123. package/plugins/pr-shepherd/.mcp.json +1 -1
  124. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +2 -2
  125. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  126. package/src/classify/types.mts +1 -1
@@ -0,0 +1,51 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ import { actionsWorkflowInProgress, selectMergeTargetContexts, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
3
+ import { parseBranchRules } from "./batch-parsers-rules.mjs";
4
+ /** Required contexts from the open layer whose base is the stack trunk. */
5
+ export function trunkRequiredContexts(ordered) {
6
+ const trunk = ordered.find((pr) => pr.stack)?.stack?.baseRefName;
7
+ if (!trunk)
8
+ return undefined;
9
+ const bottom = ordered.find((pr) => pr.state === "OPEN" && pr.baseRefName === trunk);
10
+ if (!bottom)
11
+ return undefined;
12
+ return parseBranchRules(bottom.baseRef).requiredStatusCheckContexts;
13
+ }
14
+ /** Record missing required contexts. A running Actions workflow does not clear the names. */
15
+ export function applyUnreportedRequiredChecks(raw, checks, mergeTargetContexts) {
16
+ const trunk = raw.stack?.baseRefName;
17
+ const required = selectMergeTargetContexts({
18
+ localContexts: parseBranchRules(raw.baseRef).requiredStatusCheckContexts,
19
+ ...(mergeTargetContexts !== undefined && { trunkContexts: mergeTargetContexts }),
20
+ baseRefName: raw.baseRefName,
21
+ ...(trunk !== undefined && mergeTargetContexts !== undefined && { trunkRefName: trunk }),
22
+ });
23
+ const missing = unreportedRequiredContexts(required, reportedNames(raw));
24
+ if (missing.length === 0)
25
+ return;
26
+ checks.unreportedRequired = missing;
27
+ const suites = raw.commits.nodes[0]?.commit.checkSuites?.nodes ?? [];
28
+ if (actionsWorkflowInProgress(suites, new Set(loadConfig().checks.ciTriggerEvents))) {
29
+ checks.actionsWorkflowInProgress = true;
30
+ }
31
+ }
32
+ function reportedNames(raw) {
33
+ const names = new Set();
34
+ const rollups = [
35
+ raw.commits.nodes[0]?.commit.statusCheckRollup,
36
+ raw.mergeQueueEntry?.headCommit?.statusCheckRollup,
37
+ ];
38
+ for (const rollup of rollups) {
39
+ for (const node of rollup?.contexts.nodes ?? []) {
40
+ if (!node)
41
+ continue;
42
+ const name = node.__typename === "CheckRun" ? node.name : node.context;
43
+ if (typeof name !== "string")
44
+ continue;
45
+ const trimmed = name.trim();
46
+ if (trimmed)
47
+ names.add(trimmed);
48
+ }
49
+ }
50
+ return names;
51
+ }
@@ -5,6 +5,7 @@ export interface FetchedPollSummary {
5
5
  selection: PollSummarySelection;
6
6
  prs: PollSummaryItem[];
7
7
  stackAncestry?: PollSummaryStackAncestry[];
8
+ allowedMergeMethods?: import("../config/merge-method.mts").MergeMethod[];
8
9
  }
9
10
  export declare function fetchPollSummary(opts: PollSummaryCommandOptions, repo: RepoInfo): Promise<FetchedPollSummary>;
10
11
  /** Fresh, read-only snapshot used to bind a one-PR READY receipt to stack routing. */
@@ -1,8 +1,9 @@
1
1
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
2
  import { graphqlWithRateLimit } from "./client.mjs";
3
- import { missingRepositoryError } from "./errors.mjs";
3
+ import { GitHubRequestError, isRetryableGraphQlResourceLimit, missingRepositoryError, } from "./errors.mjs";
4
4
  import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
5
5
  import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
6
+ import { trunkRequiredContexts } from "./poll-summary-unreported.mjs";
6
7
  import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
7
8
  import { MAX_STACK_ENTRIES_PER_PAGE, readStack, readStackTopology, stackAncestryGaps, } from "./stack-read.mjs";
8
9
  const MAX_EXPLICIT_PRS_PER_QUERY = 50;
@@ -62,16 +63,40 @@ async function fetchExplicitChunk(prs, repo) {
62
63
  async function fetchStackSummary(opts, repo) {
63
64
  const anchor = opts.stackPrNumber;
64
65
  const topology = await readStackTopology(anchor, repo);
65
- const { stackNumber, stackSize, viewerCanAdminister, ordered } = await readStack(POLL_STACK_SUMMARY_QUERY, anchor, repo, { first: Math.min(topology.stackSize, MAX_STACK_ENTRIES_PER_PAGE) });
66
+ const { stackNumber, stackSize, viewerLogin, viewerCanAdminister, ordered, allowedMergeMethods } = await readStackSummary(anchor, repo, topology.stackSize);
66
67
  for (const pr of ordered)
67
68
  await hydratePollSummaryChecks(pr, repo);
68
69
  const stackAncestry = stackAncestryGaps(ordered);
70
+ const required = trunkRequiredContexts(ordered);
69
71
  return {
70
72
  selection: { kind: "stack", anchor, stackNumber, stackSize },
71
- prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
73
+ prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, required, viewerLogin))),
72
74
  ...(stackAncestry.length > 0 && { stackAncestry }),
75
+ ...(allowedMergeMethods && { allowedMergeMethods }),
73
76
  };
74
77
  }
78
+ /**
79
+ * A wide check matrix can blow GitHub's per-query resource limit on the full
80
+ * page. Halve `first` down to one entry and let `readStack` follow `after`.
81
+ * The shared summary fragment stays intact: one layer is the same shape as an
82
+ * explicit summary, and check hydration still completes windows past 100.
83
+ */
84
+ async function readStackSummary(anchor, repo, stackSize) {
85
+ let first = Math.min(stackSize, MAX_STACK_ENTRIES_PER_PAGE);
86
+ for (;;) {
87
+ try {
88
+ return await readStack(POLL_STACK_SUMMARY_QUERY, anchor, repo, { first });
89
+ }
90
+ catch (err) {
91
+ if (!(err instanceof GitHubRequestError) ||
92
+ !isRetryableGraphQlResourceLimit(err.graphqlErrors) ||
93
+ first <= 1) {
94
+ throw err;
95
+ }
96
+ first = Math.floor(first / 2);
97
+ }
98
+ }
99
+ }
75
100
  function deduplicate(values) {
76
101
  return [...new Set(values)];
77
102
  }
@@ -14,15 +14,27 @@ export declare const BATCH_PR_PAGE_QUERY: string;
14
14
  export declare const PR_FINGERPRINT_QUERY: string;
15
15
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
16
16
  export declare const POLL_SUMMARY_FRAGMENT: string;
17
+ /** Trunk branch rules plus how far `headRef` is behind that branch. */
18
+ export declare const REF_RULES_QUERY: string;
17
19
  /**
18
20
  * Pages older status contexts for one commit with the same node selection as
19
21
  * the compact summary, so hydrated and first-page nodes fingerprint alike.
20
22
  */
21
23
  export declare const POLL_SUMMARY_CHECK_PAGE_QUERY: string;
24
+ /**
25
+ * Annotation totals for one commit, used only when a summary layer otherwise
26
+ * looks ready. `$before` pages older than the newest 100 contexts.
27
+ */
28
+ export declare const POLL_SUMMARY_ANNOTATION_PROBE_QUERY: string;
22
29
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
23
30
  export declare const POLL_STACK_SUMMARY_QUERY: string;
24
31
  /** Reads only native-stack membership and the refs that link adjacent entries. */
25
32
  export declare const POLL_STACK_TOPOLOGY_QUERY: string;
33
+ /**
34
+ * Whether a conflicting upper stack layer already contains its PR base, plus
35
+ * the open stack entries needed to name the bottom layer. Not part of BatchPr.
36
+ */
37
+ export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
26
38
  /** PR head fields plus a single review thread for `commit-suggestion`. */
27
39
  export declare const SUGGESTION_THREADS_QUERY: string;
28
40
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -9,7 +9,7 @@
9
9
  import { readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
12
- const withSharedFragments = (query) => `${gql("pr-merge-policy.gql")}\n${gql("commit-check-suites.gql")}\n${query}`;
12
+ const withSharedFragments = (query) => `${gql("ref-rules.gql")}\n${gql("pr-merge-policy.gql")}\n${gql("commit-check-suites.gql")}\n${query}`;
13
13
  /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
14
14
  export const BATCH_PR_QUERY = withSharedFragments(gql("batch-pr.gql"));
15
15
  /** Slim @include follow-up for outstanding batch-query connections. */
@@ -18,16 +18,28 @@ export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
18
18
  export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"));
19
19
  const POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT = gql("poll-summary-check-contexts.gql");
20
20
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
21
- export const POLL_SUMMARY_FRAGMENT = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
21
+ export const POLL_SUMMARY_FRAGMENT = `${gql("ref-rules.gql")}\n${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
22
+ /** Trunk branch rules plus how far `headRef` is behind that branch. */
23
+ export const REF_RULES_QUERY = `${gql("ref-rules.gql")}\n${gql("ref-rules-query.gql")}`;
22
24
  /**
23
25
  * Pages older status contexts for one commit with the same node selection as
24
26
  * the compact summary, so hydrated and first-page nodes fingerprint alike.
25
27
  */
26
28
  export const POLL_SUMMARY_CHECK_PAGE_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-check-page.gql")}`;
29
+ /**
30
+ * Annotation totals for one commit, used only when a summary layer otherwise
31
+ * looks ready. `$before` pages older than the newest 100 contexts.
32
+ */
33
+ export const POLL_SUMMARY_ANNOTATION_PROBE_QUERY = gql("poll-summary-annotation-probe.gql");
27
34
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
28
35
  export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
29
36
  /** Reads only native-stack membership and the refs that link adjacent entries. */
30
37
  export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
38
+ /**
39
+ * Whether a conflicting upper stack layer already contains its PR base, plus
40
+ * the open stack entries needed to name the bottom layer. Not part of BatchPr.
41
+ */
42
+ export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-target.gql");
31
43
  /** PR head fields plus a single review thread for `commit-suggestion`. */
32
44
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
33
45
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -1,16 +1,31 @@
1
+ type PushCheckNode = {
2
+ __typename?: string;
3
+ checkSuite?: {
4
+ createdAt?: string | null;
5
+ workflowRun?: {
6
+ event?: string | null;
7
+ createdAt?: string | null;
8
+ } | null;
9
+ } | null;
10
+ };
1
11
  /**
2
12
  * Whether a removed merge-queue commit still belongs to the current PR head.
3
13
  *
4
14
  * A merge-commit queue lists that head as a parent. Squash and rebase queues
5
- * build a one-parent commit on the base (or on the previous group commit), so
6
- * the PR head is not an ancestor. Missing parents are unverifiable: GitHub
7
- * keeps returning the latest removal after the synthetic commit is gone.
8
- * A one-parent removal is stale when the current head's committer time is
9
- * later than the removal, which is a push after the ejection.
15
+ * build a one-parent commit on the base, so parent identity cannot show a
16
+ * later push. Missing parents are unverifiable: GitHub keeps returning the
17
+ * latest removal after the synthetic commit is gone. A one-parent removal is
18
+ * stale once this head reached the PR after the removal. That time is the
19
+ * earliest pull_request check on the head, with the head's committer time as
20
+ * the fallback when no check time is available.
10
21
  */
11
22
  export declare function queueRemovalAppliesToHead(input: {
12
23
  parentOids: readonly string[] | null | undefined;
13
24
  headOid: string;
14
25
  headCommittedAtUnix?: number;
26
+ headPushedAtUnix?: number;
15
27
  removedAtUnix?: number;
16
28
  }): boolean;
29
+ /** Earliest pull_request check-suite time on the head commit, when one exists. */
30
+ export declare function headPushUnixFromCheckNodes(nodes: readonly (PushCheckNode | null)[] | null | undefined): number | undefined;
31
+ export {};
@@ -1,12 +1,15 @@
1
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
+ const PULL_REQUEST_EVENTS = new Set(["pull_request", "pull_request_target"]);
1
3
  /**
2
4
  * Whether a removed merge-queue commit still belongs to the current PR head.
3
5
  *
4
6
  * A merge-commit queue lists that head as a parent. Squash and rebase queues
5
- * build a one-parent commit on the base (or on the previous group commit), so
6
- * the PR head is not an ancestor. Missing parents are unverifiable: GitHub
7
- * keeps returning the latest removal after the synthetic commit is gone.
8
- * A one-parent removal is stale when the current head's committer time is
9
- * later than the removal, which is a push after the ejection.
7
+ * build a one-parent commit on the base, so parent identity cannot show a
8
+ * later push. Missing parents are unverifiable: GitHub keeps returning the
9
+ * latest removal after the synthetic commit is gone. A one-parent removal is
10
+ * stale once this head reached the PR after the removal. That time is the
11
+ * earliest pull_request check on the head, with the head's committer time as
12
+ * the fallback when no check time is available.
10
13
  */
11
14
  export function queueRemovalAppliesToHead(input) {
12
15
  const parents = input.parentOids ?? [];
@@ -16,11 +19,35 @@ export function queueRemovalAppliesToHead(input) {
16
19
  return true;
17
20
  if (parents.length > 1)
18
21
  return false;
19
- if (input.headCommittedAtUnix !== undefined &&
20
- input.removedAtUnix !== undefined &&
21
- input.removedAtUnix > 0 &&
22
- input.headCommittedAtUnix > input.removedAtUnix) {
22
+ const removedAt = input.removedAtUnix;
23
+ if (removedAt === undefined || removedAt <= 0)
24
+ return true;
25
+ const pushedAt = input.headPushedAtUnix;
26
+ if (pushedAt !== undefined && pushedAt > 0)
27
+ return pushedAt <= removedAt;
28
+ const committedAt = input.headCommittedAtUnix;
29
+ if (committedAt !== undefined && committedAt > removedAt)
23
30
  return false;
24
- }
25
31
  return true;
26
32
  }
33
+ /** Earliest pull_request check-suite time on the head commit, when one exists. */
34
+ export function headPushUnixFromCheckNodes(nodes) {
35
+ let earliest;
36
+ for (const node of nodes ?? []) {
37
+ if (node?.__typename !== "CheckRun")
38
+ continue;
39
+ const run = node.checkSuite?.workflowRun;
40
+ const event = run?.event;
41
+ if (event == null || !PULL_REQUEST_EVENTS.has(event))
42
+ continue;
43
+ const raw = node.checkSuite?.createdAt || run?.createdAt;
44
+ if (!raw)
45
+ continue;
46
+ const at = parseCreatedAt(raw);
47
+ if (at <= 0)
48
+ continue;
49
+ if (earliest === undefined || at < earliest)
50
+ earliest = at;
51
+ }
52
+ return earliest;
53
+ }
@@ -1,3 +1,4 @@
1
+ import { type MergeMethod } from "../config/merge-method.mts";
1
2
  import type { PollSummaryStackAncestry } from "../types.mts";
2
3
  import { type RepoInfo } from "./client.mts";
3
4
  /** GitHub's `first` ceiling for one native-stack entries page. */
@@ -15,9 +16,12 @@ export interface StackMemberRefs {
15
16
  export interface StackRead<Pr extends StackMemberRefs> {
16
17
  stackNumber: number;
17
18
  stackSize: number;
19
+ viewerLogin: string | null;
18
20
  viewerCanAdminister: boolean;
19
21
  /** Every member, bottom-to-top, each observed in the same paged read. */
20
22
  ordered: Pr[];
23
+ /** Present when the query selected repository merge settings, including an empty list. */
24
+ allowedMergeMethods?: MergeMethod[];
21
25
  }
22
26
  /**
23
27
  * Page through one native stack and fail closed on anything short of the full
@@ -1,3 +1,4 @@
1
+ import { readAllowedMergeMethods } from "../config/merge-method.mjs";
1
2
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
3
  import { graphqlWithRateLimit } from "./client.mjs";
3
4
  import { missingRepositoryError } from "./errors.mjs";
@@ -15,13 +16,17 @@ export async function readStack(query, anchor, repo, variables = {}) {
15
16
  let stackNumber = 0;
16
17
  let stackSize = 0;
17
18
  let viewerCanAdminister = false;
19
+ let viewerLogin = null;
20
+ let allowedMergeMethods;
18
21
  const entries = [];
19
22
  do {
20
23
  const response = await graphqlWithRateLimit(query, { owner: repo.owner, repo: repo.name, anchor, after, ...variables });
24
+ viewerLogin = response.data.viewer?.login ?? viewerLogin;
21
25
  const repository = response.data.repository;
22
26
  if (!repository)
23
27
  throw missingRepositoryError(repo);
24
28
  viewerCanAdminister = repository.viewerCanAdminister;
29
+ allowedMergeMethods = readAllowedMergeMethods(repository) ?? allowedMergeMethods;
25
30
  if (!repository.pullRequest) {
26
31
  throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
27
32
  }
@@ -59,7 +64,14 @@ export async function readStack(query, anchor, repo, variables = {}) {
59
64
  const ordered = [...unique.values()]
60
65
  .sort((left, right) => left.position - right.position)
61
66
  .map((entry) => entry.pullRequest);
62
- return { stackNumber, stackSize, viewerCanAdminister, ordered };
67
+ return {
68
+ stackNumber,
69
+ stackSize,
70
+ viewerLogin,
71
+ viewerCanAdminister,
72
+ ordered,
73
+ ...(allowedMergeMethods && { allowedMergeMethods }),
74
+ };
63
75
  }
64
76
  /** Membership and linking refs only — no per-PR CI or review hydration. */
65
77
  export function readStackTopology(anchor, repo) {
@@ -7,6 +7,7 @@ import { isRepositoryQualifiedPrReference, normalizeRepositoryIdentity, parsePrR
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
8
  import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
9
9
  import { formatPollSummaryResult } from "../cli/poll-summary-formatter.mjs";
10
+ import { projectStackOverview } from "../cli/stack-overview.mjs";
10
11
  import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
11
12
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
12
13
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
@@ -104,7 +105,11 @@ export function createPrShepherdMcpServer(options = {}) {
104
105
  };
105
106
  return runTool(() => runIterateSelector(shepherd, requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
106
107
  ? formatPollSummaryResult(result)
107
- : formatIterateResult(result, opts), (result) => isPollSummary(result) ? result : projectIterateLean(result, opts));
108
+ : formatIterateResult(result, opts), (result) => isPollSummary(result)
109
+ ? result.selection.kind === "stack"
110
+ ? projectStackOverview(result)
111
+ : result
112
+ : projectIterateLean(result, opts));
108
113
  });
109
114
  server.registerTool("apply", {
110
115
  description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * One close/reopen of a head that was missing required checks.
3
+ *
4
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/ci-retrigger.json`
5
+ * Reopening does not change the head SHA, so the next tick must not close the PR again.
6
+ */
7
+ interface Retrigger {
8
+ headSha: string;
9
+ contexts: string[];
10
+ }
11
+ /** The stored head and context set, or undefined when no usable marker exists. */
12
+ export declare function readCiRetrigger(key: {
13
+ owner: string;
14
+ repo: string;
15
+ pr: number;
16
+ }): Promise<Retrigger | undefined>;
17
+ /** Remember that this head was already closed and reopened for these contexts. */
18
+ export declare function writeCiRetrigger(key: {
19
+ owner: string;
20
+ repo: string;
21
+ pr: number;
22
+ }, record: Retrigger): Promise<void>;
23
+ /** True when this head was already retriggered for the same required contexts. */
24
+ export declare function sameCiRetrigger(record: Retrigger | undefined, headSha: string, contexts: readonly string[]): boolean;
25
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One close/reopen of a head that was missing required checks.
3
+ *
4
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/ci-retrigger.json`
5
+ * Reopening does not change the head SHA, so the next tick must not close the PR again.
6
+ */
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+ import { resolvePrStatePath } from "./base.mjs";
10
+ /** The stored head and context set, or undefined when no usable marker exists. */
11
+ export async function readCiRetrigger(key) {
12
+ return readRetrigger(resolvePrStatePath(key, "ci-retrigger.json"));
13
+ }
14
+ /** Remember that this head was already closed and reopened for these contexts. */
15
+ export async function writeCiRetrigger(key, record) {
16
+ const path = resolvePrStatePath(key, "ci-retrigger.json");
17
+ await mkdir(dirname(path), { recursive: true });
18
+ const next = {
19
+ headSha: record.headSha,
20
+ contexts: [...record.contexts].sort(),
21
+ };
22
+ await writeFile(path, JSON.stringify(next));
23
+ }
24
+ /** True when this head was already retriggered for the same required contexts. */
25
+ export function sameCiRetrigger(record, headSha, contexts) {
26
+ if (!record || record.headSha !== headSha)
27
+ return false;
28
+ const stored = [...record.contexts].sort();
29
+ const current = [...contexts].sort();
30
+ return stored.length === current.length && stored.every((name, index) => name === current[index]);
31
+ }
32
+ async function readRetrigger(path) {
33
+ try {
34
+ const parsed = JSON.parse(await readFile(path, "utf8"));
35
+ if (parsed === null || typeof parsed !== "object")
36
+ return undefined;
37
+ const headSha = parsed.headSha;
38
+ const contexts = parsed.contexts;
39
+ if (typeof headSha !== "string" || !Array.isArray(contexts))
40
+ return undefined;
41
+ if (!contexts.every((name) => typeof name === "string"))
42
+ return undefined;
43
+ return { headSha, contexts };
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * When Shepherd first saw a conflicting head that had no CI.
3
+ *
4
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/conflicting-head.json`
5
+ * GitHub's Commit object has no push timestamp, so the missing-CI grace
6
+ * starts at this observation instead of `committedDate`.
7
+ */
8
+ /** Unix seconds of the first observation of `headSha`, or undefined when state cannot be used. */
9
+ export declare function conflictingHeadFirstSeenUnix(key: {
10
+ owner: string;
11
+ repo: string;
12
+ pr: number;
13
+ }, headSha: string, nowMs: number): Promise<number | undefined>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * When Shepherd first saw a conflicting head that had no CI.
3
+ *
4
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/conflicting-head.json`
5
+ * GitHub's Commit object has no push timestamp, so the missing-CI grace
6
+ * starts at this observation instead of `committedDate`.
7
+ */
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { dirname } from "node:path";
10
+ import { resolvePrStatePath } from "./base.mjs";
11
+ /** Unix seconds of the first observation of `headSha`, or undefined when state cannot be used. */
12
+ export async function conflictingHeadFirstSeenUnix(key, headSha, nowMs) {
13
+ const nowUnix = Math.floor(nowMs / 1000);
14
+ try {
15
+ const path = resolvePrStatePath(key, "conflicting-head.json");
16
+ const existing = await readSeen(path);
17
+ if (existing?.headSha === headSha)
18
+ return existing.firstSeenAtUnix;
19
+ await mkdir(dirname(path), { recursive: true });
20
+ const next = { headSha, firstSeenAtUnix: nowUnix };
21
+ await writeFile(path, JSON.stringify(next));
22
+ return nowUnix;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ async function readSeen(path) {
29
+ try {
30
+ const parsed = JSON.parse(await readFile(path, "utf8"));
31
+ if (parsed === null || typeof parsed !== "object")
32
+ return undefined;
33
+ const headSha = parsed.headSha;
34
+ const firstSeenAtUnix = parsed.firstSeenAtUnix;
35
+ if (typeof headSha !== "string" || typeof firstSeenAtUnix !== "number")
36
+ return undefined;
37
+ if (!Number.isFinite(firstSeenAtUnix))
38
+ return undefined;
39
+ return { headSha, firstSeenAtUnix };
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
@@ -2,7 +2,7 @@ import type { AgentCheck, AgentComment, AgentThread } from "./report.mts";
2
2
  import type { ResolveCommand } from "./iterate.mts";
3
3
  import type { CheckStatus, Review } from "./github.mts";
4
4
  import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
5
- export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "stall-state-unavailable" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed";
5
+ export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "required-checks-unreported" | "stall-state-unavailable" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed" | "merge-method-unavailable";
6
6
  export interface AgentStalledCheck {
7
7
  name: string;
8
8
  status: CheckStatus;
@@ -108,6 +108,8 @@ export interface Review {
108
108
  staleBotCr?: boolean;
109
109
  /** Commit OID the review was made against, when available from GraphQL. */
110
110
  commitOid?: string;
111
+ /** Review HTML URL, when the batch query selected it. */
112
+ url?: string;
111
113
  /** True when commitOid !== headRefOid AND all associated threads are resolved/outdated. Distinct from `staleBotCr` (time-based). Reviews with no threads are not marked stale. */
112
114
  staleReview?: boolean;
113
115
  }
@@ -139,10 +141,17 @@ export interface BatchPrData extends BatchPrMergeFields {
139
141
  mergeStateStatus: MergeStateStatus;
140
142
  reviewDecision: ReviewDecision;
141
143
  headRefOid: string;
144
+ /**
145
+ * Earliest pull_request check-suite time on the head commit. Omitted when
146
+ * that signal is unavailable; queue-removal freshness then uses committer time.
147
+ */
148
+ headPushedAtUnix?: number;
142
149
  headRefName: string;
143
150
  /** `"owner/name"` of the head repository; null when the fork has been deleted. */
144
151
  headRepoWithOwner: string | null;
145
152
  viewerAuthorization?: ViewerAuthorization;
153
+ /** GitHub `viewer.login` for the token that fetched this batch. */
154
+ viewerLogin?: string;
146
155
  baseRefName: string;
147
156
  /** Base commit GitHub recorded for this PR (`PullRequest.baseRefOid`), not the branch's live tip. */
148
157
  baseRefOid?: string;
@@ -164,6 +173,8 @@ export interface BatchPrData extends BatchPrMergeFields {
164
173
  removedMergeQueueChecks?: CheckRun[];
165
174
  removedMergeQueueChecksIncomplete?: true;
166
175
  branchProtection: BranchProtection | null;
176
+ /** Repository merge methods. Omitted when the batch did not select them. Empty when none are enabled. */
177
+ allowedMergeMethods?: import("../config/merge-method.mts").MergeMethod[];
167
178
  activity?: PrActivitySummary;
168
179
  }
169
180
  export {};
@@ -1,4 +1,4 @@
1
- import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment } from "./report.mts";
1
+ import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment, RuleAutoResolveReport } from "./report.mts";
2
2
  import type { ActiveCheck, PrActivitySummary } from "./activity.mts";
3
3
  import type { BranchProtection, MergeStateStatus, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
4
4
  import type { MergeRequirements, StackDraftHold } from "./merge-requirements.mts";
@@ -6,7 +6,7 @@ import type { EscalateDetails } from "./escalate.mts";
6
6
  import type { MergeCommandPlan } from "./merge-action.mts";
7
7
  import type { ProtectedRun } from "./protected-run.mts";
8
8
  import type { ApiUsage, GraphqlQuotaWarning } from "./api-usage.mts";
9
- export type ShepherdAction = "wait" | "fix_code" | "mark_ready" | "merge" | "cancel" | "escalate";
9
+ export type ShepherdAction = "wait" | "ready" | "fix_code" | "mark_ready" | "merge" | "cancel" | "escalate";
10
10
  export interface IterateResultSummary {
11
11
  passing: number;
12
12
  skipped: number;
@@ -29,6 +29,11 @@ export interface IterateResultBase {
29
29
  summary: IterateResultSummary;
30
30
  /** Validated base branch (e.g. "main") for this PR. */
31
31
  baseBranch: string;
32
+ /**
33
+ * Set when a native-stack upper layer is conflicting but already contains its
34
+ * PR base, so the dirty state is against the stack trunk rather than that base.
35
+ */
36
+ stackTrunkConflict?: string;
32
37
  /** Null when no classic protection rule exists or the base ref is unavailable. */
33
38
  branchProtection: BranchProtection | null;
34
39
  mergeRequirements?: MergeRequirements;
@@ -40,11 +45,17 @@ export interface IterateResultBase {
40
45
  inProgressChecks?: ActiveCheck[];
41
46
  ignoredNames?: string[];
42
47
  supersededNames?: string[];
48
+ /** Required merge-target contexts with no check run and no status context. Omitted when empty. */
49
+ unreportedRequiredChecks?: string[];
50
+ /** Commits on the stack trunk that the bottom open layer does not contain. Omitted when zero. */
51
+ trunkBehindBy?: number;
43
52
  activity?: PrActivitySummary;
44
53
  mergeQueue?: import("./merge-queue.mts").MergeQueueReport;
45
54
  apiUsage?: ApiUsage;
46
55
  quotaWarning?: GraphqlQuotaWarning;
47
56
  fingerprintReused?: true;
57
+ /** Classification-rule resolve/minimize applied during this fetch. Omitted when nothing happened. */
58
+ ruleAutoResolve?: RuleAutoResolveReport;
48
59
  }
49
60
  interface IterateResultWait extends IterateResultBase {
50
61
  action: "wait";
@@ -52,6 +63,11 @@ interface IterateResultWait extends IterateResultBase {
52
63
  deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
53
64
  stackDraftHold?: StackDraftHold;
54
65
  }
66
+ /** Clean PR whose ready-delay is still counting. `--until-terminal` returns this instead of sleeping. */
67
+ interface IterateResultReady extends IterateResultBase {
68
+ action: "ready";
69
+ log: string;
70
+ }
55
71
  export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
56
72
  interface IterateResultCancel extends IterateResultBase {
57
73
  action: "cancel";
@@ -129,7 +145,7 @@ interface IterateResultEscalate extends IterateResultBase {
129
145
  action: "escalate";
130
146
  escalate: EscalateDetails;
131
147
  }
132
- export type IterateResult = IterateResultWait | IterateResultCancel | IterateResultFixCode | IterateResultMarkReady | IterateResultMerge | IterateResultEscalate;
148
+ export type IterateResult = IterateResultWait | IterateResultReady | IterateResultCancel | IterateResultFixCode | IterateResultMarkReady | IterateResultMerge | IterateResultEscalate;
133
149
  export interface IterateCommandOptions extends GlobalOptions {
134
150
  readyDelaySeconds?: number;
135
151
  noAutoMarkReady?: boolean;
@@ -11,6 +11,10 @@ export interface PollSummaryChecks {
11
11
  ignored?: number;
12
12
  superseded?: number;
13
13
  incomplete?: true;
14
+ /** Required contexts with no check run and no status context. Omitted when empty. */
15
+ unreportedRequired?: string[];
16
+ /** A relevant Actions workflow suite has not completed. */
17
+ actionsWorkflowInProgress?: true;
14
18
  }
15
19
  export interface PollSummaryReview {
16
20
  comments?: number;
@@ -39,6 +43,10 @@ export interface PollSummaryItem {
39
43
  repo: string;
40
44
  title: string;
41
45
  url: string;
46
+ /** GitHub author login, when the summary query returned one. */
47
+ authorLogin?: string;
48
+ /** Author login matches the authenticated viewer. */
49
+ owned?: true;
42
50
  /** Conservative routing hint; the selected one-PR poll makes the authoritative decision. */
43
51
  action: ShepherdAction;
44
52
  reasons: string[];
@@ -89,6 +97,11 @@ export interface PollSummaryResult {
89
97
  nextAction?: StackNextAction;
90
98
  /** Whether every open layer is independently ready and stack ancestry is linear. */
91
99
  stackMergeable?: boolean;
100
+ /**
101
+ * Repository merge methods for stack merge commands. Omitted from the lean
102
+ * stack overview. Empty when the repository enables none.
103
+ */
104
+ allowedMergeMethods?: import("../config/merge-method.mts").MergeMethod[];
92
105
  instructions?: string[];
93
106
  apiUsage?: ApiUsage;
94
107
  quotaWarning?: GraphqlQuotaWarning;