pr-shepherd 0.52.0 → 0.52.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/README.md +11 -8
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +4 -3
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -2
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-instructions.mjs +41 -0
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-summary-formatter.mjs +3 -1
- package/bin/cli/runner.d.mts +1 -0
- package/bin/cli/runner.mjs +3 -1
- package/bin/commands/check.mjs +13 -4
- package/bin/commands/clean.mjs +7 -13
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +11 -7
- package/bin/commands/iterate/escalate.mjs +4 -0
- package/bin/commands/iterate/fix-code.mjs +6 -1
- package/bin/commands/iterate/helpers.d.mts +0 -1
- package/bin/commands/iterate/helpers.mjs +0 -15
- package/bin/commands/iterate/index.mjs +57 -27
- package/bin/commands/iterate/merge-state.mjs +6 -4
- package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
- package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
- package/bin/commands/iterate/parent-first.d.mts +18 -2
- package/bin/commands/iterate/parent-first.mjs +45 -32
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +7 -12
- package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
- package/bin/commands/iterate/stale-ancestry.mjs +10 -7
- package/bin/commands/iterate/stall.mjs +40 -3
- package/bin/commands/poll-progress.d.mts +5 -1
- package/bin/commands/poll-progress.mjs +7 -1
- package/bin/commands/poll-summary-instructions.d.mts +6 -1
- package/bin/commands/poll-summary-instructions.mjs +51 -87
- package/bin/commands/poll-summary.mjs +4 -2
- package/bin/commands/poll.mjs +3 -1
- package/bin/commands/ready-delay.d.mts +9 -4
- package/bin/commands/ready-delay.mjs +34 -20
- package/bin/commands/shepherd-journal.mjs +4 -1
- package/bin/commands/stack-drain.d.mts +35 -0
- package/bin/commands/stack-drain.mjs +129 -0
- package/bin/commands/stack-layer-readiness.d.mts +7 -0
- package/bin/commands/stack-layer-readiness.mjs +35 -0
- package/bin/commands/stack-stall.d.mts +14 -0
- package/bin/commands/stack-stall.mjs +69 -0
- package/bin/commands/stack-work.d.mts +32 -0
- package/bin/commands/stack-work.mjs +39 -0
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +10 -0
- package/bin/config.json +1 -0
- package/bin/github/batch-parsers.mjs +1 -1
- package/bin/github/batch-raw-rules.d.mts +0 -3
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/errors.d.mts +5 -0
- package/bin/github/errors.mjs +4 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-stack-summary.gql +8 -2
- package/bin/github/gql/poll-stack-topology.gql +38 -0
- package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
- package/bin/github/gql/poll-summary-check-page.gql +23 -0
- package/bin/github/gql/poll-summary-fragment.gql +5 -62
- package/bin/github/gql/pr-merge-policy.gql +0 -3
- package/bin/github/merge-queue-checks.mjs +10 -1
- package/bin/github/poll-summary-check-hydration.d.mts +12 -0
- package/bin/github/poll-summary-check-hydration.mjs +55 -0
- package/bin/github/poll-summary-fingerprint.mjs +24 -4
- package/bin/github/poll-summary-projector.mjs +8 -7
- package/bin/github/poll-summary-queue-removal.mjs +10 -1
- package/bin/github/poll-summary-raw.d.mts +19 -31
- package/bin/github/poll-summary-route.mjs +8 -5
- package/bin/github/poll-summary.mjs +16 -73
- package/bin/github/queries.d.mts +7 -0
- package/bin/github/queries.mjs +9 -1
- package/bin/github/queue-removal-freshness.d.mts +16 -0
- package/bin/github/queue-removal-freshness.mjs +26 -0
- package/bin/github/stack-read.d.mts +34 -0
- package/bin/github/stack-read.mjs +92 -0
- package/bin/log/log-file.d.mts +1 -1
- package/bin/log/log-file.mjs +4 -17
- package/bin/state/base.d.mts +18 -1
- package/bin/state/base.mjs +65 -13
- package/bin/state/fix-attempts.d.mts +1 -1
- package/bin/state/fix-attempts.mjs +1 -1
- package/bin/state/graphql-quota-warnings.mjs +2 -6
- package/bin/state/iterate-stall.d.mts +7 -15
- package/bin/state/iterate-stall.mjs +6 -64
- package/bin/state/rest-cache.d.mts +1 -1
- package/bin/state/rest-cache.mjs +1 -1
- package/bin/state/stack-stall.d.mts +16 -0
- package/bin/state/stack-stall.mjs +12 -0
- package/bin/state/stall-state-store.d.mts +37 -0
- package/bin/state/stall-state-store.mjs +74 -0
- package/bin/types/escalate.d.mts +1 -1
- package/bin/types/github.d.mts +1 -1
- package/bin/types/iterate.d.mts +2 -1
- package/bin/types/merge-requirements.d.mts +19 -0
- package/bin/types/poll-summary.d.mts +5 -0
- package/bin/types/report.d.mts +1 -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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { clearStackStallState, readStackStallState, writeStackStallState, } from "../state/stack-stall.mjs";
|
|
2
|
+
import { formatDurationApprox } from "./iterate/escalate.mjs";
|
|
3
|
+
import { summaryStatusSignature } from "./poll-summary-signature.mjs";
|
|
4
|
+
import { describeIdleLayers } from "./stack-drain.mjs";
|
|
5
|
+
/**
|
|
6
|
+
* A `--stack` plan that can only wait never reruns its layers' one-PR sessions, so their own
|
|
7
|
+
* stall guards cannot fire. Escalate `stall-timeout` once that idle plan's fingerprint stays
|
|
8
|
+
* unchanged for `stallTimeoutSeconds`; any other plan, or `0`, clears the timer. When the enabled
|
|
9
|
+
* timer cannot be read or written, escalate `stall-state-unavailable` instead of restarting it.
|
|
10
|
+
*/
|
|
11
|
+
export async function applyStackStallGuard(planned, repo, stallTimeoutSeconds) {
|
|
12
|
+
const { result, idle } = planned;
|
|
13
|
+
if (result.selection.kind !== "stack")
|
|
14
|
+
return result;
|
|
15
|
+
const key = { owner: repo.owner, repo: repo.name, stack: result.selection.stackNumber };
|
|
16
|
+
if (!idle || stallTimeoutSeconds <= 0) {
|
|
17
|
+
await clearStackStallState(key);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
const fingerprint = stackStallFingerprint(result);
|
|
21
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
22
|
+
const read = await readStackStallState(key);
|
|
23
|
+
if (!read.ok)
|
|
24
|
+
return stackStateUnavailable(result, read.reason);
|
|
25
|
+
const stored = read.state;
|
|
26
|
+
if (!stored || stored.fingerprint !== fingerprint || stored.firstSeenAt > nowSeconds) {
|
|
27
|
+
const wrote = await writeStackStallState(key, { fingerprint, firstSeenAt: nowSeconds });
|
|
28
|
+
if (!wrote.ok)
|
|
29
|
+
return stackStateUnavailable(result, wrote.reason);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
const ageSeconds = nowSeconds - stored.firstSeenAt;
|
|
33
|
+
if (ageSeconds < stallTimeoutSeconds)
|
|
34
|
+
return result;
|
|
35
|
+
return {
|
|
36
|
+
...result,
|
|
37
|
+
reason: "actionable",
|
|
38
|
+
nextAction: "escalate",
|
|
39
|
+
stackMergeable: false,
|
|
40
|
+
instructions: [
|
|
41
|
+
`1. \`stall-timeout\`: the stack has not changed for ${formatDurationApprox(ageSeconds)} while no one-PR session could advance it: ${describeIdleLayers(idle)}. Stop and ask a human to unblock the waiting layers.`,
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function stackStateUnavailable(result, reason) {
|
|
46
|
+
return {
|
|
47
|
+
...result,
|
|
48
|
+
reason: "actionable",
|
|
49
|
+
nextAction: "escalate",
|
|
50
|
+
stackMergeable: false,
|
|
51
|
+
instructions: [
|
|
52
|
+
`1. \`stall-state-unavailable\`: the stack stall timer could not be saved (${reason}). Stop and ask a human to fix the state directory before resuming.`,
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The status signature plus the per-layer evidence it omits, so a push resets the timer. */
|
|
57
|
+
function stackStallFingerprint(result) {
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
status: summaryStatusSignature(result),
|
|
60
|
+
layers: result.prs.map((item) => ({
|
|
61
|
+
pr: item.pr,
|
|
62
|
+
headRefOid: item.headRefOid,
|
|
63
|
+
isDraft: item.isDraft,
|
|
64
|
+
readyReceipt: item.readyReceipt,
|
|
65
|
+
reasons: item.reasons,
|
|
66
|
+
blockedByPr: item.blockedByPr,
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PollSummaryItem } from "../types.mts";
|
|
2
|
+
type ProbedLayer = PollSummaryItem & {
|
|
3
|
+
pollCommand: string;
|
|
4
|
+
pollProbe: true;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
|
|
8
|
+
* probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
|
|
9
|
+
*/
|
|
10
|
+
export declare const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
|
|
11
|
+
/** How the stack selector routes the autonomous layers that still need work. */
|
|
12
|
+
export interface StackWork {
|
|
13
|
+
/** Layers that need a one-PR Shepherd session, with or without a known command. */
|
|
14
|
+
sessions: PollSummaryItem[];
|
|
15
|
+
/** Clean, unblocked drafts that the agent marks ready because the poll loop will not. */
|
|
16
|
+
markReady: ProbedLayer[];
|
|
17
|
+
/** Layers whose bounded probe could only report that they are still waiting. */
|
|
18
|
+
idle: ProbedLayer[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
|
|
22
|
+
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A clean draft
|
|
23
|
+
* that no lower layer blocks becomes an agent ready-for-review step instead. A stale-ancestry
|
|
24
|
+
* child keeps its session, which returns the ancestry repair.
|
|
25
|
+
*/
|
|
26
|
+
export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
|
|
27
|
+
/**
|
|
28
|
+
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
29
|
+
* first because only the one-PR session reads the full review context and current lower layers.
|
|
30
|
+
*/
|
|
31
|
+
export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
|
|
3
|
+
* probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
|
|
4
|
+
*/
|
|
5
|
+
export const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
|
|
6
|
+
/**
|
|
7
|
+
* Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
|
|
8
|
+
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A clean draft
|
|
9
|
+
* that no lower layer blocks becomes an agent ready-for-review step instead. A stale-ancestry
|
|
10
|
+
* child keeps its session, which returns the ancestry repair.
|
|
11
|
+
*/
|
|
12
|
+
export function splitStackWork(candidates, staleChildren) {
|
|
13
|
+
const work = { sessions: [], markReady: [], idle: [] };
|
|
14
|
+
for (const item of candidates) {
|
|
15
|
+
if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
|
|
16
|
+
work.sessions.push(item);
|
|
17
|
+
}
|
|
18
|
+
else if (item.blockedByPr === undefined &&
|
|
19
|
+
item.reasons.includes("draft-auto-mark-ready-disabled")) {
|
|
20
|
+
work.markReady.push(item);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
work.idle.push(item);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return work;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
30
|
+
* first because only the one-PR session reads the full review context and current lower layers.
|
|
31
|
+
*/
|
|
32
|
+
export function appendMarkReadyInstructions(instructions, layers) {
|
|
33
|
+
for (const item of layers) {
|
|
34
|
+
instructions.push(`${instructions.length + 1}. Automatic mark-ready is disabled, so marking PR #${item.pr} ready for review is your step. Run \`${item.pollCommand}\` first. If it returns \`[WAIT]\` saying PR #${item.pr} stays in draft because ${AUTO_MARK_READY_DISABLED_HOLD}, run \`gh pr ready ${item.pr} -R ${item.repo}\`; otherwise complete its instructions and leave PR #${item.pr} in draft this round.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isProbed(item) {
|
|
38
|
+
return item.pollProbe === true && item.pollCommand !== undefined;
|
|
39
|
+
}
|
package/bin/config/load.d.mts
CHANGED
|
@@ -15,6 +15,8 @@ interface PollConfig {
|
|
|
15
15
|
export interface PrShepherdConfig {
|
|
16
16
|
/** Optional user classification configuration; preserved for rule consumers. */
|
|
17
17
|
classify?: unknown;
|
|
18
|
+
/** Argv prefix for every pr-shepherd command Shepherd emits, e.g. `["pnpm", "exec", "pr-shepherd"]`. */
|
|
19
|
+
cliCommand: string[];
|
|
18
20
|
/** GitHub logins that should be treated as bots even when GitHub reports User/Unknown. */
|
|
19
21
|
botUsernames: string[];
|
|
20
22
|
/** Case-insensitive glob patterns for check/status context names Shepherd should ignore. */
|
package/bin/config/load.mjs
CHANGED
|
@@ -76,6 +76,14 @@ function parseResolveOtherHumanThreads(value) {
|
|
|
76
76
|
}
|
|
77
77
|
throw new Error(`Invalid config: iterate.resolveOtherHumanThreads must be one of "none", "outdated", or "always", got ${JSON.stringify(value)}`);
|
|
78
78
|
}
|
|
79
|
+
function parseCliCommand(value) {
|
|
80
|
+
if (!Array.isArray(value) ||
|
|
81
|
+
value.length === 0 ||
|
|
82
|
+
!value.every((arg) => typeof arg === "string" && arg.trim() !== "")) {
|
|
83
|
+
throw new Error("Invalid config: cliCommand must be a non-empty array of non-empty strings");
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
79
87
|
function parseBotUsernames(value) {
|
|
80
88
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
81
89
|
throw new Error(`Invalid config: botUsernames must be an array of strings`);
|
|
@@ -223,6 +231,7 @@ function parseGraphqlQuotaWarnings(value, pollIntervalSeconds) {
|
|
|
223
231
|
}
|
|
224
232
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
225
233
|
"classify",
|
|
234
|
+
"cliCommand",
|
|
226
235
|
"botUsernames",
|
|
227
236
|
"ignoreChecks",
|
|
228
237
|
"iterate",
|
|
@@ -284,6 +293,7 @@ function warnUnknownConfigKeys(config) {
|
|
|
284
293
|
const rawDefaults = builtins;
|
|
285
294
|
function parseConfig(value, normalizeMergeArgs = true) {
|
|
286
295
|
const config = value;
|
|
296
|
+
config.cliCommand = parseCliCommand(config.cliCommand);
|
|
287
297
|
config.botUsernames = parseBotUsernames(config.botUsernames);
|
|
288
298
|
config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
|
|
289
299
|
config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
|
package/bin/config.json
CHANGED
|
@@ -125,7 +125,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
125
125
|
null,
|
|
126
126
|
},
|
|
127
127
|
baseRefName: raw.baseRefName,
|
|
128
|
-
...(raw.
|
|
128
|
+
...(raw.baseRefOid && { baseRefOid: raw.baseRefOid }),
|
|
129
129
|
reviewRequests,
|
|
130
130
|
latestReviews,
|
|
131
131
|
reviewThreads,
|
|
@@ -30,6 +30,8 @@ export interface RawPr extends RawPrMergeFields {
|
|
|
30
30
|
viewerPermission: string | null;
|
|
31
31
|
} | null;
|
|
32
32
|
baseRefName: string;
|
|
33
|
+
/** `PullRequest.baseRefOid`: the base commit GitHub recorded for this PR, not the branch's live tip. */
|
|
34
|
+
baseRefOid?: string;
|
|
33
35
|
baseRef: import("./batch-raw-rules.mts").RawBaseRef | null;
|
|
34
36
|
reviewRequests: {
|
|
35
37
|
nodes: Array<{
|
package/bin/github/errors.d.mts
CHANGED
|
@@ -10,6 +10,11 @@ export interface GitHubGraphQlError {
|
|
|
10
10
|
}
|
|
11
11
|
/** GitHub GraphQL engine crash: HTTP 200, `data: null`, INTERNAL type/code or message. */
|
|
12
12
|
export declare function isRetryableGraphQlInternal(graphqlErrors?: GitHubGraphQlError[]): boolean;
|
|
13
|
+
/** HTTP 200 GraphQL response whose `repository` field resolved to null. */
|
|
14
|
+
export declare function missingRepositoryError(repo: {
|
|
15
|
+
owner: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}): GitHubRequestError;
|
|
13
18
|
export declare class GitHubRequestError extends ShepherdError {
|
|
14
19
|
readonly status: number;
|
|
15
20
|
readonly rateLimit?: RateLimitInfo;
|
package/bin/github/errors.mjs
CHANGED
|
@@ -47,6 +47,10 @@ function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
|
|
|
47
47
|
return EXIT.NOPERM;
|
|
48
48
|
return EXIT.UNAVAILABLE;
|
|
49
49
|
}
|
|
50
|
+
/** HTTP 200 GraphQL response whose `repository` field resolved to null. */
|
|
51
|
+
export function missingRepositoryError(repo) {
|
|
52
|
+
return new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
53
|
+
}
|
|
50
54
|
export class GitHubRequestError extends ShepherdError {
|
|
51
55
|
status;
|
|
52
56
|
rateLimit;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
query PollStackSummary(
|
|
1
|
+
query PollStackSummary(
|
|
2
|
+
$owner: String!
|
|
3
|
+
$repo: String!
|
|
4
|
+
$anchor: Int!
|
|
5
|
+
$first: Int!
|
|
6
|
+
$after: String
|
|
7
|
+
) {
|
|
2
8
|
_shepherdRateLimit: rateLimit {
|
|
3
9
|
cost
|
|
4
10
|
limit
|
|
@@ -15,7 +21,7 @@ query PollStackSummary($owner: String!, $repo: String!, $anchor: Int!, $after: S
|
|
|
15
21
|
number
|
|
16
22
|
size
|
|
17
23
|
baseRefName
|
|
18
|
-
entries(first:
|
|
24
|
+
entries(first: $first, after: $after) {
|
|
19
25
|
pageInfo {
|
|
20
26
|
hasNextPage
|
|
21
27
|
endCursor
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
query PollStackTopology($owner: String!, $repo: String!, $anchor: Int!, $after: String) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
repository(owner: $owner, name: $repo) {
|
|
11
|
+
viewerCanAdminister
|
|
12
|
+
pullRequest(number: $anchor) {
|
|
13
|
+
stack {
|
|
14
|
+
id
|
|
15
|
+
number
|
|
16
|
+
size
|
|
17
|
+
baseRefName
|
|
18
|
+
entries(first: 50, after: $after) {
|
|
19
|
+
pageInfo {
|
|
20
|
+
hasNextPage
|
|
21
|
+
endCursor
|
|
22
|
+
}
|
|
23
|
+
nodes {
|
|
24
|
+
position
|
|
25
|
+
pullRequest {
|
|
26
|
+
number
|
|
27
|
+
state
|
|
28
|
+
headRefName
|
|
29
|
+
headRefOid
|
|
30
|
+
baseRefName
|
|
31
|
+
baseRefOid
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
fragment PollSummaryCheckContexts on StatusCheckRollupContextConnection {
|
|
2
|
+
totalCount
|
|
3
|
+
pageInfo {
|
|
4
|
+
hasPreviousPage
|
|
5
|
+
startCursor
|
|
6
|
+
}
|
|
7
|
+
nodes {
|
|
8
|
+
__typename
|
|
9
|
+
... on CheckRun {
|
|
10
|
+
id
|
|
11
|
+
name
|
|
12
|
+
status
|
|
13
|
+
conclusion
|
|
14
|
+
detailsUrl
|
|
15
|
+
annotations(first: 1) {
|
|
16
|
+
totalCount
|
|
17
|
+
}
|
|
18
|
+
checkSuite {
|
|
19
|
+
workflowRun {
|
|
20
|
+
databaseId
|
|
21
|
+
event
|
|
22
|
+
workflow {
|
|
23
|
+
databaseId
|
|
24
|
+
name
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
... on StatusContext {
|
|
30
|
+
context
|
|
31
|
+
state
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
query PollSummaryCheckPage($owner: String!, $repo: String!, $oid: GitObjectID!, $before: String!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
repository(owner: $owner, name: $repo) {
|
|
11
|
+
object(oid: $oid) {
|
|
12
|
+
__typename
|
|
13
|
+
... on Commit {
|
|
14
|
+
oid
|
|
15
|
+
statusCheckRollup {
|
|
16
|
+
contexts(last: 100, before: $before) {
|
|
17
|
+
...PollSummaryCheckContexts
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -88,39 +88,10 @@ fragment PollSummaryPr on PullRequest {
|
|
|
88
88
|
}
|
|
89
89
|
mergeQueueEntry {
|
|
90
90
|
headCommit {
|
|
91
|
+
oid
|
|
91
92
|
statusCheckRollup {
|
|
92
93
|
contexts(last: 100) {
|
|
93
|
-
|
|
94
|
-
pageInfo {
|
|
95
|
-
hasPreviousPage
|
|
96
|
-
}
|
|
97
|
-
nodes {
|
|
98
|
-
__typename
|
|
99
|
-
... on CheckRun {
|
|
100
|
-
id
|
|
101
|
-
name
|
|
102
|
-
status
|
|
103
|
-
conclusion
|
|
104
|
-
detailsUrl
|
|
105
|
-
annotations(first: 1) {
|
|
106
|
-
totalCount
|
|
107
|
-
}
|
|
108
|
-
checkSuite {
|
|
109
|
-
workflowRun {
|
|
110
|
-
databaseId
|
|
111
|
-
event
|
|
112
|
-
workflow {
|
|
113
|
-
databaseId
|
|
114
|
-
name
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
... on StatusContext {
|
|
120
|
-
context
|
|
121
|
-
state
|
|
122
|
-
}
|
|
123
|
-
}
|
|
94
|
+
...PollSummaryCheckContexts
|
|
124
95
|
}
|
|
125
96
|
}
|
|
126
97
|
}
|
|
@@ -212,39 +183,11 @@ fragment PollSummaryPr on PullRequest {
|
|
|
212
183
|
commits(last: 1) {
|
|
213
184
|
nodes {
|
|
214
185
|
commit {
|
|
186
|
+
oid
|
|
187
|
+
committedDate
|
|
215
188
|
statusCheckRollup {
|
|
216
189
|
contexts(last: 100) {
|
|
217
|
-
|
|
218
|
-
pageInfo {
|
|
219
|
-
hasPreviousPage
|
|
220
|
-
}
|
|
221
|
-
nodes {
|
|
222
|
-
__typename
|
|
223
|
-
... on CheckRun {
|
|
224
|
-
id
|
|
225
|
-
name
|
|
226
|
-
status
|
|
227
|
-
conclusion
|
|
228
|
-
detailsUrl
|
|
229
|
-
annotations(first: 1) {
|
|
230
|
-
totalCount
|
|
231
|
-
}
|
|
232
|
-
checkSuite {
|
|
233
|
-
workflowRun {
|
|
234
|
-
databaseId
|
|
235
|
-
event
|
|
236
|
-
workflow {
|
|
237
|
-
databaseId
|
|
238
|
-
name
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
... on StatusContext {
|
|
244
|
-
context
|
|
245
|
-
state
|
|
246
|
-
}
|
|
247
|
-
}
|
|
190
|
+
...PollSummaryCheckContexts
|
|
248
191
|
}
|
|
249
192
|
}
|
|
250
193
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { parseCreatedAt } from "./batch-parser-helpers.mjs";
|
|
1
2
|
import { graphql } from "./client.mjs";
|
|
3
|
+
import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
|
|
2
4
|
import { requireContextNodes } from "./batch-response.mjs";
|
|
3
5
|
import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
|
|
4
6
|
function omittedCursorError(oid) {
|
|
@@ -60,8 +62,15 @@ function currentRemovalCommit(raw) {
|
|
|
60
62
|
if (addition && Date.parse(removal.createdAt) < Date.parse(addition.createdAt))
|
|
61
63
|
return undefined;
|
|
62
64
|
const parentOids = removal.beforeCommit.parents?.nodes.map((node) => node.oid);
|
|
63
|
-
|
|
65
|
+
const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
|
|
66
|
+
if (!queueRemovalAppliesToHead({
|
|
67
|
+
parentOids,
|
|
68
|
+
headOid: raw.headRefOid,
|
|
69
|
+
...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
|
|
70
|
+
removedAtUnix: Math.floor(Date.parse(removal.createdAt) / 1000),
|
|
71
|
+
})) {
|
|
64
72
|
return undefined;
|
|
73
|
+
}
|
|
65
74
|
return removal.beforeCommit;
|
|
66
75
|
}
|
|
67
76
|
/** Hydrate all status contexts for the active or most recently removed queue commit. */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type RepoInfo } from "./client.mts";
|
|
2
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
/**
|
|
4
|
+
* Complete the compact summary's `last: 100` status-context windows in place.
|
|
5
|
+
*
|
|
6
|
+
* Without this, a PR with more than 100 contexts always reports incomplete
|
|
7
|
+
* checks, so it can never earn or match a READY receipt. Every summary fetch
|
|
8
|
+
* path runs this before anything fingerprints `commits`, so one-PR receipts and
|
|
9
|
+
* aggregate stack reads hash the same evidence. A window that cannot be
|
|
10
|
+
* completed stays incomplete, which already fails readiness closed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hydratePollSummaryChecks(raw: RawSummaryPr, repo: RepoInfo): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
2
|
+
import { POLL_SUMMARY_CHECK_PAGE_QUERY } from "./queries.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* Complete the compact summary's `last: 100` status-context windows in place.
|
|
5
|
+
*
|
|
6
|
+
* Without this, a PR with more than 100 contexts always reports incomplete
|
|
7
|
+
* checks, so it can never earn or match a READY receipt. Every summary fetch
|
|
8
|
+
* path runs this before anything fingerprints `commits`, so one-PR receipts and
|
|
9
|
+
* aggregate stack reads hash the same evidence. A window that cannot be
|
|
10
|
+
* completed stays incomplete, which already fails readiness closed.
|
|
11
|
+
*/
|
|
12
|
+
export async function hydratePollSummaryChecks(raw, repo) {
|
|
13
|
+
const headCommit = raw.commits.nodes[0]?.commit;
|
|
14
|
+
if (headCommit)
|
|
15
|
+
await hydrateCommitContexts(headCommit, repo);
|
|
16
|
+
const queueCommit = raw.mergeQueueEntry?.headCommit;
|
|
17
|
+
if (queueCommit)
|
|
18
|
+
await hydrateCommitContexts(queueCommit, repo);
|
|
19
|
+
}
|
|
20
|
+
async function hydrateCommitContexts(commit, repo) {
|
|
21
|
+
const contexts = commit.statusCheckRollup?.contexts;
|
|
22
|
+
if (!contexts)
|
|
23
|
+
return;
|
|
24
|
+
const nodes = [...contexts.nodes];
|
|
25
|
+
let pageInfo = contexts.pageInfo;
|
|
26
|
+
while (pageInfo.hasPreviousPage && pageInfo.startCursor) {
|
|
27
|
+
const older = await fetchOlderContexts(commit.oid, pageInfo.startCursor, repo);
|
|
28
|
+
if (!older)
|
|
29
|
+
break;
|
|
30
|
+
nodes.unshift(...older.nodes);
|
|
31
|
+
pageInfo = older.pageInfo;
|
|
32
|
+
}
|
|
33
|
+
// A window that shifted between pages can repeat or skip contexts; the
|
|
34
|
+
// first page's total is the snapshot the hydrated list must account for.
|
|
35
|
+
const complete = !pageInfo.hasPreviousPage && nodes.length === contexts.totalCount;
|
|
36
|
+
commit.statusCheckRollup = {
|
|
37
|
+
contexts: {
|
|
38
|
+
totalCount: contexts.totalCount,
|
|
39
|
+
pageInfo: { hasPreviousPage: !complete },
|
|
40
|
+
nodes,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function fetchOlderContexts(oid, before, repo) {
|
|
45
|
+
const { data } = await graphqlWithRateLimit(POLL_SUMMARY_CHECK_PAGE_QUERY, {
|
|
46
|
+
owner: repo.owner,
|
|
47
|
+
repo: repo.name,
|
|
48
|
+
oid,
|
|
49
|
+
before,
|
|
50
|
+
});
|
|
51
|
+
const object = data.repository?.object;
|
|
52
|
+
if (object?.__typename !== "Commit" || object.oid !== oid)
|
|
53
|
+
return null;
|
|
54
|
+
return object.statusCheckRollup?.contexts ?? null;
|
|
55
|
+
}
|
|
@@ -19,10 +19,30 @@ export function fingerprintRawSummaryPr(raw) {
|
|
|
19
19
|
reviewDecision: raw.reviewDecision,
|
|
20
20
|
reviewRequests: raw.reviewRequests,
|
|
21
21
|
latestReviews: raw.latestReviews,
|
|
22
|
-
comments: raw.comments,
|
|
23
|
-
reviews: raw.reviews,
|
|
24
|
-
reviewThreads:
|
|
25
|
-
|
|
22
|
+
comments: hideBodies(raw.comments),
|
|
23
|
+
reviews: hideBodies(raw.reviews),
|
|
24
|
+
reviewThreads: {
|
|
25
|
+
...raw.reviewThreads,
|
|
26
|
+
nodes: raw.reviewThreads.nodes.map((thread) => ({
|
|
27
|
+
...thread,
|
|
28
|
+
rootComments: thread.rootComments && hideBodies(thread.rootComments),
|
|
29
|
+
comments: hideBodies(thread.comments),
|
|
30
|
+
})),
|
|
31
|
+
},
|
|
32
|
+
commits: {
|
|
33
|
+
...raw.commits,
|
|
34
|
+
nodes: raw.commits.nodes.map((node) => ({
|
|
35
|
+
...node,
|
|
36
|
+
commit: { ...node.commit, committedDate: undefined },
|
|
37
|
+
})),
|
|
38
|
+
},
|
|
26
39
|
};
|
|
27
40
|
return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
|
|
28
41
|
}
|
|
42
|
+
/** Hidden bodies are not readiness evidence: bots keep editing hidden notices after a PR settles. */
|
|
43
|
+
function hideBodies(connection) {
|
|
44
|
+
return {
|
|
45
|
+
...connection,
|
|
46
|
+
nodes: connection.nodes.map((node) => (node.isMinimized ? { ...node, body: undefined } : node)),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -19,14 +19,16 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
19
19
|
const removalEvent = currentQueueRemovalEvent(raw);
|
|
20
20
|
let { action, reasons } = routePollSummary(raw, checks, review, opts);
|
|
21
21
|
let remainingSeconds;
|
|
22
|
-
if (raw.isDraft &&
|
|
22
|
+
if (raw.isDraft &&
|
|
23
|
+
blockingReviewerInProgress &&
|
|
24
|
+
(action === "mark_ready" || reasons.includes("draft-auto-mark-ready-disabled"))) {
|
|
23
25
|
action = "wait";
|
|
24
26
|
reasons = ["blocking-reviewer-in-progress"];
|
|
25
27
|
}
|
|
26
28
|
const appearsReady = reasons.includes("appears-ready");
|
|
27
29
|
if (opts.stackPrNumber === undefined) {
|
|
28
30
|
const readyDelaySeconds = opts.readyDelaySeconds ?? (loadConfig().watch?.readyDelayMinutes ?? 10) * 60;
|
|
29
|
-
const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, {
|
|
31
|
+
const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { headSha: raw.headRefOid });
|
|
30
32
|
if (appearsReady && !readyState.shouldCancel) {
|
|
31
33
|
action = "wait";
|
|
32
34
|
reasons = ["ready-delay"];
|
|
@@ -93,9 +95,7 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
93
95
|
...((opts.stackPrNumber !== undefined && raw.state === "OPEN") ||
|
|
94
96
|
(!["wait", "cancel"].includes(action) &&
|
|
95
97
|
!(opts.stackPrNumber !== undefined && raw.stack && action === "merge"))
|
|
96
|
-
?
|
|
97
|
-
pollCommand: buildPollCommand(repoName, raw.number, raw.isDraft, opts),
|
|
98
|
-
}
|
|
98
|
+
? pollCommandFields(repoName, raw.number, raw.isDraft, opts)
|
|
99
99
|
: {}),
|
|
100
100
|
};
|
|
101
101
|
}
|
|
@@ -116,7 +116,7 @@ function detectBlockingReviewer(raw) {
|
|
|
116
116
|
return ((raw.reviewRequests?.nodes ?? []).some((request) => matches(request.requestedReviewer)) ||
|
|
117
117
|
(raw.latestReviews?.nodes ?? []).some((review) => review.state === "PENDING" && matches(review.author)));
|
|
118
118
|
}
|
|
119
|
-
function
|
|
119
|
+
function pollCommandFields(repo, pr, isDraft, opts) {
|
|
120
120
|
const autoMarkReadyDisabled = opts.noAutoMarkReady || loadConfig().actions.autoMarkReady === false;
|
|
121
121
|
const boundedDraft = isDraft && autoMarkReadyDisabled;
|
|
122
122
|
const args = boundedDraft
|
|
@@ -131,5 +131,6 @@ function buildPollCommand(repo, pr, isDraft, opts) {
|
|
|
131
131
|
}
|
|
132
132
|
if (opts.noAutoMarkReady && !boundedDraft)
|
|
133
133
|
args.push("--no-auto-mark-ready");
|
|
134
|
-
|
|
134
|
+
const pollCommand = buildPrShepherdCommand(args).text;
|
|
135
|
+
return boundedDraft ? { pollCommand, pollProbe: true } : { pollCommand };
|
|
135
136
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
|
|
2
|
+
import { parseCreatedAt } from "./batch-parser-helpers.mjs";
|
|
1
3
|
/** Latest queue removal that still applies to this PR head, not an older attempt. */
|
|
2
4
|
export function currentQueueRemovalEvent(raw) {
|
|
3
5
|
const removal = raw.mergeQueueRemovals?.nodes[0];
|
|
@@ -10,7 +12,14 @@ export function currentQueueRemovalEvent(raw) {
|
|
|
10
12
|
if (addition && Date.parse(addition.createdAt) > removalTime)
|
|
11
13
|
return null;
|
|
12
14
|
const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid);
|
|
13
|
-
|
|
15
|
+
const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
|
|
16
|
+
if (!queueRemovalAppliesToHead({
|
|
17
|
+
parentOids: parents,
|
|
18
|
+
headOid: raw.headRefOid,
|
|
19
|
+
...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
|
|
20
|
+
removedAtUnix: Math.floor(removalTime / 1000),
|
|
21
|
+
})) {
|
|
14
22
|
return null;
|
|
23
|
+
}
|
|
15
24
|
return removal;
|
|
16
25
|
}
|