pr-shepherd 0.54.1 → 0.55.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 (121) 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 +32 -9
  17. package/bin/cli/iterate-instructions.mjs +6 -0
  18. package/bin/cli/iterate-lean.mjs +17 -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 +28 -0
  27. package/bin/commands/check-unreported.mjs +101 -0
  28. package/bin/commands/check.mjs +45 -38
  29. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  30. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  31. package/bin/commands/iterate/base.mjs +11 -0
  32. package/bin/commands/iterate/check-instructions.d.mts +1 -1
  33. package/bin/commands/iterate/check-instructions.mjs +1 -1
  34. package/bin/commands/iterate/escalate.mjs +4 -0
  35. package/bin/commands/iterate/index.mjs +69 -3
  36. package/bin/commands/iterate/merge-state.mjs +12 -12
  37. package/bin/commands/iterate/merge.d.mts +12 -2
  38. package/bin/commands/iterate/merge.mjs +32 -2
  39. package/bin/commands/iterate/unreported-required.d.mts +18 -0
  40. package/bin/commands/iterate/unreported-required.mjs +136 -0
  41. package/bin/commands/poll-summary.mjs +1 -0
  42. package/bin/commands/poll.mjs +9 -2
  43. package/bin/commands/ready-mergeability.d.mts +1 -1
  44. package/bin/commands/ready-mergeability.mjs +2 -2
  45. package/bin/commands/rule-auto-resolve-format.d.mts +22 -0
  46. package/bin/commands/rule-auto-resolve-format.mjs +151 -0
  47. package/bin/commands/rule-auto-resolve.d.mts +21 -0
  48. package/bin/commands/rule-auto-resolve.mjs +113 -0
  49. package/bin/commands/shepherd-journal.d.mts +1 -1
  50. package/bin/commands/shepherd-journal.mjs +1 -1
  51. package/bin/commands/stack-drain.d.mts +0 -5
  52. package/bin/commands/stack-drain.mjs +26 -16
  53. package/bin/commands/stack-merge-flag.d.mts +7 -0
  54. package/bin/commands/stack-merge-flag.mjs +6 -0
  55. package/bin/config/load.d.mts +3 -0
  56. package/bin/config/load.mjs +9 -2
  57. package/bin/config/merge-method.d.mts +27 -0
  58. package/bin/config/merge-method.mjs +56 -0
  59. package/bin/exit-codes.d.mts +1 -1
  60. package/bin/exit-codes.mjs +2 -1
  61. package/bin/github/batch-parse-suites.d.mts +3 -0
  62. package/bin/github/batch-parse-suites.mjs +15 -0
  63. package/bin/github/batch-parsers.d.mts +1 -1
  64. package/bin/github/batch-parsers.mjs +7 -0
  65. package/bin/github/batch-raw-types.d.mts +5 -0
  66. package/bin/github/batch.d.mts +3 -0
  67. package/bin/github/batch.mjs +9 -1
  68. package/bin/github/errors.d.mts +6 -0
  69. package/bin/github/errors.mjs +23 -5
  70. package/bin/github/fingerprint-fields.d.mts +1 -0
  71. package/bin/github/fingerprint-fields.mjs +1 -1
  72. package/bin/github/gql/base-behind.gql +20 -0
  73. package/bin/github/gql/batch-pr-page.gql +2 -0
  74. package/bin/github/gql/batch-pr.gql +5 -0
  75. package/bin/github/gql/commit-check-suites.gql +1 -0
  76. package/bin/github/gql/poll-stack-summary.gql +6 -0
  77. package/bin/github/gql/poll-summary-annotation-probe.gql +32 -0
  78. package/bin/github/gql/poll-summary-check-contexts.gql +2 -3
  79. package/bin/github/gql/poll-summary-fragment.gql +16 -0
  80. package/bin/github/gql/pr-merge-policy.gql +1 -44
  81. package/bin/github/gql/ref-rules-query.gql +18 -0
  82. package/bin/github/gql/ref-rules.gql +46 -0
  83. package/bin/github/graphql-internal-retry.d.mts +1 -1
  84. package/bin/github/graphql-internal-retry.mjs +4 -3
  85. package/bin/github/merge-queue-checks.mjs +7 -3
  86. package/bin/github/merge-target-rules.d.mts +28 -0
  87. package/bin/github/merge-target-rules.mjs +77 -0
  88. package/bin/github/poll-summary-annotation-probe.d.mts +19 -0
  89. package/bin/github/poll-summary-annotation-probe.mjs +88 -0
  90. package/bin/github/poll-summary-fingerprint.mjs +1 -1
  91. package/bin/github/poll-summary-projector.d.mts +1 -1
  92. package/bin/github/poll-summary-projector.mjs +19 -12
  93. package/bin/github/poll-summary-queue-removal.mjs +7 -3
  94. package/bin/github/poll-summary-raw.d.mts +16 -0
  95. package/bin/github/poll-summary-readiness.mjs +1 -0
  96. package/bin/github/poll-summary-route.mjs +5 -0
  97. package/bin/github/poll-summary-unreported.d.mts +6 -0
  98. package/bin/github/poll-summary-unreported.mjs +51 -0
  99. package/bin/github/poll-summary.d.mts +1 -0
  100. package/bin/github/poll-summary.mjs +28 -3
  101. package/bin/github/queries.d.mts +9 -0
  102. package/bin/github/queries.mjs +11 -2
  103. package/bin/github/queue-removal-freshness.d.mts +20 -5
  104. package/bin/github/queue-removal-freshness.mjs +37 -10
  105. package/bin/github/stack-read.d.mts +4 -0
  106. package/bin/github/stack-read.mjs +13 -1
  107. package/bin/mcp/server.mjs +6 -1
  108. package/bin/state/ci-retrigger.d.mts +25 -0
  109. package/bin/state/ci-retrigger.mjs +48 -0
  110. package/bin/types/escalate.d.mts +1 -1
  111. package/bin/types/github.d.mts +11 -0
  112. package/bin/types/iterate.d.mts +16 -3
  113. package/bin/types/poll-summary.d.mts +13 -0
  114. package/bin/types/report.d.mts +34 -1
  115. package/package.json +1 -1
  116. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  117. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  118. package/plugins/pr-shepherd/.mcp.json +1 -1
  119. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  120. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  121. package/src/classify/types.mts +1 -1
