pr-shepherd 0.42.0 → 0.44.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 (145) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +24 -18
  3. package/bin/api.d.mts +3 -2
  4. package/bin/api.mjs +13 -18
  5. package/bin/checks/classify.d.mts +3 -1
  6. package/bin/checks/classify.mjs +5 -2
  7. package/bin/cli/args.mjs +14 -9
  8. package/bin/cli/default-poll.mjs +1 -0
  9. package/bin/cli/fix-formatter.mjs +20 -8
  10. package/bin/cli/handlers.mjs +1 -0
  11. package/bin/cli/help-command-pages.d.mts +9 -6
  12. package/bin/cli/help-command-pages.mjs +7 -4
  13. package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
  14. package/bin/cli/help-iterate-poll-pages.mjs +9 -4
  15. package/bin/cli/help-top-page.d.mts +1 -1
  16. package/bin/cli/help-top-page.mjs +2 -2
  17. package/bin/cli/help.d.mts +10 -7
  18. package/bin/cli/iterate-flags.d.mts +1 -0
  19. package/bin/cli/iterate-flags.mjs +2 -0
  20. package/bin/cli/iterate-formatter.d.mts +1 -1
  21. package/bin/cli/iterate-formatter.mjs +9 -6
  22. package/bin/cli/iterate-instructions.mjs +15 -0
  23. package/bin/cli/iterate-lean.d.mts +1 -2
  24. package/bin/cli/iterate-lean.mjs +16 -17
  25. package/bin/cli/iterate-merge-formatter.d.mts +3 -0
  26. package/bin/cli/iterate-merge-formatter.mjs +52 -0
  27. package/bin/cli/iterate-verbose.d.mts +6 -0
  28. package/bin/cli/iterate-verbose.mjs +14 -0
  29. package/bin/cli/journal-formatter.mjs +3 -0
  30. package/bin/cli/journal-handler.mjs +11 -7
  31. package/bin/cli/list-formatters.d.mts +3 -1
  32. package/bin/cli/list-formatters.mjs +9 -7
  33. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -1
  34. package/bin/cli/mutate-formatter.mjs +4 -0
  35. package/bin/cli/poll-handler.mjs +1 -0
  36. package/bin/cli/runner.mjs +4 -3
  37. package/bin/commands/check-terminal-report.mjs +1 -0
  38. package/bin/commands/check.mjs +64 -7
  39. package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
  40. package/bin/commands/commit-suggestion-instruction.mjs +2 -2
  41. package/bin/commands/commit-suggestion.mjs +3 -1
  42. package/bin/commands/iterate/base.d.mts +5 -0
  43. package/bin/commands/iterate/base.mjs +25 -0
  44. package/bin/commands/iterate/check-instructions.d.mts +5 -19
  45. package/bin/commands/iterate/check-instructions.mjs +24 -25
  46. package/bin/commands/iterate/classify.d.mts +2 -2
  47. package/bin/commands/iterate/classify.mjs +43 -25
  48. package/bin/commands/iterate/escalate.d.mts +3 -1
  49. package/bin/commands/iterate/escalate.mjs +29 -2
  50. package/bin/commands/iterate/fix-code.mjs +81 -26
  51. package/bin/commands/iterate/helpers.d.mts +0 -2
  52. package/bin/commands/iterate/helpers.mjs +6 -17
  53. package/bin/commands/iterate/index.mjs +31 -43
  54. package/bin/commands/iterate/mark-ready.d.mts +2 -0
  55. package/bin/commands/iterate/mark-ready.mjs +38 -0
  56. package/bin/commands/iterate/merge-state.d.mts +15 -0
  57. package/bin/commands/iterate/merge-state.mjs +82 -0
  58. package/bin/commands/iterate/merge.d.mts +13 -0
  59. package/bin/commands/iterate/merge.mjs +45 -0
  60. package/bin/commands/iterate/render.d.mts +2 -2
  61. package/bin/commands/iterate/render.mjs +19 -20
  62. package/bin/commands/iterate/stall.mjs +4 -2
  63. package/bin/commands/iterate/thread-mutation-routing.d.mts +9 -0
  64. package/bin/commands/iterate/thread-mutation-routing.mjs +29 -0
  65. package/bin/commands/journal/index.d.mts +5 -0
  66. package/bin/commands/journal/index.mjs +11 -2
  67. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  68. package/bin/commands/mark-files-as-viewed.mjs +5 -92
  69. package/bin/commands/poll.mjs +3 -1
  70. package/bin/commands/resolve-mutate.mjs +51 -9
  71. package/bin/commands/shepherd-journal.d.mts +1 -1
  72. package/bin/commands/shepherd-journal.mjs +2 -2
  73. package/bin/commands/suggestion-patch-item.d.mts +1 -1
  74. package/bin/commands/suggestion-patch-item.mjs +3 -11
  75. package/bin/commands/suggestion-patches.mjs +3 -3
  76. package/bin/comments/authors.d.mts +6 -0
  77. package/bin/comments/authors.mjs +5 -0
  78. package/bin/comments/marker.d.mts +6 -1
  79. package/bin/comments/marker.mjs +8 -1
  80. package/bin/comments/resolve.d.mts +4 -0
  81. package/bin/comments/thread-visibility.mjs +10 -10
  82. package/bin/comments/visible-comments.mjs +2 -1
  83. package/bin/config/load.d.mts +5 -1
  84. package/bin/config/load.mjs +34 -0
  85. package/bin/config/merge-command-args.d.mts +2 -0
  86. package/bin/config/merge-command-args.mjs +44 -0
  87. package/bin/config.json +3 -0
  88. package/bin/exit-codes.d.mts +2 -0
  89. package/bin/exit-codes.mjs +4 -0
  90. package/bin/github/batch-parse-checks.d.mts +3 -0
  91. package/bin/github/batch-parse-checks.mjs +29 -0
  92. package/bin/github/batch-parsers-rules.d.mts +3 -1
  93. package/bin/github/batch-parsers-rules.mjs +33 -0
  94. package/bin/github/batch-parsers.d.mts +2 -2
  95. package/bin/github/batch-parsers.mjs +40 -27
  96. package/bin/github/batch-raw-rules.d.mts +48 -0
  97. package/bin/github/batch-raw-types.d.mts +12 -0
  98. package/bin/github/batch-raw-types.mjs +0 -1
  99. package/bin/github/batch.mjs +3 -1
  100. package/bin/github/client.d.mts +1 -0
  101. package/bin/github/client.mjs +8 -1
  102. package/bin/github/gql/batch-pr-page.gql +6 -0
  103. package/bin/github/gql/batch-pr.gql +105 -0
  104. package/bin/github/gql/commit-check-contexts.gql +58 -0
  105. package/bin/github/gql/get-pr-body.gql +1 -0
  106. package/bin/github/gql/review-thread-comments.gql +1 -0
  107. package/bin/github/gql/suggestion-threads.gql +1 -0
  108. package/bin/github/merge-queue-checks.d.mts +4 -0
  109. package/bin/github/merge-queue-checks.mjs +48 -0
  110. package/bin/github/queries.d.mts +2 -0
  111. package/bin/github/queries.mjs +2 -0
  112. package/bin/github/suggestion-thread.mjs +1 -0
  113. package/bin/mcp/server.mjs +13 -6
  114. package/bin/pr-reference.d.mts +13 -0
  115. package/bin/pr-reference.mjs +23 -1
  116. package/bin/reporters/agent.mjs +11 -3
  117. package/bin/threads/transcript.d.mts +2 -0
  118. package/bin/threads/transcript.mjs +2 -0
  119. package/bin/types/activity.d.mts +2 -0
  120. package/bin/types/agent-thread.d.mts +1 -0
  121. package/bin/types/escalate.d.mts +35 -0
  122. package/bin/types/escalate.mjs +1 -0
  123. package/bin/types/github.d.mts +27 -0
  124. package/bin/types/github.mjs +0 -3
  125. package/bin/types/iterate.d.mts +23 -39
  126. package/bin/types/merge-action.d.mts +12 -0
  127. package/bin/types/merge-action.mjs +1 -0
  128. package/bin/types/merge-queue.d.mts +13 -0
  129. package/bin/types/merge-queue.mjs +1 -0
  130. package/bin/types/merge-requirements.d.mts +17 -0
  131. package/bin/types/report.d.mts +22 -1
  132. package/bin/types/review-thread.d.mts +1 -0
  133. package/bin/types/suggestion-patch.d.mts +1 -1
  134. package/bin/types.d.mts +3 -0
  135. package/bin/types.mjs +3 -0
  136. package/bin/util/markdown.d.mts +2 -0
  137. package/bin/util/markdown.mjs +7 -0
  138. package/package.json +1 -1
  139. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  140. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  141. package/plugins/pr-shepherd/.mcp.json +1 -1
  142. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +3 -3
  143. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +17 -15
  144. package/bin/commands/iterate/reruns.d.mts +0 -20
  145. package/bin/commands/iterate/reruns.mjs +0 -105
