pr-shepherd 0.55.2 → 0.56.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 +6 -2
- package/bin/api.d.mts +5 -0
- package/bin/api.mjs +12 -1
- package/bin/checks/job-log.d.mts +9 -0
- package/bin/checks/job-log.mjs +30 -0
- package/bin/checks/jobs-types.d.mts +14 -0
- package/bin/checks/jobs-types.mjs +7 -0
- package/bin/checks/related-jobs.d.mts +11 -0
- package/bin/checks/related-jobs.mjs +32 -0
- package/bin/checks/triage-budget.d.mts +17 -0
- package/bin/checks/triage-budget.mjs +49 -0
- package/bin/checks/triage.d.mts +6 -3
- package/bin/checks/triage.mjs +89 -61
- package/bin/cli/api-usage-formatter.mjs +2 -2
- package/bin/cli/fix-formatter.mjs +2 -0
- package/bin/cli/iterate-checks-formatter.mjs +2 -0
- package/bin/cli/related-jobs-format.d.mts +3 -0
- package/bin/cli/related-jobs-format.mjs +16 -0
- package/bin/commands/check-execution-context.d.mts +12 -0
- package/bin/commands/check-execution-context.mjs +38 -0
- package/bin/commands/check-fingerprint.mjs +14 -8
- package/bin/commands/check-unreported.d.mts +3 -2
- package/bin/commands/check-unreported.mjs +4 -4
- package/bin/commands/check.d.mts +2 -1
- package/bin/commands/check.mjs +21 -6
- package/bin/commands/iterate/check-instructions.d.mts +6 -3
- package/bin/commands/iterate/check-instructions.mjs +6 -5
- package/bin/commands/iterate/escalate.mjs +3 -0
- package/bin/commands/iterate/fix-code.mjs +6 -2
- package/bin/commands/iterate/helpers.mjs +1 -0
- package/bin/commands/iterate/index.mjs +20 -8
- package/bin/commands/iterate/render.mjs +11 -3
- package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
- package/bin/commands/iterate/stale-ancestry.mjs +3 -2
- package/bin/commands/poll-quota.d.mts +2 -0
- package/bin/commands/poll-quota.mjs +18 -25
- package/bin/commands/poll-rate-limit-wait.mjs +13 -8
- package/bin/commands/ready-delay.d.mts +2 -0
- package/bin/commands/ready-delay.mjs +18 -0
- package/bin/commands/resolve-mutate.mjs +8 -9
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/batch-receipt-evidence.d.mts +5 -0
- package/bin/github/batch-receipt-evidence.mjs +61 -0
- package/bin/github/batch.d.mts +4 -0
- package/bin/github/batch.mjs +42 -8
- package/bin/github/errors.d.mts +3 -0
- package/bin/github/errors.mjs +15 -6
- package/bin/github/gql/batch-pr-page.gql +1 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
- package/bin/github/gql/reply-thread-comments.gql +25 -0
- package/bin/github/gql/reply-thread-transcripts.gql +31 -0
- package/bin/github/merge-queue-checks.d.mts +2 -1
- package/bin/github/merge-queue-checks.mjs +18 -8
- package/bin/github/merge-target-rules.d.mts +2 -1
- package/bin/github/merge-target-rules.mjs +4 -4
- package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
- package/bin/github/poll-summary-annotation-probe.mjs +7 -0
- package/bin/github/queries.d.mts +5 -0
- package/bin/github/queries.mjs +5 -0
- package/bin/github/rate-limit-kind.d.mts +13 -0
- package/bin/github/rate-limit-kind.mjs +25 -0
- package/bin/github/reply-thread-transcripts.d.mts +3 -0
- package/bin/github/reply-thread-transcripts.mjs +89 -0
- package/bin/github/rest-http.mjs +1 -0
- package/bin/github/rest-text.d.mts +2 -1
- package/bin/github/rest-text.mjs +5 -2
- package/bin/github/thread-comments.d.mts +5 -1
- package/bin/github/thread-comments.mjs +30 -6
- package/bin/mcp/server.mjs +32 -5
- package/bin/quota-warning.mjs +2 -2
- package/bin/reporters/agent.mjs +1 -0
- package/bin/threads/transcript.d.mts +1 -0
- package/bin/threads/transcript.mjs +4 -1
- package/bin/types/check-classification.d.mts +10 -0
- package/bin/types/report.d.mts +4 -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
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
2
2
|
import { applyResolveOptions } from "../comments/resolve.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { fetchReplyThreadTranscripts } from "../github/reply-thread-transcripts.mjs";
|
|
4
4
|
import { markReplySeen } from "../state/seen-comments.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { threadTranscriptBodies } from "../threads/transcript.mjs";
|
|
6
6
|
import { addPrShepherdMarker } from "../comments/marker.mjs";
|
|
7
7
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
8
8
|
/** @deprecated Hidden implementation for `resolve`; use `apply review`. */
|
|
@@ -14,10 +14,10 @@ export async function runResolveMutate(opts) {
|
|
|
14
14
|
}
|
|
15
15
|
// Fetch only to retain the pre-reply transcript for successful-reply seen
|
|
16
16
|
// markers. It never determines which user-supplied IDs are sent to GitHub.
|
|
17
|
-
let
|
|
17
|
+
let transcriptById;
|
|
18
18
|
if (opts.replyThreadIds?.length) {
|
|
19
19
|
try {
|
|
20
|
-
|
|
20
|
+
transcriptById = await fetchReplyThreadTranscripts(prNumber, repo, opts.replyThreadIds);
|
|
21
21
|
}
|
|
22
22
|
catch {
|
|
23
23
|
// Seen-marker bookkeeping is best-effort. A failed read must not block
|
|
@@ -35,14 +35,13 @@ export async function runResolveMutate(opts) {
|
|
|
35
35
|
dismissMessage: opts.dismissMessage,
|
|
36
36
|
requireSha: opts.requireSha,
|
|
37
37
|
});
|
|
38
|
-
if (opts.dismissMessage &&
|
|
38
|
+
if (opts.dismissMessage && transcriptById) {
|
|
39
39
|
const markedMessage = addPrShepherdMarker(opts.dismissMessage);
|
|
40
40
|
await Promise.all(result.repliedThreads.map((id) => {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
41
|
+
const previousBody = transcriptById.get(id);
|
|
42
|
+
if (previousBody === undefined)
|
|
43
43
|
return Promise.resolve();
|
|
44
|
-
|
|
45
|
-
return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBody(thread, [markedMessage]), markedMessage);
|
|
44
|
+
return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBodies([previousBody, markedMessage]), markedMessage);
|
|
46
45
|
}));
|
|
47
46
|
}
|
|
48
47
|
return result;
|
|
@@ -11,6 +11,7 @@ export interface RawBatchResponse {
|
|
|
11
11
|
squashMergeAllowed?: boolean;
|
|
12
12
|
rebaseMergeAllowed?: boolean;
|
|
13
13
|
pullRequest: RawPr | null;
|
|
14
|
+
receiptSummary?: import("./poll-summary-raw.mts").RawSummaryPr | null;
|
|
14
15
|
} | null;
|
|
15
16
|
}
|
|
16
17
|
export interface RawPr extends RawPrMergeFields {
|
|
@@ -226,6 +227,7 @@ export type RawContextNode = {
|
|
|
226
227
|
title: string | null;
|
|
227
228
|
summary: string | null;
|
|
228
229
|
annotations?: {
|
|
230
|
+
totalCount?: number;
|
|
229
231
|
nodes: Array<{
|
|
230
232
|
message: string;
|
|
231
233
|
}>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RepoInfo } from "./client.mts";
|
|
2
|
+
import type { RawContextNode, RawPr } from "./batch-raw-types.mts";
|
|
3
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
4
|
+
/** Keep the exact PollSummaryPr shape used by v1 receipts; add only complete annotation totals. */
|
|
5
|
+
export declare function prepareBatchReceiptEvidence(summary: RawSummaryPr | null | undefined, batch: RawPr, checks: RawContextNode[], repo: RepoInfo): Promise<RawSummaryPr | null>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
|
|
2
|
+
import { markReadyAnnotationProbeComplete } from "./poll-summary-annotation-probe.mjs";
|
|
3
|
+
/** Keep the exact PollSummaryPr shape used by v1 receipts; add only complete annotation totals. */
|
|
4
|
+
export async function prepareBatchReceiptEvidence(summary, batch, checks, repo) {
|
|
5
|
+
if (!summary ||
|
|
6
|
+
summary.number !== batch.number ||
|
|
7
|
+
summary.headRefOid !== batch.headRefOid ||
|
|
8
|
+
summary.baseRefOid !== batch.baseRefOid)
|
|
9
|
+
return null;
|
|
10
|
+
// Candidate evidence is best-effort. Wider windows need their own summary
|
|
11
|
+
// pagination, so use the existing standalone receipt path instead of issuing
|
|
12
|
+
// follow-ups from inside a full BatchPr read (which may have exhausted quota).
|
|
13
|
+
const headContexts = summary.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
14
|
+
const queueContexts = summary.mergeQueueEntry?.headCommit?.statusCheckRollup?.contexts;
|
|
15
|
+
if (headContexts?.pageInfo.hasPreviousPage || queueContexts?.pageInfo.hasPreviousPage)
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
await hydratePollSummaryChecks(summary, repo);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (summary.mergeQueueEntry?.headCommit?.statusCheckRollup?.contexts.pageInfo.hasPreviousPage)
|
|
24
|
+
return null;
|
|
25
|
+
const summaryCommit = summary.commits.nodes[0]?.commit;
|
|
26
|
+
const batchCommit = batch.commits.nodes[0]?.commit;
|
|
27
|
+
if (!summaryCommit || summaryCommit.oid !== batchCommit?.oid)
|
|
28
|
+
return null;
|
|
29
|
+
const contexts = summaryCommit.statusCheckRollup?.contexts;
|
|
30
|
+
if (!contexts) {
|
|
31
|
+
if (batchCommit.statusCheckRollup !== null || checks.length > 0)
|
|
32
|
+
return null;
|
|
33
|
+
markReadyAnnotationProbeComplete(summary);
|
|
34
|
+
return summary;
|
|
35
|
+
}
|
|
36
|
+
if (contexts.pageInfo.hasPreviousPage || contexts.nodes.length !== contexts.totalCount)
|
|
37
|
+
return null;
|
|
38
|
+
if (checks.length !== contexts.totalCount)
|
|
39
|
+
return null;
|
|
40
|
+
const batchRuns = new Map();
|
|
41
|
+
for (const check of checks) {
|
|
42
|
+
if (check.__typename !== "CheckRun")
|
|
43
|
+
continue;
|
|
44
|
+
const count = check.annotations?.totalCount;
|
|
45
|
+
if (!Number.isSafeInteger(count) || count < 0 || batchRuns.has(check.id))
|
|
46
|
+
return null;
|
|
47
|
+
batchRuns.set(check.id, count);
|
|
48
|
+
}
|
|
49
|
+
const summaryRuns = contexts.nodes.filter((node) => node.__typename === "CheckRun");
|
|
50
|
+
if (summaryRuns.length !== batchRuns.size)
|
|
51
|
+
return null;
|
|
52
|
+
const seenSummaryIds = new Set();
|
|
53
|
+
for (const node of summaryRuns) {
|
|
54
|
+
if (!node.id || seenSummaryIds.has(node.id) || !batchRuns.has(node.id))
|
|
55
|
+
return null;
|
|
56
|
+
seenSummaryIds.add(node.id);
|
|
57
|
+
node.annotations = { totalCount: batchRuns.get(node.id) };
|
|
58
|
+
}
|
|
59
|
+
markReadyAnnotationProbeComplete(summary);
|
|
60
|
+
return summary;
|
|
61
|
+
}
|
package/bin/github/batch.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RateLimitInfo, type RepoInfo } from "./client.mts";
|
|
2
2
|
import type { WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
|
|
3
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
4
|
import type { BatchPrData } from "../types.mts";
|
|
4
5
|
import { type PrFingerprint } from "./fingerprint.mts";
|
|
5
6
|
interface BatchResult {
|
|
@@ -12,6 +13,8 @@ interface BatchResult {
|
|
|
12
13
|
headCheckSuitesEmpty?: true;
|
|
13
14
|
/** Actions workflow suites on the head, excluding apps that have no workflow run. */
|
|
14
15
|
headWorkflowSuites?: WorkflowSuiteSnapshot[];
|
|
16
|
+
/** Internal READY-receipt evidence from the same request, if complete. */
|
|
17
|
+
receiptSummary?: RawSummaryPr;
|
|
15
18
|
}
|
|
16
19
|
interface FetchPrBatchOptions {
|
|
17
20
|
/**
|
|
@@ -23,6 +26,7 @@ interface FetchPrBatchOptions {
|
|
|
23
26
|
* request — so there's no need to conditionally omit the field itself.
|
|
24
27
|
*/
|
|
25
28
|
paginateApprovedReviews?: boolean;
|
|
29
|
+
includeReceiptSummary?: boolean;
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
28
32
|
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
package/bin/github/batch.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { graphqlWithRateLimit } from "./client.mjs";
|
|
2
2
|
import { hydrateThreadCommentPages } from "./thread-comments.mjs";
|
|
3
|
-
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
3
|
+
import { BATCH_PR_QUERY, BATCH_PR_RECEIPT_QUERY } from "./queries.mjs";
|
|
4
|
+
import { prepareBatchReceiptEvidence } from "./batch-receipt-evidence.mjs";
|
|
5
|
+
import { GitHubRequestError, isRetryableGraphQlResourceLimit, } from "./errors.mjs";
|
|
6
|
+
import { rateLimitKind } from "./rate-limit-kind.mjs";
|
|
4
7
|
import { parseRawPr } from "./batch-parsers.mjs";
|
|
5
8
|
import { parseCheckSuitesComplete, parseHeadCheckSuitesEmpty, parseHeadWorkflowSuites, parseSuiteStartupFailures, } from "./batch-parse-suites.mjs";
|
|
6
9
|
import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
|
|
@@ -8,20 +11,50 @@ import { paginateBatchConnections } from "./batch-page.mjs";
|
|
|
8
11
|
import { requireRawPr } from "./batch-response.mjs";
|
|
9
12
|
import { hydrateMergeQueueChecks } from "./merge-queue-checks.mjs";
|
|
10
13
|
import { fingerprintFromRaw } from "./fingerprint.mjs";
|
|
14
|
+
function onlyReceiptSummaryErrors(errors) {
|
|
15
|
+
return (!!errors?.length &&
|
|
16
|
+
errors.every((error) => Array.isArray(error.path) &&
|
|
17
|
+
error.path[0] === "repository" &&
|
|
18
|
+
error.path[1] === "receiptSummary"));
|
|
19
|
+
}
|
|
11
20
|
/**
|
|
12
21
|
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
13
22
|
*/
|
|
14
23
|
export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
15
|
-
const
|
|
24
|
+
const variables = {
|
|
16
25
|
owner: repo.owner,
|
|
17
26
|
repo: repo.name,
|
|
18
27
|
pr,
|
|
19
|
-
}
|
|
28
|
+
};
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
result = await graphqlWithRateLimit(opts.includeReceiptSummary ? BATCH_PR_RECEIPT_QUERY : BATCH_PR_QUERY, variables);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!opts.includeReceiptSummary ||
|
|
35
|
+
!(error instanceof GitHubRequestError) ||
|
|
36
|
+
rateLimitKind(error) !== null ||
|
|
37
|
+
!(isRetryableGraphQlResourceLimit(error.graphqlErrors) ||
|
|
38
|
+
(error.status === 200 && onlyReceiptSummaryErrors(error.graphqlErrors))))
|
|
39
|
+
throw error;
|
|
40
|
+
// Receipt evidence is optional; a resource-limited combined query or an
|
|
41
|
+
// error confined to its sibling must not prevent the ordinary snapshot.
|
|
42
|
+
result = await graphqlWithRateLimit(BATCH_PR_QUERY, variables);
|
|
43
|
+
}
|
|
20
44
|
const raw = requireRawPr(result.data, pr, repo);
|
|
21
|
-
await hydrateMergeQueueChecks(raw, repo);
|
|
22
|
-
const paged = await paginateBatchConnections(pr, repo, raw, opts,
|
|
23
|
-
const
|
|
24
|
-
|
|
45
|
+
const queueRateLimit = await hydrateMergeQueueChecks(raw, repo, result.rateLimit);
|
|
46
|
+
const paged = await paginateBatchConnections(pr, repo, raw, opts, queueRateLimit);
|
|
47
|
+
const threadPages = await hydrateThreadCommentPages(paged.threads, paged.rateLimit);
|
|
48
|
+
let receiptSummary = null;
|
|
49
|
+
if (opts.includeReceiptSummary) {
|
|
50
|
+
try {
|
|
51
|
+
receiptSummary = await prepareBatchReceiptEvidence(result.data.repository?.receiptSummary, raw, paged.checks, repo);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Malformed supplemental evidence must not discard a complete BatchPr.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const data = parseRawPr(raw, threadPages.threads, paged.comments, paged.changesRequested, paged.reviewSummaries, paged.approvedReviews, paged.checks, result.data.repository);
|
|
25
58
|
const viewerLogin = result.data.viewer?.login;
|
|
26
59
|
if (viewerLogin)
|
|
27
60
|
data.viewerLogin = viewerLogin;
|
|
@@ -29,10 +62,11 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
29
62
|
return {
|
|
30
63
|
data,
|
|
31
64
|
fingerprint: fingerprintFromRaw(raw, result.data.repository?.viewerPermission ?? null, result.data.viewer?.login ?? null),
|
|
32
|
-
rateLimit: paged.rateLimit ?? result.rateLimit,
|
|
65
|
+
rateLimit: threadPages.rateLimit ?? paged.rateLimit ?? result.rateLimit,
|
|
33
66
|
...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
|
|
34
67
|
...(parseHeadCheckSuitesEmpty(raw) && { headCheckSuitesEmpty: true }),
|
|
35
68
|
...workflowSuites(raw),
|
|
69
|
+
...(receiptSummary && { receiptSummary }),
|
|
36
70
|
};
|
|
37
71
|
}
|
|
38
72
|
function workflowSuites(raw) {
|
package/bin/github/errors.d.mts
CHANGED
|
@@ -27,12 +27,15 @@ export declare class GitHubRequestError extends ShepherdError {
|
|
|
27
27
|
readonly retryAfterSeconds?: number;
|
|
28
28
|
readonly graphqlErrors?: GitHubGraphQlError[];
|
|
29
29
|
readonly authSource?: string;
|
|
30
|
+
/** Response text without a request path; safe input for throttle classification. */
|
|
31
|
+
readonly responseMessage?: string;
|
|
30
32
|
constructor(message: string, opts: {
|
|
31
33
|
status: number;
|
|
32
34
|
rateLimit?: RateLimitInfo;
|
|
33
35
|
retryAfterSeconds?: number;
|
|
34
36
|
graphqlErrors?: GitHubGraphQlError[];
|
|
35
37
|
authSource?: string;
|
|
38
|
+
responseMessage?: string;
|
|
36
39
|
/**
|
|
37
40
|
* Bypasses status-based classification entirely — for callers that already
|
|
38
41
|
* know the failure kind better than the HTTP status can express (e.g. a
|
package/bin/github/errors.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
import { rateLimitKind } from "./rate-limit-kind.mjs";
|
|
2
3
|
// GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
|
|
3
4
|
// PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
|
|
4
5
|
// 401/403 — the transport-level request succeeded even though one field could not
|
|
@@ -47,16 +48,21 @@ export function isRetryableGraphQlResourceLimit(graphqlErrors) {
|
|
|
47
48
|
return GRAPHQL_RESOURCE_LIMIT_MESSAGE.test(error.message);
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
|
-
function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
|
|
51
|
+
function classifyStatus(status, message, responseMessage, rateLimit, retryAfterSeconds, graphqlErrors) {
|
|
51
52
|
// Retry signals take priority over everything else: GitHub's secondary rate limit
|
|
52
53
|
// returns 403 with a Retry-After header, which is a transient throttle — not the
|
|
53
54
|
// permission-denied 403 a bad/missing token produces. Treat any retry signal as
|
|
54
55
|
// TEMPFAIL first so it isn't shadowed by the checks below.
|
|
55
|
-
const
|
|
56
|
-
|
|
56
|
+
const throttle = rateLimitKind({
|
|
57
|
+
status,
|
|
58
|
+
message,
|
|
59
|
+
responseMessage,
|
|
60
|
+
rateLimit,
|
|
61
|
+
retryAfterSeconds,
|
|
62
|
+
graphqlErrors,
|
|
63
|
+
});
|
|
64
|
+
if (throttle !== null ||
|
|
57
65
|
status >= 500 ||
|
|
58
|
-
retryAfterSeconds !== undefined ||
|
|
59
|
-
rateLimitExhausted ||
|
|
60
66
|
isRetryableGraphQlInternal(graphqlErrors) ||
|
|
61
67
|
isRetryableGraphQlResourceLimit(graphqlErrors)) {
|
|
62
68
|
return EXIT.TEMPFAIL;
|
|
@@ -75,14 +81,17 @@ export class GitHubRequestError extends ShepherdError {
|
|
|
75
81
|
retryAfterSeconds;
|
|
76
82
|
graphqlErrors;
|
|
77
83
|
authSource;
|
|
84
|
+
/** Response text without a request path; safe input for throttle classification. */
|
|
85
|
+
responseMessage;
|
|
78
86
|
constructor(message, opts) {
|
|
79
87
|
super(message, opts.exitCodeOverride ??
|
|
80
|
-
classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
|
|
88
|
+
classifyStatus(opts.status, message, opts.responseMessage, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
|
|
81
89
|
this.name = "GitHubRequestError";
|
|
82
90
|
this.status = opts.status;
|
|
83
91
|
this.rateLimit = opts.rateLimit;
|
|
84
92
|
this.retryAfterSeconds = opts.retryAfterSeconds;
|
|
85
93
|
this.graphqlErrors = opts.graphqlErrors;
|
|
86
94
|
this.authSource = opts.authSource;
|
|
95
|
+
this.responseMessage = opts.responseMessage;
|
|
87
96
|
}
|
|
88
97
|
}
|
|
@@ -4,6 +4,14 @@ query PollSummaryAnnotationProbe(
|
|
|
4
4
|
$oid: GitObjectID!
|
|
5
5
|
$before: String
|
|
6
6
|
) {
|
|
7
|
+
_shepherdRateLimit: rateLimit {
|
|
8
|
+
cost
|
|
9
|
+
limit
|
|
10
|
+
nodeCount
|
|
11
|
+
remaining
|
|
12
|
+
resetAt
|
|
13
|
+
used
|
|
14
|
+
}
|
|
7
15
|
repository(owner: $owner, name: $repo) {
|
|
8
16
|
object(oid: $oid) {
|
|
9
17
|
__typename
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
query ReplyThreadComments($id: ID!, $cursor: String!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
node(id: $id) {
|
|
11
|
+
__typename
|
|
12
|
+
... on PullRequestReviewThread {
|
|
13
|
+
id
|
|
14
|
+
comments(first: 100, after: $cursor) {
|
|
15
|
+
pageInfo {
|
|
16
|
+
hasNextPage
|
|
17
|
+
endCursor
|
|
18
|
+
}
|
|
19
|
+
nodes {
|
|
20
|
+
body
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
query ReplyThreadTranscripts($ids: [ID!]!) {
|
|
2
|
+
_shepherdRateLimit: rateLimit {
|
|
3
|
+
cost
|
|
4
|
+
limit
|
|
5
|
+
nodeCount
|
|
6
|
+
remaining
|
|
7
|
+
resetAt
|
|
8
|
+
used
|
|
9
|
+
}
|
|
10
|
+
nodes(ids: $ids) {
|
|
11
|
+
__typename
|
|
12
|
+
... on PullRequestReviewThread {
|
|
13
|
+
id
|
|
14
|
+
pullRequest {
|
|
15
|
+
number
|
|
16
|
+
repository {
|
|
17
|
+
nameWithOwner
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
comments(first: 100) {
|
|
21
|
+
pageInfo {
|
|
22
|
+
hasNextPage
|
|
23
|
+
endCursor
|
|
24
|
+
}
|
|
25
|
+
nodes {
|
|
26
|
+
body
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import { type RateLimitInfo } from "./client.mts";
|
|
1
2
|
import type { RepoInfo } from "./client.mts";
|
|
2
3
|
import type { RawPr } from "./batch-raw-types.mts";
|
|
3
4
|
/** Hydrate all status contexts for the active or most recently removed queue commit. */
|
|
4
|
-
export declare function hydrateMergeQueueChecks(raw: RawPr, repo: RepoInfo): Promise<
|
|
5
|
+
export declare function hydrateMergeQueueChecks(raw: RawPr, repo: RepoInfo, initialRateLimit?: RateLimitInfo): Promise<RateLimitInfo | undefined>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseCreatedAt } from "./batch-parser-helpers.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
3
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
3
4
|
import { headPushUnixFromCheckNodes, queueRemovalAppliesToHead, } from "./queue-removal-freshness.mjs";
|
|
4
5
|
import { requireContextNodes } from "./batch-response.mjs";
|
|
5
6
|
import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
|
|
@@ -18,13 +19,20 @@ function initialQueueCursor(existing, oid) {
|
|
|
18
19
|
return null;
|
|
19
20
|
return nextPageCursor(existing, oid);
|
|
20
21
|
}
|
|
21
|
-
async function fetchQueuePage(oid, repo, cursor) {
|
|
22
|
-
|
|
22
|
+
async function fetchQueuePage(oid, repo, cursor, gate) {
|
|
23
|
+
if (gate.rateLimit?.remaining === 0) {
|
|
24
|
+
throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; merge queue check pagination incomplete", {
|
|
25
|
+
status: 403,
|
|
26
|
+
rateLimit: gate.rateLimit,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const result = await graphqlWithRateLimit(COMMIT_CHECK_CONTEXTS_QUERY, {
|
|
23
30
|
owner: repo.owner,
|
|
24
31
|
repo: repo.name,
|
|
25
32
|
oid,
|
|
26
33
|
...(cursor !== null && { cursor }),
|
|
27
34
|
});
|
|
35
|
+
gate.rateLimit = result.rateLimit ?? gate.rateLimit;
|
|
28
36
|
const object = result.data.repository?.object;
|
|
29
37
|
if (object?.__typename !== "Commit" || object.oid !== oid) {
|
|
30
38
|
if (cursor === null)
|
|
@@ -33,13 +41,13 @@ async function fetchQueuePage(oid, repo, cursor) {
|
|
|
33
41
|
}
|
|
34
42
|
return object.statusCheckRollup?.contexts ?? null;
|
|
35
43
|
}
|
|
36
|
-
async function hydrateCommitContexts(commit, repo) {
|
|
44
|
+
async function hydrateCommitContexts(commit, repo, gate) {
|
|
37
45
|
const existing = commit.statusCheckRollup?.contexts;
|
|
38
46
|
const nodes = existing ? [...requireContextNodes(existing.nodes)] : [];
|
|
39
47
|
let cursor = initialQueueCursor(existing, commit.oid);
|
|
40
48
|
while (cursor !== undefined) {
|
|
41
49
|
// eslint-disable-next-line no-await-in-loop
|
|
42
|
-
const next = await fetchQueuePage(commit.oid, repo, cursor);
|
|
50
|
+
const next = await fetchQueuePage(commit.oid, repo, cursor, gate);
|
|
43
51
|
if (!next) {
|
|
44
52
|
if (cursor === null) {
|
|
45
53
|
cursor = undefined;
|
|
@@ -78,11 +86,13 @@ function currentRemovalCommit(raw) {
|
|
|
78
86
|
return removal.beforeCommit;
|
|
79
87
|
}
|
|
80
88
|
/** Hydrate all status contexts for the active or most recently removed queue commit. */
|
|
81
|
-
export async function hydrateMergeQueueChecks(raw, repo) {
|
|
89
|
+
export async function hydrateMergeQueueChecks(raw, repo, initialRateLimit) {
|
|
90
|
+
const gate = { rateLimit: initialRateLimit };
|
|
82
91
|
const active = raw.mergeQueueEntry?.headCommit;
|
|
83
92
|
const removed = currentRemovalCommit(raw);
|
|
84
93
|
if (active)
|
|
85
|
-
await hydrateCommitContexts(active, repo);
|
|
94
|
+
await hydrateCommitContexts(active, repo, gate);
|
|
86
95
|
if (removed && removed.oid !== active?.oid)
|
|
87
|
-
await hydrateCommitContexts(removed, repo);
|
|
96
|
+
await hydrateCommitContexts(removed, repo, gate);
|
|
97
|
+
return gate.rateLimit;
|
|
88
98
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CheckExecutionContext } from "../commands/check-execution-context.mts";
|
|
1
2
|
export interface MergeTargetStatus {
|
|
2
3
|
contexts: string[];
|
|
3
4
|
trunkBehindBy?: number;
|
|
@@ -17,7 +18,7 @@ export declare function loadMergeTargetStatus(input: {
|
|
|
17
18
|
stack?: {
|
|
18
19
|
baseRefName: string;
|
|
19
20
|
} | null;
|
|
20
|
-
}): Promise<MergeTargetStatus>;
|
|
21
|
+
}, context?: CheckExecutionContext): Promise<MergeTargetStatus>;
|
|
21
22
|
/**
|
|
22
23
|
* Commits on the PR base that `headRef` does not contain.
|
|
23
24
|
* Pass the head commit OID. A fork's branch name can exist on the base
|
|
@@ -8,14 +8,14 @@ import { readStackTopology } from "./stack-read.mjs";
|
|
|
8
8
|
* Required status contexts for the branch GitHub actually merges into.
|
|
9
9
|
* A native stack uses the trunk ref, and `behindBy` is that trunk against the bottom open layer.
|
|
10
10
|
*/
|
|
11
|
-
export async function loadMergeTargetStatus(input) {
|
|
11
|
+
export async function loadMergeTargetStatus(input, context) {
|
|
12
12
|
const trunk = input.stack?.baseRefName;
|
|
13
13
|
if (!trunk)
|
|
14
14
|
return { contexts: [...input.localContexts] };
|
|
15
15
|
let headRef = input.headRefName;
|
|
16
16
|
let stackBottomPr = input.pr;
|
|
17
17
|
if (trunk !== input.baseRefName) {
|
|
18
|
-
const bottom = await bottomOpenLayer(input.pr, { owner: input.owner, name: input.name }, trunk);
|
|
18
|
+
const bottom = await bottomOpenLayer(input.pr, { owner: input.owner, name: input.name }, trunk, context);
|
|
19
19
|
headRef = bottom.headRefName;
|
|
20
20
|
stackBottomPr = bottom.number;
|
|
21
21
|
}
|
|
@@ -26,8 +26,8 @@ export async function loadMergeTargetStatus(input) {
|
|
|
26
26
|
stackBottomPr,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
async function bottomOpenLayer(pr, repo, trunk) {
|
|
30
|
-
const topology = await readStackTopology(pr, repo);
|
|
29
|
+
async function bottomOpenLayer(pr, repo, trunk, context) {
|
|
30
|
+
const topology = await (context?.readStackTopology(pr, repo) ?? readStackTopology(pr, repo));
|
|
31
31
|
const bottom = topology.ordered.find((member) => member.state === "OPEN" && member.baseRefName === trunk);
|
|
32
32
|
if (!bottom) {
|
|
33
33
|
throw new ShepherdError(`Native stack for PR #${pr} has no open layer based on ${trunk}`, EXIT.TEMPFAIL);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type RepoInfo } from "./client.mts";
|
|
2
2
|
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
/** Annotation totals copied from the same full-snapshot request; no follow-up probe is needed. */
|
|
4
|
+
export declare function markReadyAnnotationProbeComplete(pr: RawSummaryPr): void;
|
|
3
5
|
/**
|
|
4
6
|
* True when a ready layer's annotation probe failed or stopped short of every
|
|
5
7
|
* check page. Callers must not store or compare a fingerprint of that snapshot:
|
|
@@ -4,6 +4,11 @@ import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
|
|
|
4
4
|
import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
|
|
5
5
|
import { POLL_SUMMARY_ANNOTATION_PROBE_QUERY } from "./queries.mjs";
|
|
6
6
|
const PROBE_UNAVAILABLE = Symbol.for("prShepherd.annotationProbeUnavailable");
|
|
7
|
+
const COMPLETE_FROM_BATCH = new WeakSet();
|
|
8
|
+
/** Annotation totals copied from the same full-snapshot request; no follow-up probe is needed. */
|
|
9
|
+
export function markReadyAnnotationProbeComplete(pr) {
|
|
10
|
+
COMPLETE_FROM_BATCH.add(pr);
|
|
11
|
+
}
|
|
7
12
|
/**
|
|
8
13
|
* True when a ready layer's annotation probe failed or stopped short of every
|
|
9
14
|
* check page. Callers must not store or compare a fingerprint of that snapshot:
|
|
@@ -24,6 +29,8 @@ export function readyFingerprint(raw, stored) {
|
|
|
24
29
|
* a check gains an annotation.
|
|
25
30
|
*/
|
|
26
31
|
export async function hydrateReadyAnnotationProbe(pr, repo, review) {
|
|
32
|
+
if (COMPLETE_FROM_BATCH.has(pr))
|
|
33
|
+
return;
|
|
27
34
|
const checks = summarizePollSummaryChecks(pr);
|
|
28
35
|
if (!isCurrentSummaryReady(pr, checks, review))
|
|
29
36
|
return;
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -14,6 +14,8 @@ 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
|
+
/** Exact compact summary sibling only when a one-PR READY receipt needs evidence. */
|
|
18
|
+
export declare const BATCH_PR_RECEIPT_QUERY: string;
|
|
17
19
|
/** Trunk branch rules plus how far `headRef` is behind that branch. */
|
|
18
20
|
export declare const REF_RULES_QUERY: string;
|
|
19
21
|
/** How far a non-stack head is behind its PR base. One compare, no rules payload. */
|
|
@@ -41,6 +43,9 @@ export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
|
|
|
41
43
|
export declare const SUGGESTION_THREADS_QUERY: string;
|
|
42
44
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
|
43
45
|
export declare const REVIEW_THREAD_COMMENTS_QUERY: string;
|
|
46
|
+
/** Requested review-thread transcripts for best-effort reply seen markers. */
|
|
47
|
+
export declare const REPLY_THREAD_TRANSCRIPTS_QUERY: string;
|
|
48
|
+
export declare const REPLY_THREAD_COMMENTS_QUERY: string;
|
|
44
49
|
/** Fetch inline annotations for a single CheckRun by node ID. */
|
|
45
50
|
export declare const CHECK_RUN_ANNOTATIONS_QUERY: string;
|
|
46
51
|
/**
|
package/bin/github/queries.mjs
CHANGED
|
@@ -19,6 +19,8 @@ export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"
|
|
|
19
19
|
const POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT = gql("poll-summary-check-contexts.gql");
|
|
20
20
|
/** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
|
|
21
21
|
export const POLL_SUMMARY_FRAGMENT = `${gql("ref-rules.gql")}\n${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
|
|
22
|
+
/** Exact compact summary sibling only when a one-PR READY receipt needs evidence. */
|
|
23
|
+
export const BATCH_PR_RECEIPT_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}\n${BATCH_PR_QUERY.replace(" pullRequest(number: $pr) {", " receiptSummary: pullRequest(number: $pr) { ...PollSummaryPr }\n pullRequest(number: $pr) {")}`;
|
|
22
24
|
/** Trunk branch rules plus how far `headRef` is behind that branch. */
|
|
23
25
|
export const REF_RULES_QUERY = `${gql("ref-rules.gql")}\n${gql("ref-rules-query.gql")}`;
|
|
24
26
|
/** How far a non-stack head is behind its PR base. One compare, no rules payload. */
|
|
@@ -46,6 +48,9 @@ export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-targe
|
|
|
46
48
|
export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
|
|
47
49
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
|
48
50
|
export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
|
|
51
|
+
/** Requested review-thread transcripts for best-effort reply seen markers. */
|
|
52
|
+
export const REPLY_THREAD_TRANSCRIPTS_QUERY = gql("reply-thread-transcripts.gql");
|
|
53
|
+
export const REPLY_THREAD_COMMENTS_QUERY = gql("reply-thread-comments.gql");
|
|
49
54
|
/** Fetch inline annotations for a single CheckRun by node ID. */
|
|
50
55
|
export const CHECK_RUN_ANNOTATIONS_QUERY = gql("check-run-annotations.gql");
|
|
51
56
|
/**
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RateLimitInfo } from "./http-utils.mts";
|
|
2
|
+
export type RateLimitKind = "primary" | "secondary";
|
|
3
|
+
/** Interpret GitHub's throttle signals independently of the quota resource header. */
|
|
4
|
+
export declare function rateLimitKind(input: {
|
|
5
|
+
status: number;
|
|
6
|
+
message: string;
|
|
7
|
+
responseMessage?: string;
|
|
8
|
+
rateLimit?: RateLimitInfo;
|
|
9
|
+
retryAfterSeconds?: number;
|
|
10
|
+
graphqlErrors?: Array<{
|
|
11
|
+
message: string;
|
|
12
|
+
}>;
|
|
13
|
+
}): RateLimitKind | null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Interpret GitHub's throttle signals independently of the quota resource header. */
|
|
2
|
+
export function rateLimitKind(input) {
|
|
3
|
+
const exhausted = input.rateLimit !== undefined && input.rateLimit.remaining <= 0;
|
|
4
|
+
const responseText = input.responseMessage ?? input.message;
|
|
5
|
+
const graphqlMessages = (input.graphqlErrors ?? []).map((error) => error.message);
|
|
6
|
+
const textThrottleCapable = input.status === 403 || input.status === 429;
|
|
7
|
+
const explicitlySecondary = /secondary (?:rate )?limit|abuse detection/i;
|
|
8
|
+
if ((textThrottleCapable && explicitlySecondary.test(responseText)) ||
|
|
9
|
+
graphqlMessages.some((message) => explicitlySecondary.test(message))) {
|
|
10
|
+
return "secondary";
|
|
11
|
+
}
|
|
12
|
+
// A 429 with an empty primary bucket is an actual primary-limit response.
|
|
13
|
+
if (input.status === 429 && exhausted)
|
|
14
|
+
return "primary";
|
|
15
|
+
if (exhausted)
|
|
16
|
+
return "primary";
|
|
17
|
+
if (input.status === 429 ||
|
|
18
|
+
input.retryAfterSeconds !== undefined ||
|
|
19
|
+
(textThrottleCapable && /\brate limit\b/i.test(responseText)) ||
|
|
20
|
+
graphqlMessages.some((message) => /\brate limit\b/i.test(message))) {
|
|
21
|
+
// Without a measured empty bucket, do not spend REST core on a probe.
|
|
22
|
+
return "secondary";
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type RepoInfo } from "./client.mts";
|
|
2
|
+
/** Best-effort transcript evidence; it never filters user-supplied mutation IDs. */
|
|
3
|
+
export declare function fetchReplyThreadTranscripts(pr: number, repo: RepoInfo, requestedIds: readonly string[]): Promise<Map<string, string>>;
|