@@ -0,0 +1,19 @@
1
+ import { type RepoInfo } from "./client.mts";
2
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /**
4
+ * True when a ready layer's annotation probe failed or stopped short of every
5
+ * check page. Callers must not store or compare a fingerprint of that snapshot:
6
+ * it is missing annotation totals a later successful probe would include.
7
+ */
8
+ export declare function annotationProbeUnavailable(pr: RawSummaryPr): boolean;
9
+ /** Stored hash when the probe missed, so a later complete probe can still match. */
10
+ export declare function readyFingerprint(raw: RawSummaryPr, stored: string | null): string | null;
11
+ /**
12
+ * Annotation totals are omitted from the always-on summary. A layer that
13
+ * otherwise looks ready loads them once so a READY receipt still changes when
14
+ * a check gains an annotation.
15
+ */
16
+ export declare function hydrateReadyAnnotationProbe(pr: RawSummaryPr, repo: RepoInfo, review: {
17
+ actionable?: number;
18
+ incomplete?: true;
19
+ }): Promise<void>;
@@ -0,0 +1,88 @@
1
+ import { graphqlWithRateLimit } from "./client.mjs";
2
+ import { isCurrentSummaryReady } from "./poll-summary-readiness.mjs";
3
+ import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
4
+ import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
5
+ import { POLL_SUMMARY_ANNOTATION_PROBE_QUERY } from "./queries.mjs";
6
+ const PROBE_UNAVAILABLE = Symbol.for("prShepherd.annotationProbeUnavailable");
7
+ /**
8
+ * True when a ready layer's annotation probe failed or stopped short of every
9
+ * check page. Callers must not store or compare a fingerprint of that snapshot:
10
+ * it is missing annotation totals a later successful probe would include.
11
+ */
12
+ export function annotationProbeUnavailable(pr) {
13
+ return Boolean(pr[PROBE_UNAVAILABLE]);
14
+ }
15
+ /** Stored hash when the probe missed, so a later complete probe can still match. */
16
+ export function readyFingerprint(raw, stored) {
17
+ if (annotationProbeUnavailable(raw))
18
+ return stored;
19
+ return fingerprintRawSummaryPr(raw);
20
+ }
21
+ /**
22
+ * Annotation totals are omitted from the always-on summary. A layer that
23
+ * otherwise looks ready loads them once so a READY receipt still changes when
24
+ * a check gains an annotation.
25
+ */
26
+ export async function hydrateReadyAnnotationProbe(pr, repo, review) {
27
+ const checks = summarizePollSummaryChecks(pr);
28
+ if (!isCurrentSummaryReady(pr, checks, review))
29
+ return;
30
+ if (!(await hydrateCommitAnnotations(pr, repo))) {
31
+ pr[PROBE_UNAVAILABLE] = true;
32
+ }
33
+ }
34
+ async function hydrateCommitAnnotations(pr, repo) {
35
+ const commit = pr.commits.nodes[0]?.commit;
36
+ const nodes = commit?.statusCheckRollup?.contexts.nodes;
37
+ if (!commit || !nodes?.some((node) => node.__typename === "CheckRun" && node.id))
38
+ return true;
39
+ const pages = await collectProbePages(commit.oid, repo);
40
+ if (!pages)
41
+ return false;
42
+ const probed = new Map(pages.flatMap((node) => node.__typename === "CheckRun" && node.id ? [[node.id, node.annotations]] : []));
43
+ for (const node of nodes) {
44
+ if (node.__typename !== "CheckRun" || !node.id)
45
+ continue;
46
+ const annotations = probed.get(node.id);
47
+ if (annotations)
48
+ node.annotations = annotations;
49
+ }
50
+ return true;
51
+ }
52
+ /** Every check page, oldest last. Null when a page cannot be trusted. */
53
+ async function collectProbePages(oid, repo) {
54
+ const pages = [];
55
+ let before = null;
56
+ const seen = new Set();
57
+ for (;;) {
58
+ const contexts = await fetchProbePage(oid, repo, before);
59
+ if (!contexts)
60
+ return null;
61
+ pages.push(...contexts.nodes);
62
+ const pageInfo = contexts.pageInfo;
63
+ if (!pageInfo?.hasPreviousPage)
64
+ return pages;
65
+ const cursor = pageInfo.startCursor;
66
+ if (!cursor || seen.has(cursor))
67
+ return null;
68
+ seen.add(cursor);
69
+ before = cursor;
70
+ }
71
+ }
72
+ async function fetchProbePage(oid, repo, before) {
73
+ try {
74
+ const { data } = await graphqlWithRateLimit(POLL_SUMMARY_ANNOTATION_PROBE_QUERY, {
75
+ owner: repo.owner,
76
+ repo: repo.name,
77
+ oid,
78
+ before,
79
+ });
80
+ const object = data.repository?.object;
81
+ if (object?.__typename !== "Commit" || object.oid !== oid)
82
+ return null;
83
+ return object.statusCheckRollup?.contexts ?? null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
@@ -33,7 +33,7 @@ export function fingerprintRawSummaryPr(raw) {
33
33
  ...raw.commits,
34
34
  nodes: raw.commits.nodes.map((node) => ({
35
35
  ...node,
36
- commit: { ...node.commit, committedDate: undefined },
36
+ commit: { ...node.commit, committedDate: undefined, checkSuites: undefined },
37
37
  })),
38
38
  },
39
39
  };
@@ -1,4 +1,4 @@
1
1
  import type { PollSummaryCommandOptions, PollSummaryItem } from "../types.mts";
2
2
  import type { RepoInfo } from "./client.mts";
3
3
  import type { RawSummaryPr } from "./poll-summary-raw.mts";
4
- export declare function summarizePollSummaryPr(raw: RawSummaryPr, repo: RepoInfo, opts: PollSummaryCommandOptions, viewerCanAdminister?: boolean): Promise<PollSummaryItem>;
4
+ export declare function summarizePollSummaryPr(raw: RawSummaryPr, repo: RepoInfo, opts: PollSummaryCommandOptions, viewerCanAdminister?: boolean, mergeTargetContexts?: readonly string[], viewerLogin?: string | null): Promise<PollSummaryItem>;
@@ -6,15 +6,17 @@ import { loadSeenMap } from "../state/seen-comments.mjs";
6
6
  import { isReadyReceiptCurrent, readReadyReceipt } from "../state/ready-receipts.mjs";
7
7
  import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
8
8
  import { summarizePollSummaryReview } from "./poll-summary-review.mjs";
9
- import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
10
9
  import { currentQueueRemovalEvent } from "./poll-summary-queue-removal.mjs";
11
10
  import { isCurrentSummaryReady } from "./poll-summary-readiness.mjs";
12
11
  import { applyOpenCheckBlockers } from "./poll-summary-check-blockers.mjs";
13
12
  import { normalizePollSummaryState, routePollSummary } from "./poll-summary-route.mjs";
14
- export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdminister = false) {
13
+ import { hydrateReadyAnnotationProbe, readyFingerprint } from "./poll-summary-annotation-probe.mjs";
14
+ import { applyUnreportedRequiredChecks } from "./poll-summary-unreported.mjs";
15
+ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdminister = false, mergeTargetContexts, viewerLogin = null) {
15
16
  const repoName = `${repo.owner}/${repo.name}`;
16
17
  const seen = await loadSeenMap({ owner: repo.owner, repo: repo.name, pr: raw.number });
17
18
  const checks = summarizePollSummaryChecks(raw);
19
+ applyUnreportedRequiredChecks(raw, checks, mergeTargetContexts);
18
20
  const review = await summarizePollSummaryReview(raw, seen, viewerCanAdminister);
19
21
  const blockingReviewerInProgress = detectBlockingReviewer(raw);
20
22
  const removalEvent = currentQueueRemovalEvent(raw);
@@ -44,15 +46,17 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
44
46
  baseRefName: raw.stack.baseRefName,
45
47
  }
