pr-shepherd 0.60.1 → 1.0.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 (197) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -5
  3. package/bin/api.d.mts +35 -24
  4. package/bin/api.mjs +68 -48
  5. package/bin/checks/classify.d.mts +1 -1
  6. package/bin/checks/classify.mjs +4 -4
  7. package/bin/checks/log-excerpt.mjs +1 -1
  8. package/bin/cli/arg-flags.mjs +0 -3
  9. package/bin/cli/clean-handler.d.mts +1 -1
  10. package/bin/cli/clean-handler.mjs +5 -15
  11. package/bin/cli/default-poll.d.mts +2 -0
  12. package/bin/cli/default-poll.mjs +9 -6
  13. package/bin/cli/formatters.d.mts +2 -3
  14. package/bin/cli/formatters.mjs +0 -26
  15. package/bin/cli/handlers.d.mts +3 -3
  16. package/bin/cli/handlers.mjs +45 -35
  17. package/bin/cli/help-command-pages.d.mts +13 -158
  18. package/bin/cli/help-command-pages.mjs +12 -157
  19. package/bin/cli/help-iterate-poll-pages.d.mts +4 -3
  20. package/bin/cli/help-iterate-poll-pages.mjs +76 -36
  21. package/bin/cli/help-log-file-page.d.mts +1 -1
  22. package/bin/cli/help-log-file-page.mjs +3 -5
  23. package/bin/cli/help-top-page.d.mts +1 -1
  24. package/bin/cli/help-top-page.mjs +19 -20
  25. package/bin/cli/help.d.mts +14 -159
  26. package/bin/cli/iterate-checks-formatter.mjs +1 -1
  27. package/bin/cli/iterate-flags.d.mts +2 -4
  28. package/bin/cli/iterate-flags.mjs +4 -20
  29. package/bin/cli/iterate-formatter.d.mts +1 -1
  30. package/bin/cli/iterate-formatter.mjs +6 -6
  31. package/bin/cli/iterate-instructions.d.mts +1 -1
  32. package/bin/cli/iterate-instructions.mjs +5 -6
  33. package/bin/cli/iterate-lean.mjs +32 -40
  34. package/bin/cli/iterate-verbose.mjs +1 -1
  35. package/bin/cli/journal-formatter.mjs +0 -3
  36. package/bin/cli/journal-handler.d.mts +1 -1
  37. package/bin/cli/journal-handler.mjs +2 -1
  38. package/bin/cli/mutate-formatter.mjs +0 -9
  39. package/bin/cli/next-check-format.d.mts +0 -4
  40. package/bin/cli/next-check-format.mjs +0 -4
  41. package/bin/cli/poll-handler.d.mts +3 -0
  42. package/bin/cli/poll-handler.mjs +88 -47
  43. package/bin/cli/poll-summary-emitter.mjs +2 -2
  44. package/bin/cli/poll-summary-formatter.mjs +2 -2
  45. package/bin/cli/poll-targets.d.mts +2 -2
  46. package/bin/cli/poll-targets.mjs +15 -35
  47. package/bin/cli/removed-flags.d.mts +3 -0
  48. package/bin/cli/removed-flags.mjs +24 -0
  49. package/bin/cli/runner.d.mts +1 -1
  50. package/bin/cli/runner.mjs +2 -2
  51. package/bin/cli/stack-overview.d.mts +0 -1
  52. package/bin/cli/stack-overview.mjs +1 -2
  53. package/bin/cli/{fix-formatter-extra.d.mts → triage-formatter-extra.d.mts} +1 -2
  54. package/bin/cli/{fix-formatter-extra.mjs → triage-formatter-extra.mjs} +0 -6
  55. package/bin/cli/triage-formatter.d.mts +4 -0
  56. package/bin/cli/{fix-formatter.mjs → triage-formatter.mjs} +31 -42
  57. package/bin/cli/unknown-flags.d.mts +6 -0
  58. package/bin/cli/unknown-flags.mjs +29 -0
  59. package/bin/cli-parser.mjs +41 -62
  60. package/bin/commands/check-annotations.d.mts +1 -1
  61. package/bin/commands/check-annotations.mjs +1 -1
  62. package/bin/commands/check-review-budget.d.mts +1 -1
  63. package/bin/commands/check-review-budget.mjs +1 -1
  64. package/bin/commands/check-wait-detectors.d.mts +1 -1
  65. package/bin/commands/check-wait-detectors.mjs +1 -1
  66. package/bin/commands/check.d.mts +1 -1
  67. package/bin/commands/check.mjs +7 -7
  68. package/bin/commands/commit-suggestion-instruction.d.mts +2 -2
  69. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  70. package/bin/commands/iterate/api-usage.mjs +8 -8
  71. package/bin/commands/iterate/check-instructions.d.mts +4 -4
  72. package/bin/commands/iterate/check-instructions.mjs +8 -8
  73. package/bin/commands/iterate/classify.mjs +2 -2
  74. package/bin/commands/iterate/escalate.mjs +6 -6
  75. package/bin/commands/iterate/index.mjs +20 -23
  76. package/bin/commands/iterate/merge-plan.mjs +3 -3
  77. package/bin/commands/iterate/merge-state.mjs +4 -7
  78. package/bin/commands/iterate/native-stack-rebase.mjs +1 -1
  79. package/bin/commands/iterate/render.d.mts +1 -1
  80. package/bin/commands/iterate/render.mjs +2 -2
  81. package/bin/commands/iterate/stall.mjs +11 -9
  82. package/bin/commands/iterate/{fix-loop-instruction.mjs → triage-loop-instruction.mjs} +7 -3
  83. package/bin/commands/iterate/{fix-code.d.mts → triage.d.mts} +2 -2
  84. package/bin/commands/iterate/{fix-code.mjs → triage.mjs} +14 -23
  85. package/bin/commands/iterate/unreported-required.mjs +2 -5
  86. package/bin/commands/journal/index.d.mts +1 -3
  87. package/bin/commands/journal/index.mjs +1 -1
  88. package/bin/commands/mark-files-as-viewed.d.mts +1 -3
  89. package/bin/commands/mark-files-as-viewed.mjs +1 -1
  90. package/bin/commands/next-check.d.mts +2 -2
  91. package/bin/commands/next-check.mjs +4 -2
  92. package/bin/commands/poll-progress.mjs +1 -1
  93. package/bin/commands/poll-summary-explicit-instructions.mjs +1 -1
  94. package/bin/commands/poll-summary-instructions.mjs +7 -7
  95. package/bin/commands/poll-summary.mjs +4 -4
  96. package/bin/commands/poll.d.mts +1 -1
  97. package/bin/commands/poll.mjs +4 -4
  98. package/bin/commands/resolve-mutate.d.mts +1 -1
  99. package/bin/commands/resolve-mutate.mjs +1 -1
  100. package/bin/commands/rest-stack-merge-readiness.mjs +1 -1
  101. package/bin/commands/single-tick.d.mts +5 -0
  102. package/bin/commands/single-tick.mjs +7 -0
  103. package/bin/commands/stack-drain.mjs +1 -1
  104. package/bin/commands/stack-merge-flag.mjs +1 -1
  105. package/bin/commands/stack-stall.d.mts +2 -2
  106. package/bin/commands/stack-stall.mjs +2 -2
  107. package/bin/commands/stack-work.d.mts +2 -2
  108. package/bin/commands/stack-work.mjs +2 -2
  109. package/bin/commands/suggestion-patch-item.d.mts +0 -1
  110. package/bin/commands/suggestion-patch-item.mjs +0 -12
  111. package/bin/commands/suggestion-patches.d.mts +2 -2
  112. package/bin/commands/suggestion-patches.mjs +1 -1
  113. package/bin/commands/tick-instructions.d.mts +9 -0
  114. package/bin/commands/tick-instructions.mjs +23 -0
  115. package/bin/commands/tick.d.mts +15 -0
  116. package/bin/commands/{event-mode.mjs → tick.mjs} +39 -55
  117. package/bin/comments/minimize-policy.mjs +1 -3
  118. package/bin/comments/resolve.d.mts +0 -23
  119. package/bin/comments/resolve.mjs +0 -4
  120. package/bin/comments/review-output-budget.d.mts +1 -1
  121. package/bin/comments/review-output-budget.mjs +1 -1
  122. package/bin/comments/sha-poll.mjs +3 -1
  123. package/bin/config/default-bot-usernames.d.mts +2 -0
  124. package/bin/config/default-bot-usernames.mjs +15 -0
  125. package/bin/config/load.d.mts +4 -138
  126. package/bin/config/load.mjs +88 -443
  127. package/bin/config/merge-command-args.mjs +2 -2
  128. package/bin/config/merge-method.d.mts +1 -2
  129. package/bin/config/merge-method.mjs +1 -8
  130. package/bin/config/renames.d.mts +8 -0
  131. package/bin/config/renames.mjs +87 -0
  132. package/bin/config/resolve.d.mts +31 -0
  133. package/bin/config/resolve.mjs +89 -0
  134. package/bin/config/schema.d.mts +161 -0
  135. package/bin/config/schema.mjs +168 -0
  136. package/bin/exit-codes.d.mts +1 -1
  137. package/bin/exit-codes.mjs +3 -3
  138. package/bin/github/poll-summary-check-blockers.mjs +1 -1
  139. package/bin/github/poll-summary-command.mjs +8 -6
  140. package/bin/github/poll-summary-projector.mjs +2 -2
  141. package/bin/github/poll-summary-review.mjs +1 -1
  142. package/bin/github/poll-summary-route.mjs +9 -9
  143. package/bin/github/poll-summary-unreported.mjs +1 -1
  144. package/bin/github/poll-summary.mjs +1 -1
  145. package/bin/github/queries.d.mts +1 -1
  146. package/bin/github/queries.mjs +1 -1
  147. package/bin/mcp/server.mjs +48 -54
  148. package/bin/merge-status/derive.mjs +1 -1
  149. package/bin/state/durable-state.d.mts +1 -3
  150. package/bin/state/durable-state.mjs +2 -8
  151. package/bin/state/fix-attempts.d.mts +2 -2
  152. package/bin/state/fix-attempts.mjs +1 -1
  153. package/bin/state/merge-request.mjs +1 -1
  154. package/bin/state/pr-fingerprint.mjs +4 -11
  155. package/bin/state/seen-comments.d.mts +4 -5
  156. package/bin/state/seen-comments.mjs +8 -14
  157. package/bin/state/stack-stall.d.mts +1 -1
  158. package/bin/state/stack-stall.mjs +1 -1
  159. package/bin/state/stall-state-store.d.mts +1 -1
  160. package/bin/state/stall-state-store.mjs +1 -1
  161. package/bin/types/escalate.d.mts +2 -2
  162. package/bin/types/iterate.d.mts +12 -26
  163. package/bin/types/merge-queue.d.mts +2 -2
  164. package/bin/types/next-check.d.mts +1 -1
  165. package/bin/types/poll-summary.d.mts +3 -6
  166. package/bin/types/report.d.mts +2 -2
  167. package/bin/types/suggestion-patch.d.mts +1 -8
  168. package/package.json +2 -1
  169. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  170. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  171. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  172. package/plugins/pr-shepherd/.mcp.json +1 -1
  173. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +6 -6
  174. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -1
  175. package/plugins/pr-shepherd/skills/pr-shepherd/references/{cloud-event-loop.md → event-loop.md} +5 -5
  176. package/plugins/pr-shepherd/skills/pr-shepherd/references/mcp-fallback.md +3 -3
  177. package/plugins/pr-shepherd/skills/pr-shepherd/references/merge-queue-ejection.md +1 -1
  178. package/plugins/pr-shepherd/skills/pr-shepherd/references/suggestion-patches.md +1 -1
  179. package/plugins/pr-shepherd/skills/pr-shepherd/references/{fix-code-loop.md → triage-loop.md} +3 -3
  180. package/plugins/pr-shepherd/skills/reduce-pr-noise/SKILL.md +1 -1
  181. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +2 -2
  182. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/settings.md +11 -11
  183. package/pr-shepherd.schema.json +361 -0
  184. package/bin/cli/event-state-scope.d.mts +0 -19
  185. package/bin/cli/event-state-scope.mjs +0 -60
  186. package/bin/cli/fix-formatter.d.mts +0 -4
  187. package/bin/commands/commit-suggestion.d.mts +0 -8
  188. package/bin/commands/commit-suggestion.mjs +0 -26
  189. package/bin/commands/event-instructions.d.mts +0 -11
  190. package/bin/commands/event-instructions.mjs +0 -27
  191. package/bin/commands/event-mode.d.mts +0 -14
  192. package/bin/commands/poll-mode.d.mts +0 -8
  193. package/bin/commands/poll-mode.mjs +0 -14
  194. package/bin/config.json +0 -68
  195. package/bin/types/protected-run.d.mts +0 -6
  196. package/bin/types/protected-run.mjs +0 -1
  197. /package/bin/commands/iterate/{fix-loop-instruction.d.mts → triage-loop-instruction.d.mts} +0 -0
