canary-test-cli 5.15.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit the machine-readable api-delta.json v1 artifact.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/guardian/delta_emitter.py`. Serializes an
|
|
5
|
+
* `ApiDiff` into the frozen contract (docs/specs/api-delta-contract.md) that
|
|
6
|
+
* downstream tooling consumes to trigger library-stub regeneration. Generic and
|
|
7
|
+
* company-neutral — the shape carries only HTTP method/path/change categories.
|
|
8
|
+
*
|
|
9
|
+
* The builder is pure (the caller supplies `generated`) so it stays testable.
|
|
10
|
+
*/
|
|
11
|
+
import { writeFileSync } from 'node:fs';
|
|
12
|
+
import { classifyChanges } from './diff-extractor.js';
|
|
13
|
+
/** Python: `build_api_delta`. Build the api-delta.json v1 object from a diff. */
|
|
14
|
+
export function buildApiDelta(diff, sha, suite, generated) {
|
|
15
|
+
return {
|
|
16
|
+
schema_version: 1,
|
|
17
|
+
sut: { sha, suite },
|
|
18
|
+
generated,
|
|
19
|
+
summary: {
|
|
20
|
+
added: diff.added.length,
|
|
21
|
+
removed: diff.removed.length,
|
|
22
|
+
changed: diff.changed.length,
|
|
23
|
+
total: diff.added.length + diff.removed.length + diff.changed.length,
|
|
24
|
+
},
|
|
25
|
+
endpoints: {
|
|
26
|
+
added: diff.added.map((ec) => ({
|
|
27
|
+
method: ec.method.toUpperCase(),
|
|
28
|
+
path: ec.path,
|
|
29
|
+
})),
|
|
30
|
+
removed: diff.removed.map((ec) => ({
|
|
31
|
+
method: ec.method.toUpperCase(),
|
|
32
|
+
path: ec.path,
|
|
33
|
+
})),
|
|
34
|
+
changed: diff.changed.map((ec) => ({
|
|
35
|
+
method: ec.method.toUpperCase(),
|
|
36
|
+
path: ec.path,
|
|
37
|
+
changes: classifyChanges(ec.before, ec.after),
|
|
38
|
+
})),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Python: `write_api_delta`. Write the delta to `path` as indented JSON. */
|
|
43
|
+
export function writeApiDelta(delta, path) {
|
|
44
|
+
writeFileSync(path, `${JSON.stringify(delta, null, 2)}\n`, 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=delta-emitter.js.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI spec diff extractor.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/guardian/diff_extractor.py`. Compares two
|
|
5
|
+
* OpenAPI specs (before/after a commit) and produces a structured list of
|
|
6
|
+
* added, removed, and changed endpoints.
|
|
7
|
+
*
|
|
8
|
+
* Input: parsed objects (from JSON.parse or a YAML loader).
|
|
9
|
+
* Output: an `ApiDiff` with three lists of `EndpointChange`.
|
|
10
|
+
*/
|
|
11
|
+
/** Python: `ChangeType(str, Enum)`. */
|
|
12
|
+
export var ChangeType;
|
|
13
|
+
(function (ChangeType) {
|
|
14
|
+
ChangeType["ADDED"] = "added";
|
|
15
|
+
ChangeType["REMOVED"] = "removed";
|
|
16
|
+
ChangeType["CHANGED"] = "changed";
|
|
17
|
+
})(ChangeType || (ChangeType = {}));
|
|
18
|
+
/**
|
|
19
|
+
* Python: `EndpointChange` dataclass. A class (rather than a bare interface) so
|
|
20
|
+
* the constructor supplies the same field defaults the dataclass does
|
|
21
|
+
* (`operation_id`/`summary` -> "", `before`/`after` -> {}).
|
|
22
|
+
*/
|
|
23
|
+
export class EndpointChange {
|
|
24
|
+
path;
|
|
25
|
+
method;
|
|
26
|
+
change_type;
|
|
27
|
+
// `string | null`: mirrors Python `dict.get("operationId", "")` — a MISSING
|
|
28
|
+
// key defaults to "", but a present-null value passes through as `null`.
|
|
29
|
+
operation_id;
|
|
30
|
+
summary;
|
|
31
|
+
before;
|
|
32
|
+
after;
|
|
33
|
+
constructor(init) {
|
|
34
|
+
this.path = init.path;
|
|
35
|
+
this.method = init.method;
|
|
36
|
+
this.change_type = init.change_type;
|
|
37
|
+
// Default only on undefined (missing key), never on a present null.
|
|
38
|
+
this.operation_id =
|
|
39
|
+
init.operation_id === undefined ? '' : init.operation_id;
|
|
40
|
+
this.summary = init.summary === undefined ? '' : init.summary;
|
|
41
|
+
this.before = init.before === undefined ? {} : init.before;
|
|
42
|
+
this.after = init.after === undefined ? {} : init.after;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Python: `ApiDiff` dataclass. */
|
|
46
|
+
export class ApiDiff {
|
|
47
|
+
added;
|
|
48
|
+
removed;
|
|
49
|
+
changed;
|
|
50
|
+
constructor(added, removed, changed) {
|
|
51
|
+
this.added = added;
|
|
52
|
+
this.removed = removed;
|
|
53
|
+
this.changed = changed;
|
|
54
|
+
}
|
|
55
|
+
/** Python: `is_empty` property. */
|
|
56
|
+
get isEmpty() {
|
|
57
|
+
return !(this.added.length || this.removed.length || this.changed.length);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const HTTP_METHODS = [
|
|
61
|
+
'get',
|
|
62
|
+
'post',
|
|
63
|
+
'put',
|
|
64
|
+
'patch',
|
|
65
|
+
'delete',
|
|
66
|
+
'head',
|
|
67
|
+
'options',
|
|
68
|
+
];
|
|
69
|
+
// Frozen vocabulary for a changed endpoint (see
|
|
70
|
+
// docs/specs/api-delta-contract.md).
|
|
71
|
+
export const VALID_CHANGES = [
|
|
72
|
+
'params',
|
|
73
|
+
'request-body',
|
|
74
|
+
'response',
|
|
75
|
+
'auth',
|
|
76
|
+
'status-codes',
|
|
77
|
+
];
|
|
78
|
+
/** Collision-free composite key for a (path, method) pair (Python tuple key). */
|
|
79
|
+
function opKey(path, method) {
|
|
80
|
+
return JSON.stringify([path, method]);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Python-truthiness for JSON-shaped values: `None`/`undefined`, `false`, `0`,
|
|
84
|
+
* `""`, empty array, and empty object are all falsy (mirrors `if op:`).
|
|
85
|
+
*/
|
|
86
|
+
function pyTruthy(value) {
|
|
87
|
+
if (value === null || value === undefined || value === false)
|
|
88
|
+
return false;
|
|
89
|
+
if (value === 0 || value === '')
|
|
90
|
+
return false;
|
|
91
|
+
if (Array.isArray(value))
|
|
92
|
+
return value.length > 0;
|
|
93
|
+
if (typeof value === 'object')
|
|
94
|
+
return Object.keys(value).length > 0;
|
|
95
|
+
return Boolean(value);
|
|
96
|
+
}
|
|
97
|
+
/** Python `a or b`: the fallback wins only when `a` is falsy. */
|
|
98
|
+
function pyOr(value, fallback) {
|
|
99
|
+
return pyTruthy(value) ? value : fallback;
|
|
100
|
+
}
|
|
101
|
+
/** Python `dict.get(key, default)`: default only on a missing key. */
|
|
102
|
+
function pyGet(obj, key, fallback) {
|
|
103
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Deep equality mirroring Python `==` on JSON-shaped data: arrays compare
|
|
107
|
+
* order-sensitively, objects compare by key set (order-insensitive), and
|
|
108
|
+
* `None`/`undefined` are interchangeable.
|
|
109
|
+
*/
|
|
110
|
+
function pyEqual(a, b) {
|
|
111
|
+
if (a === b)
|
|
112
|
+
return true;
|
|
113
|
+
if (a === null || a === undefined)
|
|
114
|
+
return b === null || b === undefined;
|
|
115
|
+
if (b === null || b === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
const aArr = Array.isArray(a);
|
|
118
|
+
const bArr = Array.isArray(b);
|
|
119
|
+
if (aArr || bArr) {
|
|
120
|
+
if (!aArr || !bArr || a.length !== b.length)
|
|
121
|
+
return false;
|
|
122
|
+
for (let i = 0; i < a.length; i++) {
|
|
123
|
+
if (!pyEqual(a[i], b[i]))
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
129
|
+
const ao = a;
|
|
130
|
+
const bo = b;
|
|
131
|
+
const aKeys = Object.keys(ao);
|
|
132
|
+
const bKeys = Object.keys(bo);
|
|
133
|
+
if (aKeys.length !== bKeys.length)
|
|
134
|
+
return false;
|
|
135
|
+
for (const key of aKeys) {
|
|
136
|
+
if (!Object.prototype.hasOwnProperty.call(bo, key))
|
|
137
|
+
return false;
|
|
138
|
+
if (!pyEqual(ao[key], bo[key]))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
function setEqual(a, b) {
|
|
146
|
+
if (a.size !== b.size)
|
|
147
|
+
return false;
|
|
148
|
+
for (const x of a) {
|
|
149
|
+
if (!b.has(x))
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Python: `classify_changes`. Classify a changed OpenAPI operation into the
|
|
156
|
+
* frozen change vocabulary. Returns every applicable category ordered by
|
|
157
|
+
* `VALID_CHANGES`; a change confined to non-contract fields returns [].
|
|
158
|
+
*/
|
|
159
|
+
export function classifyChanges(before, after) {
|
|
160
|
+
const found = new Set();
|
|
161
|
+
if (!pyEqual(pyOr(before['parameters'], []), pyOr(after['parameters'], []))) {
|
|
162
|
+
found.add('params');
|
|
163
|
+
}
|
|
164
|
+
if (!pyEqual(before['requestBody'], after['requestBody'])) {
|
|
165
|
+
found.add('request-body');
|
|
166
|
+
}
|
|
167
|
+
if (!pyEqual(before['security'], after['security'])) {
|
|
168
|
+
found.add('auth');
|
|
169
|
+
}
|
|
170
|
+
const beforeResp = pyOr(before['responses'], {});
|
|
171
|
+
const afterResp = pyOr(after['responses'], {});
|
|
172
|
+
const beforeKeys = new Set(Object.keys(beforeResp));
|
|
173
|
+
const afterKeys = new Set(Object.keys(afterResp));
|
|
174
|
+
if (!setEqual(beforeKeys, afterKeys)) {
|
|
175
|
+
found.add('status-codes');
|
|
176
|
+
}
|
|
177
|
+
for (const code of beforeKeys) {
|
|
178
|
+
if (afterKeys.has(code) && !pyEqual(beforeResp[code], afterResp[code])) {
|
|
179
|
+
found.add('response');
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return VALID_CHANGES.filter((c) => found.has(c));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Python: `_iter_operations`. Insertion-ordered to match Python's dict
|
|
187
|
+
* iteration order.
|
|
188
|
+
*/
|
|
189
|
+
function iterOperations(spec) {
|
|
190
|
+
const result = new Map();
|
|
191
|
+
const paths = pyOr(spec['paths'], {});
|
|
192
|
+
for (const [path, pathItemRaw] of Object.entries(paths)) {
|
|
193
|
+
const pathItem = pathItemRaw;
|
|
194
|
+
for (const method of HTTP_METHODS) {
|
|
195
|
+
const op = pathItem[method];
|
|
196
|
+
if (pyTruthy(op)) {
|
|
197
|
+
result.set(opKey(path, method), {
|
|
198
|
+
path,
|
|
199
|
+
method,
|
|
200
|
+
op: op,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Python: `extract_api_diff`. Compare two OpenAPI spec objects and return the
|
|
209
|
+
* diff.
|
|
210
|
+
*/
|
|
211
|
+
export function extractApiDiff(before, after) {
|
|
212
|
+
const beforeOps = iterOperations(before);
|
|
213
|
+
const afterOps = iterOperations(after);
|
|
214
|
+
const added = [];
|
|
215
|
+
const removed = [];
|
|
216
|
+
const changed = [];
|
|
217
|
+
for (const [key, { path, method, op }] of afterOps) {
|
|
218
|
+
if (!beforeOps.has(key)) {
|
|
219
|
+
added.push(new EndpointChange({
|
|
220
|
+
path,
|
|
221
|
+
method,
|
|
222
|
+
change_type: ChangeType.ADDED,
|
|
223
|
+
operation_id: pyGet(op, 'operationId', ''),
|
|
224
|
+
summary: pyGet(op, 'summary', ''),
|
|
225
|
+
after: op,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const beforeOp = beforeOps.get(key).op;
|
|
230
|
+
if (!pyEqual(op, beforeOp)) {
|
|
231
|
+
changed.push(new EndpointChange({
|
|
232
|
+
path,
|
|
233
|
+
method,
|
|
234
|
+
change_type: ChangeType.CHANGED,
|
|
235
|
+
operation_id: pyGet(op, 'operationId', pyGet(beforeOp, 'operationId', '')),
|
|
236
|
+
summary: pyGet(op, 'summary', ''),
|
|
237
|
+
before: beforeOp,
|
|
238
|
+
after: op,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const [key, { path, method, op }] of beforeOps) {
|
|
244
|
+
if (!afterOps.has(key)) {
|
|
245
|
+
removed.push(new EndpointChange({
|
|
246
|
+
path,
|
|
247
|
+
method,
|
|
248
|
+
change_type: ChangeType.REMOVED,
|
|
249
|
+
operation_id: pyGet(op, 'operationId', ''),
|
|
250
|
+
summary: pyGet(op, 'summary', ''),
|
|
251
|
+
before: op,
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return new ApiDiff(added, removed, changed);
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=diff-extractor.js.map
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote the guardian gate from soft to hard by registering its status check
|
|
3
|
+
* as a **required** check in branch protection — the one admin step the
|
|
4
|
+
* operator guide says the guardian can't do for you.
|
|
5
|
+
*
|
|
6
|
+
* Faithful TypeScript port of `agent/guardian/hard_gate.py`.
|
|
7
|
+
*
|
|
8
|
+
* Structure mirrors {@link ./pr-comment}: a {@link BranchProtection} interface,
|
|
9
|
+
* an in-memory fake for network-free tests, and a thin `fetch` REST client. The
|
|
10
|
+
* decision logic ({@link planHardGate}) is pure. Any barrier — no admin scope, a
|
|
11
|
+
* plan without branch protection, a bad token, a network error — surfaces as
|
|
12
|
+
* {@link HardGateBlocked} carrying a manual playbook, never a silent no-op and
|
|
13
|
+
* never a crash (the #294/#295 fail-loud contract).
|
|
14
|
+
*
|
|
15
|
+
* Two hazards this module is built to avoid:
|
|
16
|
+
*
|
|
17
|
+
* - **Clobbering existing protection.** `GET .../required_status_checks` returns
|
|
18
|
+
* 404 both for an unprotected branch AND a protected branch that simply has no
|
|
19
|
+
* required-checks section. Treating the latter as "unprotected" and PUT-ing a
|
|
20
|
+
* fresh ruleset would wipe review/admin/restriction protections. We
|
|
21
|
+
* disambiguate via the parent `.../protection` endpoint and only ever
|
|
22
|
+
* PUT-create when the branch is *genuinely* unprotected; otherwise we PATCH
|
|
23
|
+
* the checks sub-resource, which leaves every other protection untouched.
|
|
24
|
+
* - **Registering a phantom check.** A required context that no workflow reports
|
|
25
|
+
* stays "Expected" forever and blocks *every* merge — worse than not
|
|
26
|
+
* promoting. So before registering we verify the context against the checks a
|
|
27
|
+
* recent commit actually reported, and refuse (listing the real ones) unless
|
|
28
|
+
* `force`.
|
|
29
|
+
*
|
|
30
|
+
* Python→TS nuances:
|
|
31
|
+
* - **async**: the seam is `Promise`-returning (Node `fetch` is async), so
|
|
32
|
+
* {@link applyHardGate} is async and the fakes are `async`. The pure
|
|
33
|
+
* {@link planHardGate} and {@link renderPlaybook} stay synchronous.
|
|
34
|
+
* - **error mapping**: `fetch` resolves on a non-2xx status. The real client
|
|
35
|
+
* maps 401/403→{@link GitHubPermissionError} off `resp.status`, and throws a
|
|
36
|
+
* status-bearing {@link HttpError} for other codes so the 404 disambiguation
|
|
37
|
+
* can inspect the status (the analog of urllib's `HTTPError.code`). Python's
|
|
38
|
+
* `OSError` (URLError / timeout / non-403 HTTPError) maps to any non-permission
|
|
39
|
+
* error caught in {@link applyHardGate}.
|
|
40
|
+
*/
|
|
41
|
+
import { GitHubPermissionError } from './pr-comment.js';
|
|
42
|
+
/**
|
|
43
|
+
* A barrier stopped the required-check registration.
|
|
44
|
+
*
|
|
45
|
+
* Carries a human-readable `reason` and a `playbook` of manual steps, so a
|
|
46
|
+
* permission/plan/network barrier degrades to actionable guidance rather than a
|
|
47
|
+
* crash or a silent skip.
|
|
48
|
+
*/
|
|
49
|
+
export class HardGateBlocked extends Error {
|
|
50
|
+
reason;
|
|
51
|
+
playbook;
|
|
52
|
+
constructor(reason, playbook) {
|
|
53
|
+
super(reason);
|
|
54
|
+
this.name = 'HardGateBlocked';
|
|
55
|
+
this.reason = reason;
|
|
56
|
+
this.playbook = playbook;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Decide the change without touching the network (PURE).
|
|
61
|
+
*
|
|
62
|
+
* `existingContexts` is the branch's current required-check contexts, an empty
|
|
63
|
+
* list if the branch is protected but requires no checks yet, or `null`
|
|
64
|
+
* **only** if the branch has no protection at all. That three-way distinction
|
|
65
|
+
* is load-bearing: `null` is the only value that permits the
|
|
66
|
+
* protection-creating path.
|
|
67
|
+
*/
|
|
68
|
+
export function planHardGate(existingContexts, checkContext, branch) {
|
|
69
|
+
const creates = existingContexts === null;
|
|
70
|
+
const current = [...(existingContexts ?? [])];
|
|
71
|
+
const already = current.includes(checkContext);
|
|
72
|
+
const resulting = already ? current : [...current, checkContext];
|
|
73
|
+
return {
|
|
74
|
+
check_context: checkContext,
|
|
75
|
+
branch,
|
|
76
|
+
already_required: already,
|
|
77
|
+
creates_protection: creates,
|
|
78
|
+
resulting_contexts: [...new Set(resulting)].sort(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* In-memory {@link BranchProtection} for tests — no network.
|
|
83
|
+
*
|
|
84
|
+
* State model: `contexts` is the required-check list; `null` means no checks
|
|
85
|
+
* section. `protected` says whether the branch has protection at all (implied
|
|
86
|
+
* true when `contexts` is a list). So `contexts=null, protected=true` models the
|
|
87
|
+
* dangerous protected-without-checks state, and `contexts=null,
|
|
88
|
+
* protected=false` a genuinely unprotected branch. `admin=false` →
|
|
89
|
+
* reads/writes reject (no scope); `plan_supported=false` → writes reject.
|
|
90
|
+
* `read_error` injects an arbitrary read failure.
|
|
91
|
+
*/
|
|
92
|
+
export class FakeBranchProtectionClient {
|
|
93
|
+
contexts;
|
|
94
|
+
protected;
|
|
95
|
+
admin;
|
|
96
|
+
plan_supported;
|
|
97
|
+
observed;
|
|
98
|
+
read_error;
|
|
99
|
+
write_count;
|
|
100
|
+
last_create;
|
|
101
|
+
store;
|
|
102
|
+
constructor(init = {}) {
|
|
103
|
+
this.contexts = init.contexts ?? null;
|
|
104
|
+
this.protected = init.protected ?? false;
|
|
105
|
+
this.admin = init.admin ?? true;
|
|
106
|
+
this.plan_supported = init.plan_supported ?? true;
|
|
107
|
+
this.observed = init.observed ?? [];
|
|
108
|
+
this.read_error = init.read_error ?? null;
|
|
109
|
+
this.write_count = 0;
|
|
110
|
+
this.last_create = null;
|
|
111
|
+
this.store = new Map();
|
|
112
|
+
// Mirrors the Python dataclass `__post_init__`: a seeded contexts list
|
|
113
|
+
// implies the branch is protected, and is always stored under "main".
|
|
114
|
+
if (this.contexts !== null) {
|
|
115
|
+
this.store.set('main', [...this.contexts]);
|
|
116
|
+
this.protected = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
contextsFor(branch) {
|
|
120
|
+
return this.store.get(branch) ?? null;
|
|
121
|
+
}
|
|
122
|
+
async requiredCheckContexts(branch) {
|
|
123
|
+
if (this.read_error !== null) {
|
|
124
|
+
throw this.read_error;
|
|
125
|
+
}
|
|
126
|
+
if (!this.admin) {
|
|
127
|
+
throw new GitHubPermissionError('token lacks admin scope for branch protection');
|
|
128
|
+
}
|
|
129
|
+
const stored = this.store.get(branch);
|
|
130
|
+
if (stored !== undefined) {
|
|
131
|
+
return stored;
|
|
132
|
+
}
|
|
133
|
+
return this.protected ? [] : null;
|
|
134
|
+
}
|
|
135
|
+
async observedCheckContexts(branch) {
|
|
136
|
+
void branch;
|
|
137
|
+
return [...this.observed];
|
|
138
|
+
}
|
|
139
|
+
async setRequiredChecks(branch, contexts, create) {
|
|
140
|
+
if (!this.admin) {
|
|
141
|
+
throw new GitHubPermissionError('token lacks admin scope');
|
|
142
|
+
}
|
|
143
|
+
if (!this.plan_supported) {
|
|
144
|
+
throw new GitHubPermissionError('Upgrade your plan to use branch protection on this repository');
|
|
145
|
+
}
|
|
146
|
+
this.write_count += 1;
|
|
147
|
+
this.last_create = create;
|
|
148
|
+
this.store.set(branch, [...contexts]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Manual steps to require the guardian check — the fallback when canary can't
|
|
153
|
+
* do it (no admin, unsupported plan, bad token, network error).
|
|
154
|
+
*/
|
|
155
|
+
export function renderPlaybook(repo, branch, checkContext) {
|
|
156
|
+
return (`Manual steps to require the '${checkContext}' check on ` +
|
|
157
|
+
`${repo}@${branch}:\n` +
|
|
158
|
+
` 1. Open https://github.com/${repo}/settings/branches\n` +
|
|
159
|
+
` 2. Add or edit a branch protection rule for '${branch}'.\n` +
|
|
160
|
+
` 3. Enable 'Require status checks to pass before merging' and add ` +
|
|
161
|
+
`'${checkContext}' to the required checks.\n` +
|
|
162
|
+
`Or, with an admin token, run:\n` +
|
|
163
|
+
` gh api -X PATCH ` +
|
|
164
|
+
`repos/${repo}/branches/${branch}/protection/required_status_checks ` +
|
|
165
|
+
`-f 'checks[][context]=${checkContext}'\n` +
|
|
166
|
+
`Confirm '${checkContext}' is the exact context a recent PR reported ` +
|
|
167
|
+
`(a wrong name registers a gate that never fires — and can block every ` +
|
|
168
|
+
`merge).`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Register `checkContext` as required on `branch`.
|
|
172
|
+
*
|
|
173
|
+
* Verifies the context is one a recent commit actually reported (unless
|
|
174
|
+
* `force`) so we never register a phantom check that would block every merge.
|
|
175
|
+
* Returns the plan on success (including the idempotent already-required
|
|
176
|
+
* no-op). Rejects with {@link HardGateBlocked} carrying a playbook on any
|
|
177
|
+
* barrier.
|
|
178
|
+
*/
|
|
179
|
+
export async function applyHardGate(client, repo, branch, checkContext, force = false) {
|
|
180
|
+
const playbook = renderPlaybook(repo, branch, checkContext);
|
|
181
|
+
const blocked = (reason) => new HardGateBlocked(reason, playbook);
|
|
182
|
+
// --- verify the context is real, before we touch protection ---
|
|
183
|
+
if (!force) {
|
|
184
|
+
let observed;
|
|
185
|
+
try {
|
|
186
|
+
observed = await client.observedCheckContexts(branch);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Python catches (GitHubPermissionError, OSError) → best-effort empty.
|
|
190
|
+
observed = [];
|
|
191
|
+
}
|
|
192
|
+
if (observed.length === 0) {
|
|
193
|
+
throw blocked(`could not confirm any check has reported on ${repo}@${branch}, ` +
|
|
194
|
+
`so cannot verify '${checkContext}' is a real context; ` +
|
|
195
|
+
'requiring an unreported check would block every merge. ' +
|
|
196
|
+
'Open a PR so the guardian check runs at least once, or pass ' +
|
|
197
|
+
'--force to register it anyway.');
|
|
198
|
+
}
|
|
199
|
+
if (!observed.includes(checkContext)) {
|
|
200
|
+
const sorted = [...observed].sort();
|
|
201
|
+
throw blocked(`'${checkContext}' is not among the checks recently reported on ` +
|
|
202
|
+
`${repo}@${branch}: ${reprList(sorted)}. Requiring it would block ` +
|
|
203
|
+
'every merge. Re-run --check with one of those, or --force to ' +
|
|
204
|
+
'override.');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// --- read state (disambiguating unprotected vs protected-without-checks) ---
|
|
208
|
+
let existing;
|
|
209
|
+
try {
|
|
210
|
+
existing = await client.requiredCheckContexts(branch);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
if (err instanceof GitHubPermissionError) {
|
|
214
|
+
throw blocked(`cannot read branch protection for ${repo}@${branch}: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
// OSError analog: URLError / timeout / non-403 HTTPError.
|
|
217
|
+
throw blocked(`failed reading branch protection for ${repo}@${branch}: ${errText(err)}`);
|
|
218
|
+
}
|
|
219
|
+
const plan = planHardGate(existing, checkContext, branch);
|
|
220
|
+
if (plan.already_required) {
|
|
221
|
+
return plan;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
await client.setRequiredChecks(branch, plan.resulting_contexts, plan.creates_protection);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (err instanceof GitHubPermissionError) {
|
|
228
|
+
throw blocked(`cannot update branch protection for ${repo}@${branch}: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
throw blocked(`failed updating branch protection for ${repo}@${branch}: ${errText(err)}`);
|
|
231
|
+
}
|
|
232
|
+
return plan;
|
|
233
|
+
}
|
|
234
|
+
/** Render a string list the way Python's `repr(list)` reads: `['a', 'b']`. */
|
|
235
|
+
function reprList(items) {
|
|
236
|
+
return `[${items.map((s) => `'${s}'`).join(', ')}]`;
|
|
237
|
+
}
|
|
238
|
+
function errText(err) {
|
|
239
|
+
return err instanceof Error ? err.message : String(err);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* A GitHub REST HTTP error carrying its status, the analog of urllib's
|
|
243
|
+
* `HTTPError.code`. Thrown by {@link RestBranchProtectionClient} for non-2xx
|
|
244
|
+
* responses other than 401/403 so the 404 disambiguation can inspect the code.
|
|
245
|
+
*/
|
|
246
|
+
export class HttpError extends Error {
|
|
247
|
+
status;
|
|
248
|
+
constructor(status, message) {
|
|
249
|
+
super(message);
|
|
250
|
+
this.name = 'HttpError';
|
|
251
|
+
this.status = status;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Thin real {@link BranchProtection} over the GitHub REST API (`fetch`). */
|
|
255
|
+
export class RestBranchProtectionClient {
|
|
256
|
+
repo;
|
|
257
|
+
token;
|
|
258
|
+
static API = 'https://api.github.com';
|
|
259
|
+
constructor(repo, token) {
|
|
260
|
+
this.repo = repo;
|
|
261
|
+
this.token = token;
|
|
262
|
+
}
|
|
263
|
+
async request(method, path, body) {
|
|
264
|
+
const url = `${RestBranchProtectionClient.API}/repos/${this.repo}/${path}`;
|
|
265
|
+
const headers = {
|
|
266
|
+
Authorization: `Bearer ${this.token}`,
|
|
267
|
+
Accept: 'application/vnd.github+json',
|
|
268
|
+
};
|
|
269
|
+
const init = { method, headers };
|
|
270
|
+
if (body !== undefined) {
|
|
271
|
+
headers['Content-Type'] = 'application/json';
|
|
272
|
+
init.body = JSON.stringify(body);
|
|
273
|
+
}
|
|
274
|
+
const resp = await fetch(url, init);
|
|
275
|
+
if (!resp.ok) {
|
|
276
|
+
// 401 (bad/expired token) and 403 (no admin scope / rate limit) both mean
|
|
277
|
+
// "we can't proceed here" → permission error → playbook.
|
|
278
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
279
|
+
throw new GitHubPermissionError(`HTTP ${resp.status}: ${resp.statusText || 'forbidden'}`);
|
|
280
|
+
}
|
|
281
|
+
// Other codes propagate as a status-bearing error → fail-loud, and the
|
|
282
|
+
// 404 disambiguation reads `.status`.
|
|
283
|
+
throw new HttpError(resp.status, `HTTP ${resp.status}: ${resp.statusText || ''}`);
|
|
284
|
+
}
|
|
285
|
+
const raw = (await resp.text()) || '{}';
|
|
286
|
+
return JSON.parse(raw);
|
|
287
|
+
}
|
|
288
|
+
async branchIsUnprotected(branch) {
|
|
289
|
+
// True only if the branch has NO protection object at all (parent 404). A
|
|
290
|
+
// protected branch without a checks section returns 200 here.
|
|
291
|
+
try {
|
|
292
|
+
await this.request('GET', `branches/${branch}/protection`);
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async requiredCheckContexts(branch) {
|
|
303
|
+
let data;
|
|
304
|
+
try {
|
|
305
|
+
data = await this.request('GET', `branches/${branch}/protection/required_status_checks`);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
309
|
+
// Ambiguous 404: genuinely unprotected, OR protected without a checks
|
|
310
|
+
// section. Only the former may take the create path.
|
|
311
|
+
return (await this.branchIsUnprotected(branch)) ? null : [];
|
|
312
|
+
}
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
const checks = data['checks'];
|
|
316
|
+
if (Array.isArray(checks)) {
|
|
317
|
+
return checks
|
|
318
|
+
.map((c) => (isRecord(c) ? String(c['context'] ?? '') : ''))
|
|
319
|
+
.filter((context) => context !== '');
|
|
320
|
+
}
|
|
321
|
+
const contexts = data['contexts'];
|
|
322
|
+
return Array.isArray(contexts) ? contexts : []; // legacy shape
|
|
323
|
+
}
|
|
324
|
+
async observedCheckContexts(branch) {
|
|
325
|
+
let data;
|
|
326
|
+
try {
|
|
327
|
+
data = await this.request('GET', `commits/${branch}/check-runs`);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
// URLError / GitHubPermissionError → best-effort empty. (An HttpError for
|
|
331
|
+
// another status also degrades to empty here, matching the Python
|
|
332
|
+
// best-effort intent for observed checks.)
|
|
333
|
+
void err;
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const runs = data['check_runs'];
|
|
337
|
+
if (!Array.isArray(runs)) {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
const names = new Set();
|
|
341
|
+
for (const r of runs) {
|
|
342
|
+
if (isRecord(r)) {
|
|
343
|
+
const name = String(r['name'] ?? '');
|
|
344
|
+
if (name !== '')
|
|
345
|
+
names.add(name);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return [...names].sort();
|
|
349
|
+
}
|
|
350
|
+
async setRequiredChecks(branch, contexts, create) {
|
|
351
|
+
const checks = contexts.map((c) => ({ context: c }));
|
|
352
|
+
if (create) {
|
|
353
|
+
// Genuinely unprotected — PUT a minimal ruleset requiring only the checks;
|
|
354
|
+
// other protections stay unset.
|
|
355
|
+
await this.request('PUT', `branches/${branch}/protection`, {
|
|
356
|
+
required_status_checks: { strict: false, checks },
|
|
357
|
+
enforce_admins: null,
|
|
358
|
+
required_pull_request_reviews: null,
|
|
359
|
+
restrictions: null,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
// Protected already — PATCH the sub-resource (a partial update that
|
|
364
|
+
// touches ONLY required status checks, preserving reviews / restrictions /
|
|
365
|
+
// enforce_admins / the existing `strict` flag).
|
|
366
|
+
await this.request('PATCH', `branches/${branch}/protection/required_status_checks`, { checks });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function isRecord(value) {
|
|
371
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=hard-gate.js.map
|