46
48
  : undefined;
47
- const fingerprint = opts.stackPrNumber !== undefined ? fingerprintRawSummaryPr(raw) : null;
48
- const receipt = fingerprint
49
- ? await readReadyReceipt({ owner: repo.owner, repo: repo.name, pr: raw.number })
50
- : null;
51
- // A queued PR's target branch can advance as earlier queue entries merge.
52
- // Keep the pre-enqueue base binding for the receipt comparison while the
53
- // merge group itself supplies the current mergeability evidence.
49
+ if (opts.stackPrNumber !== undefined)
50
+ await hydrateReadyAnnotationProbe(raw, repo, review);
51
+ const receipt = opts.stackPrNumber === undefined
52
+ ? null
53
+ : await readReadyReceipt({ owner: repo.owner, repo: repo.name, pr: raw.number });
54
+ const fingerprint = opts.stackPrNumber === undefined
55
+ ? null
56
+ : readyFingerprint(raw, receipt?.readinessFingerprint ?? null);
57
+ // A queued PR's base can advance. Keep the receipt base while the probe is complete.
54
58
  const receiptFingerprint = raw.isInMergeQueue && receipt
55
- ? fingerprintRawSummaryPr({ ...raw, baseRefOid: receipt.baseRefOid })
59
+ ? readyFingerprint({ ...raw, baseRefOid: receipt.baseRefOid }, receipt.readinessFingerprint)
56
60
  : fingerprint;