@@ -1,43 +1,41 @@
1
- /* eslint-disable max-lines */
2
1
  import { readFileSync, statSync } from "node:fs";
3
2
  import { dirname, join, resolve } from "node:path";
4
3
  import { homedir } from "node:os";
5
- import { parse } from "yaml";
6
- import builtins from "../config.json" with { type: "json" };
4
+ import { isScalar, parse, parseDocument } from "yaml";
7
5
  import { getEffectiveCwd } from "../execution-context.mjs";
8
- import { findMergeStrategies } from "./merge-command-args.mjs";
9
- import { parseMergeMethod } from "./merge-method.mjs";
10
- import { parseGithubTransport } from "../github/transport-mode.mjs";
11
- import { STANDARD_RULE_NAMES } from "../classify/standard/index.mjs";
12
- const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
13
- const RESOLVE_OTHER_HUMAN_THREADS = ["none", "outdated", "always"];
14
- export const INSTRUCTION_STYLES = ["playbook", "inline"];
15
- export function isInstructionStyle(value) {
16
- return INSTRUCTION_STYLES.some((style) => style === value);
17
- }
18
- export const POLL_MODES = ["auto", "poll", "event"];
19
- export function isPollMode(value) {
20
- return POLL_MODES.some((mode) => mode === value);
6
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
7
+ import { configError, issueLines, resolveConfig } from "./resolve.mjs";
8
+ import { CONFIG_VERSION, configFileSchema } from "./schema.mjs";
9
+ /** Accepted config file names, checked in every directory from cwd up to `$HOME`. */
10
+ const CONFIG_FILE_NAMES = [
11
+ ".pr-shepherdrc.yml",
12
+ ".pr-shepherdrc.yaml",
13
+ join(".pr-shepherd", "config.yml"),
14
+ join(".pr-shepherd", "config.yaml"),
15
+ ];
16
+ function configFileIn(dir) {
17
+ const found = CONFIG_FILE_NAMES.map((name) => join(dir, name)).filter((candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile() === true);
18
+ if (found.length > 1) {
19
+ throw new ShepherdError(`multiple config files in ${dir}: ${found.join(", ")}. Keep exactly one.`, EXIT.CONFIG);
20
+ }
21
+ return found[0];
21
22
  }
22
- const RC_FILENAME = ".pr-shepherdrc.yml";
23
23
  /**
24
- * Collect `.pr-shepherdrc.yml` files from `startDir` toward `$HOME` (closest first).
25
- * `$HOME/.pr-shepherdrc.yml` is always included when it exists, even if cwd is
26
- * outside the home directory. The walk never includes the filesystem root, so a
27
- * `/.pr-shepherdrc.yml` cannot override the user-level file.
24
+ * Collect config files from `startDir` toward `$HOME` (closest first). The `$HOME` file is
25
+ * always included when it exists, even if cwd is outside the home directory. The walk never
26
+ * includes the filesystem root, so a root-level file cannot override the user-level file.
28
27
  */
29
- function collectRcFiles(startDir) {
28
+ function collectConfigFiles(startDir) {
30
29
  const home = resolve(homedir());
31
- const seen = new Set();
32
30
  const files = [];
31
+ const visited = new Set();
33
32
  const add = (dir) => {
34
- const candidate = resolve(join(dir, RC_FILENAME));
35
- if (seen.has(candidate))
33
+ if (visited.has(dir))
36
34
  return;
37
- if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
38
- seen.add(candidate);
39
- files.push(candidate);
40
- }
35
+ visited.add(dir);
36
+ const file = configFileIn(dir);
37
+ if (file !== undefined)
38
+ files.push(file);
41
39
  };
42
40
  let current = resolve(startDir);
43
41
  while (current !== dirname(current)) {
@@ -49,432 +47,79 @@ function collectRcFiles(startDir) {
49
47
  add(home);
50
48
  return files;
51
49
  }
50
+ function isPlainObject(value) {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52
+ }
52
53
  function deepMerge(base, override) {
53
54
  const result = { ...base };
54
- for (const key of Object.keys(override)) {
55
- const overVal = override[key];
55
+ for (const [key, overVal] of Object.entries(override)) {
56
56
  const baseVal = base[key];
57
- if (overVal !== null &&
58
- typeof overVal === "object" &&
59
- !Array.isArray(overVal) &&
60
- typeof baseVal === "object" &&
61
- baseVal !== null &&
62
- !Array.isArray(baseVal)) {
63
- result[key] = deepMerge(baseVal, overVal);
64
- }
65
- else if (overVal !== undefined) {
66
- result[key] = overVal;
67
- }
57
+ result[key] =
58
+ isPlainObject(overVal) && isPlainObject(baseVal) ? deepMerge(baseVal, overVal) : overVal;
68
59
  }
69
60
  return result;
70
61
  }
71
- function isMinimizeCommentsPolicy(value) {
72
- return MINIMIZE_COMMENTS_POLICIES.some((policy) => policy === value);
73
- }
74
- function parseMinimizeCommentsPolicy(value) {
75
- if (value === "users") {
76
- process.stderr.write('pr-shepherd: config: iterate.minimizeComments: "users" is deprecated and is now treated as "none".\n');
77
- return "none";
78
- }
79
- if (isMinimizeCommentsPolicy(value))
80
- return value;
81
- throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
82
- }
83
- function parseResolveOtherHumanThreads(value) {
84
- if (typeof value === "string" &&
85
- RESOLVE_OTHER_HUMAN_THREADS.includes(value)) {
86
- return value;
87
- }
88
- throw new Error(`Invalid config: iterate.resolveOtherHumanThreads must be one of "none", "outdated", or "always", got ${JSON.stringify(value)}`);
89
- }
90
- function parseInstructionStyle(value) {
91
- if (isInstructionStyle(value))
92
- return value;
93
- throw new Error(`Invalid config: iterate.instructions must be "playbook" or "inline", got ${JSON.stringify(value)}`);
94
- }
95
- function parseReviewOutputMaxChars(value) {
96
- if (typeof value === "number" && Number.isInteger(value) && value >= 0)
97
- return value;
98
- throw new Error(`Invalid config: iterate.reviewOutputMaxChars must be a non-negative integer, got ${JSON.stringify(value)}`);
99
- }
100
- function parseCliCommand(value) {
101
- if (!Array.isArray(value) ||
102
- value.length === 0 ||
103
- !value.every((arg) => typeof arg === "string" && arg.trim() !== "")) {
104
- throw new Error("Invalid config: cliCommand must be a non-empty array of non-empty strings");
105
- }
106
- return value;
107
- }
108
- function parseBotUsernames(value) {
109
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
110
- throw new Error(`Invalid config: botUsernames must be an array of strings`);
111
- }
112
- return value;
113
- }
114
- function parseIgnoreChecks(value) {
115
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
116
- throw new Error(`Invalid config: ignoreChecks must be an array of strings`);
117
- }
118
- return value;
119
- }
120
- function parseNeverCancelRuns(value) {
121
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
122
- throw new Error(`Invalid config: actions.neverCancelRuns must be an array of strings`);
123
- }
124
- return value;
125
- }
126
- function parseIgnoreLogLines(value) {
127
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
128
- throw new Error(`Invalid config: checks.ignoreLogLines must be an array of strings`);
129
- }
130
- for (const pattern of value) {
131
- try {
132
- new RegExp(pattern);
133
- }
134
- catch {
135
- throw new Error(`Invalid config: checks.ignoreLogLines contains an invalid regular expression: ${pattern}`);
136
- }
137
- }
138
- return value;
139
- }
140
- const SHEPHERD_OWNED_MERGE_FLAGS = [
141
- "--repo",
142
- "-R",
143
- "--auto",
144
- "--disable-auto",
145
- "--match-head-commit",
146
- "--admin",
147
- "--body-file",
148
- "-F",
149
- "--help",
150
- "-h",
151
- ];
152
- function parseMergeCommandArgs(value) {
153
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
154
- throw new Error("Invalid config: merge.commandArgs must be an array of strings");
155
- }
156
- for (const arg of value) {
157
- if (SHEPHERD_OWNED_MERGE_FLAGS.some((flag) => arg === flag ||
158
- arg.startsWith(`${flag}=`) ||
159
- (flag.startsWith("-") && !flag.startsWith("--") && arg.startsWith(flag)))) {
160
- throw new Error(`Invalid config: merge.commandArgs cannot include Shepherd-owned ${arg}`);
161
- }
162
- }
163
- const strategies = findMergeStrategies(value);
164
- if (strategies.length > 1) {
165
- throw new Error(`Invalid config: merge.commandArgs includes multiple merge strategies: ${strategies.join(", ")}`);
166
- }
167
- return [...value];
168
- }
169
- function parsePollConfig(value) {
170
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
171
- throw new Error("Invalid config: poll must be a plain object");
172
- }
173
- const record = value;
174
- const intervalSeconds = parsePollDuration(record["intervalSeconds"], "intervalSeconds");
175
- const timeoutSeconds = parsePollDuration(record["timeoutSeconds"], "timeoutSeconds");
176
- const debounceSeconds = parsePollDuration(record["debounceSeconds"], "debounceSeconds", true);
177
- const reconcileSeconds = parsePollDuration(record["reconcileSeconds"], "reconcileSeconds", true);
178
- const stackIntervalFactor = parseStackIntervalFactor(record["stackIntervalFactor"]);
179
- assertAggregateIntervalFits(intervalSeconds, stackIntervalFactor);
180
- const quietStatus = record["quietStatus"];
181
- if (typeof quietStatus !== "boolean") {
182
- throw new Error(`Invalid config: poll.quietStatus must be a boolean, got ${JSON.stringify(quietStatus)}`);
183
- }
184
- const mode = record["mode"];
185
- if (!isPollMode(mode)) {
186
- throw new Error(`Invalid config: poll.mode must be one of ${POLL_MODES.join(", ")}, got ${JSON.stringify(mode)}`);
187
- }
188
- return {
189
- intervalSeconds,
190
- stackIntervalFactor,
191
- timeoutSeconds,
192
- debounceSeconds,
193
- quietStatus,
194
- mode,
195
- reconcileSeconds,
196
- };
197
- }
198
- function parseStackIntervalFactor(value) {
199
- if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
200
- throw new Error(`Invalid config: poll.stackIntervalFactor must be a finite number greater than or equal to 1, got ${JSON.stringify(value)}`);
201
- }
202
- return value;
203
- }
204
- /** Largest `setTimeout` delay. A bigger aggregate sleep would clamp to ~24.8 days. */
205
- const MAX_POLL_INTERVAL_MS = 2 ** 31 - 1;
206
- function assertAggregateIntervalFits(intervalSeconds, factor) {
207
- const ms = intervalSeconds * factor * 1000;
208
- if (!Number.isFinite(ms) || ms > MAX_POLL_INTERVAL_MS) {
209
- throw new Error(`Invalid config: poll.intervalSeconds * poll.stackIntervalFactor must be finite and at most ${MAX_POLL_INTERVAL_MS} milliseconds, got ${JSON.stringify(ms)}`);
210
- }
211
- }
212
- function parsePollDuration(value, key, allowZero = false) {
213
- if (typeof value !== "number" ||
214
- !Number.isFinite(value) ||
215
- (allowZero ? value < 0 : value <= 0)) {
216
- const range = allowZero ? "a non-negative" : "a positive";
217
- throw new Error(`Invalid config: poll.${key} must be ${range} finite number, got ${JSON.stringify(value)}`);
218
- }
219
- return value;
220
- }
221
- function parseGraphqlQuotaWarnings(value, pollIntervalSeconds) {
222
- if (!Array.isArray(value)) {
223
- throw new Error("Invalid config: watch.graphqlQuotaWarnings must be an array");
224
- }
225
- const seen = new Set();
226
- const parsed = value.map((item, index) => {
227
- if (item === null || typeof item !== "object" || Array.isArray(item)) {
228
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}] must be an object`);
229
- }
230
- const record = item;
231
- const remainingPercent = record["remainingPercent"];
232
- const pollIntervalMinutes = record["pollIntervalMinutes"];
233
- const pollIntervalFactor = record["pollIntervalFactor"];
234
- if (typeof remainingPercent !== "number" ||
235
- !Number.isInteger(remainingPercent) ||
236
- remainingPercent < 1 ||
237
- remainingPercent > 100) {
238
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].remainingPercent must be an integer from 1 to 100`);
239
- }
240
- if (pollIntervalMinutes === undefined && pollIntervalFactor === undefined) {
241
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}] must define pollIntervalMinutes, pollIntervalFactor, or both`);
242
- }
243
- if (pollIntervalMinutes !== undefined &&
244
- (typeof pollIntervalMinutes !== "number" ||
245
- !Number.isFinite(pollIntervalMinutes) ||
246
- pollIntervalMinutes <= 0)) {
247
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].pollIntervalMinutes must be a positive number`);
248
- }
249
- if (pollIntervalFactor !== undefined &&
250
- (typeof pollIntervalFactor !== "number" ||
251
- !Number.isFinite(pollIntervalFactor) ||
252
- pollIntervalFactor < 1)) {
253
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].pollIntervalFactor must be a number greater than or equal to 1`);
254
- }
255
- if (seen.has(remainingPercent)) {
256
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings has duplicate remainingPercent ${remainingPercent}`);
257
- }
258
- seen.add(remainingPercent);
259
- const factorMinutes = typeof pollIntervalFactor === "number" ? (pollIntervalSeconds * pollIntervalFactor) / 60 : 0;
260
- const absoluteMinutes = typeof pollIntervalMinutes === "number" ? pollIntervalMinutes : 0;
261
- const resolvedPollIntervalMinutes = Math.max(absoluteMinutes, factorMinutes);
262
- if (!Number.isFinite(resolvedPollIntervalMinutes)) {
263
- throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}] resolves to a non-finite poll interval`);
264
- }
265
- return {
266
- remainingPercent,
267
- pollIntervalMinutes: resolvedPollIntervalMinutes,
268
- };
269
- });
270
- parsed.sort((left, right) => right.remainingPercent - left.remainingPercent);
271
- for (let index = 1; index < parsed.length; index += 1) {
272
- const previous = parsed[index - 1];
273
- const current = parsed[index];
274
- if (previous !== undefined &&
275
- current !== undefined &&
276
- current.pollIntervalMinutes < previous.pollIntervalMinutes) {
277
- throw new Error("Invalid config: watch.graphqlQuotaWarnings pollIntervalMinutes must not decrease as remainingPercent decreases");
278
- }
279
- }
280
- return parsed;
281
- }
282
- const KNOWN_CONFIG_KEYS = new Set([
283
- "github",
284
- "classify",
285
- "cliCommand",
286
- "botUsernames",
287
- "ignoreChecks",
288
- "iterate",
289
- "poll",
290
- "watch",
291
- "resolve",
292
- "checks",
293
- "mergeStatus",
294
- "merge",
295
- "actions",
296
- ]);
297
- const KNOWN_NESTED_KEYS = {
298
- github: new Set(["transport"]),
299
- iterate: new Set([
300
- "fixAttemptsPerThread",
301
- "stallTimeoutMinutes",
302
- "minimizeApprovals",
303
- "minimizeComments",
304
- "behindBaseHint",
305
- "resolveOtherHumanThreads",
306
- "instructions",
307
- "reviewOutputMaxChars",
308
- ]),
309
- poll: new Set([
310
- "intervalSeconds",
311
- "stackIntervalFactor",
312
- "timeoutSeconds",
313
- "debounceSeconds",
314
- "quietStatus",
315
- "mode",
316
- "reconcileSeconds",
317
- ]),
318
- watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
319
- resolve: new Set(["shaPoll"]),
320
- checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
321
- mergeStatus: new Set(["blockingReviewerLogins"]),
322
- merge: new Set(["commandArgs", "method"]),
323
- actions: new Set([
324
- "autoMinimizeSuppressed",
325
- "autoMarkReady",
326
- "neverCancelRuns",
327
- "workWhileQueued",
328
- "autoResolveOutdated",
329
- "commitSuggestions",
330
- ]),
331
- };
332
- function warnUnknownConfigKeys(config) {
333
- for (const key of Object.keys(config)) {
334
- if (!KNOWN_CONFIG_KEYS.has(key)) {
335
- process.stderr.write(`pr-shepherd: config: unknown key "${key}" ignored.\n`);
336
- delete config[key];
337
- continue;
338
- }
339
- const value = config[key];
340
- const nested = KNOWN_NESTED_KEYS[key];
341
- if (nested === undefined ||
342
- value === null ||
343
- typeof value !== "object" ||
344
- Array.isArray(value)) {
345
- continue;
346
- }
347
- for (const child of Object.keys(value)) {
348
- if (!nested.has(child)) {
349
- process.stderr.write(`pr-shepherd: config: unknown key "${key}.${child}" ignored.\n`);
350
- delete value[child];
351
- }
352
- }
353
- }
354
- }
355
- const rawDefaults = builtins;
356
- /**
357
- * Resolves `classify.standard` (`{ all?: boolean, <rule>?: boolean }`) to enabled rule names.
358
- * A named entry overrides `all`. Unknown names and non-boolean values warn and are ignored.
359
- */
360
- function parseStandardRules(classify) {
361
- if (classify === null || typeof classify !== "object" || Array.isArray(classify))
362
- return [];
363
- const standard = classify["standard"];
364
- if (standard === undefined)
365
- return [];
366
- if (standard === null || typeof standard !== "object" || Array.isArray(standard)) {
367
- process.stderr.write("pr-shepherd: config: classify.standard must be a map of rule name to boolean; ignored.\n");
368
- return [];
369
- }
370
- const entries = standard;
371
- const flags = new Map();
372
- for (const [name, value] of Object.entries(entries)) {
373
- if (name !== "all" && !STANDARD_RULE_NAMES.includes(name)) {
374
- process.stderr.write(`pr-shepherd: config: unknown standard classification rule "classify.standard.${name}" ignored.\n`);
375
- continue;
376
- }
377
- if (typeof value !== "boolean") {
378
- process.stderr.write(`pr-shepherd: config: classify.standard.${name} must be a boolean; ignored.\n`);
379
- continue;
380
- }
381
- flags.set(name, value);
382
- }
383
- const all = flags.get("all") ?? false;
384
- return STANDARD_RULE_NAMES.filter((name) => flags.get(name) ?? all).sort();
385
- }
386
- function parseConfig(value, normalizeMergeArgs = true) {
387
- const config = value;
388
- const github = value["github"];
389
- if (github === null || typeof github !== "object" || Array.isArray(github)) {
390
- throw new Error("Invalid config: github must be a plain object");
391
- }
392
- config.github = {
393
- transport: parseGithubTransport(github["transport"] ?? "auto"),
394
- };
395
- config.cliCommand = parseCliCommand(config.cliCommand);
396
- config.botUsernames = parseBotUsernames(config.botUsernames);
397
- config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
398
- config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
399
- if (config.merge && normalizeMergeArgs) {
400
- config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
401
- if (config.merge.method !== undefined)
402
- config.merge.method = parseMergeMethod(config.merge.method);
403
- const fromArgs = findMergeStrategies(config.merge.commandArgs)[0];
404
- if (config.merge.method && fromArgs && fromArgs !== config.merge.method) {
405
- throw new Error(`Invalid config: merge.method ${config.merge.method} conflicts with merge.commandArgs strategy ${fromArgs}`);
406
- }
407
- }
408
- config.poll = parsePollConfig(config.poll);
409
- config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings, config.poll.intervalSeconds);
410
- config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
411
- config.iterate.resolveOtherHumanThreads = parseResolveOtherHumanThreads(config.iterate.resolveOtherHumanThreads);
412
- config.iterate.instructions = parseInstructionStyle(config.iterate.instructions);
413
- config.iterate.reviewOutputMaxChars = parseReviewOutputMaxChars(config.iterate.reviewOutputMaxChars);
414
- config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
415
- config.standardRules = parseStandardRules(config.classify);
416
- return config;
417
- }
418
- const defaults = parseConfig(structuredClone(rawDefaults), false);
419
- const configCache = new Map();
420
- function stripDeprecatedActionKeys(parsed) {
421
- const rawActions = parsed.actions;
422
- if (rawActions === null || typeof rawActions !== "object" || Array.isArray(rawActions))
423
- return;
424
- const actions = rawActions;
425
- for (const key of ["autoResolveOutdated", "commitSuggestions"]) {
426
- if (key in actions) {
427
- process.stderr.write(`pr-shepherd: config: actions.${key} is deprecated and ignored.\n`);
428
- delete actions[key];
429
- }
430
- }
431
- }
432
- function readRcFile(rcPath) {
62
+ /** Parse and validate one config file without applying defaults. */
63
+ function readConfigFile(file) {
64
+ let raw;
65
+ let text;
433
66
  try {
434
- const raw = readFileSync(rcPath, "utf8");
435
- const parsed = (parse(raw) ?? {});
436
- stripDeprecatedActionKeys(parsed);
437
- warnUnknownConfigKeys(parsed);
438
- return parsed;
67
+ text = readFileSync(file, "utf8");
68
+ raw = parse(text);
439
69
  }
440
70
  catch (err) {
441
- process.stderr.write(`pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`);
442
- return null;
443
- }
71
+ throw configError(file, [err instanceof Error ? err.message : String(err)]);
72
+ }
73
+ // `parse` returns null for an empty document and for an explicit `null` or `~` scalar.
74
+ if (raw === null && !isExplicitNull(text))
75
+ raw = {};
76
+ if (!isPlainObject(raw))
77
+ throw configError(file, ["the top level must be a mapping"]);
78
+ // An empty file sets nothing, so it needs no version.
79
+ if (Object.keys(raw).length === 0)
80
+ return raw;
81
+ const version = raw["version"];
82
+ // A newer format may rename or add keys, so report only the version mismatch.
83
+ if (typeof version === "number" && Number.isInteger(version) && version > CONFIG_VERSION) {
84
+ throw configError(file, [
85
+ `\`version: ${version}\` needs a newer pr-shepherd; this release reads config version ${CONFIG_VERSION}`,
86
+ ]);
87
+ }
88
+ const result = configFileSchema.safeParse(raw);
89
+ if (!result.success) {
90
+ throw configError(file, result.error.issues.flatMap((issue) => issue.path.length === 1 && issue.path[0] === "version"
91
+ ? [versionIssue(version)]
92
+ : issueLines(issue, raw)));
93
+ }
94
+ return raw;
95
+ }
96
+ function isExplicitNull(text) {
97
+ const contents = parseDocument(text).contents;
98
+ return isScalar(contents) && contents.source !== "";
99
+ }
100
+ function versionIssue(version) {
101
+ return version === undefined
102
+ ? `\`version\` is required: add \`version: ${CONFIG_VERSION}\` (0.x config files have none; see docs/configuration.md#version)`
103
+ : `\`version\` must be ${CONFIG_VERSION}`;
444
104
  }
