pr-shepherd 0.47.0 → 0.49.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 +14 -0
- package/bin/api.d.mts +19 -3
- package/bin/api.mjs +57 -8
- package/bin/classify/apply.d.mts +2 -0
- package/bin/classify/apply.mjs +1 -1
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.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 +16 -10
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +10 -7
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/poll-handler.mjs +40 -11
- package/bin/cli/poll-summary-emitter.d.mts +4 -0
- package/bin/cli/poll-summary-emitter.mjs +22 -0
- package/bin/cli/poll-summary-formatter.d.mts +2 -0
- package/bin/cli/poll-summary-formatter.mjs +96 -0
- package/bin/cli/poll-targets.d.mts +15 -0
- package/bin/cli/poll-targets.mjs +114 -0
- package/bin/cli/validate-default-args.mjs +1 -4
- package/bin/commands/iterate/api-usage.d.mts +1 -1
- package/bin/commands/iterate/api-usage.mjs +6 -2
- package/bin/commands/iterate/run.mjs +1 -1
- package/bin/commands/poll-run.d.mts +1 -1
- package/bin/commands/poll-run.mjs +2 -2
- package/bin/commands/poll-summary.d.mts +10 -0
- package/bin/commands/poll-summary.mjs +163 -0
- package/bin/commands/poll.mjs +2 -1
- package/bin/commands/ready-delay.d.mts +3 -1
- package/bin/commands/ready-delay.mjs +3 -2
- package/bin/config/load.d.mts +7 -0
- package/bin/config/load.mjs +70 -19
- package/bin/config.json +9 -3
- package/bin/github/gql/poll-stack-summary.gql +33 -0
- package/bin/github/gql/poll-summary-fragment.gql +198 -0
- package/bin/github/poll-summary-checks.d.mts +3 -0
- package/bin/github/poll-summary-checks.mjs +61 -0
- package/bin/github/poll-summary-projector.d.mts +4 -0
- package/bin/github/poll-summary-projector.mjs +81 -0
- package/bin/github/poll-summary-raw.d.mts +134 -0
- package/bin/github/poll-summary-raw.mjs +1 -0
- package/bin/github/poll-summary-review.d.mts +4 -0
- package/bin/github/poll-summary-review.mjs +85 -0
- package/bin/github/poll-summary-route.d.mts +4 -0
- package/bin/github/poll-summary-route.mjs +47 -0
- package/bin/github/poll-summary.d.mts +7 -0
- package/bin/github/poll-summary.mjs +110 -0
- package/bin/github/queries.d.mts +4 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/mcp/server.mjs +39 -6
- package/bin/pr-reference.d.mts +2 -0
- package/bin/pr-reference.mjs +4 -0
- package/bin/quota-warning.mjs +1 -1
- package/bin/types/iterate.d.mts +3 -3
- package/bin/types/poll-summary.d.mts +82 -0
- package/bin/types/poll-summary.mjs +1 -0
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- 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 +2 -2
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type SeenMarker } from "../state/seen-comments.mts";
|
|
2
|
+
import type { PollSummaryReview } from "../types.mts";
|
|
3
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
4
|
+
export declare function summarizePollSummaryReview(raw: RawSummaryPr, seen: Map<string, SeenMarker>, viewerCanAdminister: boolean): Promise<PollSummaryReview>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
2
|
+
import { applyRules } from "../classify/apply.mjs";
|
|
3
|
+
import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
|
|
4
|
+
import { loadConfig } from "../config/load.mjs";
|
|
5
|
+
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
6
|
+
import { classifyItem } from "../state/seen-comments.mjs";
|
|
7
|
+
const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
|
|
8
|
+
export async function summarizePollSummaryReview(raw, seen, viewerCanAdminister) {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const bots = normalizeBotUsernames(config.botUsernames);
|
|
11
|
+
const rules = await loadRules(discoverRuleFiles(getEffectiveCwd()));
|
|
12
|
+
let actionable = 0;
|
|
13
|
+
let incomplete = raw.comments.pageInfo.hasPreviousPage ||
|
|
14
|
+
raw.reviews.pageInfo.hasPreviousPage ||
|
|
15
|
+
raw.reviewThreads.pageInfo.hasPreviousPage;
|
|
16
|
+
for (const comment of raw.comments.nodes) {
|
|
17
|
+
if (!comment.isMinimized &&
|
|
18
|
+
!isSuppressed(rules, "pr-comment", comment) &&
|
|
19
|
+
classifyItem(comment.id, comment.body, seen) !== "unchanged")
|
|
20
|
+
actionable += 1;
|
|
21
|
+
}
|
|
22
|
+
const latestStateByAuthor = new Map((raw.latestReviews?.nodes ?? []).flatMap((review) => review.author ? [[review.author.login.toLowerCase(), review.state]] : []));
|
|
23
|
+
for (const review of raw.reviews.nodes) {
|
|
24
|
+
if (review.state === "COMMENTED") {
|
|
25
|
+
if (review.body.trim() !== "" &&
|
|
26
|
+
!review.isMinimized &&
|
|
27
|
+
!isSuppressed(rules, "review-summary", review) &&
|
|
28
|
+
classifyItem(review.id, review.body, seen) !== "unchanged")
|
|
29
|
+
actionable += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (review.state !== "CHANGES_REQUESTED")
|
|
33
|
+
continue;
|
|
34
|
+
const login = review.author?.login.toLowerCase();
|
|
35
|
+
if (login && latestStateByAuthor.get(login) === "APPROVED")
|
|
36
|
+
continue;
|
|
37
|
+
if (isSuppressed(rules, "changes-requested", review))
|
|
38
|
+
continue;
|
|
39
|
+
const isBot = review.author?.__typename === "Bot" || bots.has(review.author?.login.toLowerCase() ?? "");
|
|
40
|
+
if ((isBot && viewerCanAdminister) ||
|
|
41
|
+
classifyItem(review.id, review.body, seen) !== "unchanged")
|
|
42
|
+
actionable += 1;
|
|
43
|
+
}
|
|
44
|
+
for (const thread of raw.reviewThreads.nodes) {
|
|
45
|
+
if (thread.comments.pageInfo.hasPreviousPage)
|
|
46
|
+
incomplete = true;
|
|
47
|
+
const root = thread.rootComments?.nodes[0] ?? thread.comments.nodes[0];
|
|
48
|
+
const transcript = thread.comments.nodes
|
|
49
|
+
.map((comment) => comment.body)
|
|
50
|
+
.join(THREAD_COMMENT_SEPARATOR);
|
|
51
|
+
if (isSuppressed(rules, "review-thread", root, thread.id, thread.path, transcript))
|
|
52
|
+
continue;
|
|
53
|
+
const repeatable = root?.viewerDidAuthor === true ||
|
|
54
|
+
root?.author?.__typename === "Bot" ||
|
|
55
|
+
bots.has(root?.author?.login.toLowerCase() ?? "");
|
|
56
|
+
const unseen = classifyItem(thread.id, transcript, seen) !== "unchanged";
|
|
57
|
+
if ((!thread.isResolved && (repeatable || unseen)) ||
|
|
58
|
+
((thread.isResolved || thread.isOutdated) && unseen))
|
|
59
|
+
actionable += 1;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...(raw.comments.totalCount > 0 && { comments: raw.comments.totalCount }),
|
|
63
|
+
...(raw.reviews.totalCount > 0 && { reviews: raw.reviews.totalCount }),
|
|
64
|
+
...(raw.reviewThreads.totalCount > 0 && { threads: raw.reviewThreads.totalCount }),
|
|
65
|
+
...(actionable > 0 && { actionable }),
|
|
66
|
+
...(incomplete && { incomplete: true }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function isSuppressed(rules, kind, raw, id = raw?.id ?? "unknown", path, body = raw?.body ?? "") {
|
|
70
|
+
const item = {
|
|
71
|
+
kind,
|
|
72
|
+
id,
|
|
73
|
+
author: raw?.author?.login ?? "unknown",
|
|
74
|
+
authorType: raw?.author?.__typename === "Bot"
|
|
75
|
+
? "Bot"
|
|
76
|
+
: raw?.author?.__typename === "User"
|
|
77
|
+
? "User"
|
|
78
|
+
: "Unknown",
|
|
79
|
+
...(raw?.authorAssociation && { authorAssociation: raw.authorAssociation }),
|
|
80
|
+
body,
|
|
81
|
+
...(raw?.url && { url: raw.url }),
|
|
82
|
+
...(kind === "review-thread" && { path }),
|
|
83
|
+
};
|
|
84
|
+
return applyRules(rules, item).suppress === true;
|
|
85
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PollSummaryChecks, PollSummaryCommandOptions, PollSummaryItem, PollSummaryReview } from "../types.mts";
|
|
2
|
+
import type { RawSummaryPr } from "./poll-summary-raw.mts";
|
|
3
|
+
export declare function routePollSummary(raw: RawSummaryPr, checks: PollSummaryChecks, review: PollSummaryReview, opts: PollSummaryCommandOptions): Pick<PollSummaryItem, "action" | "reasons">;
|
|
4
|
+
export declare function normalizePollSummaryState(state: string): PollSummaryItem["state"];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.mjs";
|
|
2
|
+
export function routePollSummary(raw, checks, review, opts) {
|
|
3
|
+
const actions = loadConfig().actions;
|
|
4
|
+
const state = normalizePollSummaryState(raw.state);
|
|
5
|
+
if (state === "MERGED" || state === "CLOSED") {
|
|
6
|
+
return { action: "cancel", reasons: [state.toLowerCase()] };
|
|
7
|
+
}
|
|
8
|
+
if (raw.mergeable === "CONFLICTING" || raw.mergeStateStatus === "DIRTY") {
|
|
9
|
+
return { action: "fix_code", reasons: ["merge-conflicts"] };
|
|
10
|
+
}
|
|
11
|
+
if ((checks.failing ?? 0) > 0)
|
|
12
|
+
return { action: "fix_code", reasons: ["failing-checks"] };
|
|
13
|
+
if ((review.actionable ?? 0) > 0) {
|
|
14
|
+
if (opts.merge && raw.isInMergeQueue && actions.workWhileQueued !== true) {
|
|
15
|
+
return { action: "wait", reasons: ["review-work-deferred-while-queued"] };
|
|
16
|
+
}
|
|
17
|
+
return { action: "fix_code", reasons: ["review-work"] };
|
|
18
|
+
}
|
|
19
|
+
if ((checks.inProgress ?? 0) > 0 ||
|
|
20
|
+
raw.mergeable === "UNKNOWN" ||
|
|
21
|
+
raw.mergeStateStatus === "UNKNOWN" ||
|
|
22
|
+
raw.mergeStateStatus === "BEHIND" ||
|
|
23
|
+
raw.mergeStateStatus === "BLOCKED" ||
|
|
24
|
+
raw.mergeStateStatus === "HAS_HOOKS") {
|
|
25
|
+
return { action: "wait", reasons: ["pending-or-unknown"] };
|
|
26
|
+
}
|
|
27
|
+
if (raw.isDraft) {
|
|
28
|
+
if (opts.noAutoMarkReady || actions.autoMarkReady === false) {
|
|
29
|
+
return { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] };
|
|
30
|
+
}
|
|
31
|
+
return raw.viewerCanUpdate
|
|
32
|
+
? { action: "mark_ready", reasons: ["draft-appears-ready"] }
|
|
33
|
+
: { action: "escalate", reasons: ["mark-ready-authorization-required"] };
|
|
34
|
+
}
|
|
35
|
+
if (opts.merge && raw.isInMergeQueue) {
|
|
36
|
+
return { action: "wait", reasons: ["already-in-merge-queue"] };
|
|
37
|
+
}
|
|
38
|
+
if (opts.merge && raw.stack) {
|
|
39
|
+
return { action: "fix_code", reasons: ["authoritative-poll-required"] };
|
|
40
|
+
}
|
|
41
|
+
if (opts.merge && !raw.stack)
|
|
42
|
+
return { action: "merge", reasons: ["appears-ready"] };
|
|
43
|
+
return { action: "cancel", reasons: ["appears-ready"] };
|
|
44
|
+
}
|
|
45
|
+
export function normalizePollSummaryState(state) {
|
|
46
|
+
return state === "OPEN" || state === "CLOSED" || state === "MERGED" ? state : "UNKNOWN";
|
|
47
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection } from "../types.mts";
|
|
2
|
+
import { type RepoInfo } from "./client.mts";
|
|
3
|
+
export interface FetchedPollSummary {
|
|
4
|
+
selection: PollSummarySelection;
|
|
5
|
+
prs: PollSummaryItem[];
|
|
6
|
+
}
|
|
7
|
+
export declare function fetchPollSummary(opts: PollSummaryCommandOptions, repo: RepoInfo): Promise<FetchedPollSummary>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
import { graphqlWithRateLimit } from "./client.mjs";
|
|
3
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
4
|
+
import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
|
|
5
|
+
import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
|
|
6
|
+
const MAX_EXPLICIT_PRS_PER_QUERY = 50;
|
|
7
|
+
export async function fetchPollSummary(opts, repo) {
|
|
8
|
+
if (opts.stackPrNumber !== undefined)
|
|
9
|
+
return fetchStackSummary(opts, repo);
|
|
10
|
+
const requested = deduplicate(opts.prNumbers ?? []);
|
|
11
|
+
if (requested.length === 0) {
|
|
12
|
+
throw new ShepherdError("Aggregate poll requires at least two PRs or --stack <PR>", EXIT.USAGE);
|
|
13
|
+
}
|
|
14
|
+
const raw = [];
|
|
15
|
+
let viewerCanAdminister = false;
|
|
16
|
+
for (let offset = 0; offset < requested.length; offset += MAX_EXPLICIT_PRS_PER_QUERY) {
|
|
17
|
+
const chunk = requested.slice(offset, offset + MAX_EXPLICIT_PRS_PER_QUERY);
|
|
18
|
+
const fetched = await fetchExplicitChunk(chunk, repo);
|
|
19
|
+
viewerCanAdminister = fetched.viewerCanAdminister;
|
|
20
|
+
raw.push(...fetched.prs);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
selection: { kind: "prs", requested },
|
|
24
|
+
prs: await Promise.all(raw.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function fetchExplicitChunk(prs, repo) {
|
|
28
|
+
const declarations = prs.map((_, index) => `$pr${index}: Int!`).join(", ");
|
|
29
|
+
const aliases = prs
|
|
30
|
+
.map((_, index) => `pr${index}: pullRequest(number: $pr${index}) { ...PollSummaryPr }`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
const query = `${POLL_SUMMARY_FRAGMENT}\nquery PollSummary($owner: String!, $repo: String!, ${declarations}) {\n _shepherdRateLimit: rateLimit { cost limit nodeCount remaining resetAt used }\n repository(owner: $owner, name: $repo) {\n viewerCanAdminister\n ${aliases}\n }\n}`;
|
|
33
|
+
const variables = Object.fromEntries(prs.map((pr, index) => [`pr${index}`, pr]));
|
|
34
|
+
const result = await graphqlWithRateLimit(query, {
|
|
35
|
+
owner: repo.owner,
|
|
36
|
+
repo: repo.name,
|
|
37
|
+
...variables,
|
|
38
|
+
});
|
|
39
|
+
if (!result.data.repository)
|
|
40
|
+
throw missingRepository(repo);
|
|
41
|
+
const rawPrs = prs.map((pr, index) => {
|
|
42
|
+
const raw = result.data.repository[`pr${index}`];
|
|
43
|
+
if (!raw)
|
|
44
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
45
|
+
return raw;
|
|
46
|
+
});
|
|
47
|
+
return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
|
|
48
|
+
}
|
|
49
|
+
async function fetchStackSummary(opts, repo) {
|
|
50
|
+
const anchor = opts.stackPrNumber;
|
|
51
|
+
let after = null;
|
|
52
|
+
let stackId = null;
|
|
53
|
+
let stackNumber = 0;
|
|
54
|
+
let stackSize = 0;
|
|
55
|
+
let viewerCanAdminister = false;
|
|
56
|
+
const entries = [];
|
|
57
|
+
do {
|
|
58
|
+
const response = (await graphqlWithRateLimit(POLL_STACK_SUMMARY_QUERY, {
|
|
59
|
+
owner: repo.owner,
|
|
60
|
+
repo: repo.name,
|
|
61
|
+
anchor,
|
|
62
|
+
after,
|
|
63
|
+
}));
|
|
64
|
+
const repository = response.data.repository;
|
|
65
|
+
if (!repository)
|
|
66
|
+
throw missingRepository(repo);
|
|
67
|
+
viewerCanAdminister = repository.viewerCanAdminister;
|
|
68
|
+
if (!repository.pullRequest) {
|
|
69
|
+
throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
|
|
70
|
+
}
|
|
71
|
+
const stack = repository.pullRequest.stack;
|
|
72
|
+
if (!stack) {
|
|
73
|
+
throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
|
|
74
|
+
}
|
|
75
|
+
if (stackId !== null && stack.id !== stackId) {
|
|
76
|
+
throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
|
|
77
|
+
}
|
|
78
|
+
stackId = stack.id;
|
|
79
|
+
stackNumber = stack.number;
|
|
80
|
+
stackSize = stack.size;
|
|
81
|
+
for (const entry of stack.entries.nodes) {
|
|
82
|
+
if (!entry.pullRequest) {
|
|
83
|
+
throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
|
|
84
|
+
}
|
|
85
|
+
entries.push({ position: entry.position, pullRequest: entry.pullRequest });
|
|
86
|
+
}
|
|
87
|
+
const pageInfo = stack.entries.pageInfo;
|
|
88
|
+
if (pageInfo.hasNextPage && !pageInfo.endCursor) {
|
|
89
|
+
throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
|
|
90
|
+
}
|
|
91
|
+
after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
|
|
92
|
+
} while (after !== null);
|
|
93
|
+
const unique = new Map();
|
|
94
|
+
for (const entry of entries)
|
|
95
|
+
unique.set(entry.pullRequest.number, entry);
|
|
96
|
+
if (!unique.has(anchor) || unique.size !== stackSize) {
|
|
97
|
+
throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
|
|
98
|
+
}
|
|
99
|
+
const ordered = [...unique.values()].sort((left, right) => left.position - right.position);
|
|
100
|
+
return {
|
|
101
|
+
selection: { kind: "stack", anchor, stackNumber, stackSize },
|
|
102
|
+
prs: await Promise.all(ordered.map((entry) => summarizePollSummaryPr(entry.pullRequest, repo, opts, viewerCanAdminister))),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function deduplicate(values) {
|
|
106
|
+
return [...new Set(values)];
|
|
107
|
+
}
|
|
108
|
+
function missingRepository(repo) {
|
|
109
|
+
return new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
110
|
+
}
|
package/bin/github/queries.d.mts
CHANGED
|
@@ -12,6 +12,10 @@ export declare const BATCH_PR_QUERY: string;
|
|
|
12
12
|
export declare const BATCH_PR_PAGE_QUERY: string;
|
|
13
13
|
/** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
|
|
14
14
|
export declare const PR_FINGERPRINT_QUERY: string;
|
|
15
|
+
/** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
|
|
16
|
+
export declare const POLL_SUMMARY_FRAGMENT: string;
|
|
17
|
+
/** Discovers and summarizes every entry in one native GitHub pull-request stack. */
|
|
18
|
+
export declare const POLL_STACK_SUMMARY_QUERY: string;
|
|
15
19
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
16
20
|
export declare const SUGGESTION_THREADS_QUERY: string;
|
|
17
21
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
package/bin/github/queries.mjs
CHANGED
|
@@ -16,6 +16,10 @@ export const BATCH_PR_QUERY = withSharedFragments(gql("batch-pr.gql"));
|
|
|
16
16
|
export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
|
|
17
17
|
/** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
|
|
18
18
|
export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"));
|
|
19
|
+
/** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
|
|
20
|
+
export const POLL_SUMMARY_FRAGMENT = gql("poll-summary-fragment.gql");
|
|
21
|
+
/** Discovers and summarizes every entry in one native GitHub pull-request stack. */
|
|
22
|
+
export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
|
|
19
23
|
/** PR head fields plus a single review thread for `commit-suggestion`. */
|
|
20
24
|
export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
|
|
21
25
|
/** Fetches additional comments for a single review thread when its nested connection paginates. */
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -3,9 +3,10 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
|
|
6
|
-
import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
|
|
6
|
+
import { isRepositoryQualifiedPrReference, normalizeRepositoryIdentity, parsePrReference, } from "../pr-reference.mjs";
|
|
7
7
|
import { formatJournalResult } from "../cli/journal-formatter.mjs";
|
|
8
8
|
import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
|
|
9
|
+
import { formatPollSummaryResult } from "../cli/poll-summary-formatter.mjs";
|
|
9
10
|
import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
|
|
10
11
|
import { errorToExitCode, EXIT } from "../exit-codes.mjs";
|
|
11
12
|
const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
|
|
@@ -14,8 +15,11 @@ const pr = z
|
|
|
14
15
|
.refine(isRepositoryQualifiedPrReference, { message: QUALIFIED_PR_ERROR })
|
|
15
16
|
.describe("GitHub pull-request URL or owner/repo#number; the explicit repository may differ from the server working directory");
|
|
16
17
|
const ids = z.array(z.string().min(1)).optional();
|
|
17
|
-
const iterateInputSchema = z
|
|
18
|
-
|
|
18
|
+
const iterateInputSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
pr: pr.optional(),
|
|
21
|
+
prs: z.array(pr).min(1).optional(),
|
|
22
|
+
stack: pr.optional(),
|
|
19
23
|
readyDelaySeconds: z.number().nonnegative().optional(),
|
|
20
24
|
stallTimeoutSeconds: z.number().nonnegative().optional(),
|
|
21
25
|
noAutoMarkReady: z.boolean().optional(),
|
|
@@ -28,7 +32,12 @@ const iterateInputSchema = z.object({
|
|
|
28
32
|
.array(z.string())
|
|
29
33
|
.optional()
|
|
30
34
|
.describe("Deprecated per-call no-op retained for compatibility."),
|
|
31
|
-
})
|
|
35
|
+
})
|
|
36
|
+
.refine((input) => [input.pr, input.prs, input.stack].filter((value) => value !== undefined).length === 1, {
|
|
37
|
+
message: "exactly one of pr, prs, or stack is required",
|
|
38
|
+
})
|
|
39
|
+
.refine((input) => !input.prs ||
|
|
40
|
+
new Set(input.prs.map((ref) => normalizeRepositoryIdentity(parsePrReference(ref)?.repository ?? ""))).size === 1, { message: "all prs must belong to the same repository" });
|
|
32
41
|
const reviewMutationsOperationSchema = z.object({
|
|
33
42
|
type: z.literal("review_mutations"),
|
|
34
43
|
resolveThreadIds: ids,
|
|
@@ -80,7 +89,7 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
80
89
|
const shepherd = options.shepherd ?? createPrShepherd({ cwd: options.cwd });
|
|
81
90
|
const server = new McpServer({ name: "pr-shepherd", version: readPackageVersion() });
|
|
82
91
|
server.registerTool("iterate", {
|
|
83
|
-
description: "Inspect
|
|
92
|
+
description: "Inspect one pull request, an explicit same-repository set, or a native stack and return one Shepherd tick.",
|
|
84
93
|
inputSchema: iterateInputSchema,
|
|
85
94
|
annotations: {
|
|
86
95
|
readOnlyHint: false,
|
|
@@ -93,7 +102,9 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
93
102
|
const opts = {
|
|
94
103
|
readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
|
|
95
104
|
};
|
|
96
|
-
return runTool(() => shepherd
|
|
105
|
+
return runTool(() => runIterateSelector(shepherd, requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
|
|
106
|
+
? formatPollSummaryResult(result)
|
|
107
|
+
: formatIterateResult(result, opts), (result) => isPollSummary(result) ? result : projectIterateLean(result, opts));
|
|
97
108
|
});
|
|
98
109
|
server.registerTool("apply", {
|
|
99
110
|
description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
|
|
@@ -127,6 +138,28 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
127
138
|
}, async (input) => runTool(() => shepherd.buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
|
|
128
139
|
return server;
|
|
129
140
|
}
|
|
141
|
+
function runIterateSelector(shepherd, input) {
|
|
142
|
+
return "prs" in input || "stack" in input
|
|
143
|
+
? shepherd.iterate(input)
|
|
144
|
+
: shepherd.iterate(input);
|
|
145
|
+
}
|
|
146
|
+
function requireRepositoryQualifiedIterate(input) {
|
|
147
|
+
if ([input.pr, input.prs, input.stack].filter((value) => value !== undefined).length !== 1) {
|
|
148
|
+
throw new PrShepherdValidationError("exactly one of pr, prs, or stack is required");
|
|
149
|
+
}
|
|
150
|
+
const refs = Array.isArray(input.prs) ? input.prs : [input.pr ?? input.stack];
|
|
151
|
+
if (refs.length === 0 || refs.some((ref) => !isRepositoryQualifiedPrReference(ref))) {
|
|
152
|
+
throw new PrShepherdValidationError(QUALIFIED_PR_ERROR);
|
|
153
|
+
}
|
|
154
|
+
const repositories = new Set(refs.map((ref) => normalizeRepositoryIdentity(parsePrReference(ref)?.repository ?? "")));
|
|
155
|
+
if (repositories.size !== 1) {
|
|
156
|
+
throw new PrShepherdValidationError("all prs must belong to the same repository");
|
|
157
|
+
}
|
|
158
|
+
return input;
|
|
159
|
+
}
|
|
160
|
+
function isPollSummary(result) {
|
|
161
|
+
return "mode" in result && result.mode === "summary";
|
|
162
|
+
}
|
|
130
163
|
function requireRepositoryQualifiedPr(input) {
|
|
131
164
|
if (!isRepositoryQualifiedPrReference(input.pr)) {
|
|
132
165
|
throw new PrShepherdValidationError(QUALIFIED_PR_ERROR);
|
package/bin/pr-reference.d.mts
CHANGED
|
@@ -18,3 +18,5 @@ export declare function resolveParsedPrTarget(parsed: ParsedPrReference): Resolv
|
|
|
18
18
|
/** Canonical collision-safe PR reference for generated commands and escalation messages. */
|
|
19
19
|
export declare function formatPrUrl(repository: string, prNumber: number): string;
|
|
20
20
|
export declare function isRepositoryQualifiedPrReference(pr: unknown): pr is string;
|
|
21
|
+
/** GitHub owner and repository names are case-insensitive. */
|
|
22
|
+
export declare function normalizeRepositoryIdentity(repository: string): string;
|
package/bin/pr-reference.mjs
CHANGED
|
@@ -56,3 +56,7 @@ export function isRepositoryQualifiedPrReference(pr) {
|
|
|
56
56
|
return false;
|
|
57
57
|
return parsePrReference(pr)?.repository !== undefined;
|
|
58
58
|
}
|
|
59
|
+
/** GitHub owner and repository names are case-insensitive. */
|
|
60
|
+
export function normalizeRepositoryIdentity(repository) {
|
|
61
|
+
return repository.toLowerCase();
|
|
62
|
+
}
|
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
|
}
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -153,10 +153,10 @@ export interface IterateCommandOptions extends GlobalOptions {
|
|
|
153
153
|
/** Shepherd through readiness and emit the exact merge/queue command when ready. */
|
|
154
154
|
merge?: boolean;
|
|
155
155
|
/**
|
|
156
|
-
* Internal. Defers
|
|
157
|
-
*
|
|
158
|
-
* api.mts.
|
|
156
|
+
* Internal. Defers quota warnings until an until-terminal poll breaks. Set only by
|
|
157
|
+
* `runPollCore`; excluded from the public `IterateInput` in api.mts.
|
|
159
158
|
*/
|
|
160
159
|
deferQuotaWarning?: boolean;
|
|
160
|
+
quotaWarningMinimumPollIntervalMinutes?: number;
|
|
161
161
|
}
|
|
162
162
|
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ApiUsage, GraphqlQuotaWarning } from "./api-usage.mts";
|
|
2
|
+
import type { MergeableState, MergeStateStatus, ReviewDecision } from "./github.mts";
|
|
3
|
+
import type { ShepherdAction } from "./iterate.mts";
|
|
4
|
+
export interface PollSummaryChecks {
|
|
5
|
+
passing?: number;
|
|
6
|
+
failing?: number;
|
|
7
|
+
inProgress?: number;
|
|
8
|
+
skipped?: number;
|
|
9
|
+
filtered?: number;
|
|
10
|
+
ignored?: number;
|
|
11
|
+
superseded?: number;
|
|
12
|
+
incomplete?: true;
|
|
13
|
+
}
|
|
14
|
+
export interface PollSummaryReview {
|
|
15
|
+
comments?: number;
|
|
16
|
+
reviews?: number;
|
|
17
|
+
threads?: number;
|
|
18
|
+
actionable?: number;
|
|
19
|
+
incomplete?: true;
|
|
20
|
+
}
|
|
21
|
+
interface PollSummaryStack {
|
|
22
|
+
number: number;
|
|
23
|
+
size: number;
|
|
24
|
+
position: number;
|
|
25
|
+
baseRefName: string;
|
|
26
|
+
}
|
|
27
|
+
export interface PollSummaryItem {
|
|
28
|
+
pr: number;
|
|
29
|
+
repo: string;
|
|
30
|
+
title: string;
|
|
31
|
+
url: string;
|
|
32
|
+
/** Conservative routing hint; the selected one-PR poll makes the authoritative decision. */
|
|
33
|
+
action: ShepherdAction;
|
|
34
|
+
reasons: string[];
|
|
35
|
+
state: "OPEN" | "CLOSED" | "MERGED" | "UNKNOWN";
|
|
36
|
+
mergeable: MergeableState;
|
|
37
|
+
mergeStateStatus: MergeStateStatus;
|
|
38
|
+
reviewDecision?: ReviewDecision;
|
|
39
|
+
headRefName: string;
|
|
40
|
+
headRefOid: string;
|
|
41
|
+
baseRefName: string;
|
|
42
|
+
isDraft?: true;
|
|
43
|
+
isInMergeQueue?: true;
|
|
44
|
+
blockingReviewerInProgress?: true;
|
|
45
|
+
remainingSeconds?: number;
|
|
46
|
+
checks?: PollSummaryChecks;
|
|
47
|
+
review?: PollSummaryReview;
|
|
48
|
+
stack?: PollSummaryStack;
|
|
49
|
+
pollCommand?: string;
|
|
50
|
+
}
|
|
51
|
+
export type PollSummarySelection = {
|
|
52
|
+
kind: "prs";
|
|
53
|
+
requested: number[];
|
|
54
|
+
} | {
|
|
55
|
+
kind: "stack";
|
|
56
|
+
anchor: number;
|
|
57
|
+
stackNumber: number;
|
|
58
|
+
stackSize: number;
|
|
59
|
+
};
|
|
60
|
+
export interface PollSummaryResult {
|
|
61
|
+
mode: "summary";
|
|
62
|
+
repo: string;
|
|
63
|
+
selection: PollSummarySelection;
|
|
64
|
+
reason: "actionable" | "all_terminal" | "waiting" | "timeout";
|
|
65
|
+
prs: PollSummaryItem[];
|
|
66
|
+
apiUsage?: ApiUsage;
|
|
67
|
+
quotaWarning?: GraphqlQuotaWarning;
|
|
68
|
+
}
|
|
69
|
+
export interface PollSummaryCommandOptions {
|
|
70
|
+
prNumbers?: number[];
|
|
71
|
+
stackPrNumber?: number;
|
|
72
|
+
targetRepository?: {
|
|
73
|
+
owner: string;
|
|
74
|
+
name: string;
|
|
75
|
+
};
|
|
76
|
+
merge?: boolean;
|
|
77
|
+
readyDelaySeconds?: number;
|
|
78
|
+
stallTimeoutSeconds?: number;
|
|
79
|
+
noAutoMarkReady?: boolean;
|
|
80
|
+
noAutoCancelActionable?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/bin/types.d.mts
CHANGED
package/bin/types.mjs
CHANGED
package/package.json
CHANGED
|
@@ -18,9 +18,9 @@ If the requested PR does not exist yet, review and commit the in-scope changes,
|
|
|
18
18
|
|
|
19
19
|
## Arguments: $ARGUMENTS
|
|
20
20
|
|
|
21
|
-
1. Parse
|
|
21
|
+
1. Parse optional PR numbers, repository-qualified `owner/repo#N` references, or GitHub PR URLs and an optional `--merge` flag from `$ARGUMENTS`; alternatively parse one `--stack PR` selector. Otherwise let pr-shepherd infer the current branch PR. Reject any remaining argument. Follow the target repository's local `AGENTS.md` and `CLAUDE.md` standards while making changes.
|
|
22
22
|
|
|
23
|
-
2. For the CLI, convert supplied `owner/repo#N` to `https://github.com/owner/repo/pull/N`; otherwise pass
|
|
23
|
+
2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return when any row needs work or all rows are terminal. It also returns for a quota warning or an emitted `[MERGE]` command, which is non-terminal and must run before the next invocation. A qualified reference may name a fork or upstream repository: it is the GitHub target, while the current checkout continues to supply local git/config/rules context. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first repository-qualify every supplied reference with its GitHub URL or `owner/repo#N`; resolve bare numbers through `gh pr view <number> --json url --jq .url`, and resolve an omitted target with `gh pr view --json url --jq .url`. If that does not produce the required qualified selector, stop and report that MCP cannot safely determine it. Otherwise call `iterate` with `pr`, `prs`, or `stack` as selected, plus `merge: true` when `--merge` was supplied, and print its full result.
|
|
24
24
|
|
|
25
25
|
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patches` with the same qualified PR reference; do not run a shell `pr-shepherd apply` command.
|
|
26
26
|
|