@@ -2,6 +2,7 @@ import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
2
  import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage, formatDurationApprox, } from "./escalate.mjs";
4
4
  import { checksWithActionableAnnotations } from "../check-annotations.mjs";
5
+ import { formatPrUrl } from "../../pr-reference.mjs";
5
6
  function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
6
7
  const checks = [
7
8
  ...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
@@ -36,6 +37,7 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
36
37
  });
37
38
  }
38
39
  export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
40
+ const prReference = formatPrUrl(report.repo, prNumber);
39
41
  const nowSeconds = Math.floor(Date.now() / 1000);
40
42
  const stalledChecks = findCiStartStalledChecks(report.checks.inProgress, nowSeconds, {
41
43
  stallTimeoutSeconds,
@@ -56,7 +58,7 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
56
58
  action: "escalate",
57
59
  escalate: {
58
60
  ...escalateBase,
59
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
61
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
60
62
  },
61
63
  };
62
64
  }
@@ -86,7 +88,7 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
86
88
  action: "escalate",
87
89
  escalate: {
88
90
  ...escalateBase,
89
- humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
91
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
90
92
  },
91
93
  };
92
94
  }
@@ -0,0 +1,9 @@
1
+ import { type NormalizedBotUsernames } from "../../comments/authors.mts";
2
+ import type { AgentThread, ReviewThread } from "../../types.mts";
3
+ export interface ThreadMutationRouting {
4
+ replyThreadIds: string[];
5
+ pairedResolveThreadIds: string[];
6
+ standaloneResolveThreadIds: string[];
7
+ resolveThreadIds: string[];
8
+ }
9
+ export declare function buildThreadMutationRouting(threads: Array<AgentThread | ReviewThread>, botUsernames: NormalizedBotUsernames, ruleAutoResolveThreadIds: string[]): ThreadMutationRouting;
@@ -0,0 +1,29 @@
1
+ import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "../../comments/authors.mjs";
2
+ import { threadEndedByShepherd } from "../../comments/marker.mjs";
3
+ function dedupeIds(ids) {
4
+ return [...new Set(ids)];
5
+ }
6
+ export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolveThreadIds) {
7
+ const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
8
+ const replyThreadIds = dedupeIds(threads
9
+ .filter((thread) => isOrdinaryHuman(thread) && !threadEndedByShepherd(thread))
10
+ .map((thread) => thread.id));
11
+ const pairedResolveThreadIds = dedupeIds(threads
12
+ .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) && !threadEndedByShepherd(thread))
13
+ .map((thread) => thread.id));
14
+ const pairedResolveIdSet = new Set(pairedResolveThreadIds);
15
+ // Rule-matched threads bypass author routing; resolve-mutate retains the human-author guard.
16
+ const standaloneResolveThreadIds = dedupeIds([
17
+ ...threads
18
+ .filter((thread) => !isOrdinaryHuman(thread) ||
19
+ (isViewerAuthoredHuman(thread, botUsernames) && threadEndedByShepherd(thread)))
20
+ .map((thread) => thread.id),
21
+ ...ruleAutoResolveThreadIds,
22
+ ]).filter((id) => !pairedResolveIdSet.has(id));
23
+ return {
24
+ replyThreadIds,
25
+ pairedResolveThreadIds,
26
+ standaloneResolveThreadIds,
27
+ resolveThreadIds: dedupeIds([...pairedResolveThreadIds, ...standaloneResolveThreadIds]),
28
+ };
29
+ }
@@ -1,5 +1,9 @@
1
1
  export interface RunJournalOptions {
2
2
  prNumber: number | undefined;
3
+ targetRepository?: {
4
+ owner: string;
5
+ name: string;
6
+ };
3
7
  rawItem: string;
4
8
  dryRun: boolean;
5
9
  }
