pr-shepherd 0.48.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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -0
  3. package/bin/api.d.mts +19 -3
  4. package/bin/api.mjs +57 -8
  5. package/bin/classify/apply.d.mts +2 -0
  6. package/bin/classify/apply.mjs +1 -1
  7. package/bin/cli/default-poll.mjs +1 -0
  8. package/bin/cli/help-command-pages.d.mts +1 -1
  9. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  10. package/bin/cli/help-iterate-poll-pages.mjs +10 -5
  11. package/bin/cli/help-top-page.d.mts +1 -1
  12. package/bin/cli/help-top-page.mjs +5 -3
  13. package/bin/cli/help.d.mts +2 -2
  14. package/bin/cli/poll-handler.mjs +25 -4
  15. package/bin/cli/poll-summary-emitter.d.mts +4 -0
  16. package/bin/cli/poll-summary-emitter.mjs +22 -0
  17. package/bin/cli/poll-summary-formatter.d.mts +2 -0
  18. package/bin/cli/poll-summary-formatter.mjs +96 -0
  19. package/bin/cli/poll-targets.d.mts +15 -0
  20. package/bin/cli/poll-targets.mjs +114 -0
  21. package/bin/cli/validate-default-args.mjs +1 -4
  22. package/bin/commands/poll-summary.d.mts +10 -0
  23. package/bin/commands/poll-summary.mjs +163 -0
  24. package/bin/commands/ready-delay.d.mts +3 -1
  25. package/bin/commands/ready-delay.mjs +3 -2
  26. package/bin/github/gql/poll-stack-summary.gql +33 -0
  27. package/bin/github/gql/poll-summary-fragment.gql +198 -0
  28. package/bin/github/poll-summary-checks.d.mts +3 -0
  29. package/bin/github/poll-summary-checks.mjs +61 -0
  30. package/bin/github/poll-summary-projector.d.mts +4 -0
  31. package/bin/github/poll-summary-projector.mjs +81 -0
  32. package/bin/github/poll-summary-raw.d.mts +134 -0
  33. package/bin/github/poll-summary-raw.mjs +1 -0
  34. package/bin/github/poll-summary-review.d.mts +4 -0
  35. package/bin/github/poll-summary-review.mjs +85 -0
  36. package/bin/github/poll-summary-route.d.mts +4 -0
  37. package/bin/github/poll-summary-route.mjs +47 -0
  38. package/bin/github/poll-summary.d.mts +7 -0
  39. package/bin/github/poll-summary.mjs +110 -0
  40. package/bin/github/queries.d.mts +4 -0
  41. package/bin/github/queries.mjs +4 -0
  42. package/bin/mcp/server.mjs +39 -6
  43. package/bin/pr-reference.d.mts +2 -0
  44. package/bin/pr-reference.mjs +4 -0
  45. package/bin/types/poll-summary.d.mts +82 -0
  46. package/bin/types/poll-summary.mjs +1 -0
  47. package/bin/types.d.mts +1 -0
  48. package/bin/types.mjs +1 -0
  49. package/package.json +1 -1
  50. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  51. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  52. package/plugins/pr-shepherd/.mcp.json +1 -1
  53. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +2 -2
