pr-shepherd 0.56.4 → 0.56.5

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.
@@ -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.5",
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,6 +293,14 @@ 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;
296
+ const mergedBasePullRequests = hasConflicts && !stack && report.baseRefOid
297
+ ? await lookupMergedBasePullRequests({
298
+ owner: repoOwner,
299
+ repo: repoName,
300
+ baseRefName: baseLookup.branch,
301
+ baseRefOid: report.baseRefOid,
302
+ })
303
+ : [];
295
304
  // Only an upper layer can be dirty against trunk while already containing its parent.
296
305
  const trunkConflict = hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
297
306
  ? await lookupUpperLayerTrunkConflict({
@@ -326,6 +335,9 @@ export async function handleFixCode(ctx) {
326
335
  if (repairInstructions && repairInstructions.length > 0) {
327
336
  instructions.unshift(...repairInstructions);
328
337
  }
338
+ if (mergedBasePullRequests.length > 0) {
339
+ 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.`);
340
+ }
329
341
  const checkRunCount = countReportedChecks(report.checks);
330
342
  const suitesEmpty = report.headCheckSuitesEmpty === true;
331
343
  const nowMs = Date.now();
@@ -342,6 +354,7 @@ export async function handleFixCode(ctx) {
342
354
  const prospectiveResult = {
343
355
  ...base,
344
356
  baseBranch: baseLookup.branch,
357
+ ...(mergedBasePullRequests.length > 0 && { mergedBasePullRequests }),
345
358
  ...(trunkConflict && { stackTrunkConflict: trunkConflict.trunk }),
346
359
  action: "fix_code",
347
360
  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
+ }
@@ -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
+ }
@@ -39,6 +39,8 @@ export declare const POLL_STACK_TOPOLOGY_QUERY: string;
39
39
  * the open stack entries needed to name the bottom layer. 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. */
@@ -44,6 +44,8 @@ export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
44
44
  * the open stack entries needed to name the bottom layer. 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. */
@@ -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.5",
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.5",
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.5", "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.5", "pr-shepherd-mcp"]
5
5
  }
6
6
  }