@bridge_gpt/mcp-server 0.2.67 → 0.2.69

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 (127) hide show
  1. package/README.md +272 -51
  2. package/build/agent-launchers/claude-executor-adapter.js +27 -0
  3. package/build/agents.generated.js +22 -2
  4. package/build/bitbucket-workspace-remote.js +136 -0
  5. package/build/bootstrap-project-identity.js +24 -0
  6. package/build/bridge-permissions.js +1362 -0
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-settings.js +102 -2
  9. package/build/cli-help.js +27 -0
  10. package/build/codex-home.js +9 -1
  11. package/build/codex-launcher-pin.js +598 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +9 -9
  15. package/build/conductor/bridge-api-client.js +714 -0
  16. package/build/conductor/command-drift-production.js +92 -0
  17. package/build/conductor/command-drift.js +89 -0
  18. package/build/conductor/doctor.js +69 -26
  19. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  20. package/build/conductor/epic-implementer-cli.js +76 -22
  21. package/build/conductor/gate-discovery.js +890 -0
  22. package/build/conductor/install-doctor.js +15 -3
  23. package/build/conductor/json-output.js +63 -0
  24. package/build/conductor/readiness-cli.js +95 -10
  25. package/build/conductor/readiness-sections.js +369 -3
  26. package/build/conductor/readiness.js +211 -8
  27. package/build/conductor/recovery-cli.js +110 -4
  28. package/build/conductor/recovery-operations.js +99 -1
  29. package/build/connect-bitbucket-api.js +41 -6
  30. package/build/connect-bitbucket.js +106 -33
  31. package/build/connect-github-api.js +33 -14
  32. package/build/credential-store.js +207 -47
  33. package/build/docs.generated.js +57 -3
  34. package/build/doctor.js +198 -4
  35. package/build/drive-epic.js +45 -8
  36. package/build/epic-coherence-input.test-fixtures.js +4 -3
  37. package/build/epic-implementer-bin.js +1369 -25
  38. package/build/epic-workarounds-path.js +39 -0
  39. package/build/executor/cli.js +30 -0
  40. package/build/executor/completion-contract.js +20 -5
  41. package/build/executor/deps.js +4 -1
  42. package/build/executor/gate-qualification-job.js +499 -0
  43. package/build/executor/guarded-fast-forward-push.js +129 -0
  44. package/build/executor/http-client.js +43 -4
  45. package/build/executor/integration-job.js +122 -8
  46. package/build/executor/job-errors.js +8 -1
  47. package/build/executor/job-runner.js +589 -47
  48. package/build/executor/likely-tests.js +290 -0
  49. package/build/executor/live-worker-registry.js +1 -1
  50. package/build/executor/observation.js +31 -2
  51. package/build/executor/packaged-recipe.js +106 -0
  52. package/build/executor/permissions.js +115 -28
  53. package/build/executor/platform.js +1 -1
  54. package/build/executor/preflight.js +61 -12
  55. package/build/executor/process-containment.js +36 -12
  56. package/build/executor/process.js +15 -7
  57. package/build/executor/prompt-spec.js +33 -1
  58. package/build/executor/recently-failed-tests-file.js +71 -0
  59. package/build/executor/reconcile-overlap-job.js +41 -23
  60. package/build/executor/required-verification.js +1 -1
  61. package/build/executor/required-work-ledger.js +105 -17
  62. package/build/executor/retirement.js +69 -0
  63. package/build/executor/runner.js +114 -11
  64. package/build/executor/test-clock.js +15 -0
  65. package/build/executor/types.js +32 -0
  66. package/build/executor/viewer-tabs.js +58 -8
  67. package/build/executor/worker-command.js +23 -7
  68. package/build/executor/worker-diagnostics.js +37 -0
  69. package/build/executor/worker-finalization.js +31 -5
  70. package/build/executor/worker-guard-hook.js +179 -25
  71. package/build/executor/worker-log.js +4 -3
  72. package/build/executor/worker-verification-guidance.js +37 -0
  73. package/build/executor/worktree.js +88 -11
  74. package/build/first-build-advisory.js +97 -0
  75. package/build/generated-assets-advisory.js +14 -0
  76. package/build/generated-assets-catalog.js +66 -0
  77. package/build/generated-assets-context.js +46 -0
  78. package/build/generated-assets-dependencies.js +26 -0
  79. package/build/generated-assets-doctor.js +50 -0
  80. package/build/generated-assets-health.js +65 -0
  81. package/build/generated-assets-host.js +17 -0
  82. package/build/generated-assets-inventory.js +17 -0
  83. package/build/generated-assets-paths.js +60 -0
  84. package/build/generated-assets-publisher.js +133 -0
  85. package/build/generated-assets-remedy.js +25 -0
  86. package/build/generated-assets-repair.js +72 -0
  87. package/build/generated-assets-session.js +71 -0
  88. package/build/generated-assets-test-support.js +35 -0
  89. package/build/index.js +1109 -695
  90. package/build/init.js +54 -177
  91. package/build/install-bridge-conductor-gate.js +1372 -0
  92. package/build/install-bridge-conductor.js +289 -36
  93. package/build/install-bridge.js +722 -252
  94. package/build/launcher-config-inspection.js +184 -19
  95. package/build/mcp-host-targets.js +4 -0
  96. package/build/mcp-identity.js +7 -0
  97. package/build/mcp-install-state.js +50 -4
  98. package/build/merge-workflow-install.js +3 -4
  99. package/build/pipeline-utils.js +6 -2
  100. package/build/pipelines.generated.js +22 -142
  101. package/build/plan-execution-ledger.js +2 -32
  102. package/build/plane/manifest.js +16 -1
  103. package/build/plane/preflight.js +54 -1
  104. package/build/printed-remedy-commands.js +16 -1
  105. package/build/readme.generated.js +1 -1
  106. package/build/repair-assets.js +93 -0
  107. package/build/run-unit-tests-launcher.js +130 -49
  108. package/build/secret-redaction.js +33 -0
  109. package/build/session-upgrade-notice.js +86 -0
  110. package/build/setup-epic.js +108 -3
  111. package/build/start-tickets-prereqs.js +70 -16
  112. package/build/start-tickets.js +44 -5
  113. package/build/tool-call-advisory.js +162 -0
  114. package/build/tool-surface-gating.js +32 -57
  115. package/build/update-check.js +97 -16
  116. package/build/update-status.js +61 -71
  117. package/build/upgrade-advice.js +15 -11
  118. package/build/upgrade-cli.js +398 -17
  119. package/build/version.generated.js +3 -3
  120. package/build/worker-guard-hook-bin.js +3 -2
  121. package/build/worker-guard-hook-main.js +1 -1
  122. package/build/worktree-core.js +15 -2
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
  124. package/docs/EPIC-IMPLEMENTER.md +68 -2
  125. package/package.json +3 -3
  126. package/pipelines/greenfield-setup.json +4 -128
  127. package/pipelines/implement-ticket.json +3 -2