57
61
  const currentReady = isCurrentSummaryReady(raw, checks, review, {
58
62
  allowQueuedProgress: opts.stackPrNumber !== undefined && raw.isInMergeQueue,
@@ -78,6 +82,10 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
78
82
  repo: repoName,
79
83
  title: raw.title,
80
84
  url: raw.url || formatPrUrl(repoName, raw.number),
85
+ ...(raw.author?.login && { authorLogin: raw.author.login }),
86
+ ...(raw.author?.login &&
87
+ viewerLogin !== null &&
88
+ raw.author.login.toLowerCase() === viewerLogin.toLowerCase() && { owned: true }),
81
89
  action,
82
90
  reasons,
83
91
  state: normalizePollSummaryState(raw.state),
@@ -131,9 +139,8 @@ function pollCommandFields(repo, pr, isDraft, opts) {
131
139
  args.push("--merge");
132
140
  if (opts.readyDelaySeconds !== undefined)
133
141
  args.push("--ready-delay", `${opts.readyDelaySeconds}s`);
134
- if (opts.stallTimeoutSeconds !== undefined) {
142
+ if (opts.stallTimeoutSeconds !== undefined)
135
143
  args.push("--stall-timeout", `${opts.stallTimeoutSeconds}s`);
136
- }
137
144
  if (opts.noAutoMarkReady && !boundedDraft)
138
145
  args.push("--no-auto-mark-ready");
139
146
  const pollCommand = buildPrShepherdCommand(args).text;
@@ -1,4 +1,4 @@
1
- import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
1
+ import { headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
2
2
  import { parseCreatedAt } from "./batch-parser-helpers.mjs";
3
3
  /** Latest queue removal that still applies to this PR head, not an older attempt. */
4
4
  export function currentQueueRemovalEvent(raw) {
@@ -12,11 +12,15 @@ export function currentQueueRemovalEvent(raw) {
12
12
  if (addition && Date.parse(addition.createdAt) > removalTime)
13
13
  return null;
14
14
  const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid);
15
- const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
15
+ const headCommit = raw.commits?.nodes[0]?.commit;
16
+ const headPushedAtUnix = headPushUnixFromCheckNodes(headCommit?.statusCheckRollup?.contexts.nodes);
16
17
  if (!queueRemovalAppliesToHead({
17
18
  parentOids: parents,
18
19
  headOid: raw.headRefOid,
19
- ...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
20
+ ...(headCommit?.committedDate && {
21
+ headCommittedAtUnix: parseCreatedAt(headCommit.committedDate),
22
+ }),
23
+ ...(headPushedAtUnix !== undefined && { headPushedAtUnix }),
20
24
  removedAtUnix: Math.floor(removalTime / 1000),
21
25
  })) {
22
26
  return null;
@@ -1,3 +1,4 @@
1
+ import type { RawBaseRef } from "./batch-raw-rules.mts";
1
2
  export interface RawAuthor {
2
3
  __typename?: string;
3
4
  login: string;
@@ -29,9 +30,11 @@ type RawCheckContext = {
29
30
  totalCount: number;
30
31
  };
31
32
  checkSuite: {
33
+ createdAt?: string | null;
32
34
  workflowRun: {
33
35
  databaseId?: string | number;
34
36
  event: string;
37
+ createdAt?: string | null;
35
38
  workflow?: {
36
39
  name: string;
37
40
  databaseId: string | number;
@@ -60,11 +63,23 @@ export interface RawSummaryCommit {
60
63
  oid: string;
61
64
  committedDate?: string;
62
65
  statusCheckRollup: RawCheckRollup | null;
66
+ checkSuites?: {
67
+ nodes: Array<{
68
+ status?: string | null;
69
+ conclusion: string | null;
70
+ workflowRun: {
71
+ event: string | null;
72
+ } | null;
73
+ }>;
74
+ } | null;
63
75
  }
64
76
  export interface RawSummaryPr {
65
77
  number: number;
66
78
  title: string;
67
79
  url: string;
80
+ author?: {
81
+ login: string;
82
+ } | null;
68
83
  state: string;
69
84
  updatedAt?: string;
70
85
  lifecycleEvents?: {
@@ -79,6 +94,7 @@ export interface RawSummaryPr {
79
94
  headRefOid: string;
80
95
  baseRefOid: string;
81
96
  baseRefName: string;
97
+ baseRef?: RawBaseRef | null;
82
98
  mergeable: string;
83
99
  mergeStateStatus: string;
84
100
  reviewDecision: string | null;
@@ -21,5 +21,6 @@ export function isCurrentSummaryReady(raw, checks, review, options = {}) {
21
21
  (checks.failing ?? 0) === 0 &&
22
22
  (sourceChecks.failing ?? 0) === 0 &&
23
23
  (sourceChecks.inProgress ?? 0) === 0 &&
24
+ (checks.unreportedRequired?.length ?? 0) === 0 &&
24
25
  (review.actionable ?? 0) === 0);
25
26
  }
@@ -16,6 +16,11 @@ export function routePollSummary(raw, checks, review, opts) {
16
16
  }
17
17
  return { action: "fix_code", reasons: ["review-work"] };
18
18
  }
19
+ if ((checks.unreportedRequired?.length ?? 0) > 0 &&
20
+ checks.actionsWorkflowInProgress !== true &&
21
+ (checks.inProgress ?? 0) === 0) {
22
+ return { action: "fix_code", reasons: ["unreported-required-checks"] };
23
+ }
19
24
  if ((checks.inProgress ?? 0) > 0 ||
20
25
  raw.mergeable === "UNKNOWN" ||
21
26
  raw.mergeStateStatus === "UNKNOWN" ||
@@ -0,0 +1,6 @@
1
+ import type { PollSummaryChecks } from "../types.mts";
2
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /** Required contexts from the open layer whose base is the stack trunk. */
4
+ export declare function trunkRequiredContexts(ordered: readonly RawSummaryPr[]): string[] | undefined;
5
+ /** Record missing required contexts. A running Actions workflow does not clear the names. */
6
+ export declare function applyUnreportedRequiredChecks(raw: RawSummaryPr, checks: PollSummaryChecks, mergeTargetContexts: readonly string[] | undefined): void;
@@ -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,11 +14,20 @@ 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;
19
+ /** How far a non-stack head is behind its PR base. One compare, no rules payload. */
20
+ export declare const BASE_BEHIND_QUERY: string;
17
21
  /**
18
22
  * Pages older status contexts for one commit with the same node selection as
19
23
  * the compact summary, so hydrated and first-page nodes fingerprint alike.
20
24
  */
21
25
  export declare const POLL_SUMMARY_CHECK_PAGE_QUERY: string;
26
+ /**
27
+ * Annotation totals for one commit, used only when a summary layer otherwise
28
+ * looks ready. `$before` pages older than the newest 100 contexts.
29
+ */
30
+ export declare const POLL_SUMMARY_ANNOTATION_PROBE_QUERY: string;
22
31
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
23
32
  export declare const POLL_STACK_SUMMARY_QUERY: string;
24
33
  /** Reads only native-stack membership and the refs that link adjacent entries. */
@@ -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,12 +18,21 @@ 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")}`;
24
+ /** How far a non-stack head is behind its PR base. One compare, no rules payload. */
25
+ export const BASE_BEHIND_QUERY = gql("base-behind.gql");
22
26
  /**
23
27
  * Pages older status contexts for one commit with the same node selection as
24
28
  * the compact summary, so hydrated and first-page nodes fingerprint alike.
25
29
  */
26
30
  export const POLL_SUMMARY_CHECK_PAGE_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-check-page.gql")}`;
31
+ /**
32
+ * Annotation totals for one commit, used only when a summary layer otherwise
33
+ * looks ready. `$before` pages older than the newest 100 contexts.
34
+ */
35
+ export const POLL_SUMMARY_ANNOTATION_PROBE_QUERY = gql("poll-summary-annotation-probe.gql");
27
36
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
28
37
  export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
29
38
  /** Reads only native-stack membership and the refs that link adjacent entries. */
@@ -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.",