105
+ const configCache = new Map();
106
+ /**
107
+ * Load the config for the effective cwd: built-in defaults, then `$HOME`, then each ancestor
108
+ * down to cwd, closest file winning. Any invalid file throws a `ShepherdError` (exit 78).
109
+ */
445
110
  export function loadConfig() {
446
111
  const cwd = getEffectiveCwd();
447
- if (configCache.has(cwd))
448
- return configCache.get(cwd);
449
- const rcPaths = collectRcFiles(cwd);
450
- if (rcPaths.length === 0) {
451
- configCache.set(cwd, defaults);
452
- return defaults;
453
- }
454
- try {
455
- // Farthest file first so closer directories override, ESLint-style.
456
- let overlay = {};
457
- let loaded = false;
458
- for (const rcPath of [...rcPaths].reverse()) {
459
- const parsed = readRcFile(rcPath);
460
- if (parsed === null)
461
- continue;
462
- overlay = deepMerge(overlay, parsed);
463
- loaded = true;
464
- }
465
- if (!loaded) {
466
- configCache.set(cwd, defaults);
467
- return defaults;
468
- }
469
- const config = parseConfig(deepMerge(structuredClone(rawDefaults), overlay));
470
- configCache.set(cwd, config);
471
- return config;
472
- }
473
- catch (err) {
474
- process.stderr.write(`pr-shepherd: failed to parse ${rcPaths[0]}: ${err instanceof Error ? err.message : String(err)}\n`);
475
- configCache.set(cwd, defaults);
476
- return defaults;
477
- }
112
+ const cached = configCache.get(cwd);
113
+ if (cached !== undefined)
114
+ return cached;
115
+ const files = collectConfigFiles(cwd);
116
+ // Farthest file first so closer directories override, ESLint-style.
117
+ let overlay = {};
118
+ for (const file of [...files].reverse())
119
+ overlay = deepMerge(overlay, readConfigFile(file));
120
+ const config = resolveConfig(overlay, files.length > 0 ? files.join(", ") : undefined);
121
+ configCache.set(cwd, config);
122
+ return config;
478
123
  }