@@ -0,0 +1,81 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ import { updateReadyDelay } from "../commands/ready-delay.mjs";
3
+ import { formatPrUrl } from "../pr-reference.mjs";
4
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
5
+ import { loadSeenMap } from "../state/seen-comments.mjs";
6
+ import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
7
+ import { summarizePollSummaryReview } from "./poll-summary-review.mjs";
8
+ import { normalizePollSummaryState, routePollSummary } from "./poll-summary-route.mjs";
9
+ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdminister = false) {
10
+ const repoName = `${repo.owner}/${repo.name}`;
11
+ const seen = await loadSeenMap({ owner: repo.owner, repo: repo.name, pr: raw.number });
12
+ const checks = summarizePollSummaryChecks(raw);
13
+ const review = await summarizePollSummaryReview(raw, seen, viewerCanAdminister);
14
+ const blockingReviewerInProgress = detectBlockingReviewer(raw);
15
+ let { action, reasons } = routePollSummary(raw, checks, review, opts);
16
+ let remainingSeconds;
17
+ if (raw.isDraft && blockingReviewerInProgress && action === "mark_ready") {
18
+ action = "wait";
19
+ reasons = ["blocking-reviewer-in-progress"];
20
+ }
21
+ const appearsReady = reasons.includes("appears-ready");
22
+ const readyDelaySeconds = opts.readyDelaySeconds ?? (loadConfig().watch?.readyDelayMinutes ?? 10) * 60;
23
+ const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { retainElapsed: true });
24
+ if (appearsReady && !readyState.shouldCancel) {
25
+ action = "wait";
26
+ reasons = ["ready-delay"];
27
+ remainingSeconds = readyState.remainingSeconds;
28
+ }
29
+ const stack = raw.stack
30
+ ? {
31
+ number: raw.stack.number,
32
+ size: raw.stack.size,
33
+ position: raw.stackEntry?.position ?? 0,
34
+ baseRefName: raw.stack.baseRefName,
35
+ }
36
+ : undefined;
37
+ return {
38
+ pr: raw.number,
39
+ repo: repoName,
40
+ title: raw.title,
41
+ url: raw.url || formatPrUrl(repoName, raw.number),
42
+ action,
43
+ reasons,
44
+ state: normalizePollSummaryState(raw.state),
45
+ mergeable: raw.mergeable,
46
+ mergeStateStatus: raw.mergeStateStatus,
47
+ ...(raw.reviewDecision && { reviewDecision: raw.reviewDecision }),
48
+ headRefName: raw.headRefName,
49
+ headRefOid: raw.headRefOid,
50
+ baseRefName: raw.baseRefName,
51
+ ...(raw.isDraft && { isDraft: true }),
52
+ ...(raw.isInMergeQueue && { isInMergeQueue: true }),
53
+ ...(blockingReviewerInProgress && { blockingReviewerInProgress: true }),
54
+ ...(remainingSeconds !== undefined && { remainingSeconds }),
55
+ ...(Object.keys(checks).length > 0 && { checks }),
56
+ ...(Object.keys(review).length > 0 && { review }),
57
+ ...(stack && { stack }),
58
+ ...(!["wait", "cancel"].includes(action) && {
59
+ pollCommand: buildPollCommand(repoName, raw.number, opts),
60
+ }),
61
+ };
62
+ }
63
+ function detectBlockingReviewer(raw) {
64
+ const prefixes = (loadConfig().mergeStatus?.blockingReviewerLogins ?? []).map((login) => login.toLowerCase());
65
+ const matches = (author) => author !== null && prefixes.some((prefix) => author.login.toLowerCase().startsWith(prefix));
66
+ return ((raw.reviewRequests?.nodes ?? []).some((request) => matches(request.requestedReviewer)) ||
67
+ (raw.latestReviews?.nodes ?? []).some((review) => review.state === "PENDING" && matches(review.author)));
68
+ }
69
+ function buildPollCommand(repo, pr, opts) {
70
+ const args = [formatPrUrl(repo, pr), "--until-terminal"];
71
+ if (opts.merge)
72
+ args.push("--merge");
73
+ if (opts.readyDelaySeconds !== undefined)
74
+ args.push("--ready-delay", `${opts.readyDelaySeconds}s`);
75
+ if (opts.stallTimeoutSeconds !== undefined) {
76
+ args.push("--stall-timeout", `${opts.stallTimeoutSeconds}s`);
77
+ }
78
+ if (opts.noAutoMarkReady)
79
+ args.push("--no-auto-mark-ready");
80
+ return buildPrShepherdCommand(args).text;
81
+ }
@@ -0,0 +1,134 @@
1
+ export interface RawAuthor {
2
+ __typename?: string;
3
+ login: string;
4
+ }
5
+ interface RawSummaryComment {
6
+ id: string;
7
+ body: string;
8
+ isMinimized: boolean;
9
+ viewerDidAuthor?: boolean;
10
+ authorAssociation?: string;
11
+ url?: string;
12
+ author: RawAuthor | null;
13
+ }
14
+ interface SummaryConnection<T> {
15
+ totalCount: number;
16
+ pageInfo: {
17
+ hasPreviousPage: boolean;
18
+ };
19
+ nodes: T[];
20
+ }
21
+ type RawCheckContext = {
22
+ __typename: "CheckRun";
23
+ id?: string;
24
+ name: string;
25
+ status: string;
26
+ conclusion: string | null;
27
+ detailsUrl?: string;
28
+ checkSuite: {
29
+ workflowRun: {
30
+ databaseId?: string | number;
31
+ event: string;
32
+ workflow?: {
33
+ name: string;
34
+ databaseId: string | number;
35
+ } | null;
36
+ } | null;
37
+ } | null;
38
+ } | {
39
+ __typename: "StatusContext";
40
+ context: string;
41
+ state: string;
42
+ };
43
+ interface RawCheckRollup {
44
+ contexts: SummaryConnection<RawCheckContext>;
45
+ }
46
+ export interface RawSummaryPr {
47
+ number: number;
48
+ title: string;
49
+ url: string;
50
+ state: string;
51
+ isDraft: boolean;
52
+ viewerCanUpdate: boolean;
53
+ headRefName: string;
54
+ headRefOid: string;
55
+ baseRefName: string;
56
+ mergeable: string;
57
+ mergeStateStatus: string;
58
+ reviewDecision: string | null;
59
+ reviewRequests?: {
60
+ nodes: Array<{
61
+ requestedReviewer: RawAuthor | null;
62
+ }>;
63
+ };
64
+ latestReviews?: {
65
+ nodes: Array<{
66
+ state: string;
67
+ author: RawAuthor | null;
68
+ }>;
69
+ };
70
+ isInMergeQueue: boolean;
71
+ mergeQueueEntry: {
72
+ headCommit: {
73
+ statusCheckRollup: RawCheckRollup | null;
74
+ } | null;
75
+ } | null;
76
+ stack: {
77
+ number: number;
78
+ size: number;
79
+ baseRefName: string;
80
+ } | null;
81
+ stackEntry: {
82
+ position: number;
83
+ } | null;
84
+ comments: SummaryConnection<RawSummaryComment>;
85
+ reviews: SummaryConnection<RawSummaryComment & {
86
+ state: string;
87
+ }>;
88
+ reviewThreads: SummaryConnection<{
89
+ id: string;
90
+ isResolved: boolean;
91
+ isOutdated: boolean;
92
+ path: string | null;
93
+ rootComments?: {
94
+ nodes: RawSummaryComment[];
95
+ };
96
+ comments: SummaryConnection<RawSummaryComment>;
97
+ }>;
98
+ commits: {
99
+ nodes: Array<{
100
+ commit: {
101
+ statusCheckRollup: RawCheckRollup | null;
102
+ };
103
+ }>;
104
+ };
105
+ }
106
+ export interface RawExplicitResponse {
107
+ repository: ({
108
+ viewerCanAdminister: boolean;
109
+ } & Record<string, RawSummaryPr | boolean | null>) | null;
110
+ }
111
+ export interface RawStackResponse {
112
+ repository: {
113
+ viewerCanAdminister: boolean;
114
+ pullRequest: {
115
+ stack: {
116
+ id: string;
117
+ number: number;
118
+ size: number;
119
+ baseRefName: string;
120
+ entries: {
121
+ pageInfo: {
122
+ hasNextPage: boolean;
123
+ endCursor: string | null;
124
+ };
125
+ nodes: Array<{
126
+ position: number;
127
+ pullRequest: RawSummaryPr | null;
128
+ }>;
129
+ };
130
+ } | null;
131
+ } | null;
132
+ } | null;
133
+ }
134
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ }
@@ -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. */
@@ -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. */
@@ -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.object({
18
- pr,
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 the specified pull request and return the next Shepherd state.",
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.iterate(requireRepositoryQualifiedPr(input)), (result) => formatIterateResult(result, opts), (result) => projectIterateLean(result, opts));
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);
@@ -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;
@@ -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
+ }