pr-shepherd 0.52.0 → 0.53.0
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 +17 -12
- 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 +22 -0
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-summary-formatter.mjs +4 -3
- 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/api-usage.mjs +2 -2
- 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 +48 -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 +6 -7
- package/bin/commands/iterate/parent-first.mjs +10 -54
- 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-quota.mjs +2 -2
- package/bin/commands/poll-summary-instructions.d.mts +6 -1
- package/bin/commands/poll-summary-instructions.mjs +71 -122
- package/bin/commands/poll-summary.mjs +4 -2
- 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 +138 -0
- package/bin/commands/stack-layer-readiness.d.mts +6 -0
- package/bin/commands/stack-layer-readiness.mjs +34 -0
- package/bin/commands/stack-stall.d.mts +14 -0
- package/bin/commands/stack-stall.mjs +68 -0
- package/bin/commands/stack-work.d.mts +32 -0
- package/bin/commands/stack-work.mjs +38 -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/api-telemetry-aggregate.d.mts +1 -0
- package/bin/github/api-telemetry-aggregate.mjs +8 -2
- package/bin/github/api-telemetry.d.mts +4 -0
- package/bin/github/api-telemetry.mjs +12 -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/graphql-http.mjs +6 -0
- package/bin/github/http-auth.d.mts +3 -0
- package/bin/github/http-auth.mjs +6 -0
- package/bin/github/http-intermediate.d.mts +1 -0
- package/bin/github/http-intermediate.mjs +3 -0
- 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-policy.d.mts +7 -1
- package/bin/state/graphql-quota-policy.mjs +41 -14
- package/bin/state/graphql-quota-warnings.mjs +4 -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/api-usage.d.mts +2 -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 +9 -0
- package/bin/types/poll-summary.d.mts +5 -2
- 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
|
@@ -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
|
}
|
|
@@ -43,8 +43,23 @@ type RawCheckContext = {
|
|
|
43
43
|
context: string;
|
|
44
44
|
state: string;
|
|
45
45
|
};
|
|
46
|
-
interface
|
|
47
|
-
|
|
46
|
+
interface RawCheckContexts {
|
|
47
|
+
totalCount: number;
|
|
48
|
+
/** `startCursor` pages older contexts; hydration drops it so fingerprints never hash a cursor. */
|
|
49
|
+
pageInfo: {
|
|
50
|
+
hasPreviousPage: boolean;
|
|
51
|
+
startCursor?: string | null;
|
|
52
|
+
};
|
|
53
|
+
nodes: RawCheckContext[];
|
|
54
|
+
}
|
|
55
|
+
export interface RawCheckRollup {
|
|
56
|
+
contexts: RawCheckContexts;
|
|
57
|
+
}
|
|
58
|
+
/** A commit whose status contexts the compact summary reads. */
|
|
59
|
+
export interface RawSummaryCommit {
|
|
60
|
+
oid: string;
|
|
61
|
+
committedDate?: string;
|
|
62
|
+
statusCheckRollup: RawCheckRollup | null;
|
|
48
63
|
}
|
|
49
64
|
export interface RawSummaryPr {
|
|
50
65
|
number: number;
|
|
@@ -103,9 +118,7 @@ export interface RawSummaryPr {
|
|
|
103
118
|
}>;
|
|
104
119
|
} | null;
|
|
105
120
|
mergeQueueEntry: {
|
|
106
|
-
headCommit:
|
|
107
|
-
statusCheckRollup: RawCheckRollup | null;
|
|
108
|
-
} | null;
|
|
121
|
+
headCommit: RawSummaryCommit | null;
|
|
109
122
|
} | null;
|
|
110
123
|
stack: {
|
|
111
124
|
number: number;
|
|
@@ -131,9 +144,7 @@ export interface RawSummaryPr {
|
|
|
131
144
|
}>;
|
|
132
145
|
commits: {
|
|
133
146
|
nodes: Array<{
|
|
134
|
-
commit:
|
|
135
|
-
statusCheckRollup: RawCheckRollup | null;
|
|
136
|
-
};
|
|
147
|
+
commit: RawSummaryCommit;
|
|
137
148
|
}>;
|
|
138
149
|
};
|
|
139
150
|
}
|
|
@@ -142,27 +153,4 @@ export interface RawExplicitResponse {
|
|
|
142
153
|
viewerCanAdminister: boolean;
|
|
143
154
|
} & Record<string, RawSummaryPr | boolean | null>) | null;
|
|
144
155
|
}
|
|
145
|
-
export interface RawStackResponse {
|
|
146
|
-
repository: {
|
|
147
|
-
viewerCanAdminister: boolean;
|
|
148
|
-
pullRequest: {
|
|
149
|
-
stack: {
|
|
150
|
-
id: string;
|
|
151
|
-
number: number;
|
|
152
|
-
size: number;
|
|
153
|
-
baseRefName: string;
|
|
154
|
-
entries: {
|
|
155
|
-
pageInfo: {
|
|
156
|
-
hasNextPage: boolean;
|
|
157
|
-
endCursor: string | null;
|
|
158
|
-
};
|
|
159
|
-
nodes: Array<{
|
|
160
|
-
position: number;
|
|
161
|
-
pullRequest: RawSummaryPr | null;
|
|
162
|
-
}>;
|
|
163
|
-
};
|
|
164
|
-
} | null;
|
|
165
|
-
} | null;
|
|
166
|
-
} | null;
|
|
167
|
-
}
|
|
168
156
|
export {};
|
|
@@ -25,12 +25,15 @@ export function routePollSummary(raw, checks, review, opts) {
|
|
|
25
25
|
return { action: "wait", reasons: ["pending-or-unknown"] };
|
|
26
26
|
}
|
|
27
27
|
if (raw.isDraft) {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const autoMarkReadyDisabled = opts.noAutoMarkReady || actions.autoMarkReady === false;
|
|
29
|
+
// The stack selector asks the agent to mark a disabled draft ready, so
|
|
30
|
+
// that transition needs the same capability as the automatic one.
|
|
31
|
+
if (!raw.viewerCanUpdate && (!autoMarkReadyDisabled || opts.stackPrNumber !== undefined)) {
|
|
32
|
+
return { action: "escalate", reasons: ["mark-ready-authorization-required"] };
|
|
30
33
|
}
|
|
31
|
-
return
|
|
32
|
-
? { action: "
|
|
33
|
-
: { action: "
|
|
34
|
+
return autoMarkReadyDisabled
|
|
35
|
+
? { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] }
|
|
36
|
+
: { action: "mark_ready", reasons: ["draft-appears-ready"] };
|
|
34
37
|
}
|
|
35
38
|
if (opts.merge && raw.isInMergeQueue) {
|
|
36
39
|
return { action: "wait", reasons: ["already-in-merge-queue"] };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
2
|
import { graphqlWithRateLimit } from "./client.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { missingRepositoryError } from "./errors.mjs";
|
|
4
|
+
import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
|
|
4
5
|
import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
|
|
5
6
|
import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
|
|
7
|
+
import { MAX_STACK_ENTRIES_PER_PAGE, readStack, readStackTopology, stackAncestryGaps, } from "./stack-read.mjs";
|
|
6
8
|
const MAX_EXPLICIT_PRS_PER_QUERY = 50;
|
|
7
9
|
export async function fetchPollSummary(opts, repo) {
|
|
8
10
|
if (opts.stackPrNumber !== undefined)
|
|
@@ -42,93 +44,34 @@ async function fetchExplicitChunk(prs, repo) {
|
|
|
42
44
|
...variables,
|
|
43
45
|
});
|
|
44
46
|
if (!result.data.repository)
|
|
45
|
-
throw
|
|
47
|
+
throw missingRepositoryError(repo);
|
|
46
48
|
const rawPrs = prs.map((pr, index) => {
|
|
47
49
|
const raw = result.data.repository[`pr${index}`];
|
|
48
50
|
if (!raw)
|
|
49
51
|
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
50
52
|
return raw;
|
|
51
53
|
});
|
|
54
|
+
for (const raw of rawPrs)
|
|
55
|
+
await hydratePollSummaryChecks(raw, repo);
|
|
52
56
|
return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
|
|
53
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the stack's topology first so the hydrated page requests only as many
|
|
60
|
+
* entries as the stack holds: GitHub prices `first`, not the nodes returned.
|
|
61
|
+
*/
|
|
54
62
|
async function fetchStackSummary(opts, repo) {
|
|
55
63
|
const anchor = opts.stackPrNumber;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const entries = [];
|
|
62
|
-
do {
|
|
63
|
-
const response = (await graphqlWithRateLimit(POLL_STACK_SUMMARY_QUERY, {
|
|
64
|
-
owner: repo.owner,
|
|
65
|
-
repo: repo.name,
|
|
66
|
-
anchor,
|
|
67
|
-
after,
|
|
68
|
-
}));
|
|
69
|
-
const repository = response.data.repository;
|
|
70
|
-
if (!repository)
|
|
71
|
-
throw missingRepository(repo);
|
|
72
|
-
viewerCanAdminister = repository.viewerCanAdminister;
|
|
73
|
-
if (!repository.pullRequest) {
|
|
74
|
-
throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
|
|
75
|
-
}
|
|
76
|
-
const stack = repository.pullRequest.stack;
|
|
77
|
-
if (!stack) {
|
|
78
|
-
throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
|
|
79
|
-
}
|
|
80
|
-
if (stackId !== null && stack.id !== stackId) {
|
|
81
|
-
throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
|
|
82
|
-
}
|
|
83
|
-
stackId = stack.id;
|
|
84
|
-
stackNumber = stack.number;
|
|
85
|
-
stackSize = stack.size;
|
|
86
|
-
for (const entry of stack.entries.nodes) {
|
|
87
|
-
if (!entry.pullRequest) {
|
|
88
|
-
throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
|
|
89
|
-
}
|
|
90
|
-
entries.push({ position: entry.position, pullRequest: entry.pullRequest });
|
|
91
|
-
}
|
|
92
|
-
const pageInfo = stack.entries.pageInfo;
|
|
93
|
-
if (pageInfo.hasNextPage && !pageInfo.endCursor) {
|
|
94
|
-
throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
|
|
95
|
-
}
|
|
96
|
-
after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
|
|
97
|
-
} while (after !== null);
|
|
98
|
-
const unique = new Map();
|
|
99
|
-
for (const entry of entries)
|
|
100
|
-
unique.set(entry.pullRequest.number, entry);
|
|
101
|
-
if (!unique.has(anchor) || unique.size !== stackSize) {
|
|
102
|
-
throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
|
|
103
|
-
}
|
|
104
|
-
const ordered = [...unique.values()].sort((left, right) => left.position - right.position);
|
|
105
|
-
const stackAncestry = [];
|
|
106
|
-
for (let index = 1; index < ordered.length; index++) {
|
|
107
|
-
const parent = ordered[index - 1].pullRequest;
|
|
108
|
-
const child = ordered[index].pullRequest;
|
|
109
|
-
if (parent.state !== "OPEN" || child.state !== "OPEN")
|
|
110
|
-
continue;
|
|
111
|
-
if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
stackAncestry.push({
|
|
115
|
-
parentPr: parent.number,
|
|
116
|
-
parentHeadRefName: parent.headRefName,
|
|
117
|
-
parentHeadRefOid: parent.headRefOid,
|
|
118
|
-
childPr: child.number,
|
|
119
|
-
childBaseRefName: child.baseRefName,
|
|
120
|
-
childBaseRefOid: child.baseRefOid,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
64
|
+
const topology = await readStackTopology(anchor, repo);
|
|
65
|
+
const { stackNumber, stackSize, viewerCanAdminister, ordered } = await readStack(POLL_STACK_SUMMARY_QUERY, anchor, repo, { first: Math.min(topology.stackSize, MAX_STACK_ENTRIES_PER_PAGE) });
|
|
66
|
+
for (const pr of ordered)
|
|
67
|
+
await hydratePollSummaryChecks(pr, repo);
|
|
68
|
+
const stackAncestry = stackAncestryGaps(ordered);
|
|
123
69
|
return {
|
|
124
70
|
selection: { kind: "stack", anchor, stackNumber, stackSize },
|
|
125
|
-
prs: await Promise.all(ordered.map((
|
|
71
|
+
prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
|
|
126
72
|
...(stackAncestry.length > 0 && { stackAncestry }),
|
|
127
73
|
};
|
|
128
74
|
}
|
|
129
75
|
function deduplicate(values) {
|
|
130
76
|
return [...new Set(values)];
|
|
131
77
|
}
|
|
132
|
-
function missingRepository(repo) {
|
|
133
|
-
return new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
134
|
-
}
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -14,8 +14,15 @@ export declare const BATCH_PR_PAGE_QUERY: string;
|
|
|
14
14
|
export declare const PR_FINGERPRINT_QUERY: string;
|
|
15
15
|
/** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
|
|
16
16
|
export declare const POLL_SUMMARY_FRAGMENT: string;
|
|
17
|
+
/**
|
|
18
|
+
* Pages older status contexts for one commit with the same node selection as
|
|
19
|
+
* the compact summary, so hydrated and first-page nodes fingerprint alike.
|
|
20
|
+
*/
|
|
21
|
+
export declare const POLL_SUMMARY_CHECK_PAGE_QUERY: string;
|
|
17
22
|
/** Discovers and summarizes every entry in one native GitHub pull-request stack. */
|
|
18
23
|
export declare const POLL_STACK_SUMMARY_QUERY: string;
|
|
24
|
+
/** Reads only native-stack membership and the refs that link adjacent entries. */
|
|
25
|
+
export declare const POLL_STACK_TOPOLOGY_QUERY: string;
|
|
19
26
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
20
27
|
export declare const SUGGESTION_THREADS_QUERY: string;
|
|
21
28
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
package/bin/github/queries.mjs
CHANGED
|
@@ -16,10 +16,18 @@ export const BATCH_PR_QUERY = withSharedFragments(gql("batch-pr.gql"));
|
|
|
16
16
|
export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
|
|
17
17
|
/** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
|
|
18
18
|
export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"));
|
|
19
|
+
const POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT = gql("poll-summary-check-contexts.gql");
|
|
19
20
|
/** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
|
|
20
|
-
export const POLL_SUMMARY_FRAGMENT = gql("poll-summary-fragment.gql")
|
|
21
|
+
export const POLL_SUMMARY_FRAGMENT = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
|
|
22
|
+
/**
|
|
23
|
+
* Pages older status contexts for one commit with the same node selection as
|
|
24
|
+
* the compact summary, so hydrated and first-page nodes fingerprint alike.
|
|
25
|
+
*/
|
|
26
|
+
export const POLL_SUMMARY_CHECK_PAGE_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-check-page.gql")}`;
|
|
21
27
|
/** Discovers and summarizes every entry in one native GitHub pull-request stack. */
|
|
22
28
|
export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
|
|
29
|
+
/** Reads only native-stack membership and the refs that link adjacent entries. */
|
|
30
|
+
export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
|
|
23
31
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
24
32
|
export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
|
|
25
33
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a removed merge-queue commit still belongs to the current PR head.
|
|
3
|
+
*
|
|
4
|
+
* A merge-commit queue lists that head as a parent. Squash and rebase queues
|
|
5
|
+
* build a one-parent commit on the base (or on the previous group commit), so
|
|
6
|
+
* the PR head is not an ancestor. Missing parents are unverifiable: GitHub
|
|
7
|
+
* keeps returning the latest removal after the synthetic commit is gone.
|
|
8
|
+
* A one-parent removal is stale when the current head's committer time is
|
|
9
|
+
* later than the removal, which is a push after the ejection.
|
|
10
|
+
*/
|
|
11
|
+
export declare function queueRemovalAppliesToHead(input: {
|
|
12
|
+
parentOids: readonly string[] | null | undefined;
|
|
13
|
+
headOid: string;
|
|
14
|
+
headCommittedAtUnix?: number;
|
|
15
|
+
removedAtUnix?: number;
|
|
16
|
+
}): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a removed merge-queue commit still belongs to the current PR head.
|
|
3
|
+
*
|
|
4
|
+
* A merge-commit queue lists that head as a parent. Squash and rebase queues
|
|
5
|
+
* build a one-parent commit on the base (or on the previous group commit), so
|
|
6
|
+
* the PR head is not an ancestor. Missing parents are unverifiable: GitHub
|
|
7
|
+
* keeps returning the latest removal after the synthetic commit is gone.
|
|
8
|
+
* A one-parent removal is stale when the current head's committer time is
|
|
9
|
+
* later than the removal, which is a push after the ejection.
|
|
10
|
+
*/
|
|
11
|
+
export function queueRemovalAppliesToHead(input) {
|
|
12
|
+
const parents = input.parentOids ?? [];
|
|
13
|
+
if (parents.length === 0)
|
|
14
|
+
return false;
|
|
15
|
+
if (parents.includes(input.headOid))
|
|
16
|
+
return true;
|
|
17
|
+
if (parents.length > 1)
|
|
18
|
+
return false;
|
|
19
|
+
if (input.headCommittedAtUnix !== undefined &&
|
|
20
|
+
input.removedAtUnix !== undefined &&
|
|
21
|
+
input.removedAtUnix > 0 &&
|
|
22
|
+
input.headCommittedAtUnix > input.removedAtUnix) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PollSummaryStackAncestry } from "../types.mts";
|
|
2
|
+
import { type RepoInfo } from "./client.mts";
|
|
3
|
+
/** GitHub's `first` ceiling for one native-stack entries page. */
|
|
4
|
+
export declare const MAX_STACK_ENTRIES_PER_PAGE = 50;
|
|
5
|
+
/** Fields every native-stack read needs to validate membership and adjacent ancestry. */
|
|
6
|
+
export interface StackMemberRefs {
|
|
7
|
+
number: number;
|
|
8
|
+
state: string;
|
|
9
|
+
headRefName: string;
|
|
10
|
+
headRefOid: string;
|
|
11
|
+
baseRefName: string;
|
|
12
|
+
baseRefOid: string;
|
|
13
|
+
}
|
|
14
|
+
/** One complete, validated native-stack membership read. */
|
|
15
|
+
export interface StackRead<Pr extends StackMemberRefs> {
|
|
16
|
+
stackNumber: number;
|
|
17
|
+
stackSize: number;
|
|
18
|
+
viewerCanAdminister: boolean;
|
|
19
|
+
/** Every member, bottom-to-top, each observed in the same paged read. */
|
|
20
|
+
ordered: Pr[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Page through one native stack and fail closed on anything short of the full
|
|
24
|
+
* membership: a missing repository, PR, or stack; a changed stack identity; a
|
|
25
|
+
* null entry; a page without a cursor; or fewer unique members than `size`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readStack<Pr extends StackMemberRefs>(query: string, anchor: number, repo: RepoInfo, variables?: Record<string, unknown>): Promise<StackRead<Pr>>;
|
|
28
|
+
/** Membership and linking refs only — no per-PR CI or review hydration. */
|
|
29
|
+
export declare function readStackTopology(anchor: number, repo: RepoInfo): Promise<StackRead<StackMemberRefs>>;
|
|
30
|
+
/**
|
|
31
|
+
* Adjacent entries, both open, whose child base no longer matches the parent
|
|
32
|
+
* head. Both sides come from the same read, so each gap was observed together.
|
|
33
|
+
*/
|
|
34
|
+
export declare function stackAncestryGaps(ordered: StackMemberRefs[]): PollSummaryStackAncestry[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
3
|
+
import { missingRepositoryError } from "./errors.mjs";
|
|
4
|
+
import { POLL_STACK_TOPOLOGY_QUERY } from "./queries.mjs";
|
|
5
|
+
/** GitHub's `first` ceiling for one native-stack entries page. */
|
|
6
|
+
export const MAX_STACK_ENTRIES_PER_PAGE = 50;
|
|
7
|
+
/**
|
|
8
|
+
* Page through one native stack and fail closed on anything short of the full
|
|
9
|
+
* membership: a missing repository, PR, or stack; a changed stack identity; a
|
|
10
|
+
* null entry; a page without a cursor; or fewer unique members than `size`.
|
|
11
|
+
*/
|
|
12
|
+
export async function readStack(query, anchor, repo, variables = {}) {
|
|
13
|
+
let after = null;
|
|
14
|
+
let stackId = null;
|
|
15
|
+
let stackNumber = 0;
|
|
16
|
+
let stackSize = 0;
|
|
17
|
+
let viewerCanAdminister = false;
|
|
18
|
+
const entries = [];
|
|
19
|
+
do {
|
|
20
|
+
const response = await graphqlWithRateLimit(query, { owner: repo.owner, repo: repo.name, anchor, after, ...variables });
|
|
21
|
+
const repository = response.data.repository;
|
|
22
|
+
if (!repository)
|
|
23
|
+
throw missingRepositoryError(repo);
|
|
24
|
+
viewerCanAdminister = repository.viewerCanAdminister;
|
|
25
|
+
if (!repository.pullRequest) {
|
|
26
|
+
throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
|
|
27
|
+
}
|
|
28
|
+
const stack = repository.pullRequest.stack;
|
|
29
|
+
if (!stack) {
|
|
30
|
+
throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
|
|
31
|
+
}
|
|
32
|
+
if (stackId !== null && stack.id !== stackId) {
|
|
33
|
+
throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
|
|
34
|
+
}
|
|
35
|
+
stackId = stack.id;
|
|
36
|
+
stackNumber = stack.number;
|
|
37
|
+
stackSize = stack.size;
|
|
38
|
+
for (const entry of stack.entries.nodes) {
|
|
39
|
+
if (!entry.pullRequest) {
|
|
40
|
+
throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
|
|
41
|
+
}
|
|
42
|
+
entries.push({ position: entry.position, pullRequest: entry.pullRequest });
|
|
43
|
+
}
|
|
44
|
+
const pageInfo = stack.entries.pageInfo;
|
|
45
|
+
if (pageInfo.hasNextPage && !pageInfo.endCursor) {
|
|
46
|
+
throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
|
|
47
|
+
}
|
|
48
|
+
if (pageInfo.hasNextPage && pageInfo.endCursor === after) {
|
|
49
|
+
throw new ShepherdError("GitHub stack pagination returned a repeated end cursor", EXIT.TEMPFAIL);
|
|
50
|
+
}
|
|
51
|
+
after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
|
|
52
|
+
} while (after !== null);
|
|
53
|
+
const unique = new Map();
|
|
54
|
+
for (const entry of entries)
|
|
55
|
+
unique.set(entry.pullRequest.number, entry);
|
|
56
|
+
if (!unique.has(anchor) || unique.size !== stackSize) {
|
|
57
|
+
throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
|
|
58
|
+
}
|
|
59
|
+
const ordered = [...unique.values()]
|
|
60
|
+
.sort((left, right) => left.position - right.position)
|
|
61
|
+
.map((entry) => entry.pullRequest);
|
|
62
|
+
return { stackNumber, stackSize, viewerCanAdminister, ordered };
|
|
63
|
+
}
|
|
64
|
+
/** Membership and linking refs only — no per-PR CI or review hydration. */
|
|
65
|
+
export function readStackTopology(anchor, repo) {
|
|
66
|
+
return readStack(POLL_STACK_TOPOLOGY_QUERY, anchor, repo);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Adjacent entries, both open, whose child base no longer matches the parent
|
|
70
|
+
* head. Both sides come from the same read, so each gap was observed together.
|
|
71
|
+
*/
|
|
72
|
+
export function stackAncestryGaps(ordered) {
|
|
73
|
+
const gaps = [];
|
|
74
|
+
for (let index = 1; index < ordered.length; index++) {
|
|
75
|
+
const parent = ordered[index - 1];
|
|
76
|
+
const child = ordered[index];
|
|
77
|
+
if (parent.state !== "OPEN" || child.state !== "OPEN")
|
|
78
|
+
continue;
|
|
79
|
+
if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
gaps.push({
|
|
83
|
+
parentPr: parent.number,
|
|
84
|
+
parentHeadRefName: parent.headRefName,
|
|
85
|
+
parentHeadRefOid: parent.headRefOid,
|
|
86
|
+
childPr: child.number,
|
|
87
|
+
childBaseRefName: child.baseRefName,
|
|
88
|
+
childBaseRefOid: child.baseRefOid,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return gaps;
|
|
92
|
+
}
|
package/bin/log/log-file.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Append-only per-worktree markdown log.
|
|
3
3
|
*
|
|
4
|
-
* Log path: $PR_SHEPHERD_STATE_DIR/<owner
|
|
4
|
+
* Log path: $PR_SHEPHERD_STATE_DIR/<owner>/<repo>/worktrees/<basename>-<sha8>.md
|
|
5
5
|
*
|
|
6
6
|
* Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
|
|
7
7
|
* Write failures flip an internal disabled flag so the CLI never crashes because
|