pr-shepherd 0.46.8 → 0.48.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 +2 -0
- package/bin/api.d.mts +1 -1
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- 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 +6 -5
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +5 -4
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/poll-handler.mjs +15 -7
- package/bin/commands/check-fingerprint.d.mts +8 -0
- package/bin/commands/check-fingerprint.mjs +69 -0
- package/bin/commands/check.d.mts +1 -0
- package/bin/commands/check.mjs +23 -3
- package/bin/commands/iterate/api-usage.d.mts +1 -1
- package/bin/commands/iterate/api-usage.mjs +6 -2
- package/bin/commands/iterate/base.mjs +1 -0
- package/bin/commands/iterate/mark-ready.mjs +8 -0
- package/bin/commands/iterate/run.mjs +1 -1
- package/bin/commands/poll-progress.d.mts +11 -0
- package/bin/commands/poll-progress.mjs +52 -0
- package/bin/commands/poll-quota.d.mts +9 -0
- package/bin/commands/poll-quota.mjs +36 -0
- package/bin/commands/poll-run.d.mts +1 -1
- package/bin/commands/poll-run.mjs +2 -2
- package/bin/commands/poll.mjs +66 -66
- package/bin/config/load.d.mts +7 -0
- package/bin/config/load.mjs +81 -20
- package/bin/config.json +9 -3
- package/bin/github/batch-raw-rules.d.mts +4 -1
- package/bin/github/batch-raw-types.d.mts +14 -0
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +2 -0
- package/bin/github/fingerprint-fields.d.mts +58 -0
- package/bin/github/fingerprint-fields.mjs +39 -0
- package/bin/github/fingerprint.d.mts +35 -0
- package/bin/github/fingerprint.mjs +67 -0
- package/bin/github/gql/batch-pr.gql +25 -120
- package/bin/github/gql/commit-check-suites.gql +19 -0
- package/bin/github/gql/pr-fingerprint.gql +75 -0
- package/bin/github/gql/pr-merge-policy.gql +49 -0
- package/bin/github/merge-queue-checks.mjs +57 -30
- package/bin/github/queries.d.mts +2 -0
- package/bin/github/queries.mjs +4 -1
- package/bin/quota-warning.mjs +1 -1
- package/bin/state/pr-fingerprint.d.mts +20 -0
- package/bin/state/pr-fingerprint.mjs +90 -0
- package/bin/types/iterate.d.mts +12 -3
- package/bin/types/report.d.mts +2 -0
- package/package.json +2 -2
- 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,67 @@
|
|
|
1
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
2
|
+
import { PR_FINGERPRINT_QUERY } from "./queries.mjs";
|
|
3
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
4
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
5
|
+
import { commentRevisions, mergePolicyFingerprint, rulesComplete, stackKey, suiteFingerprint, hasMultiCommentThreads, threadCommentRevisions, } from "./fingerprint-fields.mjs";
|
|
6
|
+
function coreFingerprint(raw, counts, viewer) {
|
|
7
|
+
return {
|
|
8
|
+
headRefOid: raw.headRefOid,
|
|
9
|
+
updatedAt: raw.updatedAt ?? "",
|
|
10
|
+
state: raw.state,
|
|
11
|
+
isDraft: raw.isDraft,
|
|
12
|
+
mergeable: raw.mergeable,
|
|
13
|
+
mergeStateStatus: raw.mergeStateStatus,
|
|
14
|
+
reviewDecision: raw.reviewDecision,
|
|
15
|
+
isInMergeQueue: Boolean(raw.isInMergeQueue),
|
|
16
|
+
isMergeQueueEnabled: Boolean(raw.isMergeQueueEnabled),
|
|
17
|
+
mergePolicy: mergePolicyFingerprint(raw),
|
|
18
|
+
commentCount: raw.comments.totalCount ?? raw.comments.nodes.length,
|
|
19
|
+
commentRevisions: commentRevisions(raw.comments.nodes),
|
|
20
|
+
threadCount: raw.reviewThreads.totalCount ?? raw.reviewThreads.nodes.length,
|
|
21
|
+
reviewCount: counts.reviewCount,
|
|
22
|
+
reviewRevisions: counts.reviewRevisions,
|
|
23
|
+
latestCommentId: raw.comments.nodes.at(-1)?.id ?? null,
|
|
24
|
+
latestThreadId: raw.reviewThreads.nodes.at(-1)?.id ?? null,
|
|
25
|
+
latestReviewId: counts.latestReviewId,
|
|
26
|
+
checkRollupState: raw.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null,
|
|
27
|
+
...suiteFingerprint(raw.commits.nodes[0]?.commit.checkSuites),
|
|
28
|
+
viewerCanUpdate: raw.viewerCanUpdate === true,
|
|
29
|
+
viewerPermission: viewer.permission,
|
|
30
|
+
viewerLogin: viewer.login,
|
|
31
|
+
stackKey: stackKey(raw),
|
|
32
|
+
threadCommentRevisions: threadCommentRevisions(raw.reviewThreads.nodes),
|
|
33
|
+
rulesComplete: rulesComplete(raw.baseRef),
|
|
34
|
+
hasMultiCommentThreads: hasMultiCommentThreads(raw.reviewThreads.nodes),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function fingerprintFromRaw(raw, viewerPermission = null, viewerLogin = null) {
|
|
38
|
+
return coreFingerprint(raw, {
|
|
39
|
+
reviewCount: raw.allReviews?.totalCount ?? 0,
|
|
40
|
+
latestReviewId: raw.allReviews?.nodes?.at(-1)?.id ?? null,
|
|
41
|
+
reviewRevisions: commentRevisions(raw.allReviews?.nodes ?? []),
|
|
42
|
+
}, { permission: viewerPermission, login: viewerLogin });
|
|
43
|
+
}
|
|
44
|
+
export function fingerprintsEqual(left, right) {
|
|
45
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
46
|
+
}
|
|
47
|
+
export async function fetchPrFingerprint(pr, repo) {
|
|
48
|
+
const result = await graphqlWithRateLimit(PR_FINGERPRINT_QUERY, {
|
|
49
|
+
owner: repo.owner,
|
|
50
|
+
repo: repo.name,
|
|
51
|
+
pr,
|
|
52
|
+
});
|
|
53
|
+
if (!result.data.repository) {
|
|
54
|
+
throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
55
|
+
}
|
|
56
|
+
const raw = result.data.repository.pullRequest;
|
|
57
|
+
if (!raw)
|
|
58
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
59
|
+
return coreFingerprint(raw, {
|
|
60
|
+
reviewCount: raw.reviews.totalCount,
|
|
61
|
+
latestReviewId: raw.reviews.nodes.at(-1)?.id ?? null,
|
|
62
|
+
reviewRevisions: commentRevisions(raw.reviews.nodes),
|
|
63
|
+
}, {
|
|
64
|
+
permission: result.data.repository.viewerPermission,
|
|
65
|
+
login: result.data.viewer?.login ?? null,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -9,12 +9,16 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
9
9
|
resetAt
|
|
10
10
|
used
|
|
11
11
|
}
|
|
12
|
+
viewer {
|
|
13
|
+
login
|
|
14
|
+
}
|
|
12
15
|
repository(owner: $owner, name: $repo) {
|
|
13
16
|
viewerPermission
|
|
14
17
|
viewerCanAdminister
|
|
15
18
|
pullRequest(number: $pr) {
|
|
16
19
|
id
|
|
17
20
|
number
|
|
21
|
+
updatedAt
|
|
18
22
|
state
|
|
19
23
|
isDraft
|
|
20
24
|
viewerDidAuthor
|
|
@@ -32,7 +36,7 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
32
36
|
}
|
|
33
37
|
baseRefName
|
|
34
38
|
isInMergeQueue
|
|
35
|
-
|
|
39
|
+
...PrMergePolicy
|
|
36
40
|
autoMergeRequest {
|
|
37
41
|
enabledAt
|
|
38
42
|
mergeMethod
|
|
@@ -49,7 +53,8 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
49
53
|
login
|
|
50
54
|
}
|
|
51
55
|
headCommit {
|
|
52
|
-
|
|
56
|
+
oid
|
|
57
|
+
committedDate
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
mergeQueueAdditions: timelineItems(last: 1, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) {
|
|
@@ -68,7 +73,13 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
68
73
|
login
|
|
69
74
|
}
|
|
70
75
|
beforeCommit {
|
|
71
|
-
|
|
76
|
+
oid
|
|
77
|
+
committedDate
|
|
78
|
+
parents(first: 100) {
|
|
79
|
+
nodes {
|
|
80
|
+
oid
|
|
81
|
+
}
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
}
|
|
@@ -81,49 +92,6 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
81
92
|
stackEntry {
|
|
82
93
|
position
|
|
83
94
|
}
|
|
84
|
-
baseRef {
|
|
85
|
-
branchProtectionRule {
|
|
86
|
-
requiresApprovingReviews
|
|
87
|
-
requiredApprovingReviewCount
|
|
88
|
-
requiresConversationResolution
|
|
89
|
-
requiresCodeOwnerReviews
|
|
90
|
-
requireLastPushApproval
|
|
91
|
-
requiresCommitSignatures
|
|
92
|
-
requiresLinearHistory
|
|
93
|
-
requiresStatusChecks
|
|
94
|
-
requiredStatusCheckContexts
|
|
95
|
-
requiresStrictStatusChecks
|
|
96
|
-
requiresDeployments
|
|
97
|
-
requiredDeploymentEnvironments
|
|
98
|
-
}
|
|
99
|
-
rules(first: 100) {
|
|
100
|
-
nodes {
|
|
101
|
-
type
|
|
102
|
-
parameters {
|
|
103
|
-
... on PullRequestParameters {
|
|
104
|
-
requiredApprovingReviewCount
|
|
105
|
-
requiredReviewThreadResolution
|
|
106
|
-
requireCodeOwnerReview
|
|
107
|
-
requireLastPushApproval
|
|
108
|
-
}
|
|
109
|
-
... on RequiredStatusChecksParameters {
|
|
110
|
-
strictRequiredStatusChecksPolicy
|
|
111
|
-
requiredStatusChecks {
|
|
112
|
-
context
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
... on RequiredDeploymentsParameters {
|
|
116
|
-
requiredDeploymentEnvironments
|
|
117
|
-
}
|
|
118
|
-
... on CodeScanningParameters {
|
|
119
|
-
codeScanningTools {
|
|
120
|
-
tool
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
95
|
# Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
|
|
128
96
|
reviewRequests(last: 50) {
|
|
129
97
|
nodes {
|
|
@@ -153,6 +121,7 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
153
121
|
}
|
|
154
122
|
}
|
|
155
123
|
reviewThreads(last: 100) {
|
|
124
|
+
totalCount
|
|
156
125
|
pageInfo {
|
|
157
126
|
hasPreviousPage
|
|
158
127
|
startCursor
|
|
@@ -189,11 +158,13 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
189
158
|
line
|
|
190
159
|
startLine
|
|
191
160
|
createdAt
|
|
161
|
+
updatedAt
|
|
192
162
|
}
|
|
193
163
|
}
|
|
194
164
|
}
|
|
195
165
|
}
|
|
196
166
|
comments(last: 100) {
|
|
167
|
+
totalCount
|
|
197
168
|
pageInfo {
|
|
198
169
|
hasPreviousPage
|
|
199
170
|
startCursor
|
|
@@ -210,6 +181,7 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
210
181
|
}
|
|
211
182
|
body
|
|
212
183
|
createdAt
|
|
184
|
+
updatedAt
|
|
213
185
|
}
|
|
214
186
|
}
|
|
215
187
|
changesRequestedReviews: reviews(states: CHANGES_REQUESTED, last: 50) {
|
|
@@ -249,8 +221,12 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
249
221
|
createdAt
|
|
250
222
|
}
|
|
251
223
|
}
|
|
252
|
-
allReviews: reviews(last:
|
|
224
|
+
allReviews: reviews(last: 100) {
|
|
253
225
|
totalCount
|
|
226
|
+
nodes {
|
|
227
|
+
id
|
|
228
|
+
updatedAt
|
|
229
|
+
}
|
|
254
230
|
}
|
|
255
231
|
approvedReviews: reviews(states: APPROVED, last: 50) {
|
|
256
232
|
pageInfo {
|
|
@@ -276,23 +252,9 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
276
252
|
commit {
|
|
277
253
|
oid
|
|
278
254
|
committedDate
|
|
279
|
-
|
|
280
|
-
pageInfo {
|
|
281
|
-
hasNextPage
|
|
282
|
-
}
|
|
283
|
-
nodes {
|
|
284
|
-
conclusion
|
|
285
|
-
workflowRun {
|
|
286
|
-
databaseId
|
|
287
|
-
event
|
|
288
|
-
url
|
|
289
|
-
workflow {
|
|
290
|
-
name
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
255
|
+
...CommitCheckSuites
|
|
295
256
|
statusCheckRollup {
|
|
257
|
+
state
|
|
296
258
|
contexts(first: 100) {
|
|
297
259
|
pageInfo {
|
|
298
260
|
hasNextPage
|
|
@@ -345,60 +307,3 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
|
|
|
345
307
|
}
|
|
346
308
|
}
|
|
347
309
|
}
|
|
348
|
-
|
|
349
|
-
fragment QueueCheckCommit on Commit {
|
|
350
|
-
oid
|
|
351
|
-
committedDate
|
|
352
|
-
parents(first: 100) {
|
|
353
|
-
nodes {
|
|
354
|
-
oid
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
statusCheckRollup {
|
|
358
|
-
contexts(first: 100) {
|
|
359
|
-
pageInfo {
|
|
360
|
-
hasNextPage
|
|
361
|
-
endCursor
|
|
362
|
-
}
|
|
363
|
-
nodes {
|
|
364
|
-
__typename
|
|
365
|
-
... on CheckRun {
|
|
366
|
-
id
|
|
367
|
-
name
|
|
368
|
-
status
|
|
369
|
-
conclusion
|
|
370
|
-
detailsUrl
|
|
371
|
-
completedAt
|
|
372
|
-
startedAt
|
|
373
|
-
title
|
|
374
|
-
summary
|
|
375
|
-
annotations(first: 1) {
|
|
376
|
-
nodes {
|
|
377
|
-
message
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
checkSuite {
|
|
381
|
-
createdAt
|
|
382
|
-
updatedAt
|
|
383
|
-
workflowRun {
|
|
384
|
-
event
|
|
385
|
-
createdAt
|
|
386
|
-
updatedAt
|
|
387
|
-
workflow {
|
|
388
|
-
name
|
|
389
|
-
databaseId
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
... on StatusContext {
|
|
395
|
-
context
|
|
396
|
-
state
|
|
397
|
-
createdAt
|
|
398
|
-
targetUrl
|
|
399
|
-
description
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Cheap preflight. Cost is 1. Used to skip BatchPr when GitHub state is unchanged.
|
|
2
|
+
query PrFingerprint($owner: String!, $repo: String!, $pr: Int!) {
|
|
3
|
+
_shepherdRateLimit: rateLimit {
|
|
4
|
+
cost
|
|
5
|
+
limit
|
|
6
|
+
nodeCount
|
|
7
|
+
remaining
|
|
8
|
+
resetAt
|
|
9
|
+
used
|
|
10
|
+
}
|
|
11
|
+
viewer {
|
|
12
|
+
login
|
|
13
|
+
}
|
|
14
|
+
repository(owner: $owner, name: $repo) {
|
|
15
|
+
viewerPermission
|
|
16
|
+
pullRequest(number: $pr) {
|
|
17
|
+
updatedAt
|
|
18
|
+
state
|
|
19
|
+
isDraft
|
|
20
|
+
viewerCanUpdate
|
|
21
|
+
headRefOid
|
|
22
|
+
mergeable
|
|
23
|
+
mergeStateStatus
|
|
24
|
+
reviewDecision
|
|
25
|
+
isInMergeQueue
|
|
26
|
+
stack {
|
|
27
|
+
number
|
|
28
|
+
size
|
|
29
|
+
baseRefName
|
|
30
|
+
}
|
|
31
|
+
stackEntry {
|
|
32
|
+
position
|
|
33
|
+
}
|
|
34
|
+
...PrMergePolicy
|
|
35
|
+
comments(last: 100) {
|
|
36
|
+
totalCount
|
|
37
|
+
nodes {
|
|
38
|
+
id
|
|
39
|
+
updatedAt
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
reviewThreads(last: 20) {
|
|
43
|
+
totalCount
|
|
44
|
+
nodes {
|
|
45
|
+
id
|
|
46
|
+
comments(last: 1) {
|
|
47
|
+
totalCount
|
|
48
|
+
nodes {
|
|
49
|
+
id
|
|
50
|
+
updatedAt
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
reviews(last: 100) {
|
|
56
|
+
totalCount
|
|
57
|
+
nodes {
|
|
58
|
+
id
|
|
59
|
+
updatedAt
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
commits(last: 1) {
|
|
63
|
+
nodes {
|
|
64
|
+
commit {
|
|
65
|
+
oid
|
|
66
|
+
statusCheckRollup {
|
|
67
|
+
state
|
|
68
|
+
}
|
|
69
|
+
...CommitCheckSuites
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
fragment PrMergePolicy on PullRequest {
|
|
2
|
+
isMergeQueueEnabled
|
|
3
|
+
baseRef {
|
|
4
|
+
branchProtectionRule {
|
|
5
|
+
requiresApprovingReviews
|
|
6
|
+
requiredApprovingReviewCount
|
|
7
|
+
requiresConversationResolution
|
|
8
|
+
requiresCodeOwnerReviews
|
|
9
|
+
requireLastPushApproval
|
|
10
|
+
requiresCommitSignatures
|
|
11
|
+
requiresLinearHistory
|
|
12
|
+
requiresStatusChecks
|
|
13
|
+
requiredStatusCheckContexts
|
|
14
|
+
requiresStrictStatusChecks
|
|
15
|
+
requiresDeployments
|
|
16
|
+
requiredDeploymentEnvironments
|
|
17
|
+
}
|
|
18
|
+
rules(first: 100) {
|
|
19
|
+
pageInfo {
|
|
20
|
+
hasNextPage
|
|
21
|
+
}
|
|
22
|
+
nodes {
|
|
23
|
+
type
|
|
24
|
+
parameters {
|
|
25
|
+
... on PullRequestParameters {
|
|
26
|
+
requiredApprovingReviewCount
|
|
27
|
+
requiredReviewThreadResolution
|
|
28
|
+
requireCodeOwnerReview
|
|
29
|
+
requireLastPushApproval
|
|
30
|
+
}
|
|
31
|
+
... on RequiredStatusChecksParameters {
|
|
32
|
+
strictRequiredStatusChecksPolicy
|
|
33
|
+
requiredStatusChecks {
|
|
34
|
+
context
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
... on RequiredDeploymentsParameters {
|
|
38
|
+
requiredDeploymentEnvironments
|
|
39
|
+
}
|
|
40
|
+
... on CodeScanningParameters {
|
|
41
|
+
codeScanningTools {
|
|
42
|
+
tool
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,46 +1,73 @@
|
|
|
1
1
|
import { graphql } from "./client.mjs";
|
|
2
2
|
import { requireContextNodes } from "./batch-response.mjs";
|
|
3
3
|
import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
function omittedCursorError(oid) {
|
|
5
|
+
return new Error(`Merge queue check pagination interrupted: GitHub omitted the next cursor for ${oid}. Retry.`);
|
|
6
|
+
}
|
|
7
|
+
function nextPageCursor(contexts, oid) {
|
|
8
|
+
if (!contexts.pageInfo.hasNextPage)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (!contexts.pageInfo.endCursor)
|
|
11
|
+
throw omittedCursorError(oid);
|
|
12
|
+
return contexts.pageInfo.endCursor;
|
|
13
|
+
}
|
|
14
|
+
function initialQueueCursor(existing, oid) {
|
|
15
|
+
if (!existing)
|
|
16
|
+
return null;
|
|
17
|
+
return nextPageCursor(existing, oid);
|
|
18
|
+
}
|
|
19
|
+
async function fetchQueuePage(oid, repo, cursor) {
|
|
20
|
+
const result = await graphql(COMMIT_CHECK_CONTEXTS_QUERY, {
|
|
21
|
+
owner: repo.owner,
|
|
22
|
+
repo: repo.name,
|
|
23
|
+
oid,
|
|
24
|
+
...(cursor !== null && { cursor }),
|
|
25
|
+
});
|
|
26
|
+
const object = result.data.repository?.object;
|
|
27
|
+
if (object?.__typename !== "Commit" || object.oid !== oid) {
|
|
28
|
+
if (cursor === null)
|
|
29
|
+
return null;
|
|
30
|
+
throw new Error(`Merge queue check pagination interrupted: commit ${oid} disappeared or changed. Retry.`);
|
|
11
31
|
}
|
|
12
|
-
|
|
13
|
-
|
|
32
|
+
return object.statusCheckRollup?.contexts ?? null;
|
|
33
|
+
}
|
|
34
|
+
async function hydrateCommitContexts(commit, repo) {
|
|
35
|
+
const existing = commit.statusCheckRollup?.contexts;
|
|
36
|
+
const nodes = existing ? [...requireContextNodes(existing.nodes)] : [];
|
|
37
|
+
let cursor = initialQueueCursor(existing, commit.oid);
|
|
38
|
+
while (cursor !== undefined) {
|
|
14
39
|
// eslint-disable-next-line no-await-in-loop
|
|
15
|
-
const
|
|
16
|
-
owner: repo.owner,
|
|
17
|
-
repo: repo.name,
|
|
18
|
-
oid: commit.oid,
|
|
19
|
-
cursor,
|
|
20
|
-
});
|
|
21
|
-
const object = result.data.repository?.object;
|
|
22
|
-
if (object?.__typename !== "Commit" || object.oid !== commit.oid) {
|
|
23
|
-
throw new Error(`Merge queue check pagination interrupted: commit ${commit.oid} disappeared or changed. Retry.`);
|
|
24
|
-
}
|
|
25
|
-
const next = object.statusCheckRollup?.contexts;
|
|
40
|
+
const next = await fetchQueuePage(commit.oid, repo, cursor);
|
|
26
41
|
if (!next) {
|
|
42
|
+
if (cursor === null) {
|
|
43
|
+
cursor = undefined;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
27
46
|
throw new Error(`Merge queue check pagination interrupted: statusCheckRollup disappeared for ${commit.oid}. Retry.`);
|
|
28
47
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
cursor = next.pageInfo.hasNextPage ? next.pageInfo.endCursor : null;
|
|
32
|
-
if (next.pageInfo.hasNextPage && !cursor) {
|
|
33
|
-
throw new Error(`Merge queue check pagination interrupted: GitHub omitted the next cursor for ${commit.oid}. Retry.`);
|
|
34
|
-
}
|
|
48
|
+
nodes.push(...requireContextNodes(next.nodes));
|
|
49
|
+
cursor = nextPageCursor(next, commit.oid);
|
|
35
50
|
}
|
|
51
|
+
commit.statusCheckRollup = {
|
|
52
|
+
contexts: { pageInfo: { hasNextPage: false, endCursor: null }, nodes },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function currentRemovalCommit(raw) {
|
|
56
|
+
const removal = raw.mergeQueueRemovals?.nodes[0];
|
|
57
|
+
const addition = raw.mergeQueueAdditions?.nodes[0];
|
|
58
|
+
if (!removal?.beforeCommit)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (addition && Date.parse(removal.createdAt) < Date.parse(addition.createdAt))
|
|
61
|
+
return undefined;
|
|
62
|
+
const parentOids = removal.beforeCommit.parents?.nodes.map((node) => node.oid);
|
|
63
|
+
if (!parentOids?.includes(raw.headRefOid))
|
|
64
|
+
return undefined;
|
|
65
|
+
return removal.beforeCommit;
|
|
36
66
|
}
|
|
37
67
|
/** Hydrate all status contexts for the active or most recently removed queue commit. */
|
|
38
68
|
export async function hydrateMergeQueueChecks(raw, repo) {
|
|
39
69
|
const active = raw.mergeQueueEntry?.headCommit;
|
|
40
|
-
const
|
|
41
|
-
const addition = raw.mergeQueueAdditions?.nodes[0];
|
|
42
|
-
const removalIsCurrent = Boolean(removal && (!addition || Date.parse(removal.createdAt) >= Date.parse(addition.createdAt)));
|
|
43
|
-
const removed = removalIsCurrent ? removal?.beforeCommit : undefined;
|
|
70
|
+
const removed = currentRemovalCommit(raw);
|
|
44
71
|
if (active)
|
|
45
72
|
await hydrateCommitContexts(active, repo);
|
|
46
73
|
if (removed && removed.oid !== active?.oid)
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
export declare const BATCH_PR_QUERY: string;
|
|
11
11
|
/** Slim @include follow-up for outstanding batch-query connections. */
|
|
12
12
|
export declare const BATCH_PR_PAGE_QUERY: string;
|
|
13
|
+
/** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
|
|
14
|
+
export declare const PR_FINGERPRINT_QUERY: string;
|
|
13
15
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
14
16
|
export declare const SUGGESTION_THREADS_QUERY: string;
|
|
15
17
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
package/bin/github/queries.mjs
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
|
|
12
|
+
const withSharedFragments = (query) => `${gql("pr-merge-policy.gql")}\n${gql("commit-check-suites.gql")}\n${query}`;
|
|
12
13
|
/** The primary batch query that fetches CI + comments + merge status in one round-trip. */
|
|
13
|
-
export const BATCH_PR_QUERY = gql("batch-pr.gql");
|
|
14
|
+
export const BATCH_PR_QUERY = withSharedFragments(gql("batch-pr.gql"));
|
|
14
15
|
/** Slim @include follow-up for outstanding batch-query connections. */
|
|
15
16
|
export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
|
|
17
|
+
/** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
|
|
18
|
+
export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"));
|
|
16
19
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
17
20
|
export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
|
|
18
21
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
package/bin/quota-warning.mjs
CHANGED
|
@@ -2,5 +2,5 @@ export function buildQuotaAwareContinuation(warning, prefix) {
|
|
|
2
2
|
const interval = `${warning.pollIntervalMinutes}m`;
|
|
3
3
|
const timeout = `${warning.pollTimeoutMinutes}m`;
|
|
4
4
|
const resetTime = new Date(warning.resetAt * 1000).toISOString();
|
|
5
|
-
return `${prefix} GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR operations that do not need Shepherd's full snapshot, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr review\`, \`gh api\` REST endpoints) — they draw on the separate REST budget, not the depleted GraphQL pool. Do not substitute \`gh pr checks\` or \`gh pr watch\` for the Shepherd loop. Resume full-cadence pr-shepherd after the GraphQL quota resets at ${resetTime}. If you must keep polling before then, poll no more often than every ${warning.pollIntervalMinutes} minutes. With a polling CLI command, preserve the other options,
|
|
5
|
+
return `${prefix} GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR operations that do not need Shepherd's full snapshot, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr review\`, \`gh api\` REST endpoints) — they draw on the separate REST budget, not the depleted GraphQL pool. Do not substitute \`gh pr checks\` or \`gh pr watch\` for the Shepherd loop. Resume full-cadence pr-shepherd after the GraphQL quota resets at ${resetTime}. If you must keep polling before then, poll no more often than every ${warning.pollIntervalMinutes} minutes. With a polling CLI command, preserve the other options, raise any shorter interval and timeout flags to at least \`--interval ${interval} --timeout ${timeout}\`, keep any longer cadence, and omit \`--timeout\` when using \`--until-terminal\`. With a single-tick CLI, API, or MCP call, wait at least ${warning.pollIntervalMinutes} minutes before the next tick.`;
|
|
6
6
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PrFingerprint } from "../github/fingerprint.mts";
|
|
2
|
+
import type { PrShepherdConfig } from "../config/load.mts";
|
|
3
|
+
import type { ShepherdReport } from "../types.mts";
|
|
4
|
+
export interface StoredPrFingerprint {
|
|
5
|
+
version: number;
|
|
6
|
+
inputDigest: string;
|
|
7
|
+
fingerprint: PrFingerprint;
|
|
8
|
+
report: ShepherdReport;
|
|
9
|
+
}
|
|
10
|
+
export declare function fingerprintInputDigest(config: PrShepherdConfig): string;
|
|
11
|
+
export declare function loadPrFingerprint(key: {
|
|
12
|
+
owner: string;
|
|
13
|
+
repo: string;
|
|
14
|
+
pr: number;
|
|
15
|
+
}): Promise<StoredPrFingerprint | null>;
|
|
16
|
+
export declare function storePrFingerprint(key: {
|
|
17
|
+
owner: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
pr: number;
|
|
20
|
+
}, fingerprint: PrFingerprint, report: ShepherdReport, config: PrShepherdConfig): Promise<void>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { resolvePrStatePath } from "./base.mjs";
|
|
6
|
+
import { discoverRuleFiles } from "../classify/loader.mjs";
|
|
7
|
+
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
8
|
+
const VERSION = 3;
|
|
9
|
+
export function fingerprintInputDigest(config) {
|
|
10
|
+
const hash = createHash("sha256");
|
|
11
|
+
hash.update(JSON.stringify({
|
|
12
|
+
ignoreChecks: config.ignoreChecks,
|
|
13
|
+
botUsernames: config.botUsernames,
|
|
14
|
+
iterate: config.iterate,
|
|
15
|
+
watch: { readyDelayMinutes: config.watch.readyDelayMinutes },
|
|
16
|
+
checks: config.checks,
|
|
17
|
+
mergeStatus: config.mergeStatus,
|
|
18
|
+
actions: {
|
|
19
|
+
autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
|
|
20
|
+
autoMarkReady: config.actions.autoMarkReady,
|
|
21
|
+
neverCancelRuns: config.actions.neverCancelRuns,
|
|
22
|
+
workWhileQueued: config.actions.workWhileQueued,
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
for (const file of discoverRuleFiles(getEffectiveCwd())) {
|
|
26
|
+
hash.update("\0");
|
|
27
|
+
hash.update(file);
|
|
28
|
+
hash.update("\0");
|
|
29
|
+
try {
|
|
30
|
+
hash.update(readFileSync(file));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
hash.update("missing");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return hash.digest("hex").slice(0, 16);
|
|
37
|
+
}
|
|
38
|
+
export async function loadPrFingerprint(key) {
|
|
39
|
+
const path = resolvePrStatePath(key, "fingerprint.json");
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
42
|
+
if (!isStoredFingerprint(parsed))
|
|
43
|
+
return null;
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function storePrFingerprint(key, fingerprint, report, config) {
|
|
51
|
+
const path = resolvePrStatePath(key, "fingerprint.json");
|
|
52
|
+
let tmp;
|
|
53
|
+
try {
|
|
54
|
+
await mkdir(dirname(path), { recursive: true });
|
|
55
|
+
tmp = `${path}.${randomUUID()}.tmp`;
|
|
56
|
+
const payload = {
|
|
57
|
+
version: VERSION,
|
|
58
|
+
inputDigest: fingerprintInputDigest(config),
|
|
59
|
+
fingerprint,
|
|
60
|
+
report,
|
|
61
|
+
};
|
|
62
|
+
await writeFile(tmp, `${JSON.stringify(payload)}\n`, "utf8");
|
|
63
|
+
await rename(tmp, path);
|
|
64
|
+
tmp = undefined;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Fingerprint cache is an optimization; a failed write just means the next tick refetches.
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (tmp !== undefined) {
|
|
71
|
+
try {
|
|
72
|
+
await unlink(tmp);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// best-effort cleanup
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isStoredFingerprint(value) {
|
|
81
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
82
|
+
return false;
|
|
83
|
+
const record = value;
|
|
84
|
+
return (record["version"] === VERSION &&
|
|
85
|
+
typeof record["inputDigest"] === "string" &&
|
|
86
|
+
record["fingerprint"] !== null &&
|
|
87
|
+
typeof record["fingerprint"] === "object" &&
|
|
88
|
+
record["report"] !== null &&
|
|
89
|
+
typeof record["report"] === "object");
|
|
90
|
+
}
|