pr-shepherd 0.48.0 → 0.50.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 (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -2
  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 +9 -9
  9. package/bin/cli/help-command-pages.mjs +8 -8
  10. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  11. package/bin/cli/help-iterate-poll-pages.mjs +10 -5
  12. package/bin/cli/help-top-page.d.mts +1 -1
  13. package/bin/cli/help-top-page.mjs +5 -3
  14. package/bin/cli/help.d.mts +10 -10
  15. package/bin/cli/iterate-instructions.mjs +9 -2
  16. package/bin/cli/iterate-lean.mjs +11 -0
  17. package/bin/cli/poll-handler.mjs +25 -4
  18. package/bin/cli/poll-summary-emitter.d.mts +4 -0
  19. package/bin/cli/poll-summary-emitter.mjs +22 -0
  20. package/bin/cli/poll-summary-formatter.d.mts +2 -0
  21. package/bin/cli/poll-summary-formatter.mjs +96 -0
  22. package/bin/cli/poll-targets.d.mts +15 -0
  23. package/bin/cli/poll-targets.mjs +114 -0
  24. package/bin/cli/validate-default-args.mjs +1 -4
  25. package/bin/cli-parser.mjs +3 -0
  26. package/bin/commands/iterate/check-instructions.d.mts +2 -8
  27. package/bin/commands/iterate/check-instructions.mjs +2 -11
  28. package/bin/commands/iterate/escalate.mjs +30 -2
  29. package/bin/commands/iterate/fix-code.mjs +62 -20
  30. package/bin/commands/iterate/render.mjs +1 -1
  31. package/bin/commands/iterate/stall.mjs +30 -0
  32. package/bin/commands/iterate/thread-mutation-routing.d.mts +0 -2
  33. package/bin/commands/iterate/thread-mutation-routing.mjs +1 -2
  34. package/bin/commands/poll-summary.d.mts +10 -0
  35. package/bin/commands/poll-summary.mjs +163 -0
  36. package/bin/commands/ready-delay.d.mts +3 -1
  37. package/bin/commands/ready-delay.mjs +3 -2
  38. package/bin/commands/resolve-mutate.mjs +22 -68
  39. package/bin/comments/resolve.d.mts +5 -0
  40. package/bin/comments/resolve.mjs +2 -11
  41. package/bin/github/gql/poll-stack-summary.gql +33 -0
  42. package/bin/github/gql/poll-summary-fragment.gql +198 -0
  43. package/bin/github/poll-summary-checks.d.mts +3 -0
  44. package/bin/github/poll-summary-checks.mjs +61 -0
  45. package/bin/github/poll-summary-projector.d.mts +4 -0
  46. package/bin/github/poll-summary-projector.mjs +81 -0
  47. package/bin/github/poll-summary-raw.d.mts +134 -0
  48. package/bin/github/poll-summary-raw.mjs +1 -0
  49. package/bin/github/poll-summary-review.d.mts +4 -0
  50. package/bin/github/poll-summary-review.mjs +85 -0
  51. package/bin/github/poll-summary-route.d.mts +4 -0
  52. package/bin/github/poll-summary-route.mjs +47 -0
  53. package/bin/github/poll-summary.d.mts +7 -0
  54. package/bin/github/poll-summary.mjs +110 -0
  55. package/bin/github/queries.d.mts +4 -0
  56. package/bin/github/queries.mjs +4 -0
  57. package/bin/mcp/server.mjs +39 -6
  58. package/bin/pr-reference.d.mts +2 -0
  59. package/bin/pr-reference.mjs +4 -0
  60. package/bin/state/fix-attempts.d.mts +3 -4
  61. package/bin/state/fix-attempts.mjs +2 -3
  62. package/bin/types/escalate.d.mts +10 -0
  63. package/bin/types/poll-summary.d.mts +82 -0
  64. package/bin/types/poll-summary.mjs +1 -0
  65. package/bin/types.d.mts +1 -0
  66. package/bin/types.mjs +1 -0
  67. package/package.json +1 -1
  68. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  69. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  70. package/plugins/pr-shepherd/.mcp.json +1 -1
  71. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +5 -10
@@ -69,7 +69,7 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
69
69
  instructions.push(`Apply every warranted review fix in ${filesRef}.`);
70
70
  }