479
124
  /** Reset the config cache — for use in tests that change directories. */
480
125
  export function _resetConfigCache() {
@@ -20,7 +20,7 @@ export function findMergeStrategies(args) {
20
20
  const assignment = longAssignment ?? shortAssignment;
21
21
  if (assignment) {
22
22
  if (assignment[2] !== "true" && assignment[2] !== "false") {
23
- throw new Error(`Invalid config: merge.commandArgs strategy ${arg} must use true or false`);
23
+ throw new Error(`strategy ${arg} must use true or false`);
24
24
  }
25
25
  if (assignment[2] === "true") {
26
26
  strategies.push(MERGE_STRATEGY_FLAGS.get(assignment[1]));
@@ -37,7 +37,7 @@ export function findMergeStrategies(args) {
37
37
  continue;
38
38
  }
39
39
  if (/^-[dmrs].+/.test(arg)) {
40
- throw new Error(`Invalid config: merge.commandArgs cannot safely parse compound short option ${arg}; use separate flags or long options`);
40
+ throw new Error(`cannot safely parse compound short option ${arg}; use separate flags or long options`);
41
41
  }
42
42
  }
43
43
  return strategies;
@@ -6,10 +6,9 @@ export interface RepositoryMergeSettings {
6
6
  }
7
7
  /** `undefined` when the query did not select repository merge settings. */
8
8
  export declare function readAllowedMergeMethods(repository: RepositoryMergeSettings | null | undefined): MergeMethod[] | undefined;
9
- export declare function parseMergeMethod(value: unknown): MergeMethod;
10
9
  export declare function configuredMergeMethod(merge: {
11
10
  method?: MergeMethod;
12
- commandArgs?: string[];
11
+ "command-args": string[];
13
12
  }): MergeMethod | null;
14
13
  export declare function chooseMergeMethod(input: {
15
14
  allowed: readonly MergeMethod[] | undefined;
@@ -1,5 +1,4 @@
1
1
  import { findMergeStrategies } from "./merge-command-args.mjs";
2
- const MERGE_METHODS = new Set(["merge", "squash", "rebase"]);
3
2
  const METHOD_ORDER = ["merge", "squash", "rebase"];
4
3
  /** `undefined` when the query did not select repository merge settings. */
5
4
  export function readAllowedMergeMethods(repository) {
@@ -18,14 +17,8 @@ export function readAllowedMergeMethods(repository) {
18
17
  allowed.push("rebase");
19
18
  return allowed;
20
19
  }
21
- export function parseMergeMethod(value) {
22
- if (typeof value === "string" && MERGE_METHODS.has(value)) {
23
- return value;
24
- }
25
- throw new Error("Invalid config: merge.method must be merge, squash, or rebase");
26
- }
27
20
  export function configuredMergeMethod(merge) {
28
- const fromArgs = findMergeStrategies(merge.commandArgs ?? [])[0];
21
+ const fromArgs = findMergeStrategies(merge["command-args"])[0];
29
22
  return merge.method ?? fromArgs ?? null;
30
23
  }
31
24
  export function chooseMergeMethod(input) {
@@ -0,0 +1,8 @@
1
+ type PathSegment = string | number;
2
+ export declare function formatPath(path: readonly PathSegment[]): string;
3
+ /**
4
+ * Describe one unrecognized key at `path`. Old keys get a rename hint; an unknown section is
5
+ * searched for renamed keys inside it before it is reported as unknown as a whole.
6
+ */
7
+ export declare function describeUnknownKey(path: readonly PathSegment[], value: unknown): string[];
8
+ export {};