@@ -0,0 +1,1375 @@
1
+ /**
2
+ * Epic-branch CI inspection for the installer (BAPI-1405).
3
+ *
4
+ * Two read-mostly questions the install gate flow asks about a GitHub
5
+ * repository before it records the repository's epic-branch CI decision:
6
+ *
7
+ * 1. Do the repository's workflows run for a pull request whose BASE is an
8
+ * epic branch (`epic/<EPIC>`)? Answered from the working tree through the
9
+ * injected {@link GateDiscoveryFs}, deterministically, with no LLM and no
10
+ * YAML library.
11
+ * 2. Do the branch rules for `epic/<EPIC>` require approvals, and can that
12
+ * approval requirement be narrowly exempted? Answered through the user's own
13
+ * `gh` session via an injected, shell-free {@link GhRunner}.
14
+ *
15
+ * ## Trigger-coverage truth table
16
+ *
17
+ * The Python twin `classify_epic_pull_request_trigger` in
18
+ * `api/library/github/workflow_inspection.py` implements the same table with
19
+ * `yaml.safe_load`; the two must agree. Each workflow answers `covers`,
20
+ * `does_not_cover`, or `could_not_tell` with one closed reason token
21
+ * ({@link EpicCoverageReason}). Applied in this order:
22
+ *
23
+ * 1. Empty, unparseable, or non-mapping document, or no trigger key →
24
+ * `could_not_tell` / `unparseable`. The trigger key is the root `on`, which
25
+ * YAML 1.1 reads as boolean `true` when unquoted: the root keys `on`, `"on"`,
26
+ * `'on'`, and the plain booleans `true`/`True`/`TRUE`/`On`/`ON`/`yes`/`Yes`/
27
+ * `YES` are all accepted. A quoted `"on"` wins over a boolean key, as
28
+ * `parsed.get("on", parsed.get(True))` does.
29
+ * 2. Anything in the trigger block this scanner cannot read confidently —
30
+ * anchors, aliases, tags, merge keys, block scalars, non-empty flow mappings,
31
+ * multi-line flow collections, tabs in indentation, multi-document streams —
32
+ * or a trigger value that is not a string, list, or mapping →
33
+ * `could_not_tell` / `unsupported_structure`.
34
+ * 3. Trigger forms:
35
+ * - a string: `pull_request` / `pull_request_target` covers;
36
+ * - a list: covers when it names either PR event (any non-string item
37
+ * otherwise makes it `unsupported_structure`);
38
+ * - a mapping: each of `pull_request` and `pull_request_target` is judged on
39
+ * its own —
40
+ * - null or `{}` → covers; any other non-mapping → `unsupported_structure`;
41
+ * - `paths` / `paths-ignore` present → `path_filter`;
42
+ * - `types` present → passes only when it is a list of strings holding
43
+ * both `opened` and `synchronize`, else `types_filter`;
44
+ * - both `branches` and `branches-ignore` → `branch_filter_uncertain`;
45
+ * - `branches` (a string is a one-element list; an empty or non-string
46
+ * list is uncertain) → ordered, last match wins, `!` negates:
47
+ * state starts NONE_IN; a positive ALL → ALL_IN, a positive SOME keeps
48
+ * ALL_IN else → UNKNOWN; a negative ALL → NONE_IN, a negative SOME keeps
49
+ * NONE_IN else → UNKNOWN; NONE patterns change nothing. ALL_IN → covers,
50
+ * NONE_IN → `does_not_cover` / `branch_filter_excludes_epic`, UNKNOWN →
51
+ * `branch_filter_uncertain`;
52
+ * - `branches-ignore` → an empty list covers; any `!` entry or non-string
53
+ * is uncertain; any ALL pattern → `does_not_cover`; all NONE → covers;
54
+ * otherwise uncertain;
55
+ * - no filter → covers.
56
+ * Pattern classes against `epic/<EPIC>` (EPIC has no `/`; `*` and `?`
57
+ * never match `/`, `**` matches anything): ALL is exactly `**`, `epic/*`, or
58
+ * `epic/**`. NONE is a pattern with no `/`, no `**`, and no `[` (bare `*`
59
+ * included); a wildcard-free literal not starting with `epic/`; or a
60
+ * pattern whose literal text before its first wildcard (`*?[+!`) is neither
61
+ * a prefix of `epic/` nor starts with it. Everything else is SOME.
62
+ * 4. Any PR key that covers → `covers` / `pr_trigger_covers`. Indirect
63
+ * triggers and job-level `uses` only ADD runs and never block a cover.
64
+ * 5. Otherwise the first PR key that could not be told → that key's reason.
65
+ * 6. Otherwise `merge_group` / `workflow_run` / `workflow_call` among the
66
+ * trigger names → `indirect_trigger`; else a job-level `uses` →
67
+ * `reusable_workflow_job`; else `does_not_cover` with
68
+ * `branch_filter_excludes_epic` when a PR key was present, `no_pr_trigger`
69
+ * when none was.
70
+ *
71
+ * Scanner scope: the trigger block is parsed fully (block mappings, block
72
+ * sequences including the compact form, single-line flow sequences of scalars,
73
+ * `{}`, plain and quoted scalars with YAML 1.1 implicit typing, comments).
74
+ * The rest of the document is only checked for root-level shape; a document
75
+ * that is invalid YAML only OUTSIDE the trigger block and `jobs` headers is not
76
+ * detected here (the Python twin would answer `unparseable`).
77
+ *
78
+ * The repository aggregate is `covers` when any workflow covers. It is
79
+ * `does_not_cover` only after a successful, untruncated listing in which every
80
+ * workflow was read and does not cover (a confirmed empty or confirmed absent
81
+ * `.github/workflows` is `no_workflows`); otherwise `could_not_tell`.
82
+ *
83
+ * ## Approval exemption
84
+ *
85
+ * The only write this module can make is adding `refs/heads/epic/**` to the
86
+ * `conditions.ref_name.exclude` list of a repository-level ruleset whose only
87
+ * rule is `pull_request`, and only when the caller passes an eligible
88
+ * {@link ExemptionScopeAssessment} (the install flow asks for explicit consent
89
+ * first). Success is reported only after a re-read shows no `pull_request`
90
+ * rule on the branch and an unchanged set of required status check contexts.
91
+ *
92
+ * Every returned reason is a bounded token: no `gh` output, URL, or token text
93
+ * is ever returned. Nothing here throws.
94
+ */
95
+ import { MAX_DISCOVERY_FILE_BYTES, MAX_WORKFLOW_FILES, } from "./gate-discovery.js";
96
+ export const EPIC_COVERAGE_REASONS = [
97
+ "pr_trigger_covers",
98
+ "no_pr_trigger",
99
+ "branch_filter_excludes_epic",
100
+ "path_filter",
101
+ "branch_filter_uncertain",
102
+ "types_filter",
103
+ "indirect_trigger",
104
+ "reusable_workflow_job",
105
+ "unparseable",
106
+ "unsupported_structure",
107
+ "listing_failed",
108
+ "file_limit_reached",
109
+ "read_failed",
110
+ "no_workflows",
111
+ ];
112
+ export const DEFAULT_PER_READ_TIMEOUT_MS = 5_000;
113
+ export const DEFAULT_OVERALL_BUDGET_MS = 30_000;
114
+ const EPIC_PREFIX = "epic/";
115
+ const EPIC_ALL_PATTERNS = new Set(["**", "epic/*", "epic/**"]);
116
+ const GLOB_CHARS = new Set(["*", "?", "[", "+", "!"]);
117
+ const PR_TRIGGER_KEYS = ["pull_request", "pull_request_target"];
118
+ const INDIRECT_TRIGGER_KEYS = new Set(["merge_group", "workflow_run", "workflow_call"]);
119
+ /** Classify one (non-negated) filter pattern; see the module truth table. */
120
+ export function classifyEpicBranchPattern(pattern) {
121
+ if (EPIC_ALL_PATTERNS.has(pattern))
122
+ return "all";
123
+ if (!pattern.includes("/") && !pattern.includes("**") && !pattern.includes("["))
124
+ return "none";
125
+ let firstGlob = pattern.length;
126
+ for (let i = 0; i < pattern.length; i++) {
127
+ if (GLOB_CHARS.has(pattern[i])) {
128
+ firstGlob = i;
129
+ break;
130
+ }
131
+ }
132
+ if (firstGlob === pattern.length)
133
+ return pattern.startsWith(EPIC_PREFIX) ? "some" : "none";
134
+ const prefix = pattern.slice(0, firstGlob);
135
+ if (EPIC_PREFIX.startsWith(prefix) || prefix.startsWith(EPIC_PREFIX))
136
+ return "some";
137
+ return "none";
138
+ }
139
+ const COVERS = { coverage: "covers", reason: "pr_trigger_covers" };
140
+ const EXCLUDES = { coverage: "does_not_cover", reason: "branch_filter_excludes_epic" };
141
+ function couldNotTell(reason) {
142
+ return { coverage: "could_not_tell", reason };
143
+ }
144
+ /** Thrown internally when the scanner meets structure it will not guess at. */
145
+ class UnsupportedYaml extends Error {
146
+ }
147
+ function unsupported() {
148
+ throw new UnsupportedYaml("unsupported");
149
+ }
150
+ /** Map-key identity: strings by value, booleans by value, anything else opaque. */
151
+ function keyId(node) {
152
+ if (node.t === "str")
153
+ return `s:${node.v}`;
154
+ if (node.t === "bool")
155
+ return `b:${node.v}`;
156
+ if (node.t === "null")
157
+ return "n:";
158
+ unsupported();
159
+ }
160
+ function filterPatterns(node) {
161
+ if (node === undefined)
162
+ return null;
163
+ if (node.t === "str")
164
+ return [node.v];
165
+ if (node.t === "seq" && node.items.every((i) => i.t === "str")) {
166
+ return node.items.map((i) => i.v);
167
+ }
168
+ return null;
169
+ }
170
+ function classifyBranches(node) {
171
+ const patterns = filterPatterns(node);
172
+ if (patterns === null || patterns.length === 0)
173
+ return couldNotTell("branch_filter_uncertain");
174
+ let state = "none_in";
175
+ for (const raw of patterns) {
176
+ const negated = raw.startsWith("!");
177
+ const match = classifyEpicBranchPattern(negated ? raw.slice(1) : raw);
178
+ if (match === "none")
179
+ continue;
180
+ if (!negated) {
181
+ if (match === "all")
182
+ state = "all_in";
183
+ else if (state !== "all_in")
184
+ state = "unknown";
185
+ }
186
+ else if (match === "all") {
187
+ state = "none_in";
188
+ }
189
+ else if (state !== "none_in") {
190
+ state = "unknown";
191
+ }
192
+ }
193
+ if (state === "all_in")
194
+ return COVERS;
195
+ if (state === "none_in")
196
+ return EXCLUDES;
197
+ return couldNotTell("branch_filter_uncertain");
198
+ }
199
+ function classifyBranchesIgnore(node) {
200
+ if (node !== undefined && node.t === "seq" && node.items.length === 0)
201
+ return COVERS;
202
+ const patterns = filterPatterns(node);
203
+ if (patterns === null || patterns.some((p) => p.startsWith("!"))) {
204
+ return couldNotTell("branch_filter_uncertain");
205
+ }
206
+ const matches = patterns.map(classifyEpicBranchPattern);
207
+ if (matches.includes("all"))
208
+ return EXCLUDES;
209
+ if (matches.every((m) => m === "none"))
210
+ return COVERS;
211
+ return couldNotTell("branch_filter_uncertain");
212
+ }
213
+ function classifyPrEvent(node) {
214
+ if (node.t === "null")
215
+ return COVERS;
216
+ if (node.t !== "map")
217
+ return couldNotTell("unsupported_structure");
218
+ const e = node.entries;
219
+ if (e.size === 0)
220
+ return COVERS;
221
+ if (e.has("s:paths") || e.has("s:paths-ignore"))
222
+ return couldNotTell("path_filter");
223
+ if (e.has("s:types")) {
224
+ const types = e.get("s:types");
225
+ const ok = types !== undefined &&
226
+ types.t === "seq" &&
227
+ types.items.every((i) => i.t === "str") &&
228
+ types.items.some((i) => i.t === "str" && i.v === "opened") &&
229
+ types.items.some((i) => i.t === "str" && i.v === "synchronize");
230
+ if (!ok)
231
+ return couldNotTell("types_filter");
232
+ }
233
+ const hasBranches = e.has("s:branches");
234
+ const hasIgnore = e.has("s:branches-ignore");
235
+ if (hasBranches && hasIgnore)
236
+ return couldNotTell("branch_filter_uncertain");
237
+ if (hasBranches)
238
+ return classifyBranches(e.get("s:branches"));
239
+ if (hasIgnore)
240
+ return classifyBranchesIgnore(e.get("s:branches-ignore"));
241
+ return COVERS;
242
+ }
243
+ // ---------------------------------------------------------------------------
244
+ // Scalar reading (YAML 1.1 / PyYAML implicit resolution)
245
+ // ---------------------------------------------------------------------------
246
+ const BOOL_TRUE = new Set(["yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"]);
247
+ const BOOL_FALSE = new Set(["no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"]);
248
+ const NULL_WORDS = new Set(["~", "null", "Null", "NULL", ""]);
249
+ const INT_RE = /^(?:[-+]?0b[0-1_]+|[-+]?0[0-7_]+|[-+]?(?:0|[1-9][0-9_]*)|[-+]?0x[0-9a-fA-F_]+|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$/;
250
+ const FLOAT_RE = /^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
251
+ const TIMESTAMP_RE = /^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?(?:[Tt]|[ \t]+)[0-9][0-9]?:[0-9][0-9]:[0-9][0-9](?:\.[0-9]*)?(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$/;
252
+ /** Resolve a plain scalar the way PyYAML's SafeLoader does (merge/value tags are refused). */
253
+ function resolvePlain(v) {
254
+ if (NULL_WORDS.has(v))
255
+ return { t: "null" };
256
+ if (BOOL_TRUE.has(v))
257
+ return { t: "bool", v: true };
258
+ if (BOOL_FALSE.has(v))
259
+ return { t: "bool", v: false };
260
+ if (INT_RE.test(v) || FLOAT_RE.test(v) || TIMESTAMP_RE.test(v))
261
+ return { t: "other" };
262
+ if (v === "<<" || v === "=")
263
+ unsupported();
264
+ return { t: "str", v };
265
+ }
266
+ /** Characters that may not start a plain scalar (or start a construct we refuse). */
267
+ const REFUSED_STARTS = new Set(["|", ">", "&", "*", "!", "%", "@", "`", "{", "[", "]", "}", ",", "#", '"', "'"]);
268
+ function onlyCommentAfter(rem) {
269
+ return rem.trim() === "" || /^\s+#/.test(rem);
270
+ }
271
+ /** PyYAML's single-character double-quote escapes (a line-continuation escape is refused). */
272
+ const DQ_ESCAPES = {
273
+ "0": "\0",
274
+ a: "\x07",
275
+ b: "\b",
276
+ t: "\t",
277
+ "\t": "\t",
278
+ n: "\n",
279
+ v: "\v",
280
+ f: "\f",
281
+ r: "\r",
282
+ e: "\x1b",
283
+ " ": " ",
284
+ '"': '"',
285
+ "/": "/",
286
+ "\\": "\\",
287
+ N: "\u0085",
288
+ _: "\u00a0",
289
+ L: "\u2028",
290
+ P: "\u2029",
291
+ };
292
+ /**
293
+ * Read a quoted scalar starting at `text[start]`. Returns the value and the
294
+ * index after the closing quote. Double quotes decode PyYAML's escapes
295
+ * (single-character, `\xHH`, `\uHHHH`, `\UHHHHHHHH`); anything else is refused.
296
+ */
297
+ function readQuoted(text, start) {
298
+ const q = text[start];
299
+ let value = "";
300
+ for (let i = start + 1; i < text.length; i++) {
301
+ const ch = text[i];
302
+ if (q === '"' && ch === "\\") {
303
+ const next = text[i + 1] ?? "";
304
+ const simple = DQ_ESCAPES[next];
305
+ if (simple !== undefined) {
306
+ value += simple;
307
+ i++;
308
+ continue;
309
+ }
310
+ const width = next === "x" ? 2 : next === "u" ? 4 : next === "U" ? 8 : 0;
311
+ const hex = text.slice(i + 2, i + 2 + width);
312
+ if (width === 0 || !/^[0-9A-Fa-f]+$/.test(hex) || hex.length !== width)
313
+ unsupported();
314
+ const cp = parseInt(hex, 16);
315
+ if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))
316
+ unsupported();
317
+ value += String.fromCodePoint(cp);
318
+ i += 1 + width;
319
+ continue;
320
+ }
321
+ if (ch === q) {
322
+ if (q === "'" && text[i + 1] === "'") {
323
+ value += "'";
324
+ i++;
325
+ continue;
326
+ }
327
+ return { value, end: i + 1 };
328
+ }
329
+ value += ch;
330
+ }
331
+ unsupported(); // unterminated on this line: a multi-line scalar
332
+ }
333
+ /** A single-line flow sequence of scalars, e.g. `[push, "pull_request"]`. */
334
+ function parseFlowSeq(text) {
335
+ const items = [];
336
+ let i = 1;
337
+ const skipSpaces = () => {
338
+ while (text[i] === " ")
339
+ i++;
340
+ };
341
+ skipSpaces();
342
+ if (text[i] === "]") {
343
+ if (!onlyCommentAfter(text.slice(i + 1)))
344
+ unsupported();
345
+ return { t: "seq", items };
346
+ }
347
+ for (;;) {
348
+ skipSpaces();
349
+ const ch = text[i];
350
+ if (ch === undefined)
351
+ unsupported();
352
+ if (ch === '"' || ch === "'") {
353
+ const q = readQuoted(text, i);
354
+ items.push({ t: "str", v: q.value });
355
+ i = q.end;
356
+ skipSpaces();
357
+ }
358
+ else {
359
+ let j = i;
360
+ while (j < text.length && text[j] !== "," && text[j] !== "]")
361
+ j++;
362
+ const raw = text.slice(i, j).trim();
363
+ if (raw === "" ||
364
+ REFUSED_STARTS.has(raw[0]) ||
365
+ /^[-?:](\s|$)/.test(raw) ||
366
+ /[[\]{}#:\t]/.test(raw)) {
367
+ unsupported();
368
+ }
369
+ items.push(resolvePlain(raw));
370
+ i = j;
371
+ }
372
+ if (text[i] === ",") {
373
+ i++;
374
+ skipSpaces();
375
+ if (text[i] === "]") {
376
+ // A single trailing comma is valid YAML: `[a, b,]`.
377
+ if (!onlyCommentAfter(text.slice(i + 1)))
378
+ unsupported();
379
+ return { t: "seq", items };
380
+ }
381
+ if (text[i] === undefined || text[i] === ",")
382
+ unsupported();
383
+ continue;
384
+ }
385
+ if (text[i] === "]") {
386
+ if (!onlyCommentAfter(text.slice(i + 1)))
387
+ unsupported();
388
+ return { t: "seq", items };
389
+ }
390
+ unsupported();
391
+ }
392
+ }
393
+ /** Read the inline value after `key:` or `- ` (already trimmed). */
394
+ function parseInline(rest) {
395
+ if (rest === "" || rest.startsWith("#"))
396
+ return { t: "null" };
397
+ const c = rest[0];
398
+ if (c === "[")
399
+ return parseFlowSeq(rest);
400
+ if (c === "{") {
401
+ if (/^\{\s*\}(\s*|\s+#.*)$/.test(rest))
402
+ return { t: "map", entries: new Map() };
403
+ unsupported();
404
+ }
405
+ if (c === '"' || c === "'") {
406
+ const q = readQuoted(rest, 0);
407
+ if (!onlyCommentAfter(rest.slice(q.end)))
408
+ unsupported();
409
+ return { t: "str", v: q.value };
410
+ }
411
+ if (REFUSED_STARTS.has(c) || /^[-?:](\s|$)/.test(rest))
412
+ unsupported();
413
+ const comment = rest.search(/\s#/);
414
+ const value = (comment >= 0 ? rest.slice(0, comment) : rest).trim();
415
+ if (value === "" || /:(\s|$)/.test(value) || value.includes("\t"))
416
+ unsupported();
417
+ return resolvePlain(value);
418
+ }
419
+ /**
420
+ * Split a block-mapping line into key and inline rest, or null when the line
421
+ * is not a `key:` entry. Keys may be plain or quoted.
422
+ */
423
+ function splitKey(text) {
424
+ let key;
425
+ let after;
426
+ const c = text[0];
427
+ if (c === '"' || c === "'") {
428
+ let q;
429
+ try {
430
+ q = readQuoted(text, 0);
431
+ }
432
+ catch {
433
+ return null;
434
+ }
435
+ after = text.slice(q.end).replace(/^ +/, "");
436
+ if (!after.startsWith(":"))
437
+ return null;
438
+ after = after.slice(1);
439
+ if (after !== "" && after[0] !== " ")
440
+ return null;
441
+ key = { t: "str", v: q.value };
442
+ }
443
+ else {
444
+ if (c === undefined || REFUSED_STARTS.has(c) || /^[-?:](\s|$)/.test(text))
445
+ return null;
446
+ let colon = -1;
447
+ for (let i = 0; i < text.length; i++) {
448
+ if (text[i] === "#" && i > 0 && /\s/.test(text[i - 1]))
449
+ return null;
450
+ if (text[i] === ":" && (i + 1 === text.length || text[i + 1] === " ")) {
451
+ colon = i;
452
+ break;
453
+ }
454
+ }
455
+ if (colon < 0)
456
+ return null;
457
+ const raw = text.slice(0, colon).trimEnd();
458
+ if (raw === "" || raw.length > 1000 || raw.includes("\t"))
459
+ return null;
460
+ key = resolvePlain(raw);
461
+ after = text.slice(colon + 1);
462
+ }
463
+ let rest = after.trim();
464
+ if (rest.startsWith("#"))
465
+ rest = "";
466
+ return { key, rest };
467
+ }
468
+ function isSeqItem(l) {
469
+ return l.text === "-" || l.text.startsWith("- ");
470
+ }
471
+ /** Strict line reader for the trigger block: refuses tabs in indentation. */
472
+ function checkLine(l) {
473
+ if (l.tabIndent)
474
+ unsupported();
475
+ return l;
476
+ }
477
+ function parseBlockAt(ls, i) {
478
+ const l = checkLine(ls[i]);
479
+ return isSeqItem(l) ? parseSeqAt(ls, i, l.indent) : parseMapAt(ls, i, l.indent);
480
+ }
481
+ function parseValueAfterKey(ls, i, keyIndent, allowCompactSeq) {
482
+ if (i < ls.length) {
483
+ const l = checkLine(ls[i]);
484
+ if (l.indent > keyIndent)
485
+ return parseBlockAt(ls, i);
486
+ if (allowCompactSeq && l.indent === keyIndent && isSeqItem(l))
487
+ return parseSeqAt(ls, i, keyIndent);
488
+ }
489
+ return { node: { t: "null" }, next: i };
490
+ }
491
+ function parseMapAt(ls, start, n) {
492
+ const entries = new Map();
493
+ let i = start;
494
+ while (i < ls.length) {
495
+ const l = checkLine(ls[i]);
496
+ if (l.indent < n)
497
+ break;
498
+ if (l.indent > n || isSeqItem(l))
499
+ unsupported();
500
+ const kv = splitKey(l.text);
501
+ if (kv === null)
502
+ unsupported();
503
+ const id = keyId(kv.key);
504
+ let node;
505
+ let next;
506
+ if (kv.rest === "") {
507
+ ({ node, next } = parseValueAfterKey(ls, i + 1, n, true));
508
+ }
509
+ else {
510
+ node = parseInline(kv.rest);
511
+ next = i + 1;
512
+ if (next < ls.length && ls[next].indent > n)
513
+ unsupported();
514
+ }
515
+ entries.set(id, node); // duplicate keys: last wins, as in PyYAML
516
+ i = next;
517
+ }
518
+ return { node: { t: "map", entries }, next: i };
519
+ }
520
+ function parseSeqAt(ls, start, n) {
521
+ const items = [];
522
+ let i = start;
523
+ while (i < ls.length) {
524
+ const l = checkLine(ls[i]);
525
+ if (l.indent < n)
526
+ break;
527
+ if (l.indent > n)
528
+ unsupported();
529
+ if (!isSeqItem(l))
530
+ break;
531
+ const content = l.text.slice(1).trim();
532
+ if (content === "" || content.startsWith("#")) {
533
+ const r = parseValueAfterKey(ls, i + 1, n, false);
534
+ items.push(r.node);
535
+ i = r.next;
536
+ continue;
537
+ }
538
+ if (content === "-" || content.startsWith("- ") || splitKey(content) !== null)
539
+ unsupported();
540
+ items.push(parseInline(content));
541
+ i++;
542
+ if (i < ls.length && ls[i].indent > n)
543
+ unsupported();
544
+ }
545
+ return { node: { t: "seq", items }, next: i };
546
+ }
547
+ // ---------------------------------------------------------------------------
548
+ // Document scan
549
+ // ---------------------------------------------------------------------------
550
+ /** True when the text holds a character PyYAML refuses (non-printable) or a line break it treats specially. */
551
+ function hasRefusedChars(text) {
552
+ for (const ch of text) {
553
+ const cp = ch.codePointAt(0) ?? 0;
554
+ if (cp === 0x09 || cp === 0x0a)
555
+ continue;
556
+ if (cp === 0x0d)
557
+ continue; // a lone CR is caught below
558
+ if (cp >= 0x20 && cp <= 0x7e)
559
+ continue;
560
+ if (cp >= 0xa0 && cp <= 0xd7ff && cp !== 0x2028 && cp !== 0x2029)
561
+ continue;
562
+ if (cp >= 0xe000 && cp <= 0xfffc && cp !== 0xfeff)
563
+ continue;
564
+ if (cp >= 0x10000 && cp <= 0x10ffff)
565
+ continue;
566
+ return true;
567
+ }
568
+ return /\r(?!\n)/.test(text);
569
+ }
570
+ /** Does an inline root value at least close on its own line? */
571
+ function rootRestLooksSingleLine(rest) {
572
+ const c = rest[0];
573
+ if (c === undefined)
574
+ return true;
575
+ if (c === "*" || c === "&" || c === "!" || c === "%" || c === "@" || c === "`")
576
+ return false;
577
+ if (c === '"' || c === "'") {
578
+ try {
579
+ const q = readQuoted(rest, 0);
580
+ return onlyCommentAfter(rest.slice(q.end));
581
+ }
582
+ catch {
583
+ return false;
584
+ }
585
+ }
586
+ if (c === "[" || c === "{") {
587
+ let depth = 0;
588
+ let quote = null;
589
+ for (let i = 0; i < rest.length; i++) {
590
+ const ch = rest[i];
591
+ if (quote) {
592
+ if (ch === quote)
593
+ quote = null;
594
+ continue;
595
+ }
596
+ if (ch === '"' || ch === "'")
597
+ quote = ch;
598
+ else if (ch === "[" || ch === "{")
599
+ depth++;
600
+ else if (ch === "]" || ch === "}") {
601
+ depth--;
602
+ if (depth === 0)
603
+ return onlyCommentAfter(rest.slice(i + 1));
604
+ }
605
+ }
606
+ return false;
607
+ }
608
+ return true;
609
+ }
610
+ function scanRoot(text) {
611
+ if (hasRefusedChars(text))
612
+ return { kind: "unsupported" };
613
+ const lines = [];
614
+ for (const raw of text.split(/\r?\n/)) {
615
+ const trimmed = raw.trim();
616
+ if (trimmed === "" || trimmed.startsWith("#"))
617
+ continue;
618
+ const indent = raw.length - raw.trimStart().length;
619
+ lines.push({
620
+ indent,
621
+ text: trimmed,
622
+ tabIndent: raw.slice(0, indent).includes("\t"),
623
+ leadTab: raw.startsWith("\t"),
624
+ });
625
+ }
626
+ if (lines.length === 0)
627
+ return { kind: "unparseable" };
628
+ const entries = [];
629
+ let sawContent = false;
630
+ for (const l of lines) {
631
+ if (l.indent === 0) {
632
+ if (l.text === "---" || l.text.startsWith("--- ") || l.text.startsWith("---#")) {
633
+ if (sawContent || l.text !== "---")
634
+ return { kind: "unsupported" };
635
+ continue;
636
+ }
637
+ if (l.text.startsWith("...") || l.text.startsWith("%"))
638
+ return { kind: "unsupported" };
639
+ sawContent = true;
640
+ if (isSeqItem(l)) {
641
+ if (entries.length === 0)
642
+ return { kind: "unparseable" }; // a sequence document
643
+ entries[entries.length - 1].region.push(l);
644
+ continue;
645
+ }
646
+ const kv = splitKey(l.text);
647
+ if (kv === null)
648
+ return { kind: "unsupported" };
649
+ if (kv.key.t !== "str" && kv.key.t !== "bool")
650
+ return { kind: "unsupported" };
651
+ if (!rootRestLooksSingleLine(kv.rest))
652
+ return { kind: "unsupported" };
653
+ entries.push({ id: keyId(kv.key), rest: kv.rest, region: [] });
654
+ continue;
655
+ }
656
+ // Indented line: belongs to the latest root entry. A tab-led line cannot be indentation.
657
+ if (l.leadTab)
658
+ return { kind: "unsupported" };
659
+ sawContent = true;
660
+ if (entries.length === 0)
661
+ return { kind: "unsupported" };
662
+ entries[entries.length - 1].region.push(l);
663
+ }
664
+ return { kind: "ok", entries };
665
+ }
666
+ /** Find the trigger entry: a string `on` wins over a boolean-true key; last duplicate wins. */
667
+ function findTrigger(entries) {
668
+ let str = null;
669
+ let bool = null;
670
+ for (const e of entries) {
671
+ if (e.id === "s:on")
672
+ str = e;
673
+ else if (e.id === "b:true")
674
+ bool = e;
675
+ }
676
+ return str ?? bool;
677
+ }
678
+ function parseEntryValue(e) {
679
+ if (e.rest !== "") {
680
+ if (e.region.length > 0)
681
+ unsupported();
682
+ return parseInline(e.rest);
683
+ }
684
+ if (e.region.length === 0)
685
+ return { t: "null" };
686
+ const r = parseBlockAt(e.region, 0);
687
+ if (r.next !== e.region.length)
688
+ unsupported();
689
+ return r.node;
690
+ }
691
+ /** True when any job under the root `jobs` mapping has a job-level `uses` key. */
692
+ function jobsHaveUses(entries) {
693
+ let jobs = null;
694
+ for (const e of entries)
695
+ if (e.id === "s:jobs")
696
+ jobs = e;
697
+ if (jobs === null)
698
+ return false;
699
+ if (jobs.rest !== "") {
700
+ if (jobs.region.length === 0 && /^\{\s*\}(\s*|\s+#.*)$/.test(jobs.rest))
701
+ return false;
702
+ if (jobs.region.length === 0 && parseInline(jobs.rest).t !== "map")
703
+ return false;
704
+ unsupported();
705
+ }
706
+ const region = jobs.region;
707
+ if (region.length === 0)
708
+ return false;
709
+ const jobIndent = region[0].indent;
710
+ if (jobIndent === 0)
711
+ unsupported();
712
+ let childIndent = null;
713
+ let inJob = false;
714
+ for (const l of region) {
715
+ if (l.indent < jobIndent)
716
+ unsupported();
717
+ if (l.indent === jobIndent) {
718
+ checkLine(l);
719
+ const kv = splitKey(l.text);
720
+ if (kv === null || kv.rest !== "")
721
+ unsupported();
722
+ inJob = true;
723
+ childIndent = null;
724
+ continue;
725
+ }
726
+ if (!inJob)
727
+ unsupported();
728
+ childIndent ??= l.indent;
729
+ if (l.indent < childIndent)
730
+ unsupported();
731
+ if (l.indent > childIndent)
732
+ continue;
733
+ checkLine(l);
734
+ if (isSeqItem(l))
735
+ continue;
736
+ const kv = splitKey(l.text);
737
+ if (kv === null)
738
+ unsupported();
739
+ if (kv.key.t === "str" && kv.key.v === "uses")
740
+ return true;
741
+ }
742
+ return false;
743
+ }
744
+ function classifyOrThrow(text) {
745
+ if (typeof text !== "string" || text.trim() === "")
746
+ return couldNotTell("unparseable");
747
+ const root = scanRoot(text);
748
+ if (root.kind === "unparseable")
749
+ return couldNotTell("unparseable");
750
+ if (root.kind === "unsupported")
751
+ return couldNotTell("unsupported_structure");
752
+ const trigger = findTrigger(root.entries);
753
+ if (trigger === null)
754
+ return couldNotTell("unparseable");
755
+ // Every candidate trigger entry is parsed, not just the winner: a losing
756
+ // duplicate must still be valid YAML for the document to load at all.
757
+ for (const e of root.entries) {
758
+ if (e !== trigger && (e.id === "s:on" || e.id === "b:true"))
759
+ parseEntryValue(e);
760
+ }
761
+ const node = parseEntryValue(trigger);
762
+ let triggerNames;
763
+ let prResults = [];
764
+ if (node.t === "str") {
765
+ if (PR_TRIGGER_KEYS.includes(node.v))
766
+ return COVERS;
767
+ triggerNames = [node.v];
768
+ }
769
+ else if (node.t === "seq") {
770
+ if (node.items.some((i) => i.t === "str" && PR_TRIGGER_KEYS.includes(i.v))) {
771
+ return COVERS;
772
+ }
773
+ if (!node.items.every((i) => i.t === "str"))
774
+ return couldNotTell("unsupported_structure");
775
+ triggerNames = node.items.map((i) => i.v);
776
+ }
777
+ else if (node.t === "map") {
778
+ triggerNames = [...node.entries.keys()].filter((k) => k.startsWith("s:")).map((k) => k.slice(2));
779
+ prResults = PR_TRIGGER_KEYS.filter((k) => node.entries.has(`s:${k}`)).map((k) => classifyPrEvent(node.entries.get(`s:${k}`)));
780
+ if (prResults.some((r) => r.coverage === "covers"))
781
+ return COVERS;
782
+ const uncertain = prResults.find((r) => r.coverage === "could_not_tell");
783
+ if (uncertain)
784
+ return uncertain;
785
+ }
786
+ else {
787
+ return couldNotTell("unsupported_structure");
788
+ }
789
+ if (triggerNames.some((n) => INDIRECT_TRIGGER_KEYS.has(n)))
790
+ return couldNotTell("indirect_trigger");
791
+ if (jobsHaveUses(root.entries))
792
+ return couldNotTell("reusable_workflow_job");
793
+ return prResults.length > 0 ? EXCLUDES : { coverage: "does_not_cover", reason: "no_pr_trigger" };
794
+ }
795
+ /**
796
+ * Does this workflow's PR trigger run for a pull request whose base is
797
+ * `epic/<EPIC>`? Never throws; see the module truth table.
798
+ */
799
+ export function classifyEpicPullRequestTrigger(text) {
800
+ try {
801
+ return classifyOrThrow(text);
802
+ }
803
+ catch (err) {
804
+ return couldNotTell(err instanceof UnsupportedYaml ? "unsupported_structure" : "unparseable");
805
+ }
806
+ }
807
+ // ---------------------------------------------------------------------------
808
+ // Repository aggregate
809
+ // ---------------------------------------------------------------------------
810
+ const WORKFLOWS_DIR = ".github/workflows";
811
+ function isPlainEntry(name) {
812
+ return name !== "" && name !== "." && name !== ".." && !/[/\\\0]/.test(name);
813
+ }
814
+ function byCodeUnit(a, b) {
815
+ return a < b ? -1 : a > b ? 1 : 0;
816
+ }
817
+ const TIMED_OUT = Symbol("timed_out");
818
+ async function readWithTimeout(fsSeam, relPath, timeoutMs) {
819
+ let timer;
820
+ try {
821
+ const timeout = new Promise((resolve) => {
822
+ timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, timeoutMs));
823
+ });
824
+ const result = await Promise.race([fsSeam.readText(relPath), timeout]);
825
+ if (result === TIMED_OUT || typeof result !== "string")
826
+ return null;
827
+ if (Buffer.byteLength(result, "utf8") > MAX_DISCOVERY_FILE_BYTES)
828
+ return null;
829
+ return result;
830
+ }
831
+ catch {
832
+ return null;
833
+ }
834
+ finally {
835
+ if (timer !== undefined)
836
+ clearTimeout(timer);
837
+ }
838
+ }
839
+ /**
840
+ * Inspect every workflow under `.github/workflows` and aggregate. Bounded by
841
+ * {@link MAX_WORKFLOW_FILES}, {@link MAX_DISCOVERY_FILE_BYTES}, a per-read
842
+ * timeout, and an overall read budget. Never throws.
843
+ */
844
+ export async function inspectEpicBranchWorkflowCoverage(fs, budget = {}) {
845
+ const now = budget.now ?? Date.now;
846
+ const perRead = budget.perReadTimeoutMs ?? DEFAULT_PER_READ_TIMEOUT_MS;
847
+ const overall = budget.overallBudgetMs ?? DEFAULT_OVERALL_BUDGET_MS;
848
+ try {
849
+ let entries;
850
+ try {
851
+ entries = await fs.listDir(WORKFLOWS_DIR);
852
+ }
853
+ catch {
854
+ entries = null;
855
+ }
856
+ if (entries === null) {
857
+ let exists;
858
+ try {
859
+ exists = await fs.exists(WORKFLOWS_DIR);
860
+ }
861
+ catch {
862
+ exists = null;
863
+ }
864
+ return exists === false
865
+ ? { coverage: "does_not_cover", reason: "no_workflows", workflows: [] }
866
+ : { coverage: "could_not_tell", reason: "listing_failed", workflows: [] };
867
+ }
868
+ if (!Array.isArray(entries))
869
+ return { coverage: "could_not_tell", reason: "listing_failed", workflows: [] };
870
+ const all = entries
871
+ .filter((n) => typeof n === "string" && isPlainEntry(n) && /\.ya?ml$/.test(n))
872
+ .sort(byCodeUnit);
873
+ const truncated = all.length > MAX_WORKFLOW_FILES;
874
+ const files = all.slice(0, MAX_WORKFLOW_FILES);
875
+ if (files.length === 0)
876
+ return { coverage: "does_not_cover", reason: "no_workflows", workflows: [] };
877
+ const started = now();
878
+ const workflows = [];
879
+ for (const file of files) {
880
+ const path = `${WORKFLOWS_DIR}/${file}`;
881
+ const remaining = overall - (now() - started);
882
+ if (remaining <= 0) {
883
+ workflows.push({ path, coverage: "could_not_tell", reason: "read_failed" });
884
+ continue;
885
+ }
886
+ const text = await readWithTimeout(fs, path, Math.min(perRead, remaining));
887
+ if (text === null) {
888
+ workflows.push({ path, coverage: "could_not_tell", reason: "read_failed" });
889
+ continue;
890
+ }
891
+ workflows.push({ path, ...classifyEpicPullRequestTrigger(text) });
892
+ }
893
+ if (workflows.some((w) => w.coverage === "covers")) {
894
+ return { coverage: "covers", reason: "pr_trigger_covers", workflows };
895
+ }
896
+ if (truncated)
897
+ return { coverage: "could_not_tell", reason: "file_limit_reached", workflows };
898
+ if (workflows.some((w) => w.reason === "read_failed")) {
899
+ return { coverage: "could_not_tell", reason: "read_failed", workflows };
900
+ }
901
+ const unresolved = workflows.find((w) => w.coverage === "could_not_tell");
902
+ if (unresolved)
903
+ return { coverage: "could_not_tell", reason: unresolved.reason, workflows };
904
+ const excluded = workflows.some((w) => w.reason === "branch_filter_excludes_epic");
905
+ return {
906
+ coverage: "does_not_cover",
907
+ reason: excluded ? "branch_filter_excludes_epic" : "no_pr_trigger",
908
+ workflows,
909
+ };
910
+ }
911
+ catch {
912
+ return { coverage: "could_not_tell", reason: "listing_failed", workflows: [] };
913
+ }
914
+ }
915
+ /** Closed reason tokens returned by the `gh` functions. */
916
+ export const EPIC_BRANCH_RULES_REASONS = [
917
+ "invalid_input",
918
+ "gh_unavailable",
919
+ "gh_unauthenticated",
920
+ "gh_failed",
921
+ "not_found",
922
+ "unparseable_response",
923
+ "malformed_rule",
924
+ "rules_truncated",
925
+ "branch_not_proven",
926
+ "approvals_required",
927
+ "approvals_not_required",
928
+ "no_rules_apply",
929
+ ];
930
+ /** Closed reason tokens for {@link assessApprovalExemptionScope} / {@link applyApprovalExemption}. */
931
+ export const EXEMPTION_REASONS = [
932
+ "eligible",
933
+ "no_approval_rule",
934
+ "rules_unreadable",
935
+ "malformed_rule",
936
+ "not_repository_ruleset",
937
+ "ruleset_unreadable",
938
+ "ruleset_detail_incomplete",
939
+ "ruleset_not_branch_target",
940
+ "ruleset_has_other_rules",
941
+ "ruleset_has_other_conditions",
942
+ "exclusion_present",
943
+ "scope_not_eligible",
944
+ "ruleset_changed",
945
+ "apply_failed",
946
+ "verification_failed",
947
+ "approvals_still_required",
948
+ "status_checks_changed",
949
+ "confirmed",
950
+ ];
951
+ /** The ref exclusion an approved exemption adds. */
952
+ export const EPIC_EXCLUDE_REF = "refs/heads/epic/**";
953
+ const RULES_PER_PAGE = 100;
954
+ const OWNER_REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
955
+ const BRANCH_RE = /^[A-Za-z0-9._/-]{1,255}$/;
956
+ function validOwnerRepo(ownerRepo) {
957
+ return typeof ownerRepo === "string" && OWNER_REPO_RE.test(ownerRepo) && !ownerRepo.split("/").some((p) => p === "." || p === "..");
958
+ }
959
+ function validBranch(branch) {
960
+ return (typeof branch === "string" &&
961
+ BRANCH_RE.test(branch) &&
962
+ !branch.startsWith("-") &&
963
+ !branch.startsWith("/") &&
964
+ !branch.endsWith("/") &&
965
+ !branch.split("/").some((p) => p === "" || p === "." || p === ".."));
966
+ }
967
+ function encodeBranchPath(branch) {
968
+ return branch.split("/").map(encodeURIComponent).join("/");
969
+ }
970
+ const GH_JSON_HEADERS = ["-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2022-11-28"];
971
+ /** Run `gh api ...` and parse JSON. Output text is inspected for status only and never returned. */
972
+ async function ghApiJson(run, args, input) {
973
+ let result;
974
+ try {
975
+ result = await run(["api", ...GH_JSON_HEADERS, ...args], input === undefined ? undefined : { input });
976
+ }
977
+ catch {
978
+ return { ok: false, reason: "gh_unavailable" };
979
+ }
980
+ if (!result || typeof result.exitCode !== "number")
981
+ return { ok: false, reason: "gh_failed" };
982
+ if (result.exitCode !== 0) {
983
+ const err = typeof result.stderr === "string" ? result.stderr : "";
984
+ if (/HTTP 404\b/.test(err))
985
+ return { ok: false, reason: "not_found" };
986
+ if (/HTTP 401\b|gh auth login|not logged in|authentication required/i.test(err)) {
987
+ return { ok: false, reason: "gh_unauthenticated" };
988
+ }
989
+ return { ok: false, reason: "gh_failed" };
990
+ }
991
+ try {
992
+ return { ok: true, json: JSON.parse(typeof result.stdout === "string" ? result.stdout : "") };
993
+ }
994
+ catch {
995
+ return { ok: false, reason: "unparseable_response" };
996
+ }
997
+ }
998
+ function isRecord(v) {
999
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1000
+ }
1001
+ function isNonNegInt(v) {
1002
+ return typeof v === "number" && Number.isInteger(v) && v >= 0;
1003
+ }
1004
+ /** GET the effective rules for a branch and validate every entry. */
1005
+ async function readBranchRules(run, ownerRepo, branch) {
1006
+ const call = await ghApiJson(run, [
1007
+ `repos/${ownerRepo}/rules/branches/${encodeBranchPath(branch)}?per_page=${RULES_PER_PAGE}`,
1008
+ ]);
1009
+ if (!call.ok)
1010
+ return { ok: false, reason: call.reason };
1011
+ if (!Array.isArray(call.json))
1012
+ return { ok: false, reason: "unparseable_response" };
1013
+ if (call.json.length >= RULES_PER_PAGE)
1014
+ return { ok: false, reason: "rules_truncated" };
1015
+ const pullRequestRules = [];
1016
+ const checks = new Set();
1017
+ for (const rule of call.json) {
1018
+ if (!isRecord(rule) || typeof rule.type !== "string")
1019
+ return { ok: false, reason: "malformed_rule" };
1020
+ const rulesetId = isNonNegInt(rule.ruleset_id) ? rule.ruleset_id : null;
1021
+ const sourceType = typeof rule.ruleset_source_type === "string" ? rule.ruleset_source_type : null;
1022
+ const source = typeof rule.ruleset_source === "string" ? rule.ruleset_source : null;
1023
+ if (rule.type === "pull_request") {
1024
+ const p = rule.parameters;
1025
+ if (!isRecord(p) || !isNonNegInt(p.required_approving_review_count)) {
1026
+ return { ok: false, reason: "malformed_rule" };
1027
+ }
1028
+ pullRequestRules.push({
1029
+ count: p.required_approving_review_count,
1030
+ codeOwnerReview: p.require_code_owner_review === true,
1031
+ rulesetId,
1032
+ sourceType,
1033
+ source,
1034
+ });
1035
+ }
1036
+ else if (rule.type === "required_status_checks") {
1037
+ const p = rule.parameters;
1038
+ if (!isRecord(p) || !Array.isArray(p.required_status_checks))
1039
+ return { ok: false, reason: "malformed_rule" };
1040
+ for (const c of p.required_status_checks) {
1041
+ if (!isRecord(c) || typeof c.context !== "string")
1042
+ return { ok: false, reason: "malformed_rule" };
1043
+ checks.add(c.context);
1044
+ }
1045
+ }
1046
+ }
1047
+ return { ok: true, rules: { pullRequestRules, statusChecks: [...checks].sort(byCodeUnit) } };
1048
+ }
1049
+ /** Prove the branch exists: true, false (404), or null (could not tell). */
1050
+ async function branchExists(run, ownerRepo, branch) {
1051
+ const call = await ghApiJson(run, [`repos/${ownerRepo}/branches/${encodeBranchPath(branch)}`]);
1052
+ if (call.ok)
1053
+ return isRecord(call.json) ? true : null;
1054
+ return call.reason === "not_found" ? false : null;
1055
+ }
1056
+ const NO_DEFAULT_CONTEXT = { defaultBranch: null, approvals: "could_not_tell", count: null };
1057
+ /**
1058
+ * Read classic branch protection on the repository's DEFAULT branch, as
1059
+ * context only. GET-only and bounded: the repository read (for
1060
+ * `default_branch`), the protection read, and — only on a protection 404 — a
1061
+ * branch read proving the branch is visible (a 404 alone may mean "cannot
1062
+ * see"). Absent review settings → `no`. Any failure → `could_not_tell`.
1063
+ * Never throws; returns no response text.
1064
+ */
1065
+ export async function readDefaultBranchClassicContextViaGh(run, ownerRepo) {
1066
+ try {
1067
+ if (!validOwnerRepo(ownerRepo))
1068
+ return NO_DEFAULT_CONTEXT;
1069
+ const repo = await ghApiJson(run, [`repos/${ownerRepo}`]);
1070
+ if (!repo.ok || !isRecord(repo.json))
1071
+ return NO_DEFAULT_CONTEXT;
1072
+ const defaultBranch = repo.json.default_branch;
1073
+ if (typeof defaultBranch !== "string" || !validBranch(defaultBranch))
1074
+ return NO_DEFAULT_CONTEXT;
1075
+ const unknown = { defaultBranch, approvals: "could_not_tell", count: null };
1076
+ const branchPath = encodeBranchPath(defaultBranch);
1077
+ const prot = await ghApiJson(run, [`repos/${ownerRepo}/branches/${branchPath}/protection`]);
1078
+ if (!prot.ok) {
1079
+ if (prot.reason !== "not_found")
1080
+ return unknown;
1081
+ const exists = await branchExists(run, ownerRepo, defaultBranch);
1082
+ return exists === true ? { defaultBranch, approvals: "no", count: null } : unknown;
1083
+ }
1084
+ if (!isRecord(prot.json))
1085
+ return unknown;
1086
+ const reviews = prot.json.required_pull_request_reviews;
1087
+ if (reviews === undefined || reviews === null)
1088
+ return { defaultBranch, approvals: "no", count: null };
1089
+ if (!isRecord(reviews))
1090
+ return unknown;
1091
+ const count = reviews.required_approving_review_count;
1092
+ if (!isNonNegInt(count))
1093
+ return unknown;
1094
+ return { defaultBranch, approvals: count > 0 ? "yes" : "no", count };
1095
+ }
1096
+ catch {
1097
+ return NO_DEFAULT_CONTEXT;
1098
+ }
1099
+ }
1100
+ function withDefaultContext(f, c) {
1101
+ return {
1102
+ ...f,
1103
+ defaultBranch: c.defaultBranch,
1104
+ defaultBranchClassicApprovals: c.approvals,
1105
+ defaultBranchClassicApprovalCount: c.count,
1106
+ };
1107
+ }
1108
+ function rulesCouldNotTell(branch, reason) {
1109
+ return {
1110
+ branch,
1111
+ approvalsRequired: "could_not_tell",
1112
+ requiredApprovingReviewCount: null,
1113
+ requiredStatusChecks: [],
1114
+ observationQuality: "could_not_tell",
1115
+ reason,
1116
+ };
1117
+ }
1118
+ /**
1119
+ * Read the effective branch rules for `branch` (normally `epic/<EPIC>`) through
1120
+ * `gh`. A `pull_request` rule requiring at least one approval (or code-owner
1121
+ * review) → `yes` / `inferred` (bypass eligibility is unknown). No such rule,
1122
+ * every rule well formed, and the branch proven to exist → `no` / `exact`. For
1123
+ * a branch not proven to exist the same answer is `no` / `inferred`: GitHub
1124
+ * documents that "the branch does not need to exist; rules that would apply to
1125
+ * a branch with that name will be returned"
1126
+ * (https://docs.github.com/en/rest/repos/rules#get-rules-for-a-branch). That is
1127
+ * documented but not yet live-verified (Bridge-GPT/bridge-api is a private
1128
+ * repository whose plan lacks rulesets), hence `inferred`. A non-200 reply,
1129
+ * including the 403 a plan without rulesets returns, stays `could_not_tell`. It also
1130
+ * attaches the default branch's classic protection as separately labelled
1131
+ * CONTEXT ({@link readDefaultBranchClassicContextViaGh}); that context never
1132
+ * changes `approvalsRequired`. Never throws.
1133
+ */
1134
+ export async function readEpicBranchRulesViaGh(run, ownerRepo, branch) {
1135
+ try {
1136
+ if (!validOwnerRepo(ownerRepo) || !validBranch(branch))
1137
+ return rulesCouldNotTell(String(branch), "invalid_input");
1138
+ const finding = await readEpicBranchRulesOnly(run, ownerRepo, branch);
1139
+ // When gh itself is missing or signed out, the context read would fail the
1140
+ // same way; skip it rather than spend two more doomed calls.
1141
+ const ghDown = finding.reason === "gh_unavailable" || finding.reason === "gh_unauthenticated";
1142
+ const context = ghDown ? NO_DEFAULT_CONTEXT : await readDefaultBranchClassicContextViaGh(run, ownerRepo);
1143
+ return withDefaultContext(finding, context);
1144
+ }
1145
+ catch {
1146
+ return rulesCouldNotTell(typeof branch === "string" ? branch : "", "gh_failed");
1147
+ }
1148
+ }
1149
+ async function readEpicBranchRulesOnly(run, ownerRepo, branch) {
1150
+ const read = await readBranchRules(run, ownerRepo, branch);
1151
+ if (!read.ok)
1152
+ return rulesCouldNotTell(branch, read.reason);
1153
+ const { pullRequestRules, statusChecks } = read.rules;
1154
+ if (pullRequestRules.length > 0) {
1155
+ const count = Math.max(...pullRequestRules.map((r) => r.count));
1156
+ const required = count > 0 || pullRequestRules.some((r) => r.codeOwnerReview);
1157
+ return {
1158
+ branch,
1159
+ approvalsRequired: required ? "yes" : "no",
1160
+ requiredApprovingReviewCount: count,
1161
+ requiredStatusChecks: statusChecks,
1162
+ observationQuality: "inferred",
1163
+ reason: required ? "approvals_required" : "approvals_not_required",
1164
+ };
1165
+ }
1166
+ // The rules read is trusted even for a branch that does not exist yet (see
1167
+ // above); only the quality depends on whether the branch was proven to exist.
1168
+ const exists = await branchExists(run, ownerRepo, branch);
1169
+ return {
1170
+ branch,
1171
+ approvalsRequired: "no",
1172
+ requiredApprovingReviewCount: null,
1173
+ requiredStatusChecks: statusChecks,
1174
+ observationQuality: exists === true ? "exact" : "inferred",
1175
+ reason: "no_rules_apply",
1176
+ };
1177
+ }
1178
+ /**
1179
+ * Validate one ruleset's full detail and build its update body: the ruleset as
1180
+ * read (name, target, enforcement, conditions, rules, bypass_actors), with
1181
+ * {@link EPIC_EXCLUDE_REF} appended to `conditions.ref_name.exclude`.
1182
+ */
1183
+ async function checkRuleset(run, ownerRepo, id) {
1184
+ const call = await ghApiJson(run, [`repos/${ownerRepo}/rulesets/${id}`]);
1185
+ if (!call.ok || !isRecord(call.json))
1186
+ return { ok: false, reason: "ruleset_unreadable" };
1187
+ const d = call.json;
1188
+ if (d.id !== id || typeof d.name !== "string" || typeof d.enforcement !== "string") {
1189
+ return { ok: false, reason: "ruleset_detail_incomplete" };
1190
+ }
1191
+ if (d.source_type !== "Repository")
1192
+ return { ok: false, reason: "not_repository_ruleset" };
1193
+ if (typeof d.source === "string" && d.source.toLowerCase() !== ownerRepo.toLowerCase()) {
1194
+ return { ok: false, reason: "not_repository_ruleset" };
1195
+ }
1196
+ if (d.target !== "branch")
1197
+ return { ok: false, reason: "ruleset_not_branch_target" };
1198
+ if (!Array.isArray(d.rules) || !Array.isArray(d.bypass_actors) || !isRecord(d.conditions)) {
1199
+ return { ok: false, reason: "ruleset_detail_incomplete" };
1200
+ }
1201
+ if (d.rules.length !== 1 || !isRecord(d.rules[0]) || d.rules[0].type !== "pull_request") {
1202
+ return { ok: false, reason: "ruleset_has_other_rules" };
1203
+ }
1204
+ const conditionKeys = Object.keys(d.conditions);
1205
+ if (conditionKeys.length !== 1 || conditionKeys[0] !== "ref_name") {
1206
+ return { ok: false, reason: "ruleset_has_other_conditions" };
1207
+ }
1208
+ const refName = d.conditions.ref_name;
1209
+ if (!isRecord(refName) ||
1210
+ !Array.isArray(refName.include) ||
1211
+ !Array.isArray(refName.exclude) ||
1212
+ !refName.include.every((s) => typeof s === "string") ||
1213
+ !refName.exclude.every((s) => typeof s === "string")) {
1214
+ return { ok: false, reason: "ruleset_detail_incomplete" };
1215
+ }
1216
+ if (refName.exclude.includes(EPIC_EXCLUDE_REF))
1217
+ return { ok: false, reason: "exclusion_present" };
1218
+ const body = {
1219
+ name: d.name,
1220
+ target: d.target,
1221
+ enforcement: d.enforcement,
1222
+ conditions: { ...d.conditions, ref_name: { ...refName, exclude: [...refName.exclude, EPIC_EXCLUDE_REF] } },
1223
+ rules: d.rules,
1224
+ bypass_actors: d.bypass_actors,
1225
+ };
1226
+ return { ok: true, body };
1227
+ }
1228
+ function ineligible(reason, rulesetIds = []) {
1229
+ return { eligible: false, reason, rulesetIds };
1230
+ }
1231
+ /**
1232
+ * Decide whether the approval requirement on `branch` can be exempted by only
1233
+ * excluding `epic/**` from repository-level rulesets. Eligible only when every
1234
+ * applicable `pull_request` rule comes from a readable, repository-level,
1235
+ * branch-targeted ruleset whose sole rule is that `pull_request` rule and whose
1236
+ * only condition is `ref_name`. Read-only. Never throws.
1237
+ */
1238
+ export async function assessApprovalExemptionScope(run, ownerRepo, branch) {
1239
+ try {
1240
+ if (!validOwnerRepo(ownerRepo) || !validBranch(branch))
1241
+ return ineligible("rules_unreadable");
1242
+ const read = await readBranchRules(run, ownerRepo, branch);
1243
+ if (!read.ok)
1244
+ return ineligible(read.reason === "malformed_rule" ? "malformed_rule" : "rules_unreadable");
1245
+ const prRules = read.rules.pullRequestRules;
1246
+ if (prRules.length === 0)
1247
+ return ineligible("no_approval_rule");
1248
+ const ids = new Set();
1249
+ for (const r of prRules) {
1250
+ if (r.rulesetId === null || r.sourceType === null)
1251
+ return ineligible("malformed_rule");
1252
+ if (r.sourceType !== "Repository")
1253
+ return ineligible("not_repository_ruleset");
1254
+ ids.add(r.rulesetId);
1255
+ }
1256
+ const rulesetIds = [...ids].sort((a, b) => a - b);
1257
+ for (const id of rulesetIds) {
1258
+ const check = await checkRuleset(run, ownerRepo, id);
1259
+ if (!check.ok)
1260
+ return ineligible(check.reason, rulesetIds);
1261
+ }
1262
+ return { eligible: true, reason: "eligible", rulesetIds };
1263
+ }
1264
+ catch {
1265
+ return ineligible("rules_unreadable");
1266
+ }
1267
+ }
1268
+ function sameList(a, b) {
1269
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1270
+ }
1271
+ /**
1272
+ * Apply the exemption: add {@link EPIC_EXCLUDE_REF} to each assessed ruleset's
1273
+ * ref exclusions (`gh api --method PUT repos/{o}/{r}/rulesets/{id} --input -`),
1274
+ * then re-read the branch rules. Confirmed only when no `pull_request` rule
1275
+ * remains and the required status check contexts are unchanged. Refuses unless
1276
+ * `assessment.eligible`; re-validates each ruleset immediately before writing.
1277
+ * Never attempts a repair. The caller must have the user's explicit consent.
1278
+ */
1279
+ export async function applyApprovalExemption(run, ownerRepo, branch, assessment) {
1280
+ try {
1281
+ if (!assessment ||
1282
+ assessment.eligible !== true ||
1283
+ !Array.isArray(assessment.rulesetIds) ||
1284
+ assessment.rulesetIds.length === 0 ||
1285
+ !assessment.rulesetIds.every(isNonNegInt) ||
1286
+ !validOwnerRepo(ownerRepo) ||
1287
+ !validBranch(branch)) {
1288
+ return { confirmed: false, reason: "scope_not_eligible" };
1289
+ }
1290
+ const before = await readBranchRules(run, ownerRepo, branch);
1291
+ if (!before.ok)
1292
+ return { confirmed: false, reason: "ruleset_changed" };
1293
+ const bodies = [];
1294
+ for (const id of assessment.rulesetIds) {
1295
+ const check = await checkRuleset(run, ownerRepo, id);
1296
+ if (!check.ok)
1297
+ return { confirmed: false, reason: "ruleset_changed" };
1298
+ bodies.push({ id, body: check.body });
1299
+ }
1300
+ for (const { id, body } of bodies) {
1301
+ const put = await ghApiJson(run, ["--method", "PUT", `repos/${ownerRepo}/rulesets/${id}`, "--input", "-"], JSON.stringify(body));
1302
+ if (!put.ok)
1303
+ return { confirmed: false, reason: "apply_failed" };
1304
+ }
1305
+ const after = await readBranchRules(run, ownerRepo, branch);
1306
+ if (!after.ok)
1307
+ return { confirmed: false, reason: "verification_failed" };
1308
+ if (after.rules.pullRequestRules.length > 0)
1309
+ return { confirmed: false, reason: "approvals_still_required" };
1310
+ if (!sameList(before.rules.statusChecks, after.rules.statusChecks)) {
1311
+ return { confirmed: false, reason: "status_checks_changed" };
1312
+ }
1313
+ return { confirmed: true, reason: "confirmed" };
1314
+ }
1315
+ catch {
1316
+ return { confirmed: false, reason: "verification_failed" };
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Copyable, repository-specific manual steps for exempting epic branches from
1321
+ * approval rules. Makes no change itself. `epicBranch` defaults to the
1322
+ * placeholder `epic/<EPIC>`.
1323
+ */
1324
+ export function exemptionManualSteps(ownerRepo, epicBranch = "epic/<EPIC>") {
1325
+ const repo = validOwnerRepo(ownerRepo) ? ownerRepo : "<OWNER>/<REPO>";
1326
+ const branch = epicBranch === "epic/<EPIC>" || validBranch(epicBranch) ? epicBranch : "epic/<EPIC>";
1327
+ return [
1328
+ "1. List the repository's rulesets and the rules that apply to an epic branch:",
1329
+ ` gh api repos/${repo}/rulesets`,
1330
+ ` gh api repos/${repo}/rules/branches/${branch}`,
1331
+ "2. For each pull_request rule above, note its ruleset_id and view that ruleset:",
1332
+ ` gh api repos/${repo}/rulesets/<RULESET_ID>`,
1333
+ `3. Add "${EPIC_EXCLUDE_REF}" to conditions.ref_name.exclude and send the whole ruleset back:`,
1334
+ ` gh api repos/${repo}/rulesets/<RULESET_ID> | jq '{name, target, enforcement, conditions, rules, bypass_actors} | .conditions.ref_name.exclude += ["${EPIC_EXCLUDE_REF}"]' > ruleset.json`,
1335
+ ` gh api --method PUT repos/${repo}/rulesets/<RULESET_ID> --input ruleset.json`,
1336
+ " Or in the GitHub UI: Settings → Rules → Rulesets → <ruleset> → Target branches → Exclude by pattern → epic/**",
1337
+ "4. Keep required status checks enforced on epic branches. If the same ruleset also requires status checks,",
1338
+ " move the pull_request rule into its own ruleset first instead of excluding epic/** from the shared one.",
1339
+ "5. Confirm no pull_request rule remains and the required checks are unchanged:",
1340
+ ` gh api repos/${repo}/rules/branches/${branch}`,
1341
+ ];
1342
+ }
1343
+ /** Map the installer's richer exemption outcome onto the stored evidence vocabulary. */
1344
+ export function mapExemptionOutcomeToEvidence(o) {
1345
+ switch (o) {
1346
+ case "applied":
1347
+ return "exempt";
1348
+ case "declined":
1349
+ case "printed_steps":
1350
+ case "not_needed":
1351
+ return "not_exempt";
1352
+ default:
1353
+ return "unknown";
1354
+ }
1355
+ }
1356
+ /** Build the closed, secret-free evidence recorded beside the decision. */
1357
+ export function buildEpicBranchCiEvidence(coverage, rules, exemption) {
1358
+ const approvals = rules?.approvalsRequired ?? "could_not_tell";
1359
+ let quality;
1360
+ if (coverage === "could_not_tell" || approvals === "could_not_tell" || rules?.observationQuality === "could_not_tell") {
1361
+ quality = "could_not_tell";
1362
+ }
1363
+ else if (rules?.observationQuality === "inferred") {
1364
+ quality = "inferred";
1365
+ }
1366
+ else {
1367
+ quality = "exact";
1368
+ }
1369
+ return {
1370
+ workflow_triggers_cover_epic: coverage === "covers" ? true : coverage === "does_not_cover" ? false : null,
1371
+ required_approvals_apply_to_epic: approvals === "yes" ? true : approvals === "no" ? false : null,
1372
+ observation_quality: quality,
1373
+ exemption_outcome: mapExemptionOutcomeToEvidence(exemption),
1374
+ };
1375
+ }