@codyswann/lisa 3.16.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/copy-overwrite/scripts/lisa-gates.mjs +1165 -0
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +72 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +6 -2
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +3 -0
- package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +9 -0
- package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
- package/scripts/lib/per-agent-hook-filter.mjs +18 -0
- package/typescript/copy-contents/.husky/pre-commit +130 -21
- package/typescript/copy-contents/.husky/pre-push +202 -22
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Reconcile DECLARED gate/policy config against the LIVE GitHub ruleset.
|
|
7
|
+
*
|
|
8
|
+
* `lisa-gates.mjs` can already *derive* the branch-protection contexts a
|
|
9
|
+
* repository ought to require: `contextsFor(gates, {moment: "pull-request"})`.
|
|
10
|
+
* What it cannot do is say whether GitHub agrees. That answer lives in a
|
|
11
|
+
* ruleset a human edits in an admin console, and until now the only way to hold
|
|
12
|
+
* the two together was to transcribe the live list into
|
|
13
|
+
* `.github/required-checks.json` by hand — a snapshot that ships empty, expires
|
|
14
|
+
* after 90 days, and was measured wrong in BOTH directions (#2476: it claimed a
|
|
15
|
+
* context was required that nothing required, and omitted six that were).
|
|
16
|
+
*
|
|
17
|
+
* This script replaces the transcription with a comparison. It reads the live
|
|
18
|
+
* ruleset through `gh`, derives the declared list from `.lisa.config.json`, and
|
|
19
|
+
* reports three sets: MISSING (declared, not live), EXTRA (live, not declared),
|
|
20
|
+
* MATCHED. It reads repository settings the same way and compares them against
|
|
21
|
+
* the `policy` block.
|
|
22
|
+
*
|
|
23
|
+
* ## Could-not-check is not clean
|
|
24
|
+
*
|
|
25
|
+
* There is a third verdict beside matched and drifted, and it is the one this
|
|
26
|
+
* script exists to get right. If `gh` is absent, unauthenticated, refused (a
|
|
27
|
+
* private repo on a plan without rulesets answers 403), or returns something
|
|
28
|
+
* unparseable, the verdict is **UNPROVEN** — its own state, carrying its own
|
|
29
|
+
* exit code (2), with the drift sets set to `null` rather than to empty arrays.
|
|
30
|
+
* Empty arrays are what a clean repository looks like, so a failed read that
|
|
31
|
+
* produced them would render as "policy matches" and be indistinguishable from
|
|
32
|
+
* proof. The structure makes that unrepresentable: an unproven result has no
|
|
33
|
+
* sets to read, and `on_drift` does not govern it, because `on_drift` answers
|
|
34
|
+
* what to do about a drift you have MEASURED and here nothing was measured.
|
|
35
|
+
*
|
|
36
|
+
* ## An EXTRA context is never deleted without being named
|
|
37
|
+
*
|
|
38
|
+
* `repair` ADDS what is missing. It does not remove what is extra unless
|
|
39
|
+
* `--prune` is passed, and even then it names every context it removes first.
|
|
40
|
+
*
|
|
41
|
+
* The reason is that Lisa does not own the whole required list. A repository
|
|
42
|
+
* routinely requires contexts posted by external apps — `SonarCloud Code
|
|
43
|
+
* Analysis`, `GitGuardian Security Checks`, `CodeRabbit` — which no gates block
|
|
44
|
+
* declares and which `contextsFor` therefore cannot derive. Every one of those
|
|
45
|
+
* is EXTRA by construction. A repair that treated EXTRA as "delete to converge"
|
|
46
|
+
* would silently strip live protection on its first run, and the deletion would
|
|
47
|
+
* read in the audit log as a routine reconciliation. So the default is: add,
|
|
48
|
+
* report, and require a human to ask for the removal.
|
|
49
|
+
*
|
|
50
|
+
* That is also why an EXTRA context does not FAIL `on_drift: block`. It is
|
|
51
|
+
* reported by name in every mode, and it still makes the verdict DRIFT — there
|
|
52
|
+
* genuinely is a difference between the declaration and the repository. But
|
|
53
|
+
* blocking on it would make `block` a mode no repository with a SonarCloud or
|
|
54
|
+
* CodeRabbit check can ever satisfy, since by construction nothing declares
|
|
55
|
+
* those and this script refuses to remove them. A check that cannot pass while
|
|
56
|
+
* the repository is correct gets one fix from whoever is blocked by it —
|
|
57
|
+
* `--prune`, or deleting the check — and both of those lose protection. So
|
|
58
|
+
* `block` fails on the drift a repair could converge: MISSING contexts, and
|
|
59
|
+
* settings drift. Under `--prune` an EXTRA becomes removable, and therefore
|
|
60
|
+
* blocking, because the operator has said it should be.
|
|
61
|
+
*
|
|
62
|
+
* ## Only a workflow context is pinned to the Actions app
|
|
63
|
+
*
|
|
64
|
+
* A required check can name the app allowed to post it. Every context Lisa
|
|
65
|
+
* derives from a `run` gate is posted by GitHub Actions, so it is pinned. An
|
|
66
|
+
* `await` gate's context is posted by somebody else entirely — that is what
|
|
67
|
+
* awaiting means — and pinning `CodeRabbit` to the Actions integration would
|
|
68
|
+
* require a status that the only app able to post it can never satisfy. Those
|
|
69
|
+
* are added unpinned, which is GitHub's "any source".
|
|
70
|
+
*
|
|
71
|
+
* ## The alias window
|
|
72
|
+
*
|
|
73
|
+
* `--previous=a,b` is passed through to `contextsFor`'s `previousLabels`, which
|
|
74
|
+
* emits BOTH the old and the new context for one release. During a job rename
|
|
75
|
+
* the old context stops reporting the moment the workflow changes, and a
|
|
76
|
+
* required context that never reports blocks every in-flight pull request
|
|
77
|
+
* indefinitely. The fastest way out of that for whoever is blocked is deleting
|
|
78
|
+
* the requirement — which is how a rename quietly removes a guarantee. Requiring
|
|
79
|
+
* both names for one release is what avoids it. Downstream repositories call
|
|
80
|
+
* the shared workflow unpinned, so the rename reaches them before any of them
|
|
81
|
+
* has reconciled; the window is not optional.
|
|
82
|
+
*
|
|
83
|
+
* ## One writer per surface
|
|
84
|
+
*
|
|
85
|
+
* Repair writes exactly two things: required-status-check contexts on a ruleset
|
|
86
|
+
* (PUT), and repository settings (PATCH). Policy that lives in the SHAPE of a
|
|
87
|
+
* ruleset's rules — linear history, signed commits, force-push and deletion
|
|
88
|
+
* protection, conversation resolution — is compared and reported here but
|
|
89
|
+
* repaired by `scripts/lisa-github-rulesets.sh`, which owns rule construction.
|
|
90
|
+
* Reshaping rules from two places is how a repair strips a rule it did not
|
|
91
|
+
* understand.
|
|
92
|
+
*
|
|
93
|
+
* Usage:
|
|
94
|
+
* lisa-reconcile-policy.mjs [--repo=OWNER/NAME] [--moment=pull-request]
|
|
95
|
+
* [--workflow="🔍 Quality Checks"] [--previous=a,b]
|
|
96
|
+
* [--on-drift=repair|report|block] [--ruleset=NAME]
|
|
97
|
+
* [--dry-run] [--prune] [--json]
|
|
98
|
+
*
|
|
99
|
+
* Exit codes:
|
|
100
|
+
* 0 matched, or drift under `repair`/`report`, or EXTRA-only drift under
|
|
101
|
+
* `block`
|
|
102
|
+
* 1 convergeable drift under `block`, or a repair write that failed
|
|
103
|
+
* 2 UNPROVEN — nothing was measured
|
|
104
|
+
* @module lisa-reconcile-policy
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
import { spawnSync } from "node:child_process";
|
|
108
|
+
|
|
109
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
110
|
+
|
|
111
|
+
import {
|
|
112
|
+
contextsFor,
|
|
113
|
+
POLICY_SCHEMA,
|
|
114
|
+
readGates,
|
|
115
|
+
resolveMoment,
|
|
116
|
+
} from "./lisa-gates.mjs";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A ruleset as GitHub returns it. Permissive on purpose: the API adds fields,
|
|
120
|
+
* and this module only ever reads `name`, `id`, `enforcement`, and `rules`.
|
|
121
|
+
* @typedef {Record<string, *>} Ruleset
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One required status check, with the ruleset that requires it.
|
|
126
|
+
*
|
|
127
|
+
* The owner travels with the context because an EXTRA one has to be NAMED with
|
|
128
|
+
* somewhere for the reader to go and look, and because pruning needs to know
|
|
129
|
+
* which ruleset to rewrite.
|
|
130
|
+
* @typedef {object} LiveContext
|
|
131
|
+
* @property {string} context The exact context string GitHub matches on.
|
|
132
|
+
* @property {number|null} integration_id The app that posts it, when declared.
|
|
133
|
+
* @property {string} ruleset Name of the ruleset requiring it.
|
|
134
|
+
* @property {*} rulesetId Id of that ruleset.
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Why nothing could be measured.
|
|
139
|
+
* @typedef {object} Unproven
|
|
140
|
+
* @property {string} reason One of `UNPROVEN`.
|
|
141
|
+
* @property {string} detail Verbatim failure text, never summarised away.
|
|
142
|
+
* @property {string|null} command The `gh` invocation that failed.
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The three context sets. Reached only when the read succeeded.
|
|
147
|
+
* @typedef {object} ContextDrift
|
|
148
|
+
* @property {string[]} missing Declared, not required on GitHub.
|
|
149
|
+
* @property {LiveContext[]} extra Required on GitHub, not declared.
|
|
150
|
+
* @property {string[]} matched Present on both sides.
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One declared policy field, compared against what GitHub reports.
|
|
155
|
+
* @typedef {object} PolicyFinding
|
|
156
|
+
* @property {string} path Dotted path into the `policy` block.
|
|
157
|
+
* @property {*} declared What the project declared.
|
|
158
|
+
* @property {*} observed What GitHub reports.
|
|
159
|
+
* @property {string} surface `repository` or `ruleset`.
|
|
160
|
+
* @property {string} field The name it is observed under.
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Policy comparison, split by whether it agreed.
|
|
165
|
+
* @typedef {object} SettingsDrift
|
|
166
|
+
* @property {PolicyFinding[]} drift Fields that disagree.
|
|
167
|
+
* @property {PolicyFinding[]} matched Fields that agree.
|
|
168
|
+
* @property {string[]} unknown Declared paths this module cannot observe.
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One step of a repair. `manual` steps are printed and never executed.
|
|
173
|
+
* @typedef {object} RepairAction
|
|
174
|
+
* @property {string} kind `contexts`, `settings`, or `manual`.
|
|
175
|
+
* @property {string} [message] What a human must do, for a `manual` step.
|
|
176
|
+
* @property {string} [ruleset] Name of the ruleset a `contexts` step writes.
|
|
177
|
+
* @property {*} [rulesetId] Its id.
|
|
178
|
+
* @property {string[]} [add] Contexts to start requiring.
|
|
179
|
+
* @property {string[]} [remove] Contexts to stop requiring.
|
|
180
|
+
* @property {Ruleset} [payload] The body a `contexts` step PUTs.
|
|
181
|
+
* @property {Record<string, *>} [fields] The body a `settings` step PATCHes.
|
|
182
|
+
* @property {string[]} [paths] Policy paths a `settings` step repairs.
|
|
183
|
+
*/
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What became of one planned action.
|
|
187
|
+
* @typedef {object} RepairOutcome
|
|
188
|
+
* @property {RepairAction} action The action attempted.
|
|
189
|
+
* @property {boolean} applied Whether GitHub accepted the write.
|
|
190
|
+
* @property {string|null} note The failure text, or a manual step's message.
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A full reconciliation.
|
|
195
|
+
*
|
|
196
|
+
* `contexts` and `settings` are `null` — never empty — when the verdict is
|
|
197
|
+
* UNPROVEN, because empty sets are what a CLEAN repository looks like and a
|
|
198
|
+
* reader that cannot tell them apart reports a match it never measured.
|
|
199
|
+
* @typedef {object} Reconciliation
|
|
200
|
+
* @property {string|null} repo `OWNER/NAME`, or null when none resolved.
|
|
201
|
+
* @property {string} moment The moment contexts were derived for.
|
|
202
|
+
* @property {string[]} declared Contexts `contextsFor` derived.
|
|
203
|
+
* @property {string} onDrift The response in force.
|
|
204
|
+
* @property {boolean} dryRun Whether writing was suppressed.
|
|
205
|
+
* @property {boolean} prune Whether EXTRA contexts may be removed.
|
|
206
|
+
* @property {string} verdict One of `VERDICT`.
|
|
207
|
+
* @property {boolean|null} blocking Whether the measured drift is the kind a
|
|
208
|
+
* repair could converge, and therefore the kind `block` fails on. Null when
|
|
209
|
+
* nothing was measured, for the same reason the sets are.
|
|
210
|
+
* @property {Unproven|null} unproven Why nothing was measured, when nothing was.
|
|
211
|
+
* @property {ContextDrift|null} contexts Context comparison, or null.
|
|
212
|
+
* @property {SettingsDrift|null} settings Policy comparison, or null.
|
|
213
|
+
* @property {RepairAction[]} plan What would bring the repository back.
|
|
214
|
+
* @property {RepairOutcome[]} outcomes What was actually attempted.
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Everything read from the repository in one pass.
|
|
219
|
+
* @typedef {object} LivePolicy
|
|
220
|
+
* @property {true} ok Discriminator: the read succeeded.
|
|
221
|
+
* @property {Ruleset[]} rulesets Branch-target rulesets, in full.
|
|
222
|
+
* @property {LiveContext[]} contexts Every required context they carry.
|
|
223
|
+
* @property {Record<string, *>} settings Repository settings.
|
|
224
|
+
* @property {Record<string, boolean>} signals Ruleset-surface policy.
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A failed read. The discriminator is what keeps it from being mistaken for a
|
|
229
|
+
* clean `LivePolicy`.
|
|
230
|
+
* @typedef {object} FailedRead
|
|
231
|
+
* @property {false} ok Discriminator: nothing was measured.
|
|
232
|
+
* @property {Unproven} unproven Why.
|
|
233
|
+
*/
|
|
234
|
+
|
|
235
|
+
/** The three states a reconciliation can end in. */
|
|
236
|
+
export const VERDICT = Object.freeze({
|
|
237
|
+
MATCHED: "matched",
|
|
238
|
+
DRIFT: "drift",
|
|
239
|
+
UNPROVEN: "unproven",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Why a reconciliation could not be performed.
|
|
244
|
+
*
|
|
245
|
+
* Separated by operator action, not by error class: a missing CLI, a missing
|
|
246
|
+
* login, and a repository that refused the read send someone to three different
|
|
247
|
+
* places.
|
|
248
|
+
*/
|
|
249
|
+
export const UNPROVEN = Object.freeze({
|
|
250
|
+
NO_CLI: "gh-not-installed",
|
|
251
|
+
UNAUTHENTICATED: "gh-unauthenticated",
|
|
252
|
+
NO_REPO: "repository-unresolved",
|
|
253
|
+
API_ERROR: "api-error",
|
|
254
|
+
MALFORMED: "unreadable-response",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/** How to respond to measured drift. Mirrors `DRIFT_RESPONSES` in lisa-gates. */
|
|
258
|
+
export const ON_DRIFT = Object.freeze(["repair", "report", "block"]);
|
|
259
|
+
|
|
260
|
+
/** Fields GitHub rejects on a ruleset write. Same list the apply script strips. */
|
|
261
|
+
const READ_ONLY_RULESET_FIELDS = [
|
|
262
|
+
"id",
|
|
263
|
+
"source_type",
|
|
264
|
+
"source",
|
|
265
|
+
"node_id",
|
|
266
|
+
"created_at",
|
|
267
|
+
"updated_at",
|
|
268
|
+
"_links",
|
|
269
|
+
"current_user_can_bypass",
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
/** GitHub Actions integration id, the default owner of a Lisa-derived context. */
|
|
273
|
+
const ACTIONS_INTEGRATION_ID = 15368;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Where each `policy` field is observed, and under what name.
|
|
277
|
+
*
|
|
278
|
+
* `surface` is load-bearing: `repository` fields are readable and writable
|
|
279
|
+
* through `PATCH /repos/{owner}/{repo}`, while `ruleset` fields are properties
|
|
280
|
+
* of a ruleset's rule shape, which this script reads but does not write.
|
|
281
|
+
*
|
|
282
|
+
* The keys are asserted to cover `POLICY_SCHEMA` exactly, so a policy field
|
|
283
|
+
* added upstream cannot quietly become one this script never compares — which
|
|
284
|
+
* would present as a clean reconciliation of a setting nobody looked at.
|
|
285
|
+
*/
|
|
286
|
+
export const POLICY_SOURCES = Object.freeze({
|
|
287
|
+
"merge.squash": { surface: "repository", field: "allow_squash_merge" },
|
|
288
|
+
"merge.merge_commit": { surface: "repository", field: "allow_merge_commit" },
|
|
289
|
+
"merge.rebase": { surface: "repository", field: "allow_rebase_merge" },
|
|
290
|
+
"merge.auto_merge": { surface: "repository", field: "allow_auto_merge" },
|
|
291
|
+
"merge.delete_branch_on_merge": {
|
|
292
|
+
surface: "repository",
|
|
293
|
+
field: "delete_branch_on_merge",
|
|
294
|
+
},
|
|
295
|
+
"merge.allow_update_branch": {
|
|
296
|
+
surface: "repository",
|
|
297
|
+
field: "allow_update_branch",
|
|
298
|
+
},
|
|
299
|
+
"history.linear": { surface: "ruleset", field: "linear" },
|
|
300
|
+
"history.signed_commits": { surface: "ruleset", field: "signed_commits" },
|
|
301
|
+
"history.commit_signoff": {
|
|
302
|
+
surface: "repository",
|
|
303
|
+
field: "web_commit_signoff_required",
|
|
304
|
+
},
|
|
305
|
+
"protect.force_push": { surface: "ruleset", field: "force_push" },
|
|
306
|
+
"protect.deletion": { surface: "ruleset", field: "deletion" },
|
|
307
|
+
"protect.up_to_date_before_merge": {
|
|
308
|
+
surface: "ruleset",
|
|
309
|
+
field: "up_to_date_before_merge",
|
|
310
|
+
},
|
|
311
|
+
"protect.conversation_resolution": {
|
|
312
|
+
surface: "ruleset",
|
|
313
|
+
field: "conversation_resolution",
|
|
314
|
+
},
|
|
315
|
+
"protect.dismiss_stale_reviews": {
|
|
316
|
+
surface: "ruleset",
|
|
317
|
+
field: "dismiss_stale_reviews",
|
|
318
|
+
},
|
|
319
|
+
"protect.require_last_push_approval": {
|
|
320
|
+
surface: "ruleset",
|
|
321
|
+
field: "require_last_push_approval",
|
|
322
|
+
},
|
|
323
|
+
"repository.has_issues": { surface: "repository", field: "has_issues" },
|
|
324
|
+
"repository.has_wiki": { surface: "repository", field: "has_wiki" },
|
|
325
|
+
"repository.default_branch": {
|
|
326
|
+
surface: "repository",
|
|
327
|
+
field: "default_branch",
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Run `gh` and report the outcome without throwing.
|
|
333
|
+
*
|
|
334
|
+
* A missing executable is reported as `missing` rather than as a failed call,
|
|
335
|
+
* because "gh is not installed here" and "gh said no" are different findings
|
|
336
|
+
* with different fixes, and collapsing them is how an unauthenticated machine
|
|
337
|
+
* gets told to install a CLI it already has.
|
|
338
|
+
* @param {string[]} args Arguments to `gh`.
|
|
339
|
+
* @param {object} [options] Runner options.
|
|
340
|
+
* @param {string} [options.input] Body to pipe to stdin.
|
|
341
|
+
* @returns {{ok: boolean, stdout: string, stderr: string, missing?: boolean}} Outcome.
|
|
342
|
+
*/
|
|
343
|
+
export function ghRunner(args, options = {}) {
|
|
344
|
+
const result = spawnSync("gh", args, {
|
|
345
|
+
encoding: "utf8",
|
|
346
|
+
input: options.input,
|
|
347
|
+
});
|
|
348
|
+
if (result.error) {
|
|
349
|
+
const missing = result.error.code === "ENOENT";
|
|
350
|
+
return { ok: false, stdout: "", stderr: result.error.message, missing };
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
ok: result.status === 0,
|
|
354
|
+
stdout: result.stdout ?? "",
|
|
355
|
+
stderr: result.stderr ?? "",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build an unproven result.
|
|
361
|
+
* @param {string} reason One of `UNPROVEN`.
|
|
362
|
+
* @param {string} detail Verbatim failure text, never summarised away.
|
|
363
|
+
* @param {string[]} [command] The `gh` arguments that produced it.
|
|
364
|
+
* @returns {FailedRead} Unproven.
|
|
365
|
+
*/
|
|
366
|
+
function unproven(reason, detail, command = null) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
unproven: {
|
|
370
|
+
reason,
|
|
371
|
+
detail: String(detail ?? "").trim(),
|
|
372
|
+
command: command ? `gh ${command.join(" ")}` : null,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Call `gh` and parse its stdout as JSON, mapping every failure to UNPROVEN.
|
|
379
|
+
* @param {Function} gh The injected runner.
|
|
380
|
+
* @param {string[]} args Arguments to `gh`.
|
|
381
|
+
* @returns {{ok: true, value: *}|FailedRead} Parsed value, or unproven.
|
|
382
|
+
*/
|
|
383
|
+
function ghJson(gh, args) {
|
|
384
|
+
let result;
|
|
385
|
+
try {
|
|
386
|
+
result = gh(args);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
return unproven(UNPROVEN.API_ERROR, `gh threw: ${err.message}`, args);
|
|
389
|
+
}
|
|
390
|
+
if (result?.missing) {
|
|
391
|
+
return unproven(
|
|
392
|
+
UNPROVEN.NO_CLI,
|
|
393
|
+
result.stderr || "the gh executable was not found on PATH",
|
|
394
|
+
args
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (!result?.ok) {
|
|
398
|
+
const text = `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`.trim();
|
|
399
|
+
const authFailed =
|
|
400
|
+
/gh auth login|bad credentials|not logged|HTTP 401|requires authentication/iu.test(
|
|
401
|
+
text
|
|
402
|
+
);
|
|
403
|
+
return unproven(
|
|
404
|
+
authFailed ? UNPROVEN.UNAUTHENTICATED : UNPROVEN.API_ERROR,
|
|
405
|
+
text || "gh exited non-zero with no output",
|
|
406
|
+
args
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
return { ok: true, value: JSON.parse(result.stdout) };
|
|
411
|
+
} catch (err) {
|
|
412
|
+
return unproven(
|
|
413
|
+
UNPROVEN.MALFORMED,
|
|
414
|
+
`could not parse the response as JSON: ${err.message}`,
|
|
415
|
+
args
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Reduce a repository's rulesets to the policy properties they assert.
|
|
422
|
+
*
|
|
423
|
+
* `force_push` reads `non_fast_forward` because that is the rule GitHub uses to
|
|
424
|
+
* mean "force pushes are refused"; the policy field states the protection, not
|
|
425
|
+
* the permission, so `true` means force pushing is blocked.
|
|
426
|
+
* @param {Ruleset[]} rulesets Full ruleset objects.
|
|
427
|
+
* @returns {Record<string, boolean>} Observed ruleset-surface policy.
|
|
428
|
+
*/
|
|
429
|
+
export function rulesetSignals(rulesets) {
|
|
430
|
+
const signals = {
|
|
431
|
+
linear: false,
|
|
432
|
+
signed_commits: false,
|
|
433
|
+
force_push: false,
|
|
434
|
+
deletion: false,
|
|
435
|
+
up_to_date_before_merge: false,
|
|
436
|
+
conversation_resolution: false,
|
|
437
|
+
dismiss_stale_reviews: false,
|
|
438
|
+
require_last_push_approval: false,
|
|
439
|
+
};
|
|
440
|
+
for (const ruleset of rulesets ?? []) {
|
|
441
|
+
// An `evaluate`/`disabled` ruleset asserts nothing; counting it would make
|
|
442
|
+
// a dry-run ruleset read as live protection.
|
|
443
|
+
if ((ruleset?.enforcement ?? "active") !== "active") continue;
|
|
444
|
+
for (const rule of ruleset?.rules ?? []) {
|
|
445
|
+
const parameters = rule?.parameters ?? {};
|
|
446
|
+
if (rule?.type === "required_linear_history") signals.linear = true;
|
|
447
|
+
if (rule?.type === "required_signatures") signals.signed_commits = true;
|
|
448
|
+
if (rule?.type === "non_fast_forward") signals.force_push = true;
|
|
449
|
+
if (rule?.type === "deletion") signals.deletion = true;
|
|
450
|
+
if (
|
|
451
|
+
rule?.type === "required_status_checks" &&
|
|
452
|
+
parameters.strict_required_status_checks_policy
|
|
453
|
+
) {
|
|
454
|
+
signals.up_to_date_before_merge = true;
|
|
455
|
+
}
|
|
456
|
+
if (rule?.type !== "pull_request") continue;
|
|
457
|
+
if (parameters.required_review_thread_resolution) {
|
|
458
|
+
signals.conversation_resolution = true;
|
|
459
|
+
}
|
|
460
|
+
if (parameters.dismiss_stale_reviews_on_push) {
|
|
461
|
+
signals.dismiss_stale_reviews = true;
|
|
462
|
+
}
|
|
463
|
+
if (parameters.require_last_push_approval) {
|
|
464
|
+
signals.require_last_push_approval = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return signals;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Every required status check the live rulesets carry, with its owner.
|
|
473
|
+
* @param {Ruleset[]} rulesets Full ruleset objects.
|
|
474
|
+
* @returns {LiveContext[]} Live contexts.
|
|
475
|
+
*/
|
|
476
|
+
export function liveContexts(rulesets) {
|
|
477
|
+
const found = [];
|
|
478
|
+
for (const ruleset of rulesets ?? []) {
|
|
479
|
+
if ((ruleset?.enforcement ?? "active") !== "active") continue;
|
|
480
|
+
for (const rule of ruleset?.rules ?? []) {
|
|
481
|
+
if (rule?.type !== "required_status_checks") continue;
|
|
482
|
+
for (const check of rule?.parameters?.required_status_checks ?? []) {
|
|
483
|
+
if (typeof check?.context !== "string") continue;
|
|
484
|
+
found.push({
|
|
485
|
+
context: check.context,
|
|
486
|
+
integration_id: check.integration_id ?? null,
|
|
487
|
+
ruleset: ruleset.name,
|
|
488
|
+
rulesetId: ruleset.id,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return found;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Read the live ruleset and repository settings.
|
|
498
|
+
*
|
|
499
|
+
* Every failure path returns UNPROVEN rather than a partial reading. A ruleset
|
|
500
|
+
* index that was readable while a detail fetch failed still leaves the required
|
|
501
|
+
* list unknown, and half a list compared against a whole declaration reports
|
|
502
|
+
* drift that may not exist.
|
|
503
|
+
* @param {object} options Read inputs.
|
|
504
|
+
* @param {string} options.repo `OWNER/NAME`.
|
|
505
|
+
* @param {Function} options.gh Injected `gh` runner.
|
|
506
|
+
* @returns {LivePolicy|FailedRead} Live state, or why it could not be read.
|
|
507
|
+
*/
|
|
508
|
+
export function readLivePolicy({ repo, gh }) {
|
|
509
|
+
const index = ghJson(gh, ["api", `repos/${repo}/rulesets`]);
|
|
510
|
+
if (!index.ok) return index;
|
|
511
|
+
if (!Array.isArray(index.value)) {
|
|
512
|
+
return unproven(
|
|
513
|
+
UNPROVEN.MALFORMED,
|
|
514
|
+
`expected an array of rulesets, got ${typeof index.value}`,
|
|
515
|
+
["api", `repos/${repo}/rulesets`]
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const rulesets = [];
|
|
520
|
+
for (const entry of index.value) {
|
|
521
|
+
if (entry?.target && entry.target !== "branch") continue;
|
|
522
|
+
const detail = ghJson(gh, ["api", `repos/${repo}/rulesets/${entry?.id}`]);
|
|
523
|
+
if (!detail.ok) return detail;
|
|
524
|
+
rulesets.push(detail.value);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const settings = ghJson(gh, ["api", `repos/${repo}`]);
|
|
528
|
+
if (!settings.ok) return settings;
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
rulesets,
|
|
533
|
+
contexts: liveContexts(rulesets),
|
|
534
|
+
settings: settings.value ?? {},
|
|
535
|
+
signals: rulesetSignals(rulesets),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Compare declared contexts against live ones.
|
|
541
|
+
*
|
|
542
|
+
* Comparison is exact string equality, deliberately. A repository routinely
|
|
543
|
+
* carries confusable pairs — an app's required `SonarCloud Code Analysis`
|
|
544
|
+
* beside a not-required `SonarCloud SAST`, `🧹 Lint` beside `🐢 Slow Lint
|
|
545
|
+
* Rules` — and a fuzzy match raises a false alarm whose obvious fix is deleting
|
|
546
|
+
* the guard.
|
|
547
|
+
* @param {object} options Comparison inputs.
|
|
548
|
+
* @param {string[]} options.declared Contexts `contextsFor` derived.
|
|
549
|
+
* @param {LiveContext[]} options.live Contexts read from the rulesets.
|
|
550
|
+
* @returns {ContextDrift} Three sets.
|
|
551
|
+
*/
|
|
552
|
+
export function reconcileContexts({ declared, live }) {
|
|
553
|
+
const liveNames = new Set((live ?? []).map(entry => entry.context));
|
|
554
|
+
const declaredNames = new Set(declared ?? []);
|
|
555
|
+
const byName = (a, b) => a.localeCompare(b);
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
missing: [...declaredNames]
|
|
559
|
+
.filter(name => !liveNames.has(name))
|
|
560
|
+
.sort(byName),
|
|
561
|
+
extra: (live ?? [])
|
|
562
|
+
.filter(entry => !declaredNames.has(entry.context))
|
|
563
|
+
.sort((a, b) => a.context.localeCompare(b.context)),
|
|
564
|
+
matched: [...declaredNames]
|
|
565
|
+
.filter(name => liveNames.has(name))
|
|
566
|
+
.sort(byName),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Compare the declared `policy` block against observed repository state.
|
|
572
|
+
*
|
|
573
|
+
* Only declared fields are compared. A field the project did not declare has no
|
|
574
|
+
* expected value, and inventing one would report drift against Lisa's taste
|
|
575
|
+
* rather than against the project's decision.
|
|
576
|
+
* @param {object} options Comparison inputs.
|
|
577
|
+
* @param {object} options.policy The `policy` block.
|
|
578
|
+
* @param {{settings?: Record<string, *>, signals?: Record<string, boolean>}} options.live The two
|
|
579
|
+
* surfaces a policy field can be observed on. Narrower than `LivePolicy` on
|
|
580
|
+
* purpose: this is everything the comparison reads.
|
|
581
|
+
* @returns {SettingsDrift} Findings.
|
|
582
|
+
*/
|
|
583
|
+
export function reconcileSettings({ policy, live }) {
|
|
584
|
+
const drift = [];
|
|
585
|
+
const matched = [];
|
|
586
|
+
const unknown = [];
|
|
587
|
+
|
|
588
|
+
for (const [section, fields] of Object.entries(policy ?? {})) {
|
|
589
|
+
if (section === "on_drift") continue;
|
|
590
|
+
if (!fields || typeof fields !== "object") continue;
|
|
591
|
+
for (const [field, declared] of Object.entries(fields)) {
|
|
592
|
+
const path = `${section}.${field}`;
|
|
593
|
+
const source = POLICY_SOURCES[path];
|
|
594
|
+
if (!source) {
|
|
595
|
+
unknown.push(path);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const observed =
|
|
599
|
+
source.surface === "repository"
|
|
600
|
+
? live.settings?.[source.field]
|
|
601
|
+
: live.signals?.[source.field];
|
|
602
|
+
const finding = { path, declared, observed, ...source };
|
|
603
|
+
if (observed === declared) matched.push(finding);
|
|
604
|
+
else drift.push(finding);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return { drift, matched, unknown };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Choose the ruleset a context repair should write to.
|
|
612
|
+
*
|
|
613
|
+
* Ambiguity refuses rather than guesses. More than one shipped template carries
|
|
614
|
+
* a `required_status_checks` rule (`base` and `quality checks` both do), and
|
|
615
|
+
* writing to the wrong one puts a context where a different ref-name condition
|
|
616
|
+
* governs it — enforced somewhere other than where it was meant to be.
|
|
617
|
+
* Both keys are always present, one of them null. A discriminated union would
|
|
618
|
+
* read better in isolation and worse at every call site, which has to answer
|
|
619
|
+
* "did this refuse?" before it can do anything either way.
|
|
620
|
+
* @param {Ruleset[]} rulesets Live rulesets.
|
|
621
|
+
* @param {string|null} [named] An explicit `--ruleset` name.
|
|
622
|
+
* @returns {{ruleset: Ruleset|null, problem: string|null}} Target, or why not.
|
|
623
|
+
*/
|
|
624
|
+
export function repairTarget(rulesets, named = null) {
|
|
625
|
+
if (named) {
|
|
626
|
+
const hit = (rulesets ?? []).find(entry => entry?.name === named) ?? null;
|
|
627
|
+
return {
|
|
628
|
+
ruleset: hit,
|
|
629
|
+
problem: hit ? null : `no ruleset named "${named}" on this repository`,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const carriers = (rulesets ?? []).filter(entry =>
|
|
633
|
+
(entry?.rules ?? []).some(rule => rule?.type === "required_status_checks")
|
|
634
|
+
);
|
|
635
|
+
if (carriers.length === 1) return { ruleset: carriers[0], problem: null };
|
|
636
|
+
if (carriers.length === 0) {
|
|
637
|
+
return {
|
|
638
|
+
ruleset: null,
|
|
639
|
+
problem:
|
|
640
|
+
"no ruleset requires any status check, so there is nothing to add to. " +
|
|
641
|
+
"Seed one with scripts/lisa-github-rulesets.sh first.",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
ruleset: null,
|
|
646
|
+
problem:
|
|
647
|
+
`${carriers.length} rulesets require status checks ` +
|
|
648
|
+
`(${carriers.map(entry => entry.name).join(", ")}); ` +
|
|
649
|
+
`pass --ruleset=<name> to say which one owns the derived contexts.`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* The contexts at this moment that some other app posts.
|
|
655
|
+
*
|
|
656
|
+
* An `await` gate names a signal Lisa does not produce — `CodeRabbit`,
|
|
657
|
+
* `SonarCloud Code Analysis`. The name travels separately from the derived list
|
|
658
|
+
* because by the time `contextsFor` has flattened both kinds to strings, the
|
|
659
|
+
* one fact a writer needs about them — who is allowed to post this — is gone.
|
|
660
|
+
* @param {object} gates The gates block.
|
|
661
|
+
* @param {string} moment The moment contexts were derived for.
|
|
662
|
+
* @returns {string[]} Awaited context names, required ones only.
|
|
663
|
+
*/
|
|
664
|
+
export function awaitedContexts(gates, moment) {
|
|
665
|
+
return resolveMoment({ gates, moment })
|
|
666
|
+
.filter(gate => gate.level === "required" && gate.mode === "await")
|
|
667
|
+
.map(gate => gate.awaits)
|
|
668
|
+
.filter(Boolean);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Rewrite a ruleset's required contexts, returning a writable payload.
|
|
673
|
+
* @param {Ruleset} ruleset The live ruleset.
|
|
674
|
+
* @param {object} [options] Edit inputs.
|
|
675
|
+
* @param {string[]} [options.add] Contexts to require.
|
|
676
|
+
* @param {string[]} [options.remove] Contexts to stop requiring.
|
|
677
|
+
* @param {string[]} [options.awaited] Of `add`, the ones an external app posts.
|
|
678
|
+
* @returns {Ruleset} A payload with read-only fields stripped.
|
|
679
|
+
*/
|
|
680
|
+
export function rulesetPayload(
|
|
681
|
+
ruleset,
|
|
682
|
+
{ add = [], remove = [], awaited = [] } = {}
|
|
683
|
+
) {
|
|
684
|
+
const payload = structuredClone(ruleset);
|
|
685
|
+
for (const field of READ_ONLY_RULESET_FIELDS) delete payload[field];
|
|
686
|
+
|
|
687
|
+
const rules = payload.rules ?? [];
|
|
688
|
+
const rule = rules.find(entry => entry?.type === "required_status_checks");
|
|
689
|
+
// Pinning the integration is what stops another writer satisfying a check
|
|
690
|
+
// Actions is supposed to post. Applied to an awaited context it does the
|
|
691
|
+
// opposite: it names the one app that will never post it, and the required
|
|
692
|
+
// check then blocks every pull request forever.
|
|
693
|
+
const additions = add.map(context =>
|
|
694
|
+
awaited.includes(context)
|
|
695
|
+
? { context }
|
|
696
|
+
: { context, integration_id: ACTIONS_INTEGRATION_ID }
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
if (!rule) {
|
|
700
|
+
payload.rules = [
|
|
701
|
+
...rules,
|
|
702
|
+
{
|
|
703
|
+
type: "required_status_checks",
|
|
704
|
+
parameters: {
|
|
705
|
+
strict_required_status_checks_policy: false,
|
|
706
|
+
do_not_enforce_on_create: true,
|
|
707
|
+
required_status_checks: additions,
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
];
|
|
711
|
+
return payload;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const kept = (rule.parameters?.required_status_checks ?? []).filter(
|
|
715
|
+
check => !remove.includes(check?.context)
|
|
716
|
+
);
|
|
717
|
+
const present = new Set(kept.map(check => check?.context));
|
|
718
|
+
rule.parameters = {
|
|
719
|
+
...rule.parameters,
|
|
720
|
+
required_status_checks: [
|
|
721
|
+
...kept,
|
|
722
|
+
...additions.filter(check => !present.has(check.context)),
|
|
723
|
+
],
|
|
724
|
+
};
|
|
725
|
+
payload.rules = rules;
|
|
726
|
+
return payload;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The actions that would bring the repository back to its declaration.
|
|
731
|
+
*
|
|
732
|
+
* Built whether or not they will be executed, so `--dry-run` and `report` print
|
|
733
|
+
* exactly what `repair` would do rather than a description of it.
|
|
734
|
+
* @param {object} options Planning inputs.
|
|
735
|
+
* @param {ContextDrift} options.contexts Result of `reconcileContexts`.
|
|
736
|
+
* @param {SettingsDrift} options.settings Result of `reconcileSettings`.
|
|
737
|
+
* @param {LivePolicy} options.live Result of `readLivePolicy`.
|
|
738
|
+
* @param {boolean} options.prune Whether EXTRA contexts may be removed.
|
|
739
|
+
* @param {string|null} options.rulesetName Explicit target ruleset.
|
|
740
|
+
* @param {string[]} [options.awaited] Contexts an external app posts.
|
|
741
|
+
* @returns {RepairAction[]} Planned actions.
|
|
742
|
+
*/
|
|
743
|
+
export function planRepairs({
|
|
744
|
+
contexts,
|
|
745
|
+
settings,
|
|
746
|
+
live,
|
|
747
|
+
prune,
|
|
748
|
+
rulesetName,
|
|
749
|
+
awaited = [],
|
|
750
|
+
}) {
|
|
751
|
+
const plan = [];
|
|
752
|
+
const removable = prune ? contexts.extra.map(entry => entry.context) : [];
|
|
753
|
+
|
|
754
|
+
if (contexts.missing.length || removable.length) {
|
|
755
|
+
const { ruleset, problem } = repairTarget(live.rulesets, rulesetName);
|
|
756
|
+
if (!ruleset) {
|
|
757
|
+
plan.push({ kind: "manual", message: problem });
|
|
758
|
+
} else {
|
|
759
|
+
plan.push({
|
|
760
|
+
kind: "contexts",
|
|
761
|
+
ruleset: ruleset.name,
|
|
762
|
+
rulesetId: ruleset.id,
|
|
763
|
+
add: contexts.missing,
|
|
764
|
+
remove: removable,
|
|
765
|
+
payload: rulesetPayload(ruleset, {
|
|
766
|
+
add: contexts.missing,
|
|
767
|
+
remove: removable,
|
|
768
|
+
awaited,
|
|
769
|
+
}),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Rule (b): an EXTRA context is reported by name and left alone. Most of them
|
|
775
|
+
// are external apps Lisa never declares, and removing one silently strips a
|
|
776
|
+
// protection nobody asked to lose.
|
|
777
|
+
if (contexts.extra.length && !prune) {
|
|
778
|
+
plan.push({
|
|
779
|
+
kind: "manual",
|
|
780
|
+
message:
|
|
781
|
+
`${contexts.extra.length} required context(s) are live but not ` +
|
|
782
|
+
`declared: ${contexts.extra.map(entry => `"${entry.context}"`).join(", ")}. ` +
|
|
783
|
+
`They are NOT removed. Each is either an external app Lisa does not ` +
|
|
784
|
+
`manage (SonarCloud, GitGuardian, CodeRabbit) — in which case leave it ` +
|
|
785
|
+
`— or a gate that should be declared in .lisa.config.json. Re-run with ` +
|
|
786
|
+
`--prune only once you have decided, one by one, that each is neither.`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const repositoryDrift = settings.drift.filter(
|
|
791
|
+
finding => finding.surface === "repository"
|
|
792
|
+
);
|
|
793
|
+
if (repositoryDrift.length) {
|
|
794
|
+
plan.push({
|
|
795
|
+
kind: "settings",
|
|
796
|
+
fields: Object.fromEntries(
|
|
797
|
+
repositoryDrift.map(finding => [finding.field, finding.declared])
|
|
798
|
+
),
|
|
799
|
+
paths: repositoryDrift.map(finding => finding.path),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const rulesetDrift = settings.drift.filter(
|
|
804
|
+
finding => finding.surface === "ruleset"
|
|
805
|
+
);
|
|
806
|
+
if (rulesetDrift.length) {
|
|
807
|
+
plan.push({
|
|
808
|
+
kind: "manual",
|
|
809
|
+
message:
|
|
810
|
+
`${rulesetDrift.map(finding => finding.path).join(", ")} live in the ` +
|
|
811
|
+
`SHAPE of a ruleset's rules, which scripts/lisa-github-rulesets.sh ` +
|
|
812
|
+
`owns. Re-run that script to repair them; this one will not reshape ` +
|
|
813
|
+
`rules it did not build.`,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return plan;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Execute a plan. Never called under `--dry-run` or outside `repair`.
|
|
821
|
+
* @param {object} options Write inputs.
|
|
822
|
+
* @param {string} options.repo `OWNER/NAME`.
|
|
823
|
+
* @param {Function} options.gh Injected runner.
|
|
824
|
+
* @param {RepairAction[]} options.plan Planned actions.
|
|
825
|
+
* @returns {RepairOutcome[]} One outcome per action.
|
|
826
|
+
*/
|
|
827
|
+
export function applyRepairs({ repo, gh, plan }) {
|
|
828
|
+
const outcomes = [];
|
|
829
|
+
for (const action of plan) {
|
|
830
|
+
if (action.kind === "manual") {
|
|
831
|
+
outcomes.push({ action, applied: false, note: action.message });
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const [args, input] =
|
|
835
|
+
action.kind === "contexts"
|
|
836
|
+
? [
|
|
837
|
+
[
|
|
838
|
+
"api",
|
|
839
|
+
"-X",
|
|
840
|
+
"PUT",
|
|
841
|
+
`repos/${repo}/rulesets/${action.rulesetId}`,
|
|
842
|
+
"--input",
|
|
843
|
+
"-",
|
|
844
|
+
],
|
|
845
|
+
JSON.stringify(action.payload),
|
|
846
|
+
]
|
|
847
|
+
: [
|
|
848
|
+
["api", "-X", "PATCH", `repos/${repo}`, "--input", "-"],
|
|
849
|
+
JSON.stringify(action.fields),
|
|
850
|
+
];
|
|
851
|
+
let result;
|
|
852
|
+
try {
|
|
853
|
+
result = gh(args, { input });
|
|
854
|
+
} catch (err) {
|
|
855
|
+
result = { ok: false, stdout: "", stderr: err.message };
|
|
856
|
+
}
|
|
857
|
+
outcomes.push({
|
|
858
|
+
action,
|
|
859
|
+
applied: Boolean(result?.ok),
|
|
860
|
+
note: result?.ok
|
|
861
|
+
? null
|
|
862
|
+
: `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`.trim(),
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return outcomes;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Reconcile declared configuration against the live repository.
|
|
870
|
+
* @param {object} options Reconciliation inputs.
|
|
871
|
+
* @param {string|null} options.repo `OWNER/NAME`, or null when none resolved —
|
|
872
|
+
* which is itself an UNPROVEN verdict, not an error.
|
|
873
|
+
* @param {object} [options.gates] The gates block.
|
|
874
|
+
* @param {object} [options.policy] The policy block.
|
|
875
|
+
* @param {Function} [options.gh] Injected `gh` runner.
|
|
876
|
+
* @param {string} [options.moment] Moment to derive contexts for.
|
|
877
|
+
* @param {string} [options.workflowName] Calling workflow name.
|
|
878
|
+
* @param {string[]} [options.previousLabels] Labels retired this release.
|
|
879
|
+
* @param {boolean} [options.dryRun] Never write, whatever `on_drift` says.
|
|
880
|
+
* @param {boolean} [options.prune] Allow removal of EXTRA contexts.
|
|
881
|
+
* @param {string} [options.onDrift] Override `policy.on_drift`.
|
|
882
|
+
* @param {string|null} [options.rulesetName] Explicit repair target.
|
|
883
|
+
* @param {boolean} [options.ghMissing] Whether resolving `repo` failed because
|
|
884
|
+
* `gh` is not installed, rather than because nothing named the repository.
|
|
885
|
+
* @returns {Reconciliation} The reconciliation result.
|
|
886
|
+
*/
|
|
887
|
+
export function reconcile({
|
|
888
|
+
repo,
|
|
889
|
+
gates = {},
|
|
890
|
+
policy = {},
|
|
891
|
+
gh = ghRunner,
|
|
892
|
+
moment = "pull-request",
|
|
893
|
+
workflowName = "🔍 Quality Checks",
|
|
894
|
+
previousLabels = [],
|
|
895
|
+
dryRun = false,
|
|
896
|
+
prune = false,
|
|
897
|
+
onDrift = policy?.on_drift ?? "repair",
|
|
898
|
+
rulesetName = null,
|
|
899
|
+
ghMissing = false,
|
|
900
|
+
}) {
|
|
901
|
+
const declared = contextsFor(gates, { moment, workflowName, previousLabels });
|
|
902
|
+
const base = { repo, moment, declared, onDrift, dryRun, prune };
|
|
903
|
+
|
|
904
|
+
if (!repo) {
|
|
905
|
+
return {
|
|
906
|
+
...base,
|
|
907
|
+
verdict: VERDICT.UNPROVEN,
|
|
908
|
+
blocking: null,
|
|
909
|
+
unproven: unproven(
|
|
910
|
+
ghMissing ? UNPROVEN.NO_CLI : UNPROVEN.NO_REPO,
|
|
911
|
+
ghMissing
|
|
912
|
+
? "no OWNER/NAME was configured and `gh repo view` could not be " +
|
|
913
|
+
"asked, because the gh executable was not found on PATH"
|
|
914
|
+
: "no OWNER/NAME could be resolved from --repo, .lisa.config.json " +
|
|
915
|
+
"(github.org + github.repo), or `gh repo view`"
|
|
916
|
+
).unproven,
|
|
917
|
+
contexts: null,
|
|
918
|
+
settings: null,
|
|
919
|
+
plan: [],
|
|
920
|
+
outcomes: [],
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const live = readLivePolicy({ repo, gh });
|
|
925
|
+
if (!live.ok) {
|
|
926
|
+
// Rule (a): the sets are null, not empty. Empty is what clean looks like.
|
|
927
|
+
return {
|
|
928
|
+
...base,
|
|
929
|
+
verdict: VERDICT.UNPROVEN,
|
|
930
|
+
blocking: null,
|
|
931
|
+
unproven: live.unproven,
|
|
932
|
+
contexts: null,
|
|
933
|
+
settings: null,
|
|
934
|
+
plan: [],
|
|
935
|
+
outcomes: [],
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const contexts = reconcileContexts({ declared, live: live.contexts });
|
|
940
|
+
const settings = reconcileSettings({ policy, live });
|
|
941
|
+
const drifted =
|
|
942
|
+
contexts.missing.length > 0 ||
|
|
943
|
+
contexts.extra.length > 0 ||
|
|
944
|
+
settings.drift.length > 0;
|
|
945
|
+
// Every difference is DRIFT and every difference is reported. Only the part a
|
|
946
|
+
// repair could converge decides `block`, so the mode stays passable on a
|
|
947
|
+
// repository whose only EXTRA is a check this script refuses to remove.
|
|
948
|
+
const blocking =
|
|
949
|
+
contexts.missing.length > 0 ||
|
|
950
|
+
settings.drift.length > 0 ||
|
|
951
|
+
(prune && contexts.extra.length > 0);
|
|
952
|
+
const plan = drifted
|
|
953
|
+
? planRepairs({
|
|
954
|
+
contexts,
|
|
955
|
+
settings,
|
|
956
|
+
live,
|
|
957
|
+
prune,
|
|
958
|
+
rulesetName,
|
|
959
|
+
awaited: awaitedContexts(gates, moment),
|
|
960
|
+
})
|
|
961
|
+
: [];
|
|
962
|
+
const outcomes =
|
|
963
|
+
drifted && onDrift === "repair" && !dryRun
|
|
964
|
+
? applyRepairs({ repo, gh, plan })
|
|
965
|
+
: [];
|
|
966
|
+
|
|
967
|
+
return {
|
|
968
|
+
...base,
|
|
969
|
+
verdict: drifted ? VERDICT.DRIFT : VERDICT.MATCHED,
|
|
970
|
+
blocking,
|
|
971
|
+
unproven: null,
|
|
972
|
+
contexts,
|
|
973
|
+
settings,
|
|
974
|
+
plan,
|
|
975
|
+
outcomes,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The process exit code for a result.
|
|
981
|
+
*
|
|
982
|
+
* UNPROVEN is 2 in every mode, including `report`. `on_drift` decides what to do
|
|
983
|
+
* about a drift that was measured, and an unproven run measured nothing — so
|
|
984
|
+
* letting `report` map it to 0 would be a mode that turns "I could not look"
|
|
985
|
+
* into "I looked and it was fine".
|
|
986
|
+
* @param {Reconciliation} result A reconciliation result.
|
|
987
|
+
* @returns {number} 0 matched or reported, 1 blocked or failed write, 2 unproven.
|
|
988
|
+
*/
|
|
989
|
+
export function exitCodeFor(result) {
|
|
990
|
+
if (result.verdict === VERDICT.UNPROVEN) return 2;
|
|
991
|
+
if (result.verdict === VERDICT.MATCHED) return 0;
|
|
992
|
+
// A `manual` outcome is an instruction printed for a human, and `applyRepairs`
|
|
993
|
+
// records it as `applied: false` because nothing was written — not because a
|
|
994
|
+
// write was attempted and refused. Counting it as a failed write reports the
|
|
995
|
+
// routine EXTRA-context notice as a broken repair, which `render` already
|
|
996
|
+
// knows better than to do.
|
|
997
|
+
const writeFailed = result.outcomes.some(
|
|
998
|
+
outcome => outcome.action.kind !== "manual" && !outcome.applied
|
|
999
|
+
);
|
|
1000
|
+
if (writeFailed) return 1;
|
|
1001
|
+
return result.onDrift === "block" && result.blocking ? 1 : 0;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Render a result for a human.
|
|
1006
|
+
* @param {Reconciliation} result A reconciliation result.
|
|
1007
|
+
* @returns {string} The report.
|
|
1008
|
+
*/
|
|
1009
|
+
export function render(result) {
|
|
1010
|
+
if (result.verdict === VERDICT.UNPROVEN) {
|
|
1011
|
+
return [
|
|
1012
|
+
`UNPROVEN — the policy was NOT checked. This is not "matches".`,
|
|
1013
|
+
` reason: ${result.unproven.reason}`,
|
|
1014
|
+
result.unproven.command ? ` command: ${result.unproven.command}` : null,
|
|
1015
|
+
` detail: ${result.unproven.detail}`,
|
|
1016
|
+
]
|
|
1017
|
+
.filter(Boolean)
|
|
1018
|
+
.join("\n");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const lines = [
|
|
1022
|
+
`${result.verdict.toUpperCase()} — ${result.repo} at ${result.moment}`,
|
|
1023
|
+
` matched: ${result.contexts.matched.length} context(s)`,
|
|
1024
|
+
];
|
|
1025
|
+
for (const name of result.contexts.missing) {
|
|
1026
|
+
lines.push(` MISSING ${name} (declared, not required on GitHub)`);
|
|
1027
|
+
}
|
|
1028
|
+
for (const entry of result.contexts.extra) {
|
|
1029
|
+
lines.push(
|
|
1030
|
+
` EXTRA ${entry.context} (required by "${entry.ruleset}", not declared)`
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
for (const finding of result.settings.drift) {
|
|
1034
|
+
lines.push(
|
|
1035
|
+
` DRIFT ${finding.path}: declared ${JSON.stringify(finding.declared)}, ` +
|
|
1036
|
+
`live ${JSON.stringify(finding.observed)} [${finding.surface}]`
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
for (const path of result.settings.unknown) {
|
|
1040
|
+
lines.push(` SKIPPED ${path}: Lisa does not know where to observe it`);
|
|
1041
|
+
}
|
|
1042
|
+
for (const action of result.plan) {
|
|
1043
|
+
lines.push(
|
|
1044
|
+
action.kind === "manual"
|
|
1045
|
+
? ` ACTION ${action.message}`
|
|
1046
|
+
: ` PLAN ${describePlan(action)}`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
for (const outcome of result.outcomes) {
|
|
1050
|
+
if (outcome.action.kind === "manual") continue;
|
|
1051
|
+
lines.push(
|
|
1052
|
+
outcome.applied
|
|
1053
|
+
? ` WROTE ${describePlan(outcome.action)}`
|
|
1054
|
+
: ` FAILED ${describePlan(outcome.action)}: ${outcome.note}`
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
if (result.onDrift === "block" && result.verdict === VERDICT.DRIFT) {
|
|
1058
|
+
lines.push(
|
|
1059
|
+
result.blocking
|
|
1060
|
+
? ` (on_drift=block: this run FAILS.)`
|
|
1061
|
+
: ` (on_drift=block: this run passes. The only difference is EXTRA ` +
|
|
1062
|
+
`context(s), which this script will not remove and which nothing ` +
|
|
1063
|
+
`declares — blocking on them would leave --prune as the only way ` +
|
|
1064
|
+
`out, and that deletes live protection.)`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (result.dryRun) lines.push(` (--dry-run: nothing was written)`);
|
|
1068
|
+
return lines.join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* One line describing a planned write.
|
|
1073
|
+
* @param {RepairAction} action A non-manual plan action.
|
|
1074
|
+
* @returns {string} Description.
|
|
1075
|
+
*/
|
|
1076
|
+
function describePlan(action) {
|
|
1077
|
+
if (action.kind === "contexts") {
|
|
1078
|
+
const parts = [];
|
|
1079
|
+
if (action.add.length) parts.push(`require ${action.add.join(", ")}`);
|
|
1080
|
+
if (action.remove.length)
|
|
1081
|
+
parts.push(`stop requiring ${action.remove.join(", ")}`);
|
|
1082
|
+
return `ruleset "${action.ruleset}": ${parts.join("; ")}`;
|
|
1083
|
+
}
|
|
1084
|
+
return `repository settings: ${action.paths.join(", ")}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Resolve `OWNER/NAME` from a flag, the config, or `gh`.
|
|
1089
|
+
*
|
|
1090
|
+
* Reports WHY it failed, not just that it did. A machine with no `gh` and no
|
|
1091
|
+
* `github` block in its config fails here first, and collapsing that into "no
|
|
1092
|
+
* repository could be resolved" sends the reader to edit a config file when the
|
|
1093
|
+
* fix is installing a CLI — the same conflation `ghRunner` exists to avoid one
|
|
1094
|
+
* layer down.
|
|
1095
|
+
* @param {string|null} flagged An explicit `--repo`.
|
|
1096
|
+
* @param {object} config Parsed `.lisa.config.json`.
|
|
1097
|
+
* @param {Function} gh Injected runner.
|
|
1098
|
+
* @returns {{repo: string|null, ghMissing: boolean}} The repository, or null
|
|
1099
|
+
* with the reason it stayed null.
|
|
1100
|
+
*/
|
|
1101
|
+
export function resolveRepo(flagged, config, gh) {
|
|
1102
|
+
if (flagged) return { repo: flagged, ghMissing: false };
|
|
1103
|
+
const { org, repo } = config?.github ?? {};
|
|
1104
|
+
if (org && repo) return { repo: `${org}/${repo}`, ghMissing: false };
|
|
1105
|
+
let result;
|
|
1106
|
+
try {
|
|
1107
|
+
result = gh([
|
|
1108
|
+
"repo",
|
|
1109
|
+
"view",
|
|
1110
|
+
"--json",
|
|
1111
|
+
"nameWithOwner",
|
|
1112
|
+
"-q",
|
|
1113
|
+
".nameWithOwner",
|
|
1114
|
+
]);
|
|
1115
|
+
} catch {
|
|
1116
|
+
return { repo: null, ghMissing: false };
|
|
1117
|
+
}
|
|
1118
|
+
const name = result?.ok ? result.stdout.trim() : "";
|
|
1119
|
+
return { repo: name || null, ghMissing: Boolean(result?.missing) };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* CLI entry point.
|
|
1124
|
+
*/
|
|
1125
|
+
function main() {
|
|
1126
|
+
const argv = process.argv.slice(2);
|
|
1127
|
+
const flag = name => {
|
|
1128
|
+
const hit = argv.find(arg => arg.startsWith(`--${name}=`));
|
|
1129
|
+
return hit ? hit.slice(name.length + 3) : null;
|
|
1130
|
+
};
|
|
1131
|
+
const { gates, policy, config } = readGates();
|
|
1132
|
+
const onDrift = flag("on-drift") ?? policy.on_drift ?? "repair";
|
|
1133
|
+
if (!ON_DRIFT.includes(onDrift)) {
|
|
1134
|
+
throw new Error(`--on-drift must be one of ${ON_DRIFT.join(", ")}`);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const { repo, ghMissing } = resolveRepo(flag("repo"), config, ghRunner);
|
|
1138
|
+
const result = reconcile({
|
|
1139
|
+
repo,
|
|
1140
|
+
ghMissing,
|
|
1141
|
+
gates,
|
|
1142
|
+
policy,
|
|
1143
|
+
gh: ghRunner,
|
|
1144
|
+
moment: flag("moment") ?? "pull-request",
|
|
1145
|
+
workflowName: flag("workflow") ?? "🔍 Quality Checks",
|
|
1146
|
+
previousLabels: (flag("previous") ?? "")
|
|
1147
|
+
.split(",")
|
|
1148
|
+
.map(entry => entry.trim())
|
|
1149
|
+
.filter(Boolean),
|
|
1150
|
+
dryRun: argv.includes("--dry-run"),
|
|
1151
|
+
prune: argv.includes("--prune"),
|
|
1152
|
+
onDrift,
|
|
1153
|
+
rulesetName: flag("ruleset"),
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
console.log(
|
|
1157
|
+
argv.includes("--json") ? JSON.stringify(result, null, 2) : render(result)
|
|
1158
|
+
);
|
|
1159
|
+
// `process.exit` truncates a pending stdout write when stdout is a pipe, and
|
|
1160
|
+
// this report is long enough to still be buffered. Setting the code instead
|
|
1161
|
+
// lets Node flush and exit on its own — measured: exiting here cut the report
|
|
1162
|
+
// off mid-sentence, so a piped run lost the very EXTRA-context warning the
|
|
1163
|
+
// script exists to print.
|
|
1164
|
+
process.exitCode = exitCodeFor(result);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (invokedAsScript(import.meta.url)) {
|
|
1168
|
+
try {
|
|
1169
|
+
main();
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
console.error(err.message);
|
|
1172
|
+
process.exitCode = 1;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Assert `POLICY_SOURCES` still covers `POLICY_SCHEMA`.
|
|
1178
|
+
*
|
|
1179
|
+
* Exported for the test suite rather than run at import time: a module that
|
|
1180
|
+
* throws on load takes the whole doctor down over a config-shape mismatch.
|
|
1181
|
+
* @returns {string[]} Policy paths Lisa declares but this script cannot observe.
|
|
1182
|
+
*/
|
|
1183
|
+
export function unobservablePolicyFields() {
|
|
1184
|
+
const declared = Object.entries(POLICY_SCHEMA).flatMap(([section, fields]) =>
|
|
1185
|
+
Object.keys(fields).map(field => `${section}.${field}`)
|
|
1186
|
+
);
|
|
1187
|
+
return declared.filter(path => !Object.hasOwn(POLICY_SOURCES, path));
|
|
1188
|
+
}
|