@@ -9,6 +13,7 @@ export interface JournalResult {
9
13
  sectionExisted: boolean;
10
14
  dryRun: boolean;
11
15
  previewBody?: string;
16
+ authorizationSkipped?: "denied-or-unverifiable";
12
17
  }
13
18
  /** @deprecated Hidden implementation for standalone `journal`; use `apply journal`. */
14
19
  export declare function runJournal(opts: RunJournalOptions): Promise<JournalResult>;
@@ -7,13 +7,22 @@ export async function runJournal(opts) {
7
7
  throw new Error(validation.error);
8
8
  }
9
9
  const { item } = validation;
10
- const { owner, name } = await getRepoInfo();
10
+ const { owner, name } = opts.targetRepository ?? (await getRepoInfo());
11
11
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
12
12
  if (!prNumber) {
13
13
  throw new Error("PR number is required: no PR number provided and none found for current branch");
14
14
  }
15
- const { nodeId, body } = await getPullRequestBody(prNumber, owner, name);
15
+ const { nodeId, body, viewerCanUpdate } = await getPullRequestBody(prNumber, owner, name);
16
16
  const { body: newBody, mutated, sectionExisted } = appendJournalItem(body, item);
17
+ if (mutated && !opts.dryRun && viewerCanUpdate !== true) {
18
+ return {
19
+ prNumber,
20
+ mutated: false,
21
+ sectionExisted,
22
+ dryRun: false,
23
+ authorizationSkipped: "denied-or-unverifiable",
24
+ };
25
+ }
17
26
  if (mutated && !opts.dryRun) {
18
27
  await updatePullRequestBody(nodeId, newBody);
19
28
  }
@@ -1,4 +1,4 @@
1
- import { type ResolveRateLimitStop } from "../comments/rate-limit.mts";
1
+ import type { ResolveRateLimitStop } from "../comments/rate-limit.mts";
2
2
  import type { GlobalOptions } from "../types.mts";