71
71
  if (resolutionOnlyThreads.length > 0) {
72
- instructions.push('Review the threads under `## Review threads to resolve` before running mutations. Use the generated commands as shown — see "Review-mutation routing" in the pr-shepherd skill for which flag applies to which ID.');
72
+ instructions.push("Review the threads under `## Review threads to resolve` before running the generated mutations.");
73
73
  }
74
74
  instructions.push(...buildFailingCheckInstructions(failingChecks), ...repeatedWorkflowBranchRecoveryInstructions);
75
75
  if (hasAnnotations) {
@@ -1,8 +1,32 @@
1
+ /* eslint-disable max-lines */
1
2
  import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
3
  import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
4
  import { buildEscalateSuggestion, buildEscalateHumanMessage, formatDurationApprox, } from "./escalate.mjs";
4
5
  import { checksWithActionableAnnotations } from "../check-annotations.mjs";
5
6
  import { formatPrUrl } from "../../pr-reference.mjs";
7
+ function pendingReviewCommandsFromResult(result) {
8
+ if (result.action !== "fix_code")
9
+ return undefined;
10
+ const pending = {
11
+ ...(result.fix.resolveOnlyCommand?.hasMutations && {
12
+ resolveOnlyCommand: result.fix.resolveOnlyCommand,
13
+ }),
14
+ ...(result.fix.resolveCommand.hasMutations && { resolveCommand: result.fix.resolveCommand }),
15
+ };
16
+ return Object.keys(pending).length > 0 ? pending : undefined;
17
+ }
18
+ function surfacedSummariesFromResult(result) {
19
+ if (result.action !== "fix_code")
20
+ return {};
21
+ return {
22
+ ...(result.fix.firstLookSummaries.length > 0 && {
23
+ firstLookSummaries: result.fix.firstLookSummaries,
24
+ }),
25
+ ...(result.fix.editedSummaries.length > 0 && {
26
+ editedSummaries: result.fix.editedSummaries,
27
+ }),
28
+ };
29
+ }
6
30
  function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
7
31
  const checks = [
8
32
  ...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}:${f.runId ?? "no-run"}:${f.runAttempt ?? "unknown"}`),
@@ -44,13 +68,16 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
44
68
  action: prospectiveResult.action,
45
69
  });
46
70
  if (stalledChecks.length > 0) {
71
+ const pending = pendingReviewCommandsFromResult(prospectiveResult);
47
72
  const stalledDuration = formatDurationApprox(Math.max(...stalledChecks.map((c) => c.ageSeconds)));
48
73
  const escalateBase = {
49
74
  triggers: ["stall-timeout"],
50
75
  unresolvedThreads: [],
51
76
  ambiguousComments: [],
52
77
  changesRequestedReviews: [],
78
+ ...surfacedSummariesFromResult(prospectiveResult),
53
79
  stalledChecks,
80
+ ...(pending && { pendingReviewCommands: pending }),
54
81
  suggestion: buildEscalateSuggestion(["stall-timeout"], stalledDuration),
55
82
  };
56
83
  return {
@@ -76,11 +103,14 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
76
103
  }
77
104
  else if (ageSeconds >= stallTimeoutSeconds) {
78
105
  const stalledDuration = formatDurationApprox(ageSeconds);
106
+ const pending = pendingReviewCommandsFromResult(prospectiveResult);
79
107
  const escalateBase = {
80
108
  triggers: ["stall-timeout"],
81
109
  unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
82
110
  ambiguousComments: report.comments.actionable.map(toAgentComment),
83
111
  changesRequestedReviews: report.changesRequestedReviews,
112
+ ...surfacedSummariesFromResult(prospectiveResult),
113
+ ...(pending && { pendingReviewCommands: pending }),
84
114
  suggestion: buildEscalateSuggestion(["stall-timeout"], stalledDuration),
85
115
  };
86
116
  return {
@@ -1,8 +1,6 @@
1
1
  import { type NormalizedBotUsernames } from "../../comments/authors.mts";
2
- import { shouldResolveOtherHumanThread } from "../../comments/thread-resolve-policy.mts";
3
2
  import type { ResolveOtherHumanThreads } from "../../config/load.mts";
4
3
  import type { AgentThread, ReviewThread } from "../../types.mts";
5
- export { shouldResolveOtherHumanThread };
6
4
  export type RoutableThread = AgentThread | ReviewThread;
7
5
  export interface ThreadMutationRouting {
8
6
  replyThreadIds: string[];
@@ -1,7 +1,6 @@
1
1
  import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "../../comments/authors.mjs";
2
2
  import { threadEndedByShepherd } from "../../comments/marker.mjs";
3
3
  import { shouldResolveOtherHumanThread } from "../../comments/thread-resolve-policy.mjs";
4
- export { shouldResolveOtherHumanThread };
5
4
  function dedupeIds(ids) {
6
5
  return [...new Set(ids)];
7
6
  }
@@ -32,7 +31,7 @@ export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolv
32
31
  .filter((thread) => shouldPairResolve(thread, botUsernames, policy) && !threadEndedByShepherd(thread))
33
32
  .map((thread) => thread.id));
34
33
  const pairedResolveIdSet = new Set(pairedResolveThreadIds);
35
- // Rule-matched threads bypass author routing; resolve-mutate retains the human-author guard.
34
+ // Rule-matched threads bypass the generated-command author routing.
36
35
  const standaloneResolveThreadIds = dedupeIds([
37
36
  ...threads
38
37
  .filter((thread) => shouldPairResolve(thread, botUsernames, policy) && threadEndedByShepherd(thread))
@@ -0,0 +1,10 @@
1
+ import type { PollSummaryCommandOptions, PollSummaryResult } from "../types.mts";
2
+ export interface AggregatePollCommandOptions extends PollSummaryCommandOptions {
3
+ intervalSeconds: number;
4
+ timeoutSeconds: number;
5
+ debounceSeconds?: number;
6
+ quietStatus?: boolean;
7
+ untilTerminal?: boolean;
8
+ }
9
+ export declare function runPollSummary(opts: PollSummaryCommandOptions): Promise<PollSummaryResult>;
10
+ export declare function runAggregatePoll(opts: AggregatePollCommandOptions): Promise<PollSummaryResult>;
@@ -0,0 +1,163 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ import { ShepherdError } from "../exit-codes.mjs";
3
+ import { getRepoInfo } from "../github/client.mjs";
4
+ import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
5
+ import { fetchPollSummary } from "../github/poll-summary.mjs";
6
+ import { sleep } from "../util/sleep.mjs";
7
+ import { graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs } from "./poll-quota.mjs";
8
+ import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
9
+ const MAX_TIMER_MS = 2 ** 31 - 1;
10
+ const TIMER_DRIFT_TOLERANCE_MS = 500;
11
+ export function runPollSummary(opts) {
12
+ return withApiTelemetryScope(async () => attachUsage(await runPollSummaryCore(opts)));
13
+ }
14
+ export function runAggregatePoll(opts) {
15
+ return withApiTelemetryScope(() => runAggregatePollCore(opts));
16
+ }
17
+ async function runPollSummaryCore(opts) {
18
+ const repo = opts.targetRepository ?? (await getRepoInfo());
19
+ const fetched = await fetchPollSummary(opts, repo);
20
+ const allTerminal = fetched.prs.every((item) => item.action === "cancel");
21
+ const actionable = fetched.prs.some((item) => item.action !== "wait" && item.action !== "cancel");
22
+ return {
23
+ mode: "summary",
24
+ repo: `${repo.owner}/${repo.name}`,
25
+ selection: fetched.selection,
26
+ reason: allTerminal ? "all_terminal" : actionable ? "actionable" : "waiting",
27
+ prs: fetched.prs,
28
+ };
29
+ }
30
+ async function runAggregatePollCore(opts) {
31
+ const intervalMs = Math.min(opts.intervalSeconds * 1000, MAX_TIMER_MS);
32
+ const timeoutMs = Math.min(opts.timeoutSeconds * 1000, MAX_TIMER_MS);
33
+ const debounceMs = Math.min((opts.debounceSeconds ?? 60) * 1000, MAX_TIMER_MS);
34
+ const quotaBands = loadConfig().watch.graphqlQuotaWarnings;
35
+ const start = Date.now();
36
+ let tick = 0;
37
+ let debounceUntil = null;
38
+ let last;
39
+ let lastStatusSignature = null;
40
+ let rateLimitRetries = 0;
41
+ let pendingQuotaWarning;
42
+ while (true) {
43
+ tick += 1;
44
+ try {
45
+ last = await runPollSummaryCore(opts);
46
+ rateLimitRetries = 0;
47
+ }
48
+ catch (error) {
49
+ if (last?.selection.kind === "stack" && isMissingStack(error)) {
50
+ const explicit = await runPollSummaryCore({
51
+ ...opts,
52
+ stackPrNumber: undefined,
53
+ prNumbers: last.prs.map((item) => item.pr),
54
+ });
55
+ if (explicit.prs.every((item) => item.action === "cancel")) {
56
+ const warning = await aggregateQuotaWarning(explicit, quotaBands, opts.intervalSeconds);
57
+ if (warning)
58
+ pendingQuotaWarning = warning;
59
+ return attachUsage({
60
+ ...explicit,
61
+ reason: "all_terminal",
62
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
63
+ });
64
+ }
65
+ }
66
+ const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
67
+ if (retryMs === null || rateLimitRetries >= 1)
68
+ throw error;
69
+ rateLimitRetries += 1;
70
+ process.stderr.write(`[aggregate poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
71
+ await sleep(retryMs);
72
+ continue;
73
+ }
74
+ const allTerminal = last.prs.every((item) => item.action === "cancel");
75
+ const immediate = last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
76
+ const hasFix = last.prs.some((item) => item.action === "fix_code");
77
+ const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
78
+ if (warning)
79
+ pendingQuotaWarning = warning;
80
+ if (allTerminal) {
81
+ return attachUsage({
82
+ ...last,
83
+ reason: "all_terminal",
84
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
85
+ });
86
+ }
87
+ if (immediate) {
88
+ return attachUsage({
89
+ ...last,
90
+ reason: "actionable",
91
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
92
+ });
93
+ }
94
+ if (hasFix) {
95
+ if (debounceMs === 0)
96
+ return attachUsage({
97
+ ...last,
98
+ reason: "actionable",
99
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
100
+ });
101
+ debounceUntil ??= Date.now() + debounceMs;
102
+ if (Date.now() >= debounceUntil)
103
+ return attachUsage({
104
+ ...last,
105
+ reason: "actionable",
106
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
107
+ });
108
+ }
109
+ else {
110
+ debounceUntil = null;
111
+ if (opts.untilTerminal && pendingQuotaWarning) {
112
+ return attachUsage({ ...last, quotaWarning: pendingQuotaWarning });
113
+ }
114
+ }
115
+ const elapsedMs = Date.now() - start;
116
+ const sleepMs = debounceUntil
117
+ ? Math.min(intervalMs, Math.max(debounceUntil - Date.now(), 0))
118
+ : graphqlQuotaPollIntervalMs(quotaBands, summarizeApiTelemetry()?.graphql, intervalMs, MAX_TIMER_MS);
119
+ if (!opts.untilTerminal && debounceUntil === null) {
120
+ const remainingMs = timeoutMs - elapsedMs;
121
+ if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
122
+ return attachUsage({ ...last, reason: "timeout" });
123
+ }
124
+ }
125
+ const statusSignature = summaryStatusSignature(last);
126
+ if (!opts.quietStatus || hasFix || statusSignature !== lastStatusSignature) {
127
+ process.stderr.write(`[aggregate poll tick ${tick} / +${Math.round(elapsedMs / 1000)}s] ${last.prs
128
+ .map((item) => `#${item.pr} ${item.action.toUpperCase()}`)
129
+ .join(", ")}\n`);
130
+ }
131
+ lastStatusSignature = statusSignature;
132
+ await sleep(sleepMs);
133
+ }
134
+ }
135
+ async function aggregateQuotaWarning(result, bands, intervalSeconds) {
136
+ const usage = summarizeApiTelemetry()?.graphql;
137
+ const [owner, repo] = result.repo.split("/");
138
+ if (!usage || !owner || !repo)
139
+ return undefined;
140
+ return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
141
+ ...band,
142
+ pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
143
+ })), usage, true);
144
+ }
145
+ function summaryStatusSignature(result) {
146
+ return JSON.stringify(result.prs.map((item) => ({
147
+ pr: item.pr,
148
+ action: item.action,
149
+ state: item.state,
150
+ mergeable: item.mergeable,
151
+ mergeStateStatus: item.mergeStateStatus,
152
+ reviewDecision: item.reviewDecision,
153
+ checks: item.checks,
154
+ review: item.review,
155
+ })));
156
+ }
157
+ function isMissingStack(error) {
158
+ return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
159
+ }
160
+ function attachUsage(result) {
161
+ const apiUsage = summarizeApiTelemetry();
162
+ return apiUsage ? { ...result, apiUsage } : result;
163
+ }
@@ -25,5 +25,7 @@ interface ReadyDelayState {
25
25
  * When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
26
26
  * the loop and tells one-shot agents to stop.
27
27
  */
28
- export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string): Promise<ReadyDelayState>;
28
+ export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options?: {
29
+ retainElapsed?: boolean;
30
+ }): Promise<ReadyDelayState>;
29
31
  export {};
@@ -18,7 +18,7 @@ import { resolvePrStatePath } from "../state/base.mjs";
18
18
  * When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
19
19
  * the loop and tells one-shot agents to stop.
20
20
  */
21
- export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo) {
21
+ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options = {}) {
22
22
  const markerPath = readySincePath(prNumber, owner, repo);
23
23
  if (!isReady) {
24
24
  // Reset the timer.
@@ -47,7 +47,8 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
47
47
  const elapsed = now - readySince;
48
48
  const remaining = readyDelaySeconds - elapsed;
49
49
  if (remaining <= 0) {
50
- await safeUnlink(markerPath);
50
+ if (!options.retainElapsed)
51
+ await safeUnlink(markerPath);
51
52
  return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
52
53
  }
53
54
  return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
@@ -1,12 +1,9 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
3
  import { fetchPrBatch } from "../github/batch.mjs";
4
- import { loadConfig } from "../config/load.mjs";
5
- import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, normalizeBotUsernames, } from "../comments/authors.mjs";
6
- import { shouldResolveOtherHumanThread } from "./iterate/thread-mutation-routing.mjs";
7
4
  import { markReplySeen } from "../state/seen-comments.mjs";
8
5
  import { threadTranscriptBody } from "../threads/transcript.mjs";
9
- import { addPrShepherdMarker, threadEndedByShepherd } from "../comments/marker.mjs";
6
+ import { addPrShepherdMarker } from "../comments/marker.mjs";
10
7
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
11
8
  /** @deprecated Hidden implementation for `resolve`; use `apply review`. */
12
9
  export async function runResolveMutate(opts) {
@@ -15,73 +12,30 @@ export async function runResolveMutate(opts) {
15
12
  if (prNumber === null) {
16
13
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
17
14
  }
18
- const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
19
- const config = loadConfig();
20
- const botUsernames = normalizeBotUsernames(config.botUsernames);
21
- const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
22
- const humanThreadIds = new Set(data.reviewThreads
23
- .filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
24
- .map((t) => t.id));
25
- const humanCommentIds = new Set(data.comments
26
- .filter((c) => isHumanAuthor(c) && !isConfiguredBotAuthor(c, botUsernames))
27
- .map((c) => c.id));
28
- const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
29
- .filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
30
- .map((r) => r.id));
31
- // Iterate uses viewer capability fields while deciding which commands to
32
- // print. Once a caller explicitly runs apply, GitHub's mutation response is
33
- // authoritative and this path must not second-guess that intent.
34
- const requestedReplyIds = new Set(opts.replyThreadIds ?? []);
35
- const policy = config.iterate?.resolveOtherHumanThreads ?? "none";
36
- const allowedHumanResolveIds = new Set(data.reviewThreads
37
- .filter((thread) => {
38
- if (!humanThreadIds.has(thread.id))
39
- return false;
40
- const paired = requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread);
41
- if (!paired)
42
- return false;
43
- if (isViewerAuthoredHuman(thread, botUsernames))
44
- return true;
45
- return shouldResolveOtherHumanThread(thread, policy);
46
- })
47
- .map((thread) => thread.id));
48
- const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id) || allowedHumanResolveIds.has(id));
49
- const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedHumanResolveIds.has(id));
50
- const knownThreadIds = new Set(data.reviewThreads.map((thread) => thread.id));
51
- const replyThreadIds = opts.replyThreadIds?.filter((id) => knownThreadIds.has(id));
52
- const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !knownThreadIds.has(id));
53
- const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
54
- const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
55
- const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && data.changesRequestedReviews.some((review) => review.id === id));
56
- const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
57
- const skippedIneligibleDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
58
- const hasMutation = resolveThreadIds.length > 0 ||
59
- (replyThreadIds?.length ?? 0) > 0 ||
60
- minimizeCommentIds.length > 0 ||
61
- dismissReviewIds.length > 0;
15
+ // Fetch only to retain the pre-reply transcript for successful-reply seen
16
+ // markers. It never determines which user-supplied IDs are sent to GitHub.
17
+ let threadById;
18
+ if (opts.replyThreadIds?.length) {
19
+ try {
20
+ threadById = new Map((await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true })).data.reviewThreads.map((thread) => [thread.id, thread]));
21
+ }
22
+ catch {
23
+ // Seen-marker bookkeeping is best-effort. A failed read must not block
24
+ // the explicit mutation request; GitHub's mutation response is authoritative.
25
+ }
26
+ }
27
+ // Iterate capability-checks and routes its generated commands. Direct apply
28
+ // requests are user-directed: forward every supplied ID unchanged and let
29
+ // GitHub report whether each requested mutation is permitted or applicable.
62
30
  const result = await applyResolveOptions(prNumber, repo, {
63
- resolveThreadIds,
64
- replyThreadIds,
65
- minimizeCommentIds,
66
- dismissReviewIds,
31
+ resolveThreadIds: opts.resolveThreadIds,
32
+ replyThreadIds: opts.replyThreadIds,
33
+ minimizeCommentIds: opts.minimizeCommentIds,
34
+ dismissReviewIds: opts.dismissReviewIds,
67
35
  dismissMessage: opts.dismissMessage,
68
- requireSha: hasMutation ? opts.requireSha : undefined,
36
+ requireSha: opts.requireSha,
69
37
  });
70
- if (skippedHumanResolves.length > 0)
71
- result.skippedHumanResolves = skippedHumanResolves;
72
- if (skippedHumanMinimizes.length > 0)
73
- result.skippedHumanMinimizes = skippedHumanMinimizes;
74
- if (skippedHumanDismissals.length > 0)
75
- result.skippedHumanDismissals = skippedHumanDismissals;
76
- if (skippedNonHumanReplies.length > 0)
77
- result.skippedNonHumanReplies = skippedNonHumanReplies;
78
- if (skippedIneligibleDismissals.length > 0) {
79
- result.skippedDismissals = [
80
- ...(result.skippedDismissals ?? []),
81
- ...skippedIneligibleDismissals,
82
- ];
83
- }
84
- if (opts.dismissMessage) {
38
+ if (opts.dismissMessage && threadById) {
85
39
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
86
40
  await Promise.all(result.repliedThreads.map((id) => {
87
41
  const thread = threadById.get(id);
@@ -7,10 +7,15 @@ export interface ResolveResult {
7
7
  minimizedComments: string[];
8
8
  dismissedReviews: string[];
9
9
  errors: string[];
10
+ /** @deprecated Direct apply forwards every supplied dismissal ID to GitHub. */
10
11
  skippedDismissals?: string[];
12
+ /** @deprecated Direct apply no longer applies author policy. */
11
13
  skippedHumanResolves?: string[];
14
+ /** @deprecated Direct apply no longer applies author policy. */
12
15
  skippedHumanMinimizes?: string[];
16
+ /** @deprecated Direct apply no longer applies author policy. */
13
17
  skippedHumanDismissals?: string[];
18
+ /** @deprecated Direct apply forwards every supplied reply ID to GitHub. */
14
19
  skippedNonHumanReplies?: string[];
15
20
  /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
16
21
  skippedUnauthorizedReplies?: string[];
@@ -35,9 +35,6 @@ export async function applyResolveOptions(pr, repo, opts) {
35
35
  const replyThreadIds = dedupeIds(opts.replyThreadIds ?? []);
36
36
  const minimizeCommentIds = opts.minimizeCommentIds ?? [];
37
37
  const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
38
- const minimizeCommentIdSet = new Set(minimizeCommentIds);
39
- const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
40
- const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
41
38
  const result = {
42
39
  repliedThreads: [],
43
40
  resolvedThreads: [],
@@ -45,13 +42,7 @@ export async function applyResolveOptions(pr, repo, opts) {
45
42
  dismissedReviews: [],
46
43
  errors: [],
47
44
  };
48
- if (overlappingDismissIds.length > 0) {
49
- result.skippedDismissals = [];
50
- for (const id of overlappingDismissIds) {
51
- result.skippedDismissals.push(id);
52
- }
53
- }
54
- if ((filteredDismissReviewIds.length > 0 || replyThreadIds.length > 0) && !opts.dismissMessage) {
45
+ if ((dismissReviewIds.length > 0 || replyThreadIds.length > 0) && !opts.dismissMessage) {
55
46
  throw new Error("--message is required when replying to threads or dismissing reviews");
56
47
  }
57
48
  if (opts.requireSha) {
@@ -59,7 +50,7 @@ export async function applyResolveOptions(pr, repo, opts) {
59
50
  // before reviewers see the fix.
60
51
  await waitForSha(pr, repo, opts.requireSha);
61
52
  }
62
- await bulkApply(replyThreadIds, resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
53
+ await bulkApply(replyThreadIds, resolveThreadIds, minimizeCommentIds, dismissReviewIds, opts.dismissMessage ?? "", result);
63
54
  return result;
64
55
  }
65
56
  /** @deprecated Compatibility alias; use `autoResolveThreads`. */
@@ -0,0 +1,33 @@
1
+ query PollStackSummary($owner: String!, $repo: String!, $anchor: Int!, $after: String) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ repository(owner: $owner, name: $repo) {
11
+ viewerCanAdminister
12
+ pullRequest(number: $anchor) {
13
+ stack {
14
+ id
15
+ number
16
+ size
17
+ baseRefName
18
+ entries(first: 50, after: $after) {
19
+ pageInfo {
20
+ hasNextPage
21
+ endCursor
22
+ }
23
+ nodes {
24
+ position
25
+ pullRequest {
26
+ ...PollSummaryPr
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }