pr-shepherd 0.54.0 → 0.54.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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli/iterate-branch-segment.d.mts +3 -0
- package/bin/cli/iterate-branch-segment.mjs +13 -0
- package/bin/cli/iterate-formatter.mjs +5 -13
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/iterate/conflicting-head-ci.d.mts +18 -0
- package/bin/commands/iterate/conflicting-head-ci.mjs +32 -0
- package/bin/commands/iterate/fix-code.mjs +30 -1
- package/bin/commands/iterate/native-stack-rebase.d.mts +8 -2
- package/bin/commands/iterate/native-stack-rebase.mjs +14 -4
- package/bin/commands/iterate/stack-trunk-conflict.d.mts +13 -0
- package/bin/commands/iterate/stack-trunk-conflict.mjs +51 -0
- package/bin/github/batch-parse-suites.d.mts +2 -0
- package/bin/github/batch-parse-suites.mjs +5 -0
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +2 -1
- package/bin/github/gql/upper-layer-conflict-target.gql +36 -0
- package/bin/github/queries.d.mts +5 -0
- package/bin/github/queries.mjs +5 -0
- package/bin/state/conflicting-head-seen.d.mts +13 -0
- package/bin/state/conflicting-head-seen.mjs +44 -0
- package/bin/types/iterate.d.mts +5 -0
- package/bin/types/report.d.mts +2 -0
- 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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Summary-line branch phrase. Empty when the PR is not behind or conflicting. */
|
|
2
|
+
export function branchStateSegment(result) {
|
|
3
|
+
if (result.mergeStatus === "BEHIND" && result.baseBranch) {
|
|
4
|
+
return `**branch** behind PR base \`${result.baseBranch}\``;
|
|
5
|
+
}
|
|
6
|
+
if (result.mergeStatus === "CONFLICTS" && result.stackTrunkConflict) {
|
|
7
|
+
return `**branch** conflicts with stack trunk \`${result.stackTrunkConflict}\``;
|
|
8
|
+
}
|
|
9
|
+
if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
|
|
10
|
+
return `**branch** conflicts with PR base \`${result.baseBranch}\``;
|
|
11
|
+
}
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
@@ -6,6 +6,7 @@ import { formatMergeRequirementLines } from "../merge-status/requirements-format
|
|
|
6
6
|
import { appendMergeQueueHeader, formatDeferredWorkLine, formatMergeAction, } from "./iterate-merge-formatter.mjs";
|
|
7
7
|
import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
|
|
8
8
|
import { formatActivityLine } from "./iterate-activity-formatter.mjs";
|
|
9
|
+
import { branchStateSegment } from "./iterate-branch-segment.mjs";
|
|
9
10
|
/**
|
|
10
11
|
* Format an IterateResult as human-readable Markdown.
|
|
11
12
|
*
|
|
@@ -29,15 +30,10 @@ export function formatIterateResult(result, opts) {
|
|
|
29
30
|
: "";
|
|
30
31
|
const baseBranchSeg = verbose && result.baseBranch ? ` · **baseBranch** \`${result.baseBranch}\`` : "";
|
|
31
32
|
const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\`${baseBranchSeg}`;
|
|
33
|
+
const branchSeg = branchStateSegment(result);
|
|
32
34
|
let summaryLine;
|
|
33
35
|
if (verbose) {
|
|
34
|
-
|
|
35
|
-
if (result.mergeStatus === "BEHIND" && result.baseBranch) {
|
|
36
|
-
verboseBranch = ` · **branch** behind PR base \`${result.baseBranch}\``;
|
|
37
|
-
}
|
|
38
|
-
else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
|
|
39
|
-
verboseBranch = ` · **branch** conflicts with PR base \`${result.baseBranch}\``;
|
|
40
|
-
}
|
|
36
|
+
const verboseBranch = branchSeg ? ` · ${branchSeg}` : "";
|
|
41
37
|
summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress, ${result.summary.superseded} superseded · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}${verboseBranch}`;
|
|
42
38
|
}
|
|
43
39
|
else {
|
|
@@ -58,12 +54,8 @@ export function formatIterateResult(result, opts) {
|
|
|
58
54
|
segs.push(`**blockingBotReviewInProgress**`);
|
|
59
55
|
if (result.isDraft)
|
|
60
56
|
segs.push(`**isDraft**`);
|
|
61
|
-
if (
|
|
62
|
-
segs.push(
|
|
63
|
-
}
|
|
64
|
-
else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
|
|
65
|
-
segs.push(`**branch** conflicts with PR base \`${result.baseBranch}\``);
|
|
66
|
-
}
|
|
57
|
+
if (branchSeg)
|
|
58
|
+
segs.push(branchSeg);
|
|
67
59
|
summaryLine = segs.join(" · ");
|
|
68
60
|
}
|
|
69
61
|
// Surface an explicit `--ready-delay` override (set only when the user passed the flag)
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -41,6 +41,7 @@ export function projectIterateLean(result, opts) {
|
|
|
41
41
|
remainingSeconds: result.remainingSeconds,
|
|
42
42
|
}),
|
|
43
43
|
...(result.baseBranch && { baseBranch: result.baseBranch }),
|
|
44
|
+
...(result.stackTrunkConflict && { stackTrunkConflict: result.stackTrunkConflict }),
|
|
44
45
|
...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
|
|
45
46
|
...(result.mergeRequirements && { mergeRequirements: result.mergeRequirements }),
|
|
46
47
|
...(result.mergeQueue && { mergeQueue: result.mergeQueue }),
|
package/bin/commands/check.mjs
CHANGED
|
@@ -259,6 +259,7 @@ export async function runCheck(opts) {
|
|
|
259
259
|
status,
|
|
260
260
|
baseBranch: batchData.baseRefName,
|
|
261
261
|
...(batchData.baseRefOid && { baseRefOid: batchData.baseRefOid }),
|
|
262
|
+
...(result.headCheckSuitesEmpty && { headCheckSuitesEmpty: true }),
|
|
262
263
|
mergeStatus,
|
|
263
264
|
checks: {
|
|
264
265
|
passing: merged.passing,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function conflictingHeadCiNote(input: {
|
|
2
|
+
hasConflicts: boolean;
|
|
3
|
+
headCheckSuitesEmpty: boolean;
|
|
4
|
+
checkRunCount: number;
|
|
5
|
+
/** Unix seconds when Shepherd first saw this head. Omit when that time is unknown. */
|
|
6
|
+
firstSeenAtUnix: number | null | undefined;
|
|
7
|
+
nowMs: number;
|
|
8
|
+
}): string | undefined;
|
|
9
|
+
export declare function countReportedChecks(checks: {
|
|
10
|
+
passing: readonly unknown[];
|
|
11
|
+
failing: readonly unknown[];
|
|
12
|
+
inProgress: readonly unknown[];
|
|
13
|
+
skipped: readonly unknown[];
|
|
14
|
+
filtered: readonly unknown[];
|
|
15
|
+
ignored?: readonly unknown[];
|
|
16
|
+
}): number;
|
|
17
|
+
/** Place the missing-CI note with the conflict step, ahead of later repair lines. */
|
|
18
|
+
export declare function insertConflictingHeadCiNote(instructions: string[], note: string | undefined): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** How long a conflicting head may sit with no CI before Shepherd says none will start. */
|
|
2
|
+
const CONFLICTING_HEAD_CI_GRACE_MS = 2 * 60 * 1000;
|
|
3
|
+
const NO_CI_NOTE = "GitHub did not start pull_request workflows for this conflicting head: no check suites or workflow runs have appeared for at least 2 minutes. Missing CI is a consequence of the conflict, not an outage, and pushing this same head again will not start them.";
|
|
4
|
+
export function conflictingHeadCiNote(input) {
|
|
5
|
+
if (!input.hasConflicts || !input.headCheckSuitesEmpty || input.checkRunCount > 0) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
if (typeof input.firstSeenAtUnix !== "number")
|
|
9
|
+
return undefined;
|
|
10
|
+
const ageMs = input.nowMs - input.firstSeenAtUnix * 1000;
|
|
11
|
+
if (!Number.isFinite(ageMs) || ageMs < CONFLICTING_HEAD_CI_GRACE_MS)
|
|
12
|
+
return undefined;
|
|
13
|
+
return NO_CI_NOTE;
|
|
14
|
+
}
|
|
15
|
+
export function countReportedChecks(checks) {
|
|
16
|
+
return (checks.passing.length +
|
|
17
|
+
checks.failing.length +
|
|
18
|
+
checks.inProgress.length +
|
|
19
|
+
checks.skipped.length +
|
|
20
|
+
checks.filtered.length +
|
|
21
|
+
(checks.ignored?.length ?? 0));
|
|
22
|
+
}
|
|
23
|
+
/** Place the missing-CI note with the conflict step, ahead of later repair lines. */
|
|
24
|
+
export function insertConflictingHeadCiNote(instructions, note) {
|
|
25
|
+
if (!note)
|
|
26
|
+
return;
|
|
27
|
+
const conflictAt = instructions.findIndex((step) => step.includes("merge conflicts"));
|
|
28
|
+
if (conflictAt >= 0)
|
|
29
|
+
instructions.splice(conflictAt + 1, 0, note);
|
|
30
|
+
else
|
|
31
|
+
instructions.unshift(note);
|
|
32
|
+
}
|
|
@@ -8,6 +8,9 @@ import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thre
|
|
|
8
8
|
import { buildFixInstructions } from "./render.mjs";
|
|
9
9
|
import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
|
|
10
10
|
import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
|
|
11
|
+
import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
|
|
12
|
+
import { conflictingHeadCiNote, countReportedChecks, insertConflictingHeadCiNote, } from "./conflicting-head-ci.mjs";
|
|
13
|
+
import { conflictingHeadFirstSeenUnix } from "../../state/conflicting-head-seen.mjs";
|
|
11
14
|
import { applyStallGuard } from "./stall.mjs";
|
|
12
15
|
import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
|
|
13
16
|
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
@@ -282,9 +285,21 @@ export async function handleFixCode(ctx) {
|
|
|
282
285
|
}
|
|
283
286
|
const firstLookThreads = report.threads.firstLook;
|
|
284
287
|
const firstLookComments = report.comments.firstLook;
|
|
288
|
+
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
289
|
+
const headRef = report.headSha;
|
|
290
|
+
// Only an upper layer can be dirty against trunk while already containing its parent.
|
|
291
|
+
const trunkConflict = hasConflicts && stack && headRef && baseLookup.branch !== stack.baseRefName
|
|
292
|
+
? await lookupUpperLayerTrunkConflict({
|
|
293
|
+
owner: repoOwner,
|
|
294
|
+
name: repoName,
|
|
295
|
+
pr: prNumber,
|
|
296
|
+
headRef,
|
|
297
|
+
trunk: stack.baseRefName,
|
|
298
|
+
})
|
|
299
|
+
: undefined;
|
|
285
300
|
// Conflicts, and a behind branch whose rerun already failed, both ask for a branch update.
|
|
286
301
|
const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0)
|
|
287
|
-
? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch },
|
|
302
|
+
? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict)
|
|
288
303
|
: undefined;
|
|
289
304
|
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);
|
|
290
305
|
if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
|
|
@@ -296,9 +311,23 @@ export async function handleFixCode(ctx) {
|
|
|
296
311
|
if (repairInstructions && repairInstructions.length > 0) {
|
|
297
312
|
instructions.unshift(...repairInstructions);
|
|
298
313
|
}
|
|
314
|
+
const checkRunCount = countReportedChecks(report.checks);
|
|
315
|
+
const suitesEmpty = report.headCheckSuitesEmpty === true;
|
|
316
|
+
const nowMs = Date.now();
|
|
317
|
+
const firstSeenAtUnix = hasConflicts && suitesEmpty && checkRunCount === 0 && report.headSha
|
|
318
|
+
? await conflictingHeadFirstSeenUnix({ owner: repoOwner, repo: repoName, pr: prNumber }, report.headSha, nowMs)
|
|
319
|
+
: undefined;
|
|
320
|
+
insertConflictingHeadCiNote(instructions, conflictingHeadCiNote({
|
|
321
|
+
hasConflicts,
|
|
322
|
+
headCheckSuitesEmpty: suitesEmpty,
|
|
323
|
+
checkRunCount,
|
|
324
|
+
firstSeenAtUnix,
|
|
325
|
+
nowMs,
|
|
326
|
+
}));
|
|
299
327
|
const prospectiveResult = {
|
|
300
328
|
...base,
|
|
301
329
|
baseBranch: baseLookup.branch,
|
|
330
|
+
...(trunkConflict && { stackTrunkConflict: trunkConflict.trunk }),
|
|
302
331
|
action: "fix_code",
|
|
303
332
|
fix: {
|
|
304
333
|
threads,
|
|
@@ -7,6 +7,8 @@ export type NativeStackRebaseStart = {
|
|
|
7
7
|
parentBranch: string;
|
|
8
8
|
} | {
|
|
9
9
|
bottomPr: number;
|
|
10
|
+
} | {
|
|
11
|
+
trunk: string;
|
|
10
12
|
};
|
|
11
13
|
/**
|
|
12
14
|
* One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
|
|
@@ -22,12 +24,16 @@ export declare function buildNativeStackRebaseInstruction(repo: string, stackNum
|
|
|
22
24
|
* workflow keeps failing); undefined outside a stack.
|
|
23
25
|
* A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
|
|
24
26
|
* position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
|
|
25
|
-
* layer is an upper layer whose parent is its own PR base branch.
|
|
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.
|
|
26
29
|
*/
|
|
27
30
|
export declare function buildNativeStackLayerRebase(repo: string, pr: {
|
|
28
31
|
number: number;
|
|
29
32
|
baseBranch: string;
|
|
30
|
-
}, stack: StackStatus | undefined
|
|
33
|
+
}, stack: StackStatus | undefined, trunkConflict?: {
|
|
34
|
+
trunk: string;
|
|
35
|
+
bottomPr?: number;
|
|
36
|
+
}): string | undefined;
|
|
31
37
|
/** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
|
|
32
38
|
export declare function buildConflictInstruction(stackRebase: string | undefined): string;
|
|
33
39
|
/** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
|
|
@@ -12,7 +12,9 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
|
|
|
12
12
|
`check out the parent stack branch \`${start.parentBranch}\``,
|
|
13
13
|
"gh stack rebase --upstack --no-trunk",
|
|
14
14
|
]
|
|
15
|
-
:
|
|
15
|
+
: "bottomPr" in start
|
|
16
|
+
? [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"]
|
|
17
|
+
: [`check out the bottom open layer whose base is \`${start.trunk}\``, "gh stack rebase"];
|
|
16
18
|
const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\`, then confirm every layer's local branch is at its PR's head commit — a stale local layer would overwrite that PR's newer commits on push`;
|
|
17
19
|
return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`; if it stops on a conflict, resolve it and run \`gh stack rebase --continue\`.`;
|
|
18
20
|
}
|
|
@@ -21,12 +23,20 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
|
|
|
21
23
|
* workflow keeps failing); undefined outside a stack.
|
|
22
24
|
* A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
|
|
23
25
|
* position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
|
|
24
|
-
* layer is an upper layer whose parent is its own PR base branch.
|
|
26
|
+
* layer is an upper layer whose parent is its own PR base branch. `trunkConflict` means that
|
|
27
|
+
* upper layer already contains its parent, so the rebase starts at the bottom open layer.
|
|
25
28
|
*/
|
|
26
|
-
export function buildNativeStackLayerRebase(repo, pr, stack) {
|
|
29
|
+
export function buildNativeStackLayerRebase(repo, pr, stack, trunkConflict) {
|
|
27
30
|
if (!stack)
|
|
28
31
|
return undefined;
|
|
29
|
-
|
|
32
|
+
const start = trunkConflict
|
|
33
|
+
? trunkConflict.bottomPr !== undefined
|
|
34
|
+
? { bottomPr: trunkConflict.bottomPr }
|
|
35
|
+
: { trunk: trunkConflict.trunk }
|
|
36
|
+
: pr.baseBranch === stack.baseRefName
|
|
37
|
+
? { bottomPr: pr.number }
|
|
38
|
+
: { parentBranch: pr.baseBranch };
|
|
39
|
+
return buildNativeStackRebaseInstruction(repo, stack.number, start);
|
|
30
40
|
}
|
|
31
41
|
/** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
|
|
32
42
|
export function buildConflictInstruction(stackRebase) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** An upper layer already contains its parent, so the dirty state is against trunk. */
|
|
2
|
+
export interface UpperLayerTrunkConflict {
|
|
3
|
+
trunk: string;
|
|
4
|
+
/** Open layer whose PR base is the trunk. Omitted when that layer is not on this page. */
|
|
5
|
+
bottomPr?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function lookupUpperLayerTrunkConflict(input: {
|
|
8
|
+
owner: string;
|
|
9
|
+
name: string;
|
|
10
|
+
pr: number;
|
|
11
|
+
headRef: string;
|
|
12
|
+
trunk: string;
|
|
13
|
+
}): Promise<UpperLayerTrunkConflict | undefined>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { graphql } from "../../github/client.mjs";
|
|
2
|
+
import { UPPER_LAYER_CONFLICT_TARGET_QUERY } from "../../github/queries.mjs";
|
|
3
|
+
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
|
+
export async function lookupUpperLayerTrunkConflict(input) {
|
|
16
|
+
let data;
|
|
17
|
+
try {
|
|
18
|
+
({ data } = await graphql(UPPER_LAYER_CONFLICT_TARGET_QUERY, {
|
|
19
|
+
owner: input.owner,
|
|
20
|
+
name: input.name,
|
|
21
|
+
number: input.pr,
|
|
22
|
+
headRef: input.headRef,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (pollRateLimitRetryAfterMs(err) !== null)
|
|
27
|
+
throw err;
|
|
28
|
+
ignore(input.pr, err instanceof Error ? err.message : String(err));
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const pull = data.repository?.pullRequest;
|
|
32
|
+
const behindBy = pull?.baseRef?.compare?.behindBy;
|
|
33
|
+
if (!pull || behindBy == null) {
|
|
34
|
+
ignore(input.pr, pull ? "base comparison unavailable" : "pull request not found");
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// behindBy > 0: the layer is still behind its parent. Keep the parent rebase.
|
|
38
|
+
if (behindBy !== 0)
|
|
39
|
+
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
|
+
return { trunk: input.trunk, ...(bottomPr !== undefined && { bottomPr }) };
|
|
48
|
+
}
|
|
49
|
+
function ignore(pr, message) {
|
|
50
|
+
process.stderr.write(`pr-shepherd: upper-layer conflict target unavailable for PR #${pr} (ignored): ${message}\n`);
|
|
51
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { CheckRun } from "../types.mts";
|
|
2
2
|
import type { RawPr } from "./batch-raw-types.mts";
|
|
3
3
|
export declare function parseCheckSuitesComplete(raw: RawPr): boolean;
|
|
4
|
+
/** True only when the head commit's suite page finished and listed nothing. */
|
|
5
|
+
export declare function parseHeadCheckSuitesEmpty(raw: RawPr): boolean;
|
|
4
6
|
export declare function parseSuiteStartupFailures(raw: RawPr): CheckRun[];
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export function parseCheckSuitesComplete(raw) {
|
|
2
2
|
return raw.commits.nodes[0]?.commit.checkSuites?.pageInfo.hasNextPage === false;
|
|
3
3
|
}
|
|
4
|
+
/** True only when the head commit's suite page finished and listed nothing. */
|
|
5
|
+
export function parseHeadCheckSuitesEmpty(raw) {
|
|
6
|
+
const suites = raw.commits.nodes[0]?.commit.checkSuites;
|
|
7
|
+
return suites?.pageInfo.hasNextPage === false && suites.nodes.length === 0;
|
|
8
|
+
}
|
|
4
9
|
export function parseSuiteStartupFailures(raw) {
|
|
5
10
|
const suites = raw.commits.nodes[0]?.commit.checkSuites;
|
|
6
11
|
if (!suites)
|
package/bin/github/batch.d.mts
CHANGED
|
@@ -7,6 +7,8 @@ interface BatchResult {
|
|
|
7
7
|
rateLimit?: RateLimitInfo;
|
|
8
8
|
/** True when GraphQL returned a complete CheckSuite page; skip REST startup-failure fetch. */
|
|
9
9
|
checkSuitesComplete?: boolean;
|
|
10
|
+
/** True when that complete page listed no check suites on the head commit. */
|
|
11
|
+
headCheckSuitesEmpty?: true;
|
|
10
12
|
}
|
|
11
13
|
interface FetchPrBatchOptions {
|
|
12
14
|
/**
|
package/bin/github/batch.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { graphqlWithRateLimit } from "./client.mjs";
|
|
|
2
2
|
import { hydrateThreadCommentPages } from "./thread-comments.mjs";
|
|
3
3
|
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
4
4
|
import { parseRawPr } from "./batch-parsers.mjs";
|
|
5
|
-
import { parseCheckSuitesComplete, parseSuiteStartupFailures } from "./batch-parse-suites.mjs";
|
|
5
|
+
import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
|
|
6
6
|
import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
|
|
7
7
|
import { paginateBatchConnections } from "./batch-page.mjs";
|
|
8
8
|
import { requireRawPr } from "./batch-response.mjs";
|
|
@@ -28,5 +28,6 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
28
28
|
fingerprint: fingerprintFromRaw(raw, result.data.repository?.viewerPermission ?? null, result.data.viewer?.login ?? null),
|
|
29
29
|
rateLimit: paged.rateLimit ?? result.rateLimit,
|
|
30
30
|
...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
|
|
31
|
+
...(parseHeadCheckSuitesEmpty(raw) && { headCheckSuitesEmpty: true }),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
query UpperLayerConflictTarget($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
repository(owner: $owner, name: $name) {
|
|
11
|
+
pullRequest(number: $number) {
|
|
12
|
+
# compare() is invoked on the PR base. behindBy is commits on that base
|
|
13
|
+
# that the head does not contain — the same direction as REST base...head.
|
|
14
|
+
baseRef {
|
|
15
|
+
compare(headRef: $headRef) {
|
|
16
|
+
behindBy
|
|
17
|
+
}
|
|
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
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -23,6 +23,11 @@ export declare const POLL_SUMMARY_CHECK_PAGE_QUERY: string;
|
|
|
23
23
|
export declare const POLL_STACK_SUMMARY_QUERY: string;
|
|
24
24
|
/** Reads only native-stack membership and the refs that link adjacent entries. */
|
|
25
25
|
export declare const POLL_STACK_TOPOLOGY_QUERY: string;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a conflicting upper stack layer already contains its PR base, plus
|
|
28
|
+
* the open stack entries needed to name the bottom layer. Not part of BatchPr.
|
|
29
|
+
*/
|
|
30
|
+
export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
|
|
26
31
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
27
32
|
export declare const SUGGESTION_THREADS_QUERY: string;
|
|
28
33
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
package/bin/github/queries.mjs
CHANGED
|
@@ -28,6 +28,11 @@ export const POLL_SUMMARY_CHECK_PAGE_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAG
|
|
|
28
28
|
export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
|
|
29
29
|
/** Reads only native-stack membership and the refs that link adjacent entries. */
|
|
30
30
|
export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
|
|
31
|
+
/**
|
|
32
|
+
* Whether a conflicting upper stack layer already contains its PR base, plus
|
|
33
|
+
* the open stack entries needed to name the bottom layer. Not part of BatchPr.
|
|
34
|
+
*/
|
|
35
|
+
export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-target.gql");
|
|
31
36
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
32
37
|
export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
|
|
33
38
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When Shepherd first saw a conflicting head that had no CI.
|
|
3
|
+
*
|
|
4
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/conflicting-head.json`
|
|
5
|
+
* GitHub's Commit object has no push timestamp, so the missing-CI grace
|
|
6
|
+
* starts at this observation instead of `committedDate`.
|
|
7
|
+
*/
|
|
8
|
+
/** Unix seconds of the first observation of `headSha`, or undefined when state cannot be used. */
|
|
9
|
+
export declare function conflictingHeadFirstSeenUnix(key: {
|
|
10
|
+
owner: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
pr: number;
|
|
13
|
+
}, headSha: string, nowMs: number): Promise<number | undefined>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When Shepherd first saw a conflicting head that had no CI.
|
|
3
|
+
*
|
|
4
|
+
* `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/conflicting-head.json`
|
|
5
|
+
* GitHub's Commit object has no push timestamp, so the missing-CI grace
|
|
6
|
+
* starts at this observation instead of `committedDate`.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { resolvePrStatePath } from "./base.mjs";
|
|
11
|
+
/** Unix seconds of the first observation of `headSha`, or undefined when state cannot be used. */
|
|
12
|
+
export async function conflictingHeadFirstSeenUnix(key, headSha, nowMs) {
|
|
13
|
+
const nowUnix = Math.floor(nowMs / 1000);
|
|
14
|
+
try {
|
|
15
|
+
const path = resolvePrStatePath(key, "conflicting-head.json");
|
|
16
|
+
const existing = await readSeen(path);
|
|
17
|
+
if (existing?.headSha === headSha)
|
|
18
|
+
return existing.firstSeenAtUnix;
|
|
19
|
+
await mkdir(dirname(path), { recursive: true });
|
|
20
|
+
const next = { headSha, firstSeenAtUnix: nowUnix };
|
|
21
|
+
await writeFile(path, JSON.stringify(next));
|
|
22
|
+
return nowUnix;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function readSeen(path) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
31
|
+
if (parsed === null || typeof parsed !== "object")
|
|
32
|
+
return undefined;
|
|
33
|
+
const headSha = parsed.headSha;
|
|
34
|
+
const firstSeenAtUnix = parsed.firstSeenAtUnix;
|
|
35
|
+
if (typeof headSha !== "string" || typeof firstSeenAtUnix !== "number")
|
|
36
|
+
return undefined;
|
|
37
|
+
if (!Number.isFinite(firstSeenAtUnix))
|
|
38
|
+
return undefined;
|
|
39
|
+
return { headSha, firstSeenAtUnix };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -29,6 +29,11 @@ export interface IterateResultBase {
|
|
|
29
29
|
summary: IterateResultSummary;
|
|
30
30
|
/** Validated base branch (e.g. "main") for this PR. */
|
|
31
31
|
baseBranch: string;
|
|
32
|
+
/**
|
|
33
|
+
* Set when a native-stack upper layer is conflicting but already contains its
|
|
34
|
+
* PR base, so the dirty state is against the stack trunk rather than that base.
|
|
35
|
+
*/
|
|
36
|
+
stackTrunkConflict?: string;
|
|
32
37
|
/** Null when no classic protection rule exists or the base ref is unavailable. */
|
|
33
38
|
branchProtection: BranchProtection | null;
|
|
34
39
|
mergeRequirements?: MergeRequirements;
|
package/bin/types/report.d.mts
CHANGED
|
@@ -33,6 +33,8 @@ export interface ShepherdReport {
|
|
|
33
33
|
baseBranch: string;
|
|
34
34
|
/** Base commit GitHub recorded for this PR (`PullRequest.baseRefOid`), not the branch's live tip. */
|
|
35
35
|
baseRefOid?: string;
|
|
36
|
+
/** True when the head commit's check-suite page is complete and has no suites. */
|
|
37
|
+
headCheckSuitesEmpty?: true;
|
|
36
38
|
mergeStatus: MergeStatusResult;
|
|
37
39
|
checks: {
|
|
38
40
|
passing: ClassifiedCheck[];
|
package/package.json
CHANGED