3
3
  export interface MarkFilesAsViewedOptions extends GlobalOptions {
4
4
  prNumber?: number;
@@ -21,6 +21,7 @@ export interface MarkFilesAsViewedResult {
21
21
  errors: string[];
22
22
  rateLimit?: ResolveRateLimitStop;
23
23
  unmarkedPaths?: string[];
24
+ authorizationSkipped?: "unverifiable";
24
25
  }
25
26
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
26
27
  export declare function runMarkFilesAsViewed(opts: MarkFilesAsViewedOptions): Promise<MarkFilesAsViewedResult>;
@@ -1,7 +1,6 @@
1
1
  /* eslint-disable max-lines */
2
- import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
2
+ import { graphql, getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
- import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
5
4
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
6
5
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
7
6
  repository(owner: $owner, name: $repo) {
@@ -22,10 +21,9 @@ const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr
22
21
  }
23
22
  }`;
24
23
  const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
25
- const BULK_CHUNK_SIZE = 10;
26
24
  /** @deprecated Hidden implementation for `mark-files-as-viewed`; use `apply files`. */
27
25
  export async function runMarkFilesAsViewed(opts) {
28
- const repo = await getRepoInfo();
26
+ const repo = opts.targetRepository ?? (await getRepoInfo());
29
27
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
30
28
  if (!prNumber) {
31
29
  throw new ShepherdError("No PR number provided and no current branch PR found", EXIT.UNAVAILABLE);
@@ -52,8 +50,10 @@ export async function runMarkFilesAsViewed(opts) {
52
50
  missingPaths: selected.missingPaths,
53
51
  unmatchedSelectors: selected.unmatchedSelectors,
54
52
  errors: [],
53
+ ...(selected.pathsToMark.length > 0 && { authorizationSkipped: "unverifiable" }),
55
54
  };
56
- await bulkMarkFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
55
+ // GitHub exposes no exact viewer capability for markFileAsViewed. Repository role and
56
+ // viewerCanEditFiles describe different operations, so this command fails closed.
57
57
  return result;
58
58
  }
59
59
  async function fetchPullRequestFiles(pr, repo) {
@@ -132,90 +132,3 @@ function selectChangedFiles(changedFiles, opts) {
132
132
  const pathsToMark = matchedPaths.filter((path) => !alreadyViewedSet.has(path));
133
133
  return { matchedPaths, alreadyViewedPaths, missingPaths, unmatchedSelectors, pathsToMark };
134
134
  }
135
- function buildBulkMutation(paths) {
136
- const ops = paths.map((path, i) => ` m${i}: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: ${JSON.stringify(path)} }) { pullRequest { id } }`);
137
- return `mutation BulkMarkFilesAsViewed($pullRequestId: ID!) {\n${ops.join("\n")}\n}`;
138
- }
139
- async function bulkMarkFilesAsViewed(pullRequestId, paths, result) {
140
- for (let i = 0; i < paths.length; i += BULK_CHUNK_SIZE) {
141
- const chunk = paths.slice(i, i + BULK_CHUNK_SIZE);
142
- // eslint-disable-next-line no-await-in-loop
143
- const stopped = await bulkMarkFilesAsViewedChunk(pullRequestId, chunk, result, i + BULK_CHUNK_SIZE < paths.length);
144
- if (stopped) {
145
- const markedSet = new Set(result.markedPaths);
146
- result.unmarkedPaths = paths.slice(i).filter((path) => !markedSet.has(path));
147
- return;
148
- }
149
- }
150
- }
151
- async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendingAfter) {
152
- if (paths.length === 0)
153
- return false;
154
- let data = {};
155
- let graphQlErrors = [];
156
- let suppressCurrentChunkErrors = false;
157
- let rateLimitStop;
158
- try {
159
- const resp = await graphqlWithRateLimit(buildBulkMutation(paths), { pullRequestId }, { allowPartialData: true });
160
- data = resp.data;
161
- graphQlErrors = (resp.errors ?? []);
162
- const messages = graphQlErrors.map((e) => e.message);
163
- suppressCurrentChunkErrors = messages.some(isRateLimitMessage);
164
- rateLimitStop = rateLimitFromGraphQlResult(messages, {
165
- rateLimit: resp.rateLimit,
166
- retryAfterSeconds: resp.retryAfterSeconds,
167
- stopOnZeroRemaining: hasPendingAfter,
168
- });
169
- }
170
- catch (err) {
171
- const msg = err instanceof Error ? err.message : String(err);
172
- const stop = rateLimitFromError(err, msg);
173
- if (stop) {
174
- result.errors.push(`rate limit: ${stop.message}`);
175
- result.rateLimit = stop;
176
- return true;
177
- }
178
- for (const path of paths)
179
- result.errors.push(`${path}: ${msg}`);
180
- return false;
181
- }
182
- const errorMessagesByAlias = mapAliasErrors(graphQlErrors);
183
- for (let i = 0; i < paths.length; i += 1) {
184
- const alias = `m${i}`;
185
- const m = data[alias];
186
- if (m?.pullRequest?.id === pullRequestId) {
187
- result.markedPaths.push(paths[i]);
188
- }
189
- else if (!suppressCurrentChunkErrors) {
190
- result.errors.push(`${paths[i]}: ${errorMessagesByAlias.get(alias) ?? "mark returned null"}`);
191
- }
192
- }
193
- if (rateLimitStop) {
194
- result.errors.push(`rate limit: ${rateLimitStop.message}`);
195
- result.rateLimit = rateLimitStop;
196
- return true;
197
- }
198
- return false;
199
- }
200
- function mapAliasErrors(errors) {
201
- const out = new Map();
202
- for (const error of errors) {
203
- if (!Array.isArray(error.path))
204
- continue;
205
- const alias = error.path.find((part) => typeof part === "string" && isMarkAlias(part));
206
- if (typeof alias === "string")
207
- out.set(alias, error.message);
208
- }
209
- return out;
210
- }
211
- function isMarkAlias(value) {
212
- if (!value.startsWith("m"))
213
- return false;
214
- if (value.length === 1)
215
- return false;
216
- for (const char of value.slice(1)) {
217
- if (char < "0" || char > "9")
218
- return false;
219
- }
220
- return true;
221
- }
@@ -112,7 +112,9 @@ export async function runPoll(opts) {
112
112
  await sleep(intervalMs);
113
113
  continue;
114
114
  }
115
- if (untilTerminal && lastResult.action === "mark_ready" && !pastDebounce) {
115
+ if ((untilTerminal || iterateOpts.merge) &&
116
+ lastResult.action === "mark_ready" &&
117
+ !pastDebounce) {
116
118
  debounceUntil = null;
117
119
  await sleep(intervalMs);
118
120
  continue;
@@ -2,14 +2,14 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
3
  import { fetchPrBatch } from "../github/batch.mjs";
4
4
  import { loadConfig } from "../config/load.mjs";
5
- import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
5
+ import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, normalizeBotUsernames, } from "../comments/authors.mjs";
6
6
  import { markReplySeen } from "../state/seen-comments.mjs";
7
7
  import { threadTranscriptBody } from "../threads/transcript.mjs";
8
- import { addPrShepherdMarker } from "../comments/marker.mjs";
8
+ import { addPrShepherdMarker, threadEndedByShepherd } from "../comments/marker.mjs";
9
9
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
10
10
  /** @deprecated Hidden implementation for `resolve`; use `apply review`. */
11
11
  export async function runResolveMutate(opts) {
12
- const repo = await getRepoInfo();
12
+ const repo = opts.targetRepository ?? (await getRepoInfo());
13
13
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
14
14
  if (prNumber === null) {
15
15
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
@@ -27,21 +27,55 @@ export async function runResolveMutate(opts) {
27
27
  const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
28
28
  .filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
29
29
  .map((r) => r.id));
30
- const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
31
- const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
32
- const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
30
+ const replyAuthorizedIds = new Set(data.reviewThreads
31
+ .filter((thread) => thread.viewerCanReply === true)
32
+ .map((thread) => thread.id));
33
+ const resolveAuthorizedIds = new Set(data.reviewThreads
34
+ .filter((thread) => thread.viewerCanResolve === true)
35
+ .map((thread) => thread.id));
36
+ const minimizeAuthorizedIds = new Set([
37
+ ...data.comments
38
+ .filter((comment) => comment.viewerCanMinimize === true)
39
+ .map((comment) => comment.id),
40
+ ...data.reviewSummaries
41
+ .filter((review) => review.viewerCanMinimize === true)
42
+ .map((review) => review.id),
43
+ ...data.approvedReviews
44
+ .filter((review) => review.viewerCanMinimize === true)
45
+ .map((review) => review.id),
46
+ ]);
47
+ const requestedReplyIds = new Set((opts.replyThreadIds ?? []).filter((id) => replyAuthorizedIds.has(id)));
48
+ const allowedViewerHumanResolveIds = new Set(data.reviewThreads
49
+ .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) &&
50
+ (requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread)))
51
+ .map((thread) => thread.id));
52
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
53
+ resolveAuthorizedIds.has(id));
54
+ const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedViewerHumanResolveIds.has(id));
55
+ const skippedUnauthorizedResolves = (opts.resolveThreadIds ?? []).filter((id) => (!humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id)) &&
56
+ !resolveAuthorizedIds.has(id));
57
+ const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id) && replyAuthorizedIds.has(id));
33
58
  const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
34
- const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
59
+ const skippedUnauthorizedReplies = (opts.replyThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !replyAuthorizedIds.has(id));
60
+ const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id) && minimizeAuthorizedIds.has(id));
35
61
  const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
36
- const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id));
62
+ const skippedUnauthorizedMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id) && !minimizeAuthorizedIds.has(id));
63
+ const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) &&
64
+ data.changesRequestedReviews.some((review) => review.id === id) &&
65
+ data.viewerAuthorization?.viewerCanAdminister === true);
37
66
  const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
67
+ const skippedUnauthorizedDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
68
+ const hasAuthorizedMutation = resolveThreadIds.length > 0 ||
69
+ (replyThreadIds?.length ?? 0) > 0 ||
70
+ minimizeCommentIds.length > 0 ||
71
+ dismissReviewIds.length > 0;
38
72
  const result = await applyResolveOptions(prNumber, repo, {
39
73
  resolveThreadIds,
40
74
  replyThreadIds,
41
75
  minimizeCommentIds,
42
76
  dismissReviewIds,
43
77
  dismissMessage: opts.dismissMessage,
44
- requireSha: opts.requireSha,
78
+ requireSha: hasAuthorizedMutation ? opts.requireSha : undefined,
45
79
  });
46
80
  if (skippedHumanResolves.length > 0)
47
81
  result.skippedHumanResolves = skippedHumanResolves;
@@ -51,6 +85,14 @@ export async function runResolveMutate(opts) {
51
85
  result.skippedHumanDismissals = skippedHumanDismissals;
52
86
  if (skippedNonHumanReplies.length > 0)
53
87
  result.skippedNonHumanReplies = skippedNonHumanReplies;
88
+ if (skippedUnauthorizedReplies.length > 0)
89
+ result.skippedUnauthorizedReplies = skippedUnauthorizedReplies;
90
+ if (skippedUnauthorizedResolves.length > 0)
91
+ result.skippedUnauthorizedResolves = skippedUnauthorizedResolves;
92
+ if (skippedUnauthorizedMinimizes.length > 0)
93
+ result.skippedUnauthorizedMinimizes = skippedUnauthorizedMinimizes;
94
+ if (skippedUnauthorizedDismissals.length > 0)
95
+ result.skippedUnauthorizedDismissals = skippedUnauthorizedDismissals;
54
96
  if (opts.dismissMessage) {
55
97
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
56
98
  await Promise.all(result.repliedThreads.map((id) => {
@@ -11,4 +11,4 @@ export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body un
11
11
  * invocation, so it lives in the pr-shepherd skill's "Shepherd Journal" playbook instead
12
12
  * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
13
  */
14
- export declare function buildShepherdJournalInstruction(prNumber: number): string;
14
+ export declare function buildShepherdJournalInstruction(prReference: string | number): string;
@@ -11,6 +11,6 @@ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `##
11
11
  * invocation, so it lives in the pr-shepherd skill's "Shepherd Journal" playbook instead
