pr-shepherd 0.55.1 → 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 +12 -9
- 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/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 +1 -1
- package/bin/cli/help.d.mts +1 -1
- package/bin/cli/iterate-checks-formatter.mjs +2 -0
- package/bin/cli/iterate-instructions.mjs +1 -1
- 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/commit-suggestion-instruction.mjs +2 -1
- package/bin/commands/iterate/check-instructions.d.mts +9 -5
- package/bin/commands/iterate/check-instructions.mjs +15 -35
- 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/native-stack-rebase.mjs +3 -2
- package/bin/commands/iterate/render.mjs +13 -5
- package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
- package/bin/commands/iterate/stale-ancestry.mjs +3 -2
- package/bin/commands/iterate/unreported-required.mjs +1 -1
- package/bin/commands/playbook-pointer.d.mts +2 -0
- package/bin/commands/playbook-pointer.mjs +4 -0
- 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/poll-summary-instructions.mjs +1 -1
- 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/commands/shepherd-journal.d.mts +1 -1
- package/bin/commands/shepherd-journal.mjs +3 -2
- package/bin/commands/stack-drain.mjs +2 -1
- 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 +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
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +57 -70
- package/plugins/pr-shepherd/skills/pr-shepherd/references/branch-update.md +9 -0
- package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +21 -0
- package/plugins/pr-shepherd/skills/pr-shepherd/references/journal.md +7 -0
- package/plugins/pr-shepherd/skills/pr-shepherd/references/review-mutations.md +9 -0
- package/plugins/pr-shepherd/skills/pr-shepherd/references/stack-merge.md +7 -0
- package/plugins/pr-shepherd/skills/pr-shepherd/references/suggestion-patches.md +10 -0
|
@@ -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>>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
2
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
3
|
+
import { rateLimitKind } from "./rate-limit-kind.mjs";
|
|
4
|
+
import { REPLY_THREAD_COMMENTS_QUERY, REPLY_THREAD_TRANSCRIPTS_QUERY } from "./queries.mjs";
|
|
5
|
+
import { threadTranscriptBodies } from "../threads/transcript.mjs";
|
|
6
|
+
import { mapPool } from "../util/pool.mjs";
|
|
7
|
+
const IDS_PER_REQUEST = 20;
|
|
8
|
+
const CONCURRENCY = 4;
|
|
9
|
+
const MAX_COMMENT_PAGES = 100;
|
|
10
|
+
/** Best-effort transcript evidence; it never filters user-supplied mutation IDs. */
|
|
11
|
+
export async function fetchReplyThreadTranscripts(pr, repo, requestedIds) {
|
|
12
|
+
const ids = [...new Set(requestedIds)];
|
|
13
|
+
if (ids.length === 0)
|
|
14
|
+
return new Map();
|
|
15
|
+
const expectedRepo = `${repo.owner}/${repo.name}`.toLowerCase();
|
|
16
|
+
const requested = new Set(ids);
|
|
17
|
+
const chunks = [];
|
|
18
|
+
for (let offset = 0; offset < ids.length; offset += IDS_PER_REQUEST) {
|
|
19
|
+
chunks.push(ids.slice(offset, offset + IDS_PER_REQUEST));
|
|
20
|
+
}
|
|
21
|
+
let stopped = false;
|
|
22
|
+
const firstPages = await mapPool(chunks, CONCURRENCY, async (chunk) => {
|
|
23
|
+
if (stopped)
|
|
24
|
+
return [];
|
|
25
|
+
try {
|
|
26
|
+
const result = await graphqlWithRateLimit(REPLY_THREAD_TRANSCRIPTS_QUERY, {
|
|
27
|
+
ids: chunk,
|
|
28
|
+
});
|
|
29
|
+
if (result.rateLimit?.remaining === 0)
|
|
30
|
+
stopped = true;
|
|
31
|
+
return result.data.nodes.filter((node) => node?.__typename === "PullRequestReviewThread" &&
|
|
32
|
+
node.id !== undefined &&
|
|
33
|
+
requested.has(node.id) &&
|
|
34
|
+
node.pullRequest?.number === pr &&
|
|
35
|
+
node.pullRequest.repository.nameWithOwner.toLowerCase() === expectedRepo &&
|
|
36
|
+
node.comments !== undefined);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (isThrottle(error))
|
|
40
|
+
stopped = true;
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const threads = firstPages.flat();
|
|
45
|
+
const completed = await mapPool(threads, CONCURRENCY, async (thread) => {
|
|
46
|
+
try {
|
|
47
|
+
return await completeThread(thread, () => stopped, () => {
|
|
48
|
+
stopped = true;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (isThrottle(error))
|
|
53
|
+
stopped = true;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return new Map(completed.filter((entry) => entry !== null));
|
|
58
|
+
}
|
|
59
|
+
async function completeThread(thread, isStopped, stop) {
|
|
60
|
+
if (!thread.id || !thread.comments)
|
|
61
|
+
return null;
|
|
62
|
+
const bodies = thread.comments.nodes.map((comment) => comment.body);
|
|
63
|
+
let page = thread.comments;
|
|
64
|
+
const seenCursors = new Set();
|
|
65
|
+
let pages = 1;
|
|
66
|
+
while (page.pageInfo.hasNextPage) {
|
|
67
|
+
const cursor = page.pageInfo.endCursor;
|
|
68
|
+
if (!cursor || seenCursors.has(cursor) || pages >= MAX_COMMENT_PAGES || isStopped())
|
|
69
|
+
return null;
|
|
70
|
+
seenCursors.add(cursor);
|
|
71
|
+
const result = await graphqlWithRateLimit(REPLY_THREAD_COMMENTS_QUERY, {
|
|
72
|
+
id: thread.id,
|
|
73
|
+
cursor,
|
|
74
|
+
});
|
|
75
|
+
if (result.rateLimit?.remaining === 0)
|
|
76
|
+
stop();
|
|
77
|
+
const next = result.data.node;
|
|
78
|
+
if (next?.__typename !== "PullRequestReviewThread" || next.id !== thread.id || !next.comments) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
page = next.comments;
|
|
82
|
+
bodies.push(...page.nodes.map((comment) => comment.body));
|
|
83
|
+
pages += 1;
|
|
84
|
+
}
|
|
85
|
+
return bodies.length > 0 ? [thread.id, threadTranscriptBodies(bodies)] : null;
|
|
86
|
+
}
|
|
87
|
+
function isThrottle(error) {
|
|
88
|
+
return error instanceof GitHubRequestError && rateLimitKind(error) !== null;
|
|
89
|
+
}
|
package/bin/github/rest-http.mjs
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { type RateLimitInfo } from "./http-utils.mts";
|
|
2
|
+
export declare function restText(path: string, onRateLimit?: (rateLimit?: RateLimitInfo) => void): Promise<string>;
|
package/bin/github/rest-text.mjs
CHANGED
|
@@ -3,11 +3,11 @@ import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
|
3
3
|
import { GitHubRequestError } from "./errors.mjs";
|
|
4
4
|
import { makeAuthHeaders } from "./http-auth.mjs";
|
|
5
5
|
import { requestWithTokenRetry } from "./http-request.mjs";
|
|
6
|
-
import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody } from "./http-utils.mjs";
|
|
6
|
+
import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody, } from "./http-utils.mjs";
|
|
7
7
|
import { recordApiTelemetry } from "./api-telemetry.mjs";
|
|
8
8
|
import { recordIntermediateResponse } from "./http-intermediate.mjs";
|
|
9
9
|
const BASE_URL = "https://api.github.com";
|
|
10
|
-
export async function restText(path) {
|
|
10
|
+
export async function restText(path, onRateLimit) {
|
|
11
11
|
const url = `${BASE_URL}${path}`;
|
|
12
12
|
const n = nextEntry();
|
|
13
13
|
appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
|
|
@@ -28,6 +28,7 @@ export async function restText(path) {
|
|
|
28
28
|
}));
|
|
29
29
|
const durationMs = Math.round(performance.now() - retryT0);
|
|
30
30
|
const rateLimit = parseRateLimit(res.headers) ?? undefined;
|
|
31
|
+
onRateLimit?.(rateLimit);
|
|
31
32
|
const retryAfterSeconds = parseRetryAfter(res.headers);
|
|
32
33
|
recordApiTelemetry({ kind: "REST", method: "GET", authSource, rateLimit });
|
|
33
34
|
if ([301, 302, 307, 308].includes(res.status)) {
|
|
@@ -62,6 +63,7 @@ export async function restText(path) {
|
|
|
62
63
|
rateLimit,
|
|
63
64
|
retryAfterSeconds,
|
|
64
65
|
authSource,
|
|
66
|
+
responseMessage: sanitizeBody(text),
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
appendEntry(formatResponseEntry({
|
|
@@ -114,6 +116,7 @@ async function followRestTextRedirect(res, entry) {
|
|
|
114
116
|
status: redirectRes.status,
|
|
115
117
|
rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
|
|
116
118
|
retryAfterSeconds: parseRetryAfter(redirectRes.headers),
|
|
119
|
+
responseMessage: "",
|
|
117
120
|
});
|
|
118
121
|
}
|
|
119
122
|
return redirectRes.text();
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
+
import { type RateLimitInfo } from "./client.mts";
|
|
1
2
|
import type { RawThread } from "./batch-raw-types.mts";
|
|
2
|
-
export declare function hydrateThreadCommentPages(threads: RawThread[]): Promise<
|
|
3
|
+
export declare function hydrateThreadCommentPages(threads: RawThread[], initialRateLimit?: RateLimitInfo): Promise<{
|
|
4
|
+
threads: RawThread[];
|
|
5
|
+
rateLimit?: RateLimitInfo;
|
|
6
|
+
}>;
|
|
@@ -4,23 +4,47 @@ import { paginateForward } from "./pagination.mjs";
|
|
|
4
4
|
import { REVIEW_THREAD_COMMENTS_QUERY } from "./queries.mjs";
|
|
5
5
|
import { mapPool } from "../util/pool.mjs";
|
|
6
6
|
const THREAD_COMMENT_PAGE_CONCURRENCY = 4;
|
|
7
|
-
export async function hydrateThreadCommentPages(threads) {
|
|
8
|
-
const gate = {
|
|
9
|
-
|
|
7
|
+
export async function hydrateThreadCommentPages(threads, initialRateLimit) {
|
|
8
|
+
const gate = {
|
|
9
|
+
rateLimit: initialRateLimit,
|
|
10
|
+
exhausted: initialRateLimit?.remaining === 0,
|
|
11
|
+
};
|
|
12
|
+
const hydrated = await mapPool(threads, THREAD_COMMENT_PAGE_CONCURRENCY, async (thread) => {
|
|
13
|
+
if (gate.error !== undefined)
|
|
14
|
+
return thread;
|
|
15
|
+
try {
|
|
16
|
+
return await hydrateThreadCommentPage(thread, gate);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
gate.error ??= error;
|
|
20
|
+
return thread;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
if (gate.error !== undefined)
|
|
24
|
+
throw gate.error;
|
|
25
|
+
return { threads: hydrated, rateLimit: gate.rateLimit };
|
|
10
26
|
}
|
|
11
27
|
async function hydrateThreadCommentPage(thread, gate) {
|
|
12
28
|
const pageInfo = thread.comments.pageInfo;
|
|
13
29
|
if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
|
|
14
30
|
return thread;
|
|
15
31
|
const extra = await paginateForward(async (cursor) => {
|
|
16
|
-
if (gate.
|
|
17
|
-
throw
|
|
32
|
+
if (gate.error !== undefined)
|
|
33
|
+
throw gate.error;
|
|
34
|
+
if (gate.exhausted) {
|
|
35
|
+
throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; thread comment pagination incomplete", { status: 403, rateLimit: gate.rateLimit });
|
|
18
36
|
}
|
|
19
37
|
const res = await graphqlWithRateLimit(REVIEW_THREAD_COMMENTS_QUERY, {
|
|
20
38
|
threadId: thread.id,
|
|
21
39
|
...(cursor ? { commentsCursor: cursor } : {}),
|
|
22
40
|
});
|
|
23
|
-
|
|
41
|
+
if (res.rateLimit?.remaining === 0) {
|
|
42
|
+
gate.rateLimit = res.rateLimit;
|
|
43
|
+
gate.exhausted = true;
|
|
44
|
+
}
|
|
45
|
+
else if (!gate.exhausted) {
|
|
46
|
+
gate.rateLimit = res.rateLimit ?? gate.rateLimit;
|
|
47
|
+
}
|
|
24
48
|
const node = res.data.node;
|
|
25
49
|
if (!node?.comments) {
|
|
26
50
|
const nodeType = node?.__typename ?? "null";
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { formatPollSummaryResult } from "../cli/poll-summary-formatter.mjs";
|
|
|
10
10
|
import { projectStackOverview } from "../cli/stack-overview.mjs";
|
|
11
11
|
import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
|
|
12
12
|
import { errorToExitCode, EXIT } from "../exit-codes.mjs";
|
|
13
|
+
import { extractShepherdJournal } from "../journal/index.mjs";
|
|
13
14
|
const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
|
|
14
15
|
const pr = z
|
|
15
16
|
.string()
|
|
@@ -87,7 +88,8 @@ const suggestionPatchesInputSchema = z.object({
|
|
|
87
88
|
});
|
|
88
89
|
/** Creates a local-only MCP server with Shepherd's public operations. */
|
|
89
90
|
export function createPrShepherdMcpServer(options = {}) {
|
|
90
|
-
|
|
91
|
+
let shepherd = options.shepherd;
|
|
92
|
+
const getShepherd = () => (shepherd ??= createPrShepherd({ cwd: options.cwd }));
|
|
91
93
|
const server = new McpServer({ name: "pr-shepherd", version: readPackageVersion() });
|
|
92
94
|
server.registerTool("iterate", {
|
|
93
95
|
description: "Inspect one pull request, an explicit same-repository set, or a native stack and return one Shepherd tick.",
|
|
@@ -103,7 +105,7 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
103
105
|
const opts = {
|
|
104
106
|
readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
|
|
105
107
|
};
|
|
106
|
-
return runTool(() => runIterateSelector(
|
|
108
|
+
return runTool(() => runIterateSelector(getShepherd(), requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
|
|
107
109
|
? formatPollSummaryResult(result)
|
|
108
110
|
: formatIterateResult(result, opts), (result) => isPollSummary(result)
|
|
109
111
|
? result.selection.kind === "stack"
|
|
@@ -120,7 +122,32 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
120
122
|
idempotentHint: false,
|
|
121
123
|
openWorldHint: true,
|
|
122
124
|
},
|
|
123
|
-
}, async (input) => runTool(() =>
|
|
125
|
+
}, async (input) => runTool(() => getShepherd().apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
|
|
126
|
+
server.registerTool("extract_journal", {
|
|
127
|
+
description: "Extract the validated Shepherd Journal from a supplied Markdown body without I/O.",
|
|
128
|
+
inputSchema: z.object({ body: z.string() }),
|
|
129
|
+
annotations: {
|
|
130
|
+
readOnlyHint: true,
|
|
131
|
+
destructiveHint: false,
|
|
132
|
+
idempotentHint: true,
|
|
133
|
+
openWorldHint: false,
|
|
134
|
+
},
|
|
135
|
+
}, async (input) => runTool(async () => {
|
|
136
|
+
if (typeof input.body !== "string") {
|
|
137
|
+
throw new PrShepherdValidationError("body must be a Markdown string");
|
|
138
|
+
}
|
|
139
|
+
return extractShepherdJournal(input.body);
|
|
140
|
+
}, JSON.stringify));
|
|
141
|
+
server.registerTool("get_journal", {
|
|
142
|
+
description: "Fetch one pull request body with GraphQL and extract its Shepherd Journal.",
|
|
143
|
+
inputSchema: z.object({ pr }),
|
|
144
|
+
annotations: {
|
|
145
|
+
readOnlyHint: true,
|
|
146
|
+
destructiveHint: false,
|
|
147
|
+
idempotentHint: true,
|
|
148
|
+
openWorldHint: true,
|
|
149
|
+
},
|
|
150
|
+
}, async (input) => runTool(() => getShepherd().getJournal(requireRepositoryQualifiedPr(input)), JSON.stringify));
|
|
124
151
|
server.registerTool("build_suggestion_patches", {
|
|
125
152
|
description: "Build, but never apply, an ordered list of eligible review suggestion patches.",
|
|
126
153
|
inputSchema: suggestionPatchesInputSchema,
|
|
@@ -130,7 +157,7 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
130
157
|
idempotentHint: true,
|
|
131
158
|
openWorldHint: true,
|
|
132
159
|
},
|
|
133
|
-
}, async (input) => runTool(() =>
|
|
160
|
+
}, async (input) => runTool(() => getShepherd().buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
|
|
134
161
|
server.registerTool("build_suggestion_patch", {
|
|
135
162
|
description: "Deprecated: use build_suggestion_patches with a one-item suggestions array.",
|
|
136
163
|
inputSchema: suggestionPatchInputSchema,
|
|
@@ -140,7 +167,7 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
140
167
|
idempotentHint: true,
|
|
141
168
|
openWorldHint: true,
|
|
142
169
|
},
|
|
143
|
-
}, async (input) => runTool(() =>
|
|
170
|
+
}, async (input) => runTool(() => getShepherd().buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
|
|
144
171
|
return server;
|
|
145
172
|
}
|
|
146
173
|
function runIterateSelector(shepherd, input) {
|
package/bin/quota-warning.mjs
CHANGED
|
@@ -5,8 +5,8 @@ export function buildQuotaAwareContinuation(warning, prefix) {
|
|
|
5
5
|
const opening = warning.resource === "combined"
|
|
6
6
|
? "GitHub's GraphQL and REST core quotas are both low. Keep using pr-shepherd at the cadence below. Do not shift incidental calls between GraphQL and REST."
|
|
7
7
|
: warning.resource === "core"
|
|
8
|
-
? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh
|
|
9
|
-
: `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR
|
|
8
|
+
? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh api repos/OWNER/REPO/pulls/PR\` calls while REST core is below its warning threshold.`
|
|
9
|
+
: `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR reads that do not need Shepherd's full snapshot, use explicit REST endpoints such as \`gh api repos/OWNER/REPO/pulls/PR\` — they draw on the separate REST budget, not the depleted GraphQL pool.`;
|
|
10
10
|
const resetLabel = warning.resource === "combined"
|
|
11
11
|
? "both quotas have reset"
|
|
12
12
|
: warning.resource === "core"
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -66,6 +66,7 @@ export function toAgentCheck(c) {
|
|
|
66
66
|
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
67
67
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
68
68
|
...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
|
|
69
|
+
...(c.relatedJobs !== undefined && { relatedJobs: c.relatedJobs }),
|
|
69
70
|
...(c.annotations !== undefined && { annotations: c.annotations }),
|
|
70
71
|
...(c.scope !== undefined && { scope: c.scope }),
|
|
71
72
|
...(c.commitOid !== undefined && { commitOid: c.commitOid }),
|
|
@@ -13,4 +13,5 @@ export declare function threadComments(thread: {
|
|
|
13
13
|
viewerDidAuthor?: true;
|
|
14
14
|
} & Partial<Pick<ReviewThreadComment, "isMinimized" | "createdAtUnix">>>;
|
|
15
15
|
}): ReviewThreadComment[];
|
|
16
|
+
export declare function threadTranscriptBodies(bodies: string[]): string;
|
|
16
17
|
export declare function threadTranscriptBody(thread: ReviewThread, appendedBodies?: string[]): string;
|
|
@@ -29,9 +29,12 @@ export function threadComments(thread) {
|
|
|
29
29
|
];
|
|
30
30
|
}
|
|
31
31
|
const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
|
|
32
|
+
export function threadTranscriptBodies(bodies) {
|
|
33
|
+
return bodies.join(THREAD_COMMENT_SEPARATOR);
|
|
34
|
+
}
|
|
32
35
|
export function threadTranscriptBody(thread, appendedBodies = []) {
|
|
33
36
|
const bodies = thread.comments && thread.comments.length > 0
|
|
34
37
|
? threadComments(thread).map((c) => c.body)
|
|
35
38
|
: [thread.body];
|
|
36
|
-
return [...bodies, ...appendedBodies]
|
|
39
|
+
return threadTranscriptBodies([...bodies, ...appendedBodies]);
|
|
37
40
|
}
|