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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli/fix-formatter.mjs +6 -0
- package/bin/cli/iterate-lean.mjs +3 -0
- package/bin/commands/iterate/fix-code.mjs +13 -0
- package/bin/commands/iterate/index.mjs +1 -0
- package/bin/commands/iterate/merged-base-pull-requests.d.mts +8 -0
- package/bin/commands/iterate/merged-base-pull-requests.mjs +24 -0
- package/bin/commands/iterate/unreported-required.d.mts +2 -0
- package/bin/commands/iterate/unreported-required.mjs +1 -1
- package/bin/github/gql/merged-base-pull-requests.gql +31 -0
- package/bin/github/queries.d.mts +2 -0
- package/bin/github/queries.mjs +2 -0
- package/bin/state/ci-retrigger.d.mts +6 -4
- package/bin/state/ci-retrigger.mjs +6 -4
- package/bin/types/github.d.mts +13 -0
- package/bin/types/iterate.d.mts +3 -1
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -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;
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -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. */
|
package/bin/github/queries.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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;
|
package/bin/types/github.d.mts
CHANGED
|
@@ -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. */
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -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