12
12
  * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
13
  */
14
- export function buildShepherdJournalInstruction(prNumber) {
15
- return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
14
+ export function buildShepherdJournalInstruction(prReference) {
15
+ return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prReference} '- <decision>'\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
16
16
  }
@@ -10,6 +10,6 @@ export declare function buildSuggestionPatchItem({ thread, request, originalCont
10
10
  request: SuggestionPatchRequest;
11
11
  originalContent: string;
12
12
  }): SuggestionPatchResult;
13
- export declare function buildSingularInstructions(patch: SuggestionPatchResult, prNumber: number): string[];
13
+ export declare function buildSingularInstructions(patch: SuggestionPatchResult, prReference: string | number): string[];
14
14
  export declare function buildCommitCommand(patch: SuggestionPatchResult): string;
15
15
  export declare function quotePath(path: string): string;
@@ -1,7 +1,6 @@
1
1
  import { relative, resolve } from "node:path";
2
2
  import { getEffectiveCwd } from "../execution-context.mjs";
3
3
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
- import { buildPrShepherdCommand } from "../cli/runner.mjs";
5
4
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
6
5
  import { buildUnifiedDiff } from "../suggestions/patch.mjs";
7
6
  import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
@@ -62,23 +61,16 @@ export function buildSuggestionPatchItem({ thread, request, originalContent, })
62
61
  filesToStage: [thread.path],
