pr-shepherd 0.56.4 → 0.56.6

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 (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli/fix-formatter.mjs +6 -0
  3. package/bin/cli/iterate-lean.mjs +3 -0
  4. package/bin/commands/iterate/fix-code.mjs +26 -9
  5. package/bin/commands/iterate/index.mjs +1 -0
  6. package/bin/commands/iterate/merged-base-pull-requests.d.mts +8 -0
  7. package/bin/commands/iterate/merged-base-pull-requests.mjs +24 -0
  8. package/bin/commands/iterate/native-stack-rebase.d.mts +6 -5
  9. package/bin/commands/iterate/native-stack-rebase.mjs +13 -7
  10. package/bin/commands/iterate/stack-trunk-conflict.d.mts +4 -2
  11. package/bin/commands/iterate/stack-trunk-conflict.mjs +27 -18
  12. package/bin/commands/iterate/unreported-required.d.mts +2 -0
  13. package/bin/commands/iterate/unreported-required.mjs +1 -1
  14. package/bin/github/gql/merged-base-pull-requests.gql +31 -0
  15. package/bin/github/gql/upper-layer-conflict-target.gql +0 -15
  16. package/bin/github/merge-target-rules.d.mts +4 -0
  17. package/bin/github/merge-target-rules.mjs +9 -11
  18. package/bin/github/poll-summary-unreported.d.mts +3 -2
  19. package/bin/github/poll-summary-unreported.mjs +9 -4
  20. package/bin/github/poll-summary.mjs +1 -1
  21. package/bin/github/queries.d.mts +4 -2
  22. package/bin/github/queries.mjs +4 -2
  23. package/bin/github/stack-read.d.mts +2 -0
  24. package/bin/github/stack-read.mjs +13 -0
  25. package/bin/state/ci-retrigger.d.mts +6 -4
  26. package/bin/state/ci-retrigger.mjs +6 -4
  27. package/bin/types/github.d.mts +13 -0
  28. package/bin/types/iterate.d.mts +3 -1
  29. package/package.json +1 -1
  30. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  31. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  32. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.56.4",
4
+ "version": "0.56.6",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -14,6 +14,12 @@ export function formatFixCodeResult(header, result, opts = {}) {
14
14
  const verbose = opts.verbose ?? false;
15
15
  const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
16
16
  const sections = [header];
17
+ if (result.mergedBasePullRequests?.length) {
18
+ sections.push("## Merged PRs matching the current base");
19
+ sections.push(result.mergedBasePullRequests
20
+ .map((pr) => `- [#${pr.number}](${pr.url}) · state \`${pr.state}\` · head \`${pr.headRefName}\` at \`${pr.headRefOid}\` · base \`${pr.baseRefName}\` · mergedAt \`${pr.mergedAt}\` · headRepository \`${pr.headRepository.nameWithOwner}\``)
21
+ .join("\n"));
22
+ }
17
23
  const renderThreads = (heading, threads) => {
18
24
  if (threads.length === 0)
19
25
  return;
@@ -43,6 +43,9 @@ export function projectIterateLean(result, opts) {
43
43
  }),
44
44
  ...(result.baseBranch && { baseBranch: result.baseBranch }),
45
45
  ...(result.stackTrunkConflict && { stackTrunkConflict: result.stackTrunkConflict }),
46
+ ...((result.mergedBasePullRequests?.length ?? 0) > 0 && {
47
+ mergedBasePullRequests: result.mergedBasePullRequests,
48
+ }),
46
49
  ...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
47
50
  ...(result.mergeRequirements && { mergeRequirements: result.mergeRequirements }),
48
51
  ...(result.mergeQueue && { mergeQueue: result.mergeQueue }),
@@ -12,6 +12,7 @@ import { currentEjectionCommit, } from "./queue-recovery-instructions.mjs";
12
12
  import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
13
13
  import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
14
14
  import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
15
+ import { lookupMergedBasePullRequests } from "./merged-base-pull-requests.mjs";
15
16
  import { conflictingHeadCiNote, countReportedChecks, insertConflictingHeadCiNote, } from "./conflicting-head-ci.mjs";
16
17
  import { conflictingHeadFirstSeenUnix } from "../../state/conflicting-head-seen.mjs";
17
18
  import { applyStallGuard } from "./stall.mjs";
@@ -292,16 +293,28 @@ export async function handleFixCode(ctx) {
292
293
  const firstLookComments = report.comments.firstLook;
293
294
  const stack = report.mergeStatus.mergeRequirements?.stack;
294
295
  const headRef = report.headSha;
295
- // Only an upper layer can be dirty against trunk while already containing its parent.
296
- const trunkConflict = hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
297
- ? await lookupUpperLayerTrunkConflict({
296
+ const mergedBasePullRequests = hasConflicts && !stack && report.baseRefOid
297
+ ? await lookupMergedBasePullRequests({
298
298
  owner: repoOwner,
299
- name: repoName,
300
- pr: prNumber,
301
- headRef,
302
- trunk: stack.baseRefName,
299
+ repo: repoName,
300
+ baseRefName: baseLookup.branch,
301
+ baseRefOid: report.baseRefOid,
303
302
  })
304
- : undefined;
303
+ : [];
304
+ // A bottom open layer can still retain its merged parent's PR base. Its update starts
305
+ // at trunk regardless of that base comparison; true upper layers first check the parent.
306
+ const trunkConflict = hasConflicts && stack && report.stackBottomPr === prNumber
307
+ ? { trunk: stack.baseRefName, bottomPr: prNumber }
308
+ : hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
309
+ ? await lookupUpperLayerTrunkConflict({
310
+ owner: repoOwner,
311
+ name: repoName,
312
+ pr: prNumber,
313
+ headRef,
314
+ trunk: stack.baseRefName,
315
+ bottomPr: report.stackBottomPr,
316
+ })
317
+ : undefined;
305
318
  const requeue = buildRemovedQueueRecovery(report, failingAgentChecks, opts.merge);
306
319
  const queueRemovalAcknowledgment = buildStackQueueRemovalAcknowledgment(report, failingAgentChecks);
307
320
  const queueEjection = !currentEjectionCommit(report, failingAgentChecks)
@@ -314,7 +327,7 @@ export async function handleFixCode(ctx) {
314
327
  // Conflicts, a behind branch whose rerun already failed, and a queue ejection that may be
315
328
  // reproduced on the latest base all print the branch update route.
316
329
  const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0) || queueEjection
317
- ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
330
+ ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict, report.stackBottomPr)
318
331
  : undefined;
319
332
  const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase, queueEjection);
320
333
  if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
@@ -326,6 +339,9 @@ export async function handleFixCode(ctx) {
326
339
  if (repairInstructions && repairInstructions.length > 0) {
327
340
  instructions.unshift(...repairInstructions);
328
341
  }
342
+ if (mergedBasePullRequests.length > 0) {
343
+ instructions.unshift(`Inspect every PR under \`## Merged PRs matching the current base\`. If this PR is the remaining layer intended for a merged parent's base branch, run \`gh pr edit ${prReference} --base <verified-parent-base>\` after replacing \`<verified-parent-base>\` with that parent's shell-quoted base branch, then rerun Shepherd immediately and follow its fresh instructions instead of the remaining steps here. Otherwise keep the current base and follow the remaining conflict-resolution steps.`);
344
+ }
329
345
  const checkRunCount = countReportedChecks(report.checks);
330
346
  const suitesEmpty = report.headCheckSuitesEmpty === true;
331
347
  const nowMs = Date.now();
@@ -342,6 +358,7 @@ export async function handleFixCode(ctx) {
342
358
  const prospectiveResult = {
343
359
  ...base,
344
360
  baseBranch: baseLookup.branch,
361
+ ...(mergedBasePullRequests.length > 0 && { mergedBasePullRequests }),
345
362
  ...(trunkConflict && { stackTrunkConflict: trunkConflict.trunk }),
346
363
  action: "fix_code",
347
364
  fix: {
@@ -135,6 +135,7 @@ async function runIterateCore(opts) {
135
135
  stateKey: stallKey,
136
136
  headSha,
137
137
  otherAutonomousWork: hasActionableWork || staleAncestry !== null,
138
+ persistState: opts.persistSeen !== false,
138
139
  });
139
140
  if (unreportedPlan.escalate)
140
141
  return unreportedPlan.escalate;
@@ -0,0 +1,8 @@
1
+ import type { MergedBasePullRequest } from "../../types.mts";
2
+ /** A bounded, best-effort lookup; matching OIDs and repository prevent branch-name reuse false positives. */
3
+ export declare function lookupMergedBasePullRequests(input: {
4
+ owner: string;
5
+ repo: string;
6
+ baseRefName: string;
7
+ baseRefOid: string;
8
+ }): Promise<MergedBasePullRequest[]>;
@@ -0,0 +1,24 @@
1
+ import { graphql } from "../../github/client.mjs";
2
+ import { MERGED_BASE_PULL_REQUESTS_QUERY } from "../../github/queries.mjs";
3
+ import { pollRateLimitRetryAfterMs } from "../poll-quota.mjs";
4
+ /** A bounded, best-effort lookup; matching OIDs and repository prevent branch-name reuse false positives. */
5
+ export async function lookupMergedBasePullRequests(input) {
6
+ try {
7
+ const { data } = await graphql(MERGED_BASE_PULL_REQUESTS_QUERY, {
8
+ owner: input.owner,
9
+ repo: input.repo,
10
+ branch: input.baseRefName,
11
+ });
12
+ return (data.repository?.pullRequests.nodes ?? []).filter((candidate) => candidate !== null &&
13
+ candidate.state === "MERGED" &&
14
+ candidate.headRefName === input.baseRefName &&
15
+ candidate.headRefOid === input.baseRefOid &&
16
+ candidate.headRepository?.nameWithOwner === `${input.owner}/${input.repo}`);
17
+ }
18
+ catch (error) {
19
+ if (pollRateLimitRetryAfterMs(error) !== null)
20
+ throw error;
21
+ process.stderr.write(`pr-shepherd: merged-base PR lookup unavailable (ignored): ${error instanceof Error ? error.message : String(error)}\n`);
22
+ return [];
23
+ }
24
+ }
@@ -22,10 +22,11 @@ export declare function buildNativeStackRebaseInstruction(repo: string, stackNum
22
22
  /**
23
23
  * The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose
24
24
  * workflow keeps failing); undefined outside a stack.
25
- * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
26
- * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
27
- * layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
28
- * upper layer already contains its parent, so the rebase starts at the bottom open layer.
25
+ * The first open layer in validated stack order is the bottom open layer, even when GitHub
26
+ * retains its merged parent's branch as its PR base. Without that topology, a PR base matching
27
+ * the stack trunk also identifies a bottom layer. Other layers rebase from their PR base.
28
+ * `trunkConflict` means an upper layer already contains its parent, so the rebase starts
29
+ * at the bottom open layer.
29
30
  */
30
31
  export declare function buildNativeStackLayerRebase(repo: string, pr: {
31
32
  number: number;
@@ -33,7 +34,7 @@ export declare function buildNativeStackLayerRebase(repo: string, pr: {
33
34
  }, stack: StackStatus | undefined, trunkConflict?: {
34
35
  trunk: string;
35
36
  bottomPr?: number;
36
- }): string | undefined;
37
+ }, stackBottomPr?: number): string | undefined;
37
38
  /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
38
39
  export declare function buildConflictInstruction(stackRebase: string | undefined): string;
39
40
  /**
@@ -15,26 +15,32 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
15
15
  ]
16
16
  : "bottomPr" in start
17
17
  ? [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"]
18
- : [`check out the bottom open layer whose base is \`${start.trunk}\``, "gh stack rebase"];
18
+ : [
19
+ `check out the bottom open layer of the stack to rebase onto \`${start.trunk}\``,
20
+ "gh stack rebase",
21
+ ];
19
22
  const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\``;
20
23
  return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`. ${playbookPointer("Branch update")}`;
21
24
  }
22
25
  /**
23
26
  * The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose
24
27
  * workflow keeps failing); undefined outside a stack.
25
- * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
26
- * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
27
- * layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
28
- * upper layer already contains its parent, so the rebase starts at the bottom open layer.
28
+ * The first open layer in validated stack order is the bottom open layer, even when GitHub
29
+ * retains its merged parent's branch as its PR base. Without that topology, a PR base matching
30
+ * the stack trunk also identifies a bottom layer. Other layers rebase from their PR base.
31
+ * `trunkConflict` means an upper layer already contains its parent, so the rebase starts
32
+ * at the bottom open layer.
29
33
  */
30
- export function buildNativeStackLayerRebase(repo, pr, stack, trunkConflict) {
34
+ export function buildNativeStackLayerRebase(repo, pr, stack, trunkConflict, stackBottomPr) {
31
35
  if (!stack)
32
36
  return undefined;
33
37
  const start = trunkConflict
34
38
  ? trunkConflict.bottomPr !== undefined
35
39
  ? { bottomPr: trunkConflict.bottomPr }
36
40
  : { trunk: trunkConflict.trunk }
37
- : pr.baseBranch === stack.baseRefName
41
+ : (stackBottomPr !== undefined
42
+ ? pr.number === stackBottomPr
43
+ : pr.baseBranch === stack.baseRefName)
38
44
  ? { bottomPr: pr.number }
39
45
  : { parentBranch: pr.baseBranch };
40
46
  return buildNativeStackRebaseInstruction(repo, stack.number, start);
@@ -1,7 +1,7 @@
1
- /** An upper layer already contains its parent, so the dirty state is against trunk. */
1
+ /** A bottom open layer, or an upper layer that already contains its parent, updates from trunk. */
2
2
  export interface UpperLayerTrunkConflict {
3
3
  trunk: string;
4
- /** Open layer whose PR base is the trunk. Omitted when that layer is not on this page. */
4
+ /** First open layer after a verified merged prefix. Omitted when topology is unavailable. */
5
5
  bottomPr?: number;
6
6
  }
7
7
  export declare function lookupUpperLayerTrunkConflict(input: {
@@ -10,4 +10,6 @@ export declare function lookupUpperLayerTrunkConflict(input: {
10
10
  pr: number;
11
11
  headRef: string;
12
12
  trunk: string;
13
+ /** Reuse the bottom layer already resolved for the report, avoiding a topology fetch. */
14
+ bottomPr?: number;
13
15
  }): Promise<UpperLayerTrunkConflict | undefined>;
@@ -1,18 +1,34 @@
1
1
  import { graphql } from "../../github/client.mjs";
2
+ import { readStackTopology, verifiedBottomOpenLayer } from "../../github/stack-read.mjs";
2
3
  import { UPPER_LAYER_CONFLICT_TARGET_QUERY } from "../../github/queries.mjs";
3
4
  import { pollRateLimitRetryAfterMs } from "../poll-quota.mjs";
4
- /** Lowest-position open layer whose base is the stack trunk, if this page contains one. */
5
- function selectBottomOpenLayer(nodes, trunk) {
6
- const onTrunk = nodes.flatMap((node) => {
7
- const pull = node?.pullRequest;
8
- if (!node || !pull || pull.state !== "OPEN" || pull.baseRefName !== trunk)
9
- return [];
10
- return [{ position: node.position, number: pull.number }];
11
- });
12
- onTrunk.sort((a, b) => a.position - b.position);
13
- return onTrunk[0]?.number;
14
- }
15
5
  export async function lookupUpperLayerTrunkConflict(input) {
6
+ let bottomPr = input.bottomPr;
7
+ if (bottomPr === undefined) {
8
+ let topology;
9
+ try {
10
+ topology = await readStackTopology(input.pr, { owner: input.owner, name: input.name });
11
+ }
12
+ catch (err) {
13
+ if (pollRateLimitRetryAfterMs(err) !== null)
14
+ throw err;
15
+ ignore(input.pr, err instanceof Error ? err.message : String(err));
16
+ }
17
+ if (topology) {
18
+ try {
19
+ bottomPr = verifiedBottomOpenLayer(topology.ordered, input.pr).number;
20
+ }
21
+ catch (err) {
22
+ ignore(input.pr, err instanceof Error ? err.message : String(err));
23
+ // A base comparison cannot prove that a closed or unverified predecessor merged.
24
+ return undefined;
25
+ }
26
+ }
27
+ }
28
+ // Merged lower layers need not cause GitHub to retarget this PR to trunk. Even when its
29
+ // head is behind that old parent branch, the first open layer rebases the stack onto trunk.
30
+ if (bottomPr === input.pr)
31
+ return { trunk: input.trunk, bottomPr };
16
32
  let data;
17
33
  try {
18
34
  ({ data } = await graphql(UPPER_LAYER_CONFLICT_TARGET_QUERY, {
@@ -37,13 +53,6 @@ export async function lookupUpperLayerTrunkConflict(input) {
37
53
  // behindBy > 0: the layer is still behind its parent. Keep the parent rebase.
38
54
  if (behindBy !== 0)
39
55
  return undefined;
40
- const entries = pull.stack?.entries;
41
- const bottomPr = entries ? selectBottomOpenLayer(entries.nodes, input.trunk) : undefined;
42
- if (bottomPr === undefined) {
43
- ignore(input.pr, entries?.pageInfo.hasNextPage
44
- ? "stack entry page truncated before the trunk layer"
45
- : "bottom open layer not found");
46
- }
47
56
  return { trunk: input.trunk, ...(bottomPr !== undefined && { bottomPr }) };
48
57
  }
49
58
  function ignore(pr, message) {
@@ -13,6 +13,8 @@ export declare function planUnreportedRequired(input: {
13
13
  };
14
14
  headSha: string;
15
15
  otherAutonomousWork: boolean;
16
+ /** Internal preview ticks must not consume the one-time reopen opportunity. */
17
+ persistState?: boolean;
16
18
  }): Promise<UnreportedPlan>;
17
19
  export declare function buildUnreportedFixResult(base: IterateResultBase, report: ShepherdReport, instructions: string[]): IterateResult;
18
20
  export {};
@@ -72,7 +72,7 @@ export async function planUnreportedRequired(input) {
72
72
  trunk: (input.report.trunkBehindBy ?? 0) > 0,
73
73
  ...(rebase && { stackRebase: rebase }),
74
74
  });
75
- if (decision === "reopen") {
75
+ if (decision === "reopen" && input.persistState !== false) {
76
76
  await writeCiRetrigger(input.stateKey, { headSha: input.headSha, contexts: names });
77
77
  }
78
78
  return { repairInstructions: instructions };
@@ -0,0 +1,31 @@
1
+ query MergedBasePullRequests($owner: String!, $repo: String!, $branch: String!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ repository(owner: $owner, name: $repo) {
11
+ pullRequests(
12
+ headRefName: $branch
13
+ states: MERGED
14
+ first: 20
15
+ orderBy: { field: UPDATED_AT, direction: DESC }
16
+ ) {
17
+ nodes {
18
+ number
19
+ url
20
+ state
21
+ headRefName
22
+ headRefOid
23
+ baseRefName
24
+ mergedAt
25
+ headRepository {
26
+ nameWithOwner
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
@@ -16,21 +16,6 @@ query UpperLayerConflictTarget($owner: String!, $name: String!, $number: Int!, $
16
16
  behindBy
17
17
  }
18
18
  }
19
- stack {
20
- entries(first: 100) {
21
- pageInfo {
22
- hasNextPage
23
- }
24
- nodes {
25
- position
26
- pullRequest {
27
- number
28
- state
29
- baseRefName
30
- }
31
- }
32
- }
33
- }
34
19
  }
35
20
  }
36
21
  }
@@ -27,3 +27,7 @@ export declare function loadMergeTargetStatus(input: {
27
27
  * is also open, so this compare is the behind count that status hides.
28
28
  */
29
29
  export declare function loadBaseBehindBy(owner: string, name: string, baseRefName: string, headRef: string): Promise<number>;
30
+ export declare function loadRefRules(owner: string, name: string, qualifiedName: string, headRef: string): Promise<{
31
+ contexts: string[];
32
+ behindBy: number;
33
+ }>;
@@ -3,7 +3,7 @@ import { parseBranchRules } from "./batch-parsers-rules.mjs";
3
3
  import { graphqlWithRateLimit } from "./client.mjs";
4
4
  import { missingRepositoryError } from "./errors.mjs";
5
5
  import { BASE_BEHIND_QUERY, REF_RULES_QUERY } from "./queries.mjs";
6
- import { readStackTopology } from "./stack-read.mjs";
6
+ import { readStackTopology, verifiedBottomOpenLayer } from "./stack-read.mjs";
7
7
  /**
8
8
  * Required status contexts for the branch GitHub actually merges into.
9
9
  * A native stack uses the trunk ref, and `behindBy` is that trunk against the bottom open layer.
@@ -15,24 +15,22 @@ export async function loadMergeTargetStatus(input, context) {
15
15
  let headRef = input.headRefName;
16
16
  let stackBottomPr = input.pr;
17
17
  if (trunk !== input.baseRefName) {
18
- const bottom = await bottomOpenLayer(input.pr, { owner: input.owner, name: input.name }, trunk, context);
19
- headRef = bottom.headRefName;
18
+ const bottom = await bottomOpenLayer(input.pr, { owner: input.owner, name: input.name }, context);
19
+ headRef = bottom.headRefOid;
20
20
  stackBottomPr = bottom.number;
21
21
  }
22
- const loaded = await fetchRefRules(input.owner, input.name, `refs/heads/${trunk}`, headRef);
22
+ const loaded = await loadRefRules(input.owner, input.name, `refs/heads/${trunk}`, headRef);
23
23
  return {
24
24
  contexts: loaded.contexts,
25
25
  ...(loaded.behindBy > 0 && { trunkBehindBy: loaded.behindBy }),
26
26
  stackBottomPr,
27
27
  };
28
28
  }
29
- async function bottomOpenLayer(pr, repo, trunk, context) {
29
+ async function bottomOpenLayer(pr, repo, context) {
30
30
  const topology = await (context?.readStackTopology(pr, repo) ?? readStackTopology(pr, repo));
31
- const bottom = topology.ordered.find((member) => member.state === "OPEN" && member.baseRefName === trunk);
32
- if (!bottom) {
33
- throw new ShepherdError(`Native stack for PR #${pr} has no open layer based on ${trunk}`, EXIT.TEMPFAIL);
34
- }
35
- return bottom;
31
+ // GitHub can retain a merged parent's branch as the lowest open layer's
32
+ // base. Stack order, rather than retargeting, determines the review target.
33
+ return verifiedBottomOpenLayer(topology.ordered, pr);
36
34
  }
37
35
  /**
38
36
  * Commits on the PR base that `headRef` does not contain.
@@ -57,7 +55,7 @@ export async function loadBaseBehindBy(owner, name, baseRefName, headRef) {
57
55
  }
58
56
  return ref.compare?.behindBy ?? 0;
59
57
  }
60
- async function fetchRefRules(owner, name, qualifiedName, headRef) {
58
+ export async function loadRefRules(owner, name, qualifiedName, headRef) {
61
59
  const { data } = await graphqlWithRateLimit(REF_RULES_QUERY, {
62
60
  owner,
63
61
  repo: name,
@@ -1,6 +1,7 @@
1
+ import type { RepoInfo } from "./client.mts";
1
2
  import type { PollSummaryChecks } from "../types.mts";
2
3
  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;
4
+ /** Current trunk rules may remain available on a merged layer's base ref. */
5
+ export declare function trunkRequiredContexts(ordered: readonly RawSummaryPr[], repo: RepoInfo): Promise<string[] | undefined>;
5
6
  /** Record missing required contexts. A running Actions workflow does not clear the names. */
6
7
  export declare function applyUnreportedRequiredChecks(raw: RawSummaryPr, checks: PollSummaryChecks, mergeTargetContexts: readonly string[] | undefined): void;
@@ -1,15 +1,20 @@
1
1
  import { loadConfig } from "../config/load.mjs";
2
2
  import { actionsWorkflowInProgress, selectMergeTargetContexts, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
3
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) {
4
+ import { loadRefRules } from "./merge-target-rules.mjs";
5
+ /** Current trunk rules may remain available on a merged layer's base ref. */
6
+ export async function trunkRequiredContexts(ordered, repo) {
6
7
  const trunk = ordered.find((pr) => pr.stack)?.stack?.baseRefName;
7
8
  if (!trunk)
8
9
  return undefined;
9
- const bottom = ordered.find((pr) => pr.state === "OPEN" && pr.baseRefName === trunk);
10
+ const bottom = ordered.find((pr) => pr.state === "OPEN");
10
11
  if (!bottom)
11
12
  return undefined;
12
- return parseBranchRules(bottom.baseRef).requiredStatusCheckContexts;
13
+ const trunkLayer = ordered.find((pr) => pr.baseRefName === trunk && pr.baseRef != null);
14
+ if (trunkLayer)
15
+ return parseBranchRules(trunkLayer.baseRef).requiredStatusCheckContexts;
16
+ const loaded = await loadRefRules(repo.owner, repo.name, `refs/heads/${trunk}`, bottom.headRefOid);
17
+ return loaded.contexts;
13
18
  }
14
19
  /** Record missing required contexts. A running Actions workflow does not clear the names. */
15
20
  export function applyUnreportedRequiredChecks(raw, checks, mergeTargetContexts) {
@@ -72,7 +72,7 @@ async function fetchStackSummary(opts, repo) {
72
72
  await refreshUnknownSummaryMergeability(pr, repo);
73
73
  }
74
74
  const stackAncestry = stackAncestryGaps(ordered);
75
- const required = trunkRequiredContexts(ordered);
75
+ const required = await trunkRequiredContexts(ordered, repo);
76
76
  return {
77
77
  selection: { kind: "stack", anchor, stackNumber, stackSize },
78
78
  prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister, required, viewerLogin))),
@@ -35,10 +35,12 @@ export declare const POLL_STACK_SUMMARY_QUERY: string;
35
35
  /** Reads only native-stack membership and the refs that link adjacent entries. */
36
36
  export declare const POLL_STACK_TOPOLOGY_QUERY: string;
37
37
  /**
38
- * Whether a conflicting upper stack layer already contains its PR base, plus
39
- * the open stack entries needed to name the bottom layer. Not part of BatchPr.
38
+ * Whether a conflicting upper stack layer already contains its PR base.
39
+ * Bottom-layer identity comes from the validated stack topology. Not part of BatchPr.
40
40
  */
41
41
  export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
42
+ /** Merged PRs whose head may be the stale base of a non-stack conflict. */
43
+ export declare const MERGED_BASE_PULL_REQUESTS_QUERY: string;
42
44
  /** PR head fields plus a single review thread for `commit-suggestion`. */
43
45
  export declare const SUGGESTION_THREADS_QUERY: string;
44
46
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -40,10 +40,12 @@ export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-s
40
40
  /** Reads only native-stack membership and the refs that link adjacent entries. */
41
41
  export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
42
42
  /**
43
- * Whether a conflicting upper stack layer already contains its PR base, plus
44
- * the open stack entries needed to name the bottom layer. Not part of BatchPr.
43
+ * Whether a conflicting upper stack layer already contains its PR base.
44
+ * Bottom-layer identity comes from the validated stack topology. Not part of BatchPr.
45
45
  */
46
46
  export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-target.gql");
47
+ /** Merged PRs whose head may be the stale base of a non-stack conflict. */
48
+ export const MERGED_BASE_PULL_REQUESTS_QUERY = gql("merged-base-pull-requests.gql");
47
49
  /** PR head fields plus a single review thread for `commit-suggestion`. */
48
50
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
49
51
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -31,6 +31,8 @@ export interface StackRead<Pr extends StackMemberRefs> {
31
31
  export declare function readStack<Pr extends StackMemberRefs>(query: string, anchor: number, repo: RepoInfo, variables?: Record<string, unknown>): Promise<StackRead<Pr>>;
32
32
  /** Membership and linking refs only — no per-PR CI or review hydration. */
33
33
  export declare function readStackTopology(anchor: number, repo: RepoInfo): Promise<StackRead<StackMemberRefs>>;
34
+ /** The first open member can replace merged lower layers only after a fully merged prefix. */
35
+ export declare function verifiedBottomOpenLayer<Pr extends StackMemberRefs>(ordered: readonly Pr[], anchor: number): Pr;
34
36
  /**
35
37
  * Adjacent entries, both open, whose child base no longer matches the parent
36
38
  * head. Both sides come from the same read, so each gap was observed together.
@@ -77,6 +77,19 @@ export async function readStack(query, anchor, repo, variables = {}) {
77
77
  export function readStackTopology(anchor, repo) {
78
78
  return readStack(POLL_STACK_TOPOLOGY_QUERY, anchor, repo);
79
79
  }
80
+ /** The first open member can replace merged lower layers only after a fully merged prefix. */
81
+ export function verifiedBottomOpenLayer(ordered, anchor) {
82
+ const index = ordered.findIndex((member) => member.state === "OPEN");
83
+ const bottom = ordered[index];
84
+ if (!bottom) {
85
+ throw new ShepherdError(`Native stack for PR #${anchor} has no open layer`, EXIT.TEMPFAIL);
86
+ }
87
+ const predecessor = ordered.slice(0, index).find((member) => member.state !== "MERGED");
88
+ if (predecessor) {
89
+ throw new ShepherdError(`Native stack for PR #${anchor} has PR #${predecessor.number} in state ${predecessor.state} before open PR #${bottom.number}; cannot verify the bottom open layer`, EXIT.TEMPFAIL);
90
+ }
91
+ return bottom;
92
+ }
80
93
  /**
81
94
  * Adjacent entries, both open, whose child base no longer matches the parent
82
95
  * head. Both sides come from the same read, so each gap was observed together.
@@ -1,8 +1,10 @@
1
1
  /**
2
- * One close/reopen of a head that was missing required checks.
2
+ * One presented close/reopen instruction for a head that was missing required checks.
3
3
  *
4
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.
5
+ * This records that Shepherd returned the instruction, not that the caller executed it.
6
+ * Reopening does not change the head SHA, so the next presented tick must not offer it again.
7
+ * Internal debounce preview ticks do not write this marker.
6
8
  */
7
9
  interface Retrigger {
8
10
  headSha: string;
@@ -14,12 +16,12 @@ export declare function readCiRetrigger(key: {
14
16
  repo: string;
15
17
  pr: number;
16
18
  }): Promise<Retrigger | undefined>;
17
- /** Remember that this head was already closed and reopened for these contexts. */
19
+ /** Remember that the one close/reopen instruction was returned for this head and context set. */
18
20
  export declare function writeCiRetrigger(key: {
19
21
  owner: string;
20
22
  repo: string;
21
23
  pr: number;
22
24
  }, record: Retrigger): Promise<void>;
23
- /** True when this head was already retriggered for the same required contexts. */
25
+ /** True when the one close/reopen instruction was already returned for this head and contexts. */
24
26
  export declare function sameCiRetrigger(record: Retrigger | undefined, headSha: string, contexts: readonly string[]): boolean;
25
27
  export {};
@@ -1,8 +1,10 @@
1
1
  /**
2
- * One close/reopen of a head that was missing required checks.
2
+ * One presented close/reopen instruction for a head that was missing required checks.
3
3
  *
4
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.
5
+ * This records that Shepherd returned the instruction, not that the caller executed it.
6
+ * Reopening does not change the head SHA, so the next presented tick must not offer it again.
7
+ * Internal debounce preview ticks do not write this marker.
6
8
  */
7
9
  import { mkdir, readFile, writeFile } from "node:fs/promises";
8
10
  import { dirname } from "node:path";
@@ -11,7 +13,7 @@ import { resolvePrStatePath } from "./base.mjs";
11
13
  export async function readCiRetrigger(key) {
12
14
  return readRetrigger(resolvePrStatePath(key, "ci-retrigger.json"));
13
15
  }
14
- /** Remember that this head was already closed and reopened for these contexts. */
16
+ /** Remember that the one close/reopen instruction was returned for this head and context set. */
15
17
  export async function writeCiRetrigger(key, record) {
16
18
  const path = resolvePrStatePath(key, "ci-retrigger.json");
17
19
  await mkdir(dirname(path), { recursive: true });
@@ -21,7 +23,7 @@ export async function writeCiRetrigger(key, record) {
21
23
  };
22
24
  await writeFile(path, JSON.stringify(next));
23
25
  }
24
- /** True when this head was already retriggered for the same required contexts. */
26
+ /** True when the one close/reopen instruction was already returned for this head and contexts. */
25
27
  export function sameCiRetrigger(record, headSha, contexts) {
26
28
  if (!record || record.headSha !== headSha)
27
29
  return false;
@@ -6,6 +6,19 @@ export type CheckStatus = "COMPLETED" | "IN_PROGRESS" | "PENDING" | "QUEUED" | "
6
6
  export type MergeableState = "CONFLICTING" | "MERGEABLE" | "UNKNOWN";
7
7
  export type MergeStateStatus = "BEHIND" | "BLOCKED" | "CLEAN" | "DIRTY" | "DRAFT" | "HAS_HOOKS" | "UNKNOWN" | "UNSTABLE";
8
8
  export type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
9
+ /** Raw GitHub fields for a merged PR whose head is this PR's current base. */
10
+ export interface MergedBasePullRequest {
11
+ number: number;
12
+ url: string;
13
+ state: "MERGED";
14
+ headRefName: string;
15
+ headRefOid: string;
16
+ baseRefName: string;
17
+ mergedAt: string;
18
+ headRepository: {
19
+ nameWithOwner: string;
20
+ };
21
+ }
9
22
  export type AuthorType = "User" | "Bot" | "Unknown";
10
23
  type RepositoryPermission = "NONE" | "READ" | "TRIAGE" | "WRITE" | "MAINTAIN" | "ADMIN";
11
24
  /** Raw GitHub viewer fields used to gate remote actions. */
@@ -1,6 +1,6 @@
1
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
- import type { BranchProtection, MergeStateStatus, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
3
+ import type { BranchProtection, MergeStateStatus, MergedBasePullRequest, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
4
4
  import type { MergeRequirements, StackDraftHold } from "./merge-requirements.mts";
5
5
  import type { EscalateDetails } from "./escalate.mts";
6
6
  import type { MergeCommandPlan } from "./merge-action.mts";
@@ -34,6 +34,8 @@ export interface IterateResultBase {
34
34
  * PR base, so the dirty state is against the stack trunk rather than that base.
35
35
  */
36
36
  stackTrunkConflict?: string;
37
+ /** Merged PRs whose exact head commit and repository match this conflicting PR's base. */
38
+ mergedBasePullRequests?: MergedBasePullRequest[];
37
39
  /** Null when no classic protection rule exists or the base ref is unavailable. */
38
40
  branchProtection: BranchProtection | null;
39
41
  mergeRequirements?: MergeRequirements;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.4",
3
+ "version": "0.56.6",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.56.4",
3
+ "version": "0.56.6",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.56.4", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.56.6", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.56.4", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.56.6", "pr-shepherd-mcp"]
5
5
  }
6
6
  }