@bridge_gpt/mcp-server 0.2.66 → 0.2.68
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.
- package/README.md +421 -83
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +70 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bounded-wait.js +67 -10
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +857 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +72 -29
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +98 -28
- package/build/conductor/errors.js +4 -3
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +113 -12
- package/build/conductor/readiness-sections.js +410 -9
- package/build/conductor/readiness.js +388 -33
- package/build/conductor/recovery-cli.js +310 -12
- package/build/conductor/recovery-operations.js +167 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +62 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +129 -61
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1379 -28
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +33 -2
- package/build/executor/completion-contract.js +66 -10
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/deps.js +4 -1
- package/build/executor/env.js +19 -0
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +33 -1
- package/build/executor/job-runner.js +813 -87
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +37 -3
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +112 -26
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +111 -18
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +122 -13
- package/build/executor/service-unit.js +7 -1
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +113 -34
- package/build/executor/worker-guard-hook.js +316 -58
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +166 -8
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1224 -1054
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +296 -41
- package/build/install-bridge.js +547 -40
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +34 -160
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +136 -2
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +83 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +52 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/review-generation.js +22 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +152 -22
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +208 -36
- package/build/tool-error-envelope.js +61 -0
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +4 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +145 -6
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
- package/docs/EPIC-IMPLEMENTER.md +728 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -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
|
+
}
|