63
62
  };
64
63
  }
65
- export function buildSingularInstructions(patch, prNumber) {
64
+ export function buildSingularInstructions(patch, prReference) {
66
65
  const range = patch.startLine === patch.endLine
67
66
  ? `line ${patch.startLine}`
68
67
  : `lines ${patch.startLine}–${patch.endLine}`;
69
- const resolveCommand = buildPrShepherdCommand([
70
- "apply",
71
- "review",
72
- String(prNumber),
73
- "--resolve-thread-ids",
74
- patch.threadId,
75
- ]).text;
76
68
  return [
77
69
  `Apply the patch to \`${patch.path}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
78
70
  `Stage the file: \`git add -- ${quotePath(patch.path)}\``,
79
71
  `Commit: \`${buildCommitCommand(patch)}\``,
80
- `Resolve the thread on GitHub: \`${resolveCommand}\``,
81
- `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
72
+ `Use the originating iterate output for any authorization-checked GitHub review action for thread \`${patch.threadId}\` on ${String(prReference)}.`,
73
+ "Shepherd cannot verify authorization for the Git credential that would push this branch, so this output does not recommend a push.",
82
74
  ];
83
75
  }
84
76
  export function buildCommitCommand(patch) {
@@ -5,7 +5,7 @@ import { buildCommitCommand, buildSuggestionPatchItem, quotePath, validateSugges
5
5
  import { checkPatchesApply, getLocalHeadSha, getPathsStatus, isAncestor, readPrHeadFile, } from "./suggestion-patch-git.mjs";
6
6
  export async function runSuggestionPatches(opts) {
7
7
  validateRequests(opts.suggestions);
8
- const repo = await getRepoInfo();
8
+ const repo = opts.targetRepository ?? (await getRepoInfo());
9
9
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
10
10
  if (prNumber === null) {
11
11
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
@@ -96,8 +96,8 @@ function buildBatchInstructions(patches) {
96
96
  ]);
97
97
  return [
98
98
  ...patchInstructions,
99
- "Push once after every patch is committed: `git push` (or `git push --force-with-lease` after rebasing).",
100
- `Then continue with the originating iterate output's \`apply review\` instructions for thread IDs: ${patches.map((patch) => patch.threadId).join(", ")}.`,
99
+ "Shepherd cannot verify authorization for the Git credential that would push this branch, so this output does not recommend a push.",
100
+ `Continue only with authorization-checked \`apply review\` instructions from the originating iterate output for thread IDs: ${patches.map((patch) => patch.threadId).join(", ")}.`,
101
101
  ];
102
102
  }
103
103
  function errorMessage(error) {
@@ -12,3 +12,9 @@ export declare function isConfiguredBotAuthor(author: {
12
12
  login?: string;
13
13
  authorType?: AuthorType;
14
14
  }, botUsernames?: NormalizedBotUsernames): boolean;
15
+ export declare function isViewerAuthoredHuman(author: {
16
+ author?: string;
17
+ login?: string;
18
+ authorType?: AuthorType;
19
+ viewerDidAuthor?: true;
20
+ }, botUsernames?: NormalizedBotUsernames): boolean;
@@ -30,3 +30,8 @@ export function isConfiguredBotAuthor(author, botUsernames = new Set()) {
30
30
  return false;
31
31
  return botUsernames.has(normalized);
32
32
  }
33
+ export function isViewerAuthoredHuman(author, botUsernames = new Set()) {
34
+ return (author.viewerDidAuthor === true &&
35
+ isHumanAuthor(author) &&
36
+ !isConfiguredBotAuthor(author, botUsernames));
37
+ }
@@ -1,2 +1,7 @@
1
- export declare function hasPrShepherdMarker(body: string): boolean;
2
1
  export declare function addPrShepherdMarker(body: string): string;
2
+ export declare function threadEndedByShepherd(thread: {
3
+ body: string;
4
+ comments?: readonly {
5
+ body: string;
6
+ }[];
7
+ }): boolean;
@@ -1,7 +1,14 @@
1
1
  const PR_SHEPHERD_MARKER = "<!-- pr-shepherd -->";
2
- export function hasPrShepherdMarker(body) {
2
+ function hasPrShepherdMarker(body) {
3
3
  return body.startsWith(PR_SHEPHERD_MARKER);
4
4
  }
5
5
  export function addPrShepherdMarker(body) {
6
6
  return `${PR_SHEPHERD_MARKER}\n${body}`;
7
7
  }
8
+ export function threadEndedByShepherd(thread) {
9
+ const comments = thread.comments;
10
+ if (comments && comments.length > 0) {
11
+ return hasPrShepherdMarker(comments[comments.length - 1].body);
12
+ }
13
+ return hasPrShepherdMarker(thread.body);
14
+ }
@@ -12,6 +12,10 @@ export interface ResolveResult {
12
12
  skippedHumanMinimizes?: string[];
13
13
  skippedHumanDismissals?: string[];
14
14
  skippedNonHumanReplies?: string[];
15
+ skippedUnauthorizedReplies?: string[];
16
+ skippedUnauthorizedResolves?: string[];
17
+ skippedUnauthorizedMinimizes?: string[];
18
+ skippedUnauthorizedDismissals?: string[];
15
19
  rateLimit?: ResolveRateLimitStop;
16
20
  unrepliedThreads?: string[];
17
21
  unresolvedThreads?: string[];
@@ -1,14 +1,7 @@
1
1
  import { classifyItem } from "../state/seen-comments.mjs";
2
2
  import { threadTranscriptBody } from "../threads/transcript.mjs";
3
- import { isConfiguredBotAuthor } from "./authors.mjs";
4
- import { hasPrShepherdMarker } from "./marker.mjs";
5
- function threadEndedByShepherd(thread) {
6
- const comments = thread.comments;
7
- if (comments && comments.length > 0) {
8
- return hasPrShepherdMarker(comments[comments.length - 1].body);
9
- }
10
- return hasPrShepherdMarker(thread.body);
11
- }
3
+ import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "./authors.mjs";
4
+ import { threadEndedByShepherd } from "./marker.mjs";
12
5
  function withEdited(thread, edited) {
13
6
  return edited ? { ...thread, edited: true } : thread;
14
7
  }
@@ -39,7 +32,14 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
39
32
  return visible ? [visible] : [];
40
33
  });
41
34
  const resolutionOnlyThreads = unresolvedThreads
42
- .filter((t) => t.isOutdated || t.isMinimized)
35
+ .filter((t) => {
36
+ const endedByShepherd = threadEndedByShepherd(t);
37
+ const ordinaryHuman = isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames);
38
+ if (endedByShepherd && ordinaryHuman) {
39
+ return isViewerAuthoredHuman(t, botUsernames);
40
+ }
41
+ return t.isOutdated || t.isMinimized;
42
+ })
43
43
  .map((t) => classifyVisibleThread(t, seenMap) ?? t);
44
44
  const firstLookThreads = [
45
45
  ...threads.flatMap((t) => {
@@ -5,7 +5,8 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments, bot
5
5
  const minimizeIds = [];
6
6
  const toMarkSeen = [];
7
7
  for (const c of comments.filter((comment) => !comment.isMinimized)) {
8
- if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
8
+ if (c.viewerCanMinimize === true &&
9
+ shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
9
10
  actionable.push(c);
10
11
  minimizeIds.push(c.id);
11
12
  toMarkSeen.push(c); // prevents re-surfacing as first-look after GitHub marks it minimized
@@ -43,10 +43,14 @@ interface PrShepherdConfig {
43
43
  mergeStatus: {
44
44
  blockingReviewerLogins: string[];
45
45
  };
46
+ merge?: {
47
+ /** Options added to ordinary `gh pr merge` commands. Queue commands never use these. */
48
+ commandArgs: string[];
49
+ };
46
50
  actions: {
47
51
  autoMinimizeSuppressed: boolean;
48
52
  autoMarkReady: boolean;
49
- /** Case-insensitive glob patterns for workflow/check names Shepherd must not cancel. */
53
+ /** Legacy-named patterns that keep matching Actions checks visible despite ignoreChecks. */
50
54
  neverCancelRuns: string[];
51
55
  /** @deprecated Accepted for compatibility, ignored by the loader. */
52
56
  autoResolveOutdated?: boolean;
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { parse } from "yaml";
6
6
  import builtins from "../config.json" with { type: "json" };
7
7
  import { getEffectiveCwd } from "../execution-context.mjs";
8
+ import { findMergeStrategies } from "./merge-command-args.mjs";
8
9
  const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
9
10
  const RC_FILENAME = ".pr-shepherdrc.yml";
10
11
  /**
@@ -85,6 +86,35 @@ function parseNeverCancelRuns(value) {
85
86
  }
86
87
  return value;
87
88
  }
89
+ const SHEPHERD_OWNED_MERGE_FLAGS = [
90
+ "--repo",
91
+ "-R",
92
+ "--auto",
93
+ "--disable-auto",
94
+ "--match-head-commit",
95
+ "--admin",
96
+ "--body-file",
97
+ "-F",
98
+ "--help",
99
+ "-h",
100
+ ];
101
+ function parseMergeCommandArgs(value) {
102
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
103
+ throw new Error("Invalid config: merge.commandArgs must be an array of strings");
104
+ }
105
+ for (const arg of value) {
106
+ if (SHEPHERD_OWNED_MERGE_FLAGS.some((flag) => arg === flag ||
107
+ arg.startsWith(`${flag}=`) ||
108
+ (flag.startsWith("-") && !flag.startsWith("--") && arg.startsWith(flag)))) {
109
+ throw new Error(`Invalid config: merge.commandArgs cannot include Shepherd-owned ${arg}`);
110
+ }
111
+ }
112
+ const strategies = findMergeStrategies(value);
113
+ if (strategies.length > 1) {
114
+ throw new Error(`Invalid config: merge.commandArgs includes multiple merge strategies: ${strategies.join(", ")}`);
115
+ }
116
+ return strategies.length === 0 ? [...value, "--merge"] : [...value];
117
+ }
88
118
  const KNOWN_CONFIG_KEYS = new Set([
89
119
  "classify",
90
120
  "botUsernames",
@@ -94,6 +124,7 @@ const KNOWN_CONFIG_KEYS = new Set([
94
124
  "resolve",
95
125
  "checks",
96
126
  "mergeStatus",
127
+ "merge",
97
128
  "actions",
98
129
  ]);
99
130
  const KNOWN_NESTED_KEYS = {
@@ -108,6 +139,7 @@ const KNOWN_NESTED_KEYS = {
108
139
  resolve: new Set(["shaPoll"]),
109
140
  checks: new Set(["ciTriggerEvents"]),
110
141
  mergeStatus: new Set(["blockingReviewerLogins"]),
142
+ merge: new Set(["commandArgs"]),
111
143
  actions: new Set([
112
144
  "autoMinimizeSuppressed",
113
145
  "autoMarkReady",
@@ -194,6 +226,8 @@ export function loadConfig() {
194
226
  config.botUsernames = parseBotUsernames(config.botUsernames);
195
227
  config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
196
228
  config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
229
+ if (config.merge)
230
+ config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
197
231
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
198
232
  configCache.set(cwd, config);
199
233
  return config;
@@ -0,0 +1,2 @@
1
+ /** Return active merge strategies while validating compound strategy syntax. */
2
+ export declare function findMergeStrategies(args: string[]): string[];