redlinegate 0.0.1
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/LICENSE +21 -0
- package/README.md +188 -0
- package/commands/redline-init.md +54 -0
- package/commands/redline-verify.md +69 -0
- package/dist/bin/redline.js +605 -0
- package/dist/bin/redline.js.map +1 -0
- package/dist/commands/exempt.js +45 -0
- package/dist/commands/exempt.js.map +1 -0
- package/dist/commands/init.js +615 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/policy.js +25 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/remove.js +427 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/review.js +72 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/sync.js +42 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/verify.js +478 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/config/redline-json.js +187 -0
- package/dist/config/redline-json.js.map +1 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +152 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/log.js +22 -0
- package/dist/core/log.js.map +1 -0
- package/dist/core/severity.js +16 -0
- package/dist/core/severity.js.map +1 -0
- package/dist/core/version.js +15 -0
- package/dist/core/version.js.map +1 -0
- package/dist/detect/scan.js +79 -0
- package/dist/detect/scan.js.map +1 -0
- package/dist/detect/stack.js +71 -0
- package/dist/detect/stack.js.map +1 -0
- package/dist/enforce/ladder.js +148 -0
- package/dist/enforce/ladder.js.map +1 -0
- package/dist/exempt/parse.js +86 -0
- package/dist/exempt/parse.js.map +1 -0
- package/dist/metrics/options.js +257 -0
- package/dist/metrics/options.js.map +1 -0
- package/dist/metrics/run.js +72 -0
- package/dist/metrics/run.js.map +1 -0
- package/dist/platforms/azure/client.js +53 -0
- package/dist/platforms/azure/client.js.map +1 -0
- package/dist/platforms/azure/index.js +69 -0
- package/dist/platforms/azure/index.js.map +1 -0
- package/dist/platforms/azure/install.js +917 -0
- package/dist/platforms/azure/install.js.map +1 -0
- package/dist/platforms/azure/policy-types.js +70 -0
- package/dist/platforms/azure/policy-types.js.map +1 -0
- package/dist/platforms/azure/verify.js +309 -0
- package/dist/platforms/azure/verify.js.map +1 -0
- package/dist/platforms/detect.js +40 -0
- package/dist/platforms/detect.js.map +1 -0
- package/dist/platforms/github/client.js +49 -0
- package/dist/platforms/github/client.js.map +1 -0
- package/dist/platforms/github/index.js +54 -0
- package/dist/platforms/github/index.js.map +1 -0
- package/dist/platforms/github/install.js +558 -0
- package/dist/platforms/github/install.js.map +1 -0
- package/dist/platforms/github/push.js +102 -0
- package/dist/platforms/github/push.js.map +1 -0
- package/dist/platforms/github/remote.js +36 -0
- package/dist/platforms/github/remote.js.map +1 -0
- package/dist/platforms/github/verify.js +366 -0
- package/dist/platforms/github/verify.js.map +1 -0
- package/dist/platforms/http.js +58 -0
- package/dist/platforms/http.js.map +1 -0
- package/dist/platforms/pull-request-templates.js +157 -0
- package/dist/platforms/pull-request-templates.js.map +1 -0
- package/dist/platforms/remote.js +21 -0
- package/dist/platforms/remote.js.map +1 -0
- package/dist/platforms/resolve.js +69 -0
- package/dist/platforms/resolve.js.map +1 -0
- package/dist/platforms/shape.js +19 -0
- package/dist/platforms/shape.js.map +1 -0
- package/dist/platforms/types.js +24 -0
- package/dist/platforms/types.js.map +1 -0
- package/dist/policy/checks.js +91 -0
- package/dist/policy/checks.js.map +1 -0
- package/dist/policy/diff.js +50 -0
- package/dist/policy/diff.js.map +1 -0
- package/dist/registry/discover.js +74 -0
- package/dist/registry/discover.js.map +1 -0
- package/dist/registry/serialize.js +41 -0
- package/dist/registry/serialize.js.map +1 -0
- package/dist/registry/types.js +2 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/remove/host.js +294 -0
- package/dist/remove/host.js.map +1 -0
- package/dist/render/commands.js +139 -0
- package/dist/render/commands.js.map +1 -0
- package/dist/render/manifest.js +82 -0
- package/dist/render/manifest.js.map +1 -0
- package/dist/render/markers.js +166 -0
- package/dist/render/markers.js.map +1 -0
- package/dist/render/profile.js +23 -0
- package/dist/render/profile.js.map +1 -0
- package/dist/render/standards.js +169 -0
- package/dist/render/standards.js.map +1 -0
- package/dist/render/vendors.js +199 -0
- package/dist/render/vendors.js.map +1 -0
- package/dist/review/engines/api.js +95 -0
- package/dist/review/engines/api.js.map +1 -0
- package/dist/review/engines/embedded.js +20 -0
- package/dist/review/engines/embedded.js.map +1 -0
- package/dist/review/engines/types.js +2 -0
- package/dist/review/engines/types.js.map +1 -0
- package/dist/review/glob.js +29 -0
- package/dist/review/glob.js.map +1 -0
- package/dist/review/prompt.js +45 -0
- package/dist/review/prompt.js.map +1 -0
- package/dist/review/rules.js +25 -0
- package/dist/review/rules.js.map +1 -0
- package/dist/review/schema.js +84 -0
- package/dist/review/schema.js.map +1 -0
- package/dist/review/scope.js +27 -0
- package/dist/review/scope.js.map +1 -0
- package/dist/sarif/map.js +58 -0
- package/dist/sarif/map.js.map +1 -0
- package/dist/sarif/parse.js +88 -0
- package/dist/sarif/parse.js.map +1 -0
- package/dist/sarif/types.js +2 -0
- package/dist/sarif/types.js.map +1 -0
- package/dist/sync/host.js +21 -0
- package/dist/sync/host.js.map +1 -0
- package/dist/sync/plan.js +47 -0
- package/dist/sync/plan.js.map +1 -0
- package/dist/sync/render.js +57 -0
- package/dist/sync/render.js.map +1 -0
- package/dist/sync/run.js +118 -0
- package/dist/sync/run.js.map +1 -0
- package/dist/verify/host.js +52 -0
- package/dist/verify/host.js.map +1 -0
- package/dist/verify/remote.js +146 -0
- package/dist/verify/remote.js.map +1 -0
- package/package.json +52 -0
- package/platforms/azure/gate-template.yml +105 -0
- package/rulesets/redline-org-ruleset.json +46 -0
- package/rulesets/redline-ruleset.json +49 -0
- package/scripts/assign-rule-ids.mjs +129 -0
- package/scripts/build-baseline.mjs +124 -0
- package/scripts/build-correlation.mjs +74 -0
- package/scripts/build-dashboard.mjs +584 -0
- package/scripts/build-digest.mjs +164 -0
- package/scripts/build-inbox.mjs +133 -0
- package/scripts/build-registry.mjs +42 -0
- package/scripts/build-roi.mjs +181 -0
- package/scripts/check-pins.mjs +88 -0
- package/scripts/collect-telemetry.mjs +300 -0
- package/scripts/lib/__tests__/baseline.test.mjs +132 -0
- package/scripts/lib/__tests__/correlate.test.mjs +131 -0
- package/scripts/lib/__tests__/dora.test.mjs +110 -0
- package/scripts/lib/__tests__/exemptions.test.mjs +85 -0
- package/scripts/lib/__tests__/metrics-scanner.test.mjs +65 -0
- package/scripts/lib/__tests__/sarif.test.mjs +125 -0
- package/scripts/lib/__tests__/spend.test.mjs +64 -0
- package/scripts/lib/baseline.mjs +156 -0
- package/scripts/lib/correlate.mjs +125 -0
- package/scripts/lib/dora.mjs +133 -0
- package/scripts/lib/exemptions.mjs +79 -0
- package/scripts/lib/metrics.mjs +172 -0
- package/scripts/lib/rules.mjs +107 -0
- package/scripts/lib/sarif.mjs +108 -0
- package/scripts/lib/spend.mjs +69 -0
- package/scripts/measure-context.mjs +101 -0
- package/scripts/render-self.mjs +18 -0
- package/scripts/score-seeds.mjs +294 -0
- package/scripts/validate.mjs +394 -0
- package/standards/core.md +124 -0
- package/standards/manifest.json +214 -0
- package/standards/stacks/csharp.md +29 -0
- package/standards/stacks/go.md +30 -0
- package/standards/stacks/java.md +30 -0
- package/standards/stacks/javascript.md +42 -0
- package/standards/stacks/kotlin.md +27 -0
- package/standards/stacks/microservices.md +37 -0
- package/standards/stacks/nodejs.md +34 -0
- package/standards/stacks/python.md +29 -0
- package/standards/stacks/react-native.md +29 -0
- package/standards/stacks/react.md +46 -0
- package/standards/stacks/swift.md +26 -0
- package/standards/stacks/terraform.md +27 -0
- package/templates/CODEOWNERS +29 -0
- package/templates/azure/pull_request_template.md +72 -0
- package/templates/github/pull_request_template.md +72 -0
- package/templates/redline.yml +35 -0
- package/templates/repo-context.md +64 -0
- package/workflows/dashboard.yml +119 -0
- package/workflows/inbox.yml +97 -0
- package/workflows/redline-collect.yml +61 -0
- package/workflows/redline-gate.yml +301 -0
- package/workflows/redline-sync.yml +79 -0
- package/workflows/seed-canary.yml +231 -0
- package/workflows/verify-onboarding.yml +115 -0
- package/workflows/weekly-digest.yml +78 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { RedlineError } from "../../core/errors.js";
|
|
5
|
+
import { CLI_VERSION } from "../../core/version.js";
|
|
6
|
+
import { AZURE_BUILD_POLICY_DISPLAY_NAME, AZURE_STATUS_GENRE, AZURE_STATUS_NAME, POLICY_TYPE_NAMES, REDLINE_POLICY_MARKER, resolvePolicyTypeIds, } from "./policy-types.js";
|
|
7
|
+
import { isNonNullObject, isSuccess } from "../shape.js";
|
|
8
|
+
import { BEGIN_PREFIX, END, findBlock, wrapBlock } from "../../render/markers.js";
|
|
9
|
+
import { TEMPLATE_DIRS as PULL_REQUEST_TEMPLATE_DIRS } from "../pull-request-templates.js";
|
|
10
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
|
11
|
+
// Advanced Security is a separately licensed feature: a tenant without it
|
|
12
|
+
// returns 404 on the enablement endpoint. That is `unsupported`, never
|
|
13
|
+
// `denied` — the repository is not half-onboarded, the feature was never
|
|
14
|
+
// purchased. See enableSecurityFloor.
|
|
15
|
+
const ADVSEC = { host: 'advsec', apiVersion: '7.2-preview.1' };
|
|
16
|
+
function parsePolicyConfigurations(body) {
|
|
17
|
+
if (!isNonNullObject(body) || !Array.isArray(body['value']))
|
|
18
|
+
return null;
|
|
19
|
+
const configs = [];
|
|
20
|
+
for (const item of body['value']) {
|
|
21
|
+
if (!isNonNullObject(item) || typeof item['id'] !== 'number')
|
|
22
|
+
return null;
|
|
23
|
+
const type = item['type'];
|
|
24
|
+
if (!isNonNullObject(type) || typeof type['id'] !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
const settings = item['settings'];
|
|
27
|
+
if (!isNonNullObject(settings))
|
|
28
|
+
return null;
|
|
29
|
+
configs.push({ id: item['id'], type: { id: type['id'] }, settings });
|
|
30
|
+
}
|
|
31
|
+
return configs;
|
|
32
|
+
}
|
|
33
|
+
function parseCreatedPullRequestId(body) {
|
|
34
|
+
if (!isNonNullObject(body) || typeof body['pullRequestId'] !== 'number')
|
|
35
|
+
return null;
|
|
36
|
+
return body['pullRequestId'];
|
|
37
|
+
}
|
|
38
|
+
// CONTRACT with platforms/azure/gate-template.yml: Azure Repos ignores YAML
|
|
39
|
+
// `pr:` triggers (a GitHub-only feature), so the gate pipeline only ever runs
|
|
40
|
+
// through a Build Validation branch policy pointing at a registered build
|
|
41
|
+
// definition. This name and yaml path are what installGate registers and what
|
|
42
|
+
// the Build Validation policy in applyPolicy queues.
|
|
43
|
+
const GATE_DEFINITION_NAME = 'redline-gate';
|
|
44
|
+
const GATE_YAML_FILENAME = '.azuredevops/redline-gate.yml';
|
|
45
|
+
// Definition names are unique per folder, and the `name=` list filter is
|
|
46
|
+
// project-wide. Registering Redline's definitions in their own folder is what
|
|
47
|
+
// keeps two repositories in the same project from colliding on the name
|
|
48
|
+
// `redline-gate` — and adopting each other's definitions, which would point a
|
|
49
|
+
// repository's Build Validation policy at a pipeline that checks out a
|
|
50
|
+
// sibling repository and publishes redline/gate against the sibling's id.
|
|
51
|
+
const GATE_DEFINITION_FOLDER = '\\Redline';
|
|
52
|
+
// semantic-release replaces package.json's version only on a published build,
|
|
53
|
+
// so this is what an unpublished checkout reports. Pinning it into a gate
|
|
54
|
+
// template would install a version the registry has never seen.
|
|
55
|
+
const UNPUBLISHED_VERSION = '0.0.0-development';
|
|
56
|
+
function parseBuildDefinitions(body) {
|
|
57
|
+
if (!isNonNullObject(body) || !Array.isArray(body['value']))
|
|
58
|
+
return null;
|
|
59
|
+
const defs = [];
|
|
60
|
+
for (const item of body['value']) {
|
|
61
|
+
if (!isNonNullObject(item) || typeof item['id'] !== 'number' || typeof item['name'] !== 'string') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const process = item['process'];
|
|
65
|
+
const yamlFilename = isNonNullObject(process) && typeof process['yamlFilename'] === 'string'
|
|
66
|
+
? process['yamlFilename']
|
|
67
|
+
: null;
|
|
68
|
+
const repository = item['repository'];
|
|
69
|
+
const repositoryId = isNonNullObject(repository) && typeof repository['id'] === 'string' ? repository['id'] : null;
|
|
70
|
+
defs.push({
|
|
71
|
+
id: item['id'],
|
|
72
|
+
name: item['name'],
|
|
73
|
+
path: typeof item['path'] === 'string' ? item['path'] : null,
|
|
74
|
+
repositoryId,
|
|
75
|
+
yamlFilename,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return defs;
|
|
79
|
+
}
|
|
80
|
+
function parseCreatedBuildDefinitionId(body) {
|
|
81
|
+
if (!isNonNullObject(body) || typeof body['id'] !== 'number')
|
|
82
|
+
return null;
|
|
83
|
+
return body['id'];
|
|
84
|
+
}
|
|
85
|
+
// Same degradation contract as outcome(), but registering a build definition
|
|
86
|
+
// needs Build Administrator, not project administrator, and a 404 here means
|
|
87
|
+
// Azure Pipelines is not available on the project.
|
|
88
|
+
function gateOutcome(status, detail) {
|
|
89
|
+
if (isSuccess(status))
|
|
90
|
+
return { capability: 'gate', status: 'applied', detail };
|
|
91
|
+
if (status === 401 || status === 403) {
|
|
92
|
+
return {
|
|
93
|
+
capability: 'gate',
|
|
94
|
+
status: 'denied',
|
|
95
|
+
detail: `${detail} (needs build administrator) — the merge gate stays advisory until it is registered`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (status === 404) {
|
|
99
|
+
return {
|
|
100
|
+
capability: 'gate',
|
|
101
|
+
status: 'unsupported',
|
|
102
|
+
detail: `${detail} (Azure Pipelines is not available on this project)`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { capability: 'gate', status: 'denied', detail: `${detail} (HTTP ${status})` };
|
|
106
|
+
}
|
|
107
|
+
// resolvePolicyTypeIds always seeds its result from POLICY_TYPE_FALLBACK, so
|
|
108
|
+
// every POLICY_TYPE_NAMES value is guaranteed present — but its return type
|
|
109
|
+
// is the plain `Record<string, string>` (cli/platforms/azure/verify.ts also
|
|
110
|
+
// consumes it and indexes it with a plain string, so narrowing the exported
|
|
111
|
+
// type here would break that file), and noUncheckedIndexedAccess therefore
|
|
112
|
+
// still sees `string | undefined` at this call site. This proves the
|
|
113
|
+
// invariant with a real check instead of a `!` assertion.
|
|
114
|
+
function requiredTypeId(types, name) {
|
|
115
|
+
const id = types[name];
|
|
116
|
+
if (id === undefined) {
|
|
117
|
+
throw new RedlineError('host', `Azure policy type "${name}" could not be resolved`);
|
|
118
|
+
}
|
|
119
|
+
return id;
|
|
120
|
+
}
|
|
121
|
+
function outcome(capability, status, detail) {
|
|
122
|
+
if (isSuccess(status))
|
|
123
|
+
return { capability, status: 'applied', detail };
|
|
124
|
+
if (status === 401 || status === 403) {
|
|
125
|
+
return { capability, status: 'denied', detail: `${detail} (needs project administrator)` };
|
|
126
|
+
}
|
|
127
|
+
if (status === 404) {
|
|
128
|
+
return { capability, status: 'unsupported', detail: `${detail} (not available on this project)` };
|
|
129
|
+
}
|
|
130
|
+
return { capability, status: 'denied', detail: `${detail} (HTTP ${status})` };
|
|
131
|
+
}
|
|
132
|
+
// Combines several outcomes for one logical capability (three policy
|
|
133
|
+
// configuration writes fold into one "merge-policy" result) into a single
|
|
134
|
+
// outcome, ranked by how actionable the status is rather than by comparing
|
|
135
|
+
// raw HTTP status numbers — a 404 "unsupported" on one call must never mask
|
|
136
|
+
// a 403 "denied" on another just because 404 > 403.
|
|
137
|
+
// `unknown` never appears here today — nothing this file writes degrades a
|
|
138
|
+
// status into it, only verify.ts's readSecurityState does — but the status
|
|
139
|
+
// union requires every member ranked. It sits between `denied` and
|
|
140
|
+
// `unsupported`: a genuine denial must never be masked by an indeterminate
|
|
141
|
+
// read, and an indeterminate read must never be masked by a definite "not
|
|
142
|
+
// available here" or a definite success either.
|
|
143
|
+
const OUTCOME_RANK = {
|
|
144
|
+
denied: 4,
|
|
145
|
+
unknown: 3,
|
|
146
|
+
unsupported: 2,
|
|
147
|
+
already: 1,
|
|
148
|
+
applied: 0,
|
|
149
|
+
};
|
|
150
|
+
function worstOutcome(outcomes) {
|
|
151
|
+
const [first, ...rest] = outcomes;
|
|
152
|
+
if (!first)
|
|
153
|
+
throw new Error('worstOutcome requires at least one outcome');
|
|
154
|
+
return rest.reduce((worst, next) => (OUTCOME_RANK[next.status] > OUTCOME_RANK[worst.status] ? next : worst), first);
|
|
155
|
+
}
|
|
156
|
+
// Writes only when the bytes differ, and answers whether they did — same
|
|
157
|
+
// contract as cli/platforms/github/install.ts and cli/render/standards.ts.
|
|
158
|
+
// The gate template carries a pinned `redlinegate@<version>`, so under a
|
|
159
|
+
// published CLI an unconditional write rewrote this file on every single run:
|
|
160
|
+
// `redline init` reads the returned file list to decide whether a re-run has
|
|
161
|
+
// anything to do, and an always-dirty gate file made that decision worthless
|
|
162
|
+
// in one direction and, once the list was ignored, left a modified tracked
|
|
163
|
+
// file behind with no pull request in the other. `check` computes the answer
|
|
164
|
+
// and writes nothing.
|
|
165
|
+
function syncFile(cwd, relPath, contents, check) {
|
|
166
|
+
const target = join(cwd, relPath);
|
|
167
|
+
const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
168
|
+
if (current === contents)
|
|
169
|
+
return false;
|
|
170
|
+
if (check)
|
|
171
|
+
return true;
|
|
172
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
173
|
+
writeFileSync(target, contents);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// The same defect and the same two honest answers as GitHub's caller workflow
|
|
177
|
+
// (cli/platforms/github/install.ts): the gate pipeline is YAML, so appending a
|
|
178
|
+
// REDLINE marker block would give it a second `trigger:`/`steps:` key and it
|
|
179
|
+
// would run nothing at all. So the file is either Redline's to replace or
|
|
180
|
+
// nobody's to touch, and the check runs at the top of installGate so the plan
|
|
181
|
+
// phase refuses identically — a plan must not promise a write the run refuses.
|
|
182
|
+
//
|
|
183
|
+
// Attribution is strict here, where GitHub's has to leave room for a 2.1
|
|
184
|
+
// caller: `detectMigration`'s marker is `.github/workflows/redline.yml`, a
|
|
185
|
+
// GitHub path, so no legacy Azure gate file exists that a loose match would
|
|
186
|
+
// have to adopt. Either the ownership line the template opens with, or the
|
|
187
|
+
// variable block only Redline's own gate template produces.
|
|
188
|
+
const GATE_PATH = '.azuredevops/redline-gate.yml';
|
|
189
|
+
const MANAGED_BY_REDLINE = /^#[ \t]*Managed by Redline\b/m;
|
|
190
|
+
const REDLINE_GATE_VARIABLES = /^[ \t]*ADR_DIFF_THRESHOLD:/m;
|
|
191
|
+
function refuseForeignGateFile(cwd, opts) {
|
|
192
|
+
if (opts.adoptCaller === true)
|
|
193
|
+
return;
|
|
194
|
+
const target = join(cwd, GATE_PATH);
|
|
195
|
+
if (!existsSync(target))
|
|
196
|
+
return;
|
|
197
|
+
const existing = readFileSync(target, 'utf8');
|
|
198
|
+
if (MANAGED_BY_REDLINE.test(existing) || REDLINE_GATE_VARIABLES.test(existing))
|
|
199
|
+
return;
|
|
200
|
+
throw new RedlineError('failed', `${GATE_PATH} already exists in this repository and carries nothing that attributes it to ` +
|
|
201
|
+
'Redline, so installing the merge gate there would destroy it. Nothing was written', 'If that pipeline is already this repository\'s merge gate, re-run with --skip gate and Redline ' +
|
|
202
|
+
'will leave it in charge. Otherwise move or rename it and re-run redline init, or re-run with ' +
|
|
203
|
+
'--adopt-caller to hand that path to Redline. Redline cannot merge into it the way it merges ' +
|
|
204
|
+
'into a markdown file: a second `trigger:` and `steps:` key would stop the pipeline running at all.');
|
|
205
|
+
}
|
|
206
|
+
// The two sections workflows/redline-gate.yml actually reads out of a pull
|
|
207
|
+
// request body, and the reason it is these two and not the others: the
|
|
208
|
+
// `checklist` job fails the pull request outright when `## Launch readiness`
|
|
209
|
+
// is missing, and the `adr` job fails a large diff whose body carries no
|
|
210
|
+
// `docs/adr/` link, which is what `## Architecture decision` prompts for.
|
|
211
|
+
// `## Change type` is gated by nothing (the workflow says so in a comment),
|
|
212
|
+
// `## Automated review` is gated by nothing, and `# Summary` would collide
|
|
213
|
+
// with the heading a repository's own template already has. Dropping the ADR
|
|
214
|
+
// section would leave a merged repository failing a gate job it has no
|
|
215
|
+
// affordance to satisfy.
|
|
216
|
+
//
|
|
217
|
+
// `satisfied` asks what the gate job asks, not what the section looks like.
|
|
218
|
+
// The checklist job's awk matches the heading by prefix, so
|
|
219
|
+
// `## Launch readiness checklist` already satisfies it and must not be given a
|
|
220
|
+
// second, competing section. The adr job greps the whole body for `docs/adr/`,
|
|
221
|
+
// so any existing ADR link satisfies it, heading or no heading.
|
|
222
|
+
const GATED_SECTIONS = [
|
|
223
|
+
{
|
|
224
|
+
heading: 'Launch readiness',
|
|
225
|
+
satisfied: (template) => /^##[ \t]+Launch readiness/m.test(template),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
heading: 'Architecture decision',
|
|
229
|
+
satisfied: (template) => template.includes('docs/adr/'),
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
// The gate reads a `## Redline exemption` block, so a template without one
|
|
233
|
+
// leaves an author who needs a waiver with nowhere to write it. It rides along in
|
|
234
|
+
// any block Redline is writing anyway, but it is deliberately NOT in
|
|
235
|
+
// GATED_SECTIONS: no gate job fails for its absence, so it must never be the
|
|
236
|
+
// reason a marker block appears in a template a team wrote for themselves.
|
|
237
|
+
const EXEMPTION_HEADING = 'Redline exemption';
|
|
238
|
+
// The headings to put inside a block, given the gated ones that still need
|
|
239
|
+
// covering. Empty in, empty out: no block is created just to carry the optional
|
|
240
|
+
// section.
|
|
241
|
+
const blockSections = (headings) => headings.length === 0 ? [] : [...headings, EXEMPTION_HEADING];
|
|
242
|
+
function gatedSections(template, headings) {
|
|
243
|
+
const kept = [];
|
|
244
|
+
let inside = false;
|
|
245
|
+
for (const line of template.split('\n')) {
|
|
246
|
+
// The packaged template carries the markers itself, so a greenfield file is
|
|
247
|
+
// marked from the first run. The marker lines are the wrapper wrapBlock
|
|
248
|
+
// adds back, never part of the body it wraps.
|
|
249
|
+
if (line.startsWith(BEGIN_PREFIX) || line.startsWith(END))
|
|
250
|
+
continue;
|
|
251
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
252
|
+
if (heading)
|
|
253
|
+
inside = headings.includes(heading[1] ?? '');
|
|
254
|
+
if (inside)
|
|
255
|
+
kept.push(line);
|
|
256
|
+
}
|
|
257
|
+
return kept.join('\n').trim();
|
|
258
|
+
}
|
|
259
|
+
// Azure DevOps documents both `pull_request_template.md` and
|
|
260
|
+
// `pull_request_template.txt` as default templates, and states that filenames
|
|
261
|
+
// and folder locations are not case sensitive.
|
|
262
|
+
const TEMPLATE_NAMES = ['pull_request_template.md', 'pull_request_template.txt'];
|
|
263
|
+
// Azure DevOps documents these four folders, searched in this order, first
|
|
264
|
+
// match wins — `.azuredevops/`, the legacy `.vsts/`, `docs/` and the root.
|
|
265
|
+
// The order itself lives in pull-request-templates.ts, the one source shared
|
|
266
|
+
// with `redline verify` — see the comment there.
|
|
267
|
+
const TEMPLATE_DIRS = PULL_REQUEST_TEMPLATE_DIRS.azure;
|
|
268
|
+
// Resolved by listing rather than by `existsSync`, for two reasons the review
|
|
269
|
+
// found the hard way: both hosts treat the folder name as case-insensitive, and
|
|
270
|
+
// `existsSync` is case-insensitive on macOS but not on Linux, so a `.GitHub/`
|
|
271
|
+
// checkout was silently missed on CI and a second template written beside the
|
|
272
|
+
// served one. Listing also answers `isDirectory()`, which is what stops a plain
|
|
273
|
+
// file named `docs` from throwing ENOTDIR mid-run, after the gate workflow has
|
|
274
|
+
// already been written.
|
|
275
|
+
function candidateDirs(cwd) {
|
|
276
|
+
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
277
|
+
const found = [];
|
|
278
|
+
for (const wanted of TEMPLATE_DIRS) {
|
|
279
|
+
if (wanted === '') {
|
|
280
|
+
found.push({ abs: cwd, rel: '' });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const dir = entries.find((entry) => entry.isDirectory() && entry.name.toLowerCase() === wanted);
|
|
284
|
+
if (dir)
|
|
285
|
+
found.push({ abs: join(cwd, dir.name), rel: dir.name });
|
|
286
|
+
}
|
|
287
|
+
return found;
|
|
288
|
+
}
|
|
289
|
+
const under = (candidate, name) => candidate.rel === '' ? name : `${candidate.rel}/${name}`;
|
|
290
|
+
function findPullRequestTemplate(cwd) {
|
|
291
|
+
for (const candidate of candidateDirs(cwd)) {
|
|
292
|
+
const names = readdirSync(candidate.abs, { withFileTypes: true })
|
|
293
|
+
.filter((entry) => entry.isFile() && TEMPLATE_NAMES.includes(entry.name.toLowerCase()))
|
|
294
|
+
.map((entry) => entry.name);
|
|
295
|
+
// Two case variants in one directory: the host picks one and does not say
|
|
296
|
+
// which, so the tie-break is at least deterministic rather than whatever
|
|
297
|
+
// order the filesystem happened to list. Canonical spelling first,
|
|
298
|
+
// preferred extension next, byte order last.
|
|
299
|
+
names.sort((a, b) => {
|
|
300
|
+
const byName = TEMPLATE_NAMES.indexOf(a.toLowerCase()) - TEMPLATE_NAMES.indexOf(b.toLowerCase());
|
|
301
|
+
if (byName !== 0)
|
|
302
|
+
return byName;
|
|
303
|
+
if (a === b.toLowerCase())
|
|
304
|
+
return -1;
|
|
305
|
+
if (b === a.toLowerCase())
|
|
306
|
+
return 1;
|
|
307
|
+
return a < b ? -1 : 1;
|
|
308
|
+
});
|
|
309
|
+
const hit = names[0];
|
|
310
|
+
if (hit !== undefined)
|
|
311
|
+
return under(candidate, hit);
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
// Azure serves a branch-specific template *in preference to* the default, so a
|
|
316
|
+
// repository that has one gets a pull request body Redline never merged into
|
|
317
|
+
// and the checklist job fails every pull request into that branch. Unlike
|
|
318
|
+
// GitHub's `PULL_REQUEST_TEMPLATE/` directory — opt-in through a `?template=`
|
|
319
|
+
// link, where writing the default leaves the repository strictly better off —
|
|
320
|
+
// these are automatic, so merging the default alone leaves the repository no
|
|
321
|
+
// better than before Redline ran. Every one of them is merged. "Additional"
|
|
322
|
+
// templates, which sit in `pull_request_template/` but outside `branches/`,
|
|
323
|
+
// are the opt-in kind and are neither adopted nor rewritten.
|
|
324
|
+
const BRANCH_TEMPLATE_EXTENSIONS = ['.md', '.txt'];
|
|
325
|
+
// Azure documents branch names nested up to ten levels
|
|
326
|
+
// (`branches/release/october/week1.md`), and searches every candidate folder
|
|
327
|
+
// rather than stopping at the first, so a template under `docs/` is live even
|
|
328
|
+
// when `.azuredevops/` also has some.
|
|
329
|
+
const MAX_BRANCH_DEPTH = 10;
|
|
330
|
+
function childDir(abs, name) {
|
|
331
|
+
const entry = readdirSync(abs, { withFileTypes: true }).find((candidate) => candidate.isDirectory() && candidate.name.toLowerCase() === name);
|
|
332
|
+
return entry ? join(abs, entry.name) : null;
|
|
333
|
+
}
|
|
334
|
+
function collectBranchTemplates(abs, rel, depth, into) {
|
|
335
|
+
if (depth > MAX_BRANCH_DEPTH)
|
|
336
|
+
return;
|
|
337
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
338
|
+
const childRel = `${rel}/${entry.name}`;
|
|
339
|
+
if (entry.isDirectory())
|
|
340
|
+
collectBranchTemplates(join(abs, entry.name), childRel, depth + 1, into);
|
|
341
|
+
else if (entry.isFile() &&
|
|
342
|
+
BRANCH_TEMPLATE_EXTENSIONS.some((ext) => entry.name.toLowerCase().endsWith(ext))) {
|
|
343
|
+
into.push(childRel);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function findBranchTemplates(cwd) {
|
|
348
|
+
const found = [];
|
|
349
|
+
for (const candidate of candidateDirs(cwd)) {
|
|
350
|
+
const folder = childDir(candidate.abs, 'pull_request_template');
|
|
351
|
+
const branches = folder === null ? null : childDir(folder, 'branches');
|
|
352
|
+
if (branches === null)
|
|
353
|
+
continue;
|
|
354
|
+
collectBranchTemplates(branches, under(candidate, 'pull_request_template/branches'), 1, found);
|
|
355
|
+
}
|
|
356
|
+
return found.sort();
|
|
357
|
+
}
|
|
358
|
+
// A repository's own pull request template is a human-owned file, and this was
|
|
359
|
+
// the last host-writing path in either adapter that simply overwrote one.
|
|
360
|
+
// Leaving it alone is not the fix either: the gate fails any pull request
|
|
361
|
+
// whose body has no `## Launch readiness` section, so an untouched brownfield
|
|
362
|
+
// template would block the repository's own pull requests. So: write the
|
|
363
|
+
// packaged template only where the host would resolve none, and otherwise
|
|
364
|
+
// merge only the gated sections the file does not already satisfy into a
|
|
365
|
+
// REDLINE marker block, leaving every other byte alone.
|
|
366
|
+
function mergeTemplate(cwd, relPath, packaged, check) {
|
|
367
|
+
const target = join(cwd, relPath);
|
|
368
|
+
const existing = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
369
|
+
if (existing === null) {
|
|
370
|
+
return { path: relPath, changed: syncFile(cwd, relPath, packaged, check), detail: `wrote ${relPath}` };
|
|
371
|
+
}
|
|
372
|
+
// Throws on a half-edited marker pair, or on markers hidden below an unclosed
|
|
373
|
+
// code fence, rather than guessing which span is Redline's — see
|
|
374
|
+
// cli/render/markers.ts. Nothing is written on that path.
|
|
375
|
+
const span = findBlock(existing, relPath);
|
|
376
|
+
if (span !== null) {
|
|
377
|
+
// Measured against what the template provides OUTSIDE the block, never the
|
|
378
|
+
// whole file. Refreshing with every gated section unconditionally put
|
|
379
|
+
// Redline's own `## Launch readiness` inside the block while the
|
|
380
|
+
// repository's stayed outside it, and the gate's awk enforces both — so the
|
|
381
|
+
// second run broke a repository the first run had merged correctly.
|
|
382
|
+
const outside = existing.slice(0, span.start) + existing.slice(span.stop + END.length);
|
|
383
|
+
const wanted = GATED_SECTIONS.filter((section) => !section.satisfied(outside)).map((section) => section.heading);
|
|
384
|
+
if (wanted.length === 0) {
|
|
385
|
+
return {
|
|
386
|
+
path: relPath,
|
|
387
|
+
changed: false,
|
|
388
|
+
detail: `${relPath} satisfies the gate outside the Redline block — left untouched`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const contents = wrapBlock(existing, gatedSections(packaged, blockSections(wanted)), relPath);
|
|
392
|
+
const changed = syncFile(cwd, relPath, contents, check);
|
|
393
|
+
return {
|
|
394
|
+
path: relPath,
|
|
395
|
+
changed,
|
|
396
|
+
detail: changed
|
|
397
|
+
? `refreshed the Redline block in ${relPath}`
|
|
398
|
+
: `${relPath} is already up to date`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const missing = GATED_SECTIONS.filter((section) => !section.satisfied(existing)).map((section) => section.heading);
|
|
402
|
+
if (missing.length === 0) {
|
|
403
|
+
// Also where a repository onboarded before the packaged template carried
|
|
404
|
+
// markers lands: its marker-less file already answers both gate jobs, so it
|
|
405
|
+
// keeps what it has and Redline never rewrites it.
|
|
406
|
+
return {
|
|
407
|
+
path: relPath,
|
|
408
|
+
changed: false,
|
|
409
|
+
detail: `${relPath} already satisfies the gate on its own — left untouched`,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const contents = wrapBlock(existing, gatedSections(packaged, blockSections(missing)), relPath);
|
|
413
|
+
const appended = missing.map((heading) => `"## ${heading}"`).join(' and ');
|
|
414
|
+
return {
|
|
415
|
+
path: relPath,
|
|
416
|
+
changed: syncFile(cwd, relPath, contents, check),
|
|
417
|
+
detail: `kept this repository's ${relPath} and appended ${appended} inside REDLINE markers`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function syncPullRequestTemplate(cwd, defaultPath, packaged, check) {
|
|
421
|
+
const results = [mergeTemplate(cwd, findPullRequestTemplate(cwd) ?? defaultPath, packaged, check)];
|
|
422
|
+
const details = [results[0]?.detail ?? ''];
|
|
423
|
+
const branches = findBranchTemplates(cwd).map((relPath) => mergeTemplate(cwd, relPath, packaged, check));
|
|
424
|
+
if (branches.length > 0) {
|
|
425
|
+
const updated = branches.filter((result) => result.changed).length;
|
|
426
|
+
details.push(`${updated} of ${branches.length} branch-specific template(s) updated — Azure serves those in preference to the default`);
|
|
427
|
+
results.push(...branches);
|
|
428
|
+
}
|
|
429
|
+
const files = results.filter((result) => result.changed).map((result) => result.path);
|
|
430
|
+
return { files, changed: files.length > 0, detail: details.join('; ') };
|
|
431
|
+
}
|
|
432
|
+
export function createAzureInstall(client, gitFor, cliVersion = CLI_VERSION) {
|
|
433
|
+
const project = (ref) => {
|
|
434
|
+
if (!ref.project)
|
|
435
|
+
throw new RedlineError('usage', 'an Azure DevOps repository needs a project');
|
|
436
|
+
return ref.project;
|
|
437
|
+
};
|
|
438
|
+
const repoId = (ref) => {
|
|
439
|
+
if (!ref.repoId)
|
|
440
|
+
throw new RedlineError('usage', 'an Azure DevOps repository needs its id');
|
|
441
|
+
return ref.repoId;
|
|
442
|
+
};
|
|
443
|
+
let gateBuild = null;
|
|
444
|
+
async function ensureGateBuildDefinition(ref) {
|
|
445
|
+
const proj = project(ref);
|
|
446
|
+
const repo = repoId(ref);
|
|
447
|
+
const listed = await client.request('GET', `/${proj}/_apis/build/definitions?name=${GATE_DEFINITION_NAME}` +
|
|
448
|
+
`&path=${encodeURIComponent(GATE_DEFINITION_FOLDER)}&includeAllProperties=true`);
|
|
449
|
+
if (!isSuccess(listed.status)) {
|
|
450
|
+
gateBuild = { registered: false };
|
|
451
|
+
return gateOutcome(listed.status, 'gate pipeline definition');
|
|
452
|
+
}
|
|
453
|
+
const defs = parseBuildDefinitions(listed.body);
|
|
454
|
+
if (defs === null) {
|
|
455
|
+
throw new RedlineError('host', 'Azure DevOps returned an unexpected shape for build definitions');
|
|
456
|
+
}
|
|
457
|
+
// The `path` filter is re-applied locally: the list endpoint's own
|
|
458
|
+
// filtering is not something to trust a repository's merge gate to.
|
|
459
|
+
const named = defs.filter((d) => d.name === GATE_DEFINITION_NAME && d.path === GATE_DEFINITION_FOLDER);
|
|
460
|
+
const forThisRepo = named.filter((d) => d.repositoryId === repo);
|
|
461
|
+
const mine = forThisRepo.find((d) => d.yamlFilename === GATE_YAML_FILENAME);
|
|
462
|
+
if (mine) {
|
|
463
|
+
gateBuild = { registered: true, definitionId: mine.id };
|
|
464
|
+
return {
|
|
465
|
+
capability: 'gate',
|
|
466
|
+
status: 'already',
|
|
467
|
+
detail: `gate pipeline definition "${GATE_DEFINITION_NAME}" (id ${mine.id}) already registered`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const foreign = forThisRepo[0];
|
|
471
|
+
if (foreign) {
|
|
472
|
+
// Brownfield: a definition Redline did not create is human-owned and is
|
|
473
|
+
// never updated — and a second definition with the same name cannot be
|
|
474
|
+
// created. Pending admin work, not an error: an administrator must
|
|
475
|
+
// rename it or point it at the gate yaml before the gate can block.
|
|
476
|
+
gateBuild = { registered: false };
|
|
477
|
+
return {
|
|
478
|
+
capability: 'gate',
|
|
479
|
+
status: 'denied',
|
|
480
|
+
detail: `a build definition named "${GATE_DEFINITION_NAME}" (id ${foreign.id}) already exists but runs ` +
|
|
481
|
+
`${foreign.yamlFilename === null ? 'a designer pipeline' : `"${foreign.yamlFilename}"`}, not ` +
|
|
482
|
+
`${GATE_YAML_FILENAME} — it is human-owned and was left untouched; the merge gate stays advisory ` +
|
|
483
|
+
`until an administrator points it at ${GATE_YAML_FILENAME} or renames it`,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const otherRepo = named[0];
|
|
487
|
+
if (otherRepo) {
|
|
488
|
+
// Same name, same folder, a different repository — reusing it would
|
|
489
|
+
// point this repository's Build Validation policy at a pipeline that
|
|
490
|
+
// builds the other repository and publishes redline/gate against the
|
|
491
|
+
// other repository's id, blocking every pull request here. The name is
|
|
492
|
+
// also taken, so a POST cannot succeed either.
|
|
493
|
+
gateBuild = { registered: false };
|
|
494
|
+
return {
|
|
495
|
+
capability: 'gate',
|
|
496
|
+
status: 'denied',
|
|
497
|
+
detail: `build definition "${GATE_DEFINITION_FOLDER}\\${GATE_DEFINITION_NAME}" (id ${otherRepo.id}) is ` +
|
|
498
|
+
`bound to repository ${otherRepo.repositoryId ?? 'unknown'}, not ${repo} — it was left untouched ` +
|
|
499
|
+
`and the name is unavailable, so the merge gate stays advisory until an administrator renames it`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const created = await client.request('POST', `/${proj}/_apis/build/definitions`, {
|
|
503
|
+
name: GATE_DEFINITION_NAME,
|
|
504
|
+
path: GATE_DEFINITION_FOLDER,
|
|
505
|
+
process: { type: 2, yamlFilename: GATE_YAML_FILENAME },
|
|
506
|
+
repository: { id: repo, type: 'TfsGit' },
|
|
507
|
+
});
|
|
508
|
+
if (!isSuccess(created.status)) {
|
|
509
|
+
gateBuild = { registered: false };
|
|
510
|
+
return gateOutcome(created.status, 'gate pipeline definition');
|
|
511
|
+
}
|
|
512
|
+
const id = parseCreatedBuildDefinitionId(created.body);
|
|
513
|
+
if (id === null) {
|
|
514
|
+
throw new RedlineError('host', 'Azure DevOps returned an unexpected shape for a created build definition');
|
|
515
|
+
}
|
|
516
|
+
gateBuild = { registered: true, definitionId: id };
|
|
517
|
+
return {
|
|
518
|
+
capability: 'gate',
|
|
519
|
+
status: 'applied',
|
|
520
|
+
detail: `gate pipeline definition "${GATE_DEFINITION_NAME}" registered`,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
async enableSecurityFloor(ref) {
|
|
525
|
+
const res = await client.request('PATCH', `/${project(ref)}/_apis/management/repositories/${repoId(ref)}/enablement`, { advSecEnabled: true, blockPushes: true }, ADVSEC);
|
|
526
|
+
return {
|
|
527
|
+
outcomes: [
|
|
528
|
+
outcome('secret-scanning', res.status, 'advanced security secret scanning'),
|
|
529
|
+
outcome('push-protection', res.status, 'advanced security push protection'),
|
|
530
|
+
outcome('dependency-alerts', res.status, 'advanced security dependency scanning'),
|
|
531
|
+
],
|
|
532
|
+
};
|
|
533
|
+
},
|
|
534
|
+
async applyPolicy(ref, policy) {
|
|
535
|
+
const proj = project(ref);
|
|
536
|
+
const repo = repoId(ref);
|
|
537
|
+
const types = await resolvePolicyTypeIds(client, proj);
|
|
538
|
+
const defaultRef = `refs/heads/${ref.defaultBranch}`;
|
|
539
|
+
const scope = [{ repositoryId: repo, refName: defaultRef, matchKind: 'exact' }];
|
|
540
|
+
const buildTypeId = requiredTypeId(types, POLICY_TYPE_NAMES.build);
|
|
541
|
+
const statusTypeId = requiredTypeId(types, POLICY_TYPE_NAMES.status);
|
|
542
|
+
// Brownfield ownership, the rule the whole of this function turns on.
|
|
543
|
+
// Redline writes REDLINE_POLICY_MARKER into settings.displayName on
|
|
544
|
+
// every policy it creates; a policy of the same type WITHOUT it belongs
|
|
545
|
+
// to a human and is never updated, rescoped or deleted — matching on
|
|
546
|
+
// the type id alone would PUT a team's own review rules away.
|
|
547
|
+
const isRedlineOwned = (config) => {
|
|
548
|
+
const displayName = config.settings['displayName'];
|
|
549
|
+
return typeof displayName === 'string' && displayName.startsWith(REDLINE_POLICY_MARKER);
|
|
550
|
+
};
|
|
551
|
+
const isRedlineBuildPolicy = (config) => config.type.id === buildTypeId && isRedlineOwned(config);
|
|
552
|
+
// Status policies carry a second marker: the genre/name pair the gate
|
|
553
|
+
// pipeline publishes, which is what makes one Status policy a different
|
|
554
|
+
// object from another on the same branch. It also predates the
|
|
555
|
+
// displayName marker, so a repository onboarded by an earlier release
|
|
556
|
+
// is still recognised as Redline's own instead of being mistaken for a
|
|
557
|
+
// human's and abandoned.
|
|
558
|
+
const isRedlineStatusPolicy = (config) => config.type.id === statusTypeId &&
|
|
559
|
+
config.settings['statusGenre'] === AZURE_STATUS_GENRE &&
|
|
560
|
+
config.settings['statusName'] === AZURE_STATUS_NAME;
|
|
561
|
+
// Only policies that govern the branch this run writes are candidates.
|
|
562
|
+
// A scope entry naming another ref belongs to a branch an operator
|
|
563
|
+
// chose deliberately: matching it would rewrite it with the
|
|
564
|
+
// default-branch scope and silently move it.
|
|
565
|
+
const coversDefaultBranch = (config) => {
|
|
566
|
+
const configScope = config.settings['scope'];
|
|
567
|
+
if (!Array.isArray(configScope))
|
|
568
|
+
return false;
|
|
569
|
+
return configScope.some((entry) => {
|
|
570
|
+
if (!isNonNullObject(entry) || entry['repositoryId'] !== repo)
|
|
571
|
+
return false;
|
|
572
|
+
const refName = entry['refName'];
|
|
573
|
+
// No ref at all means the whole repository, which includes the
|
|
574
|
+
// default branch.
|
|
575
|
+
if (refName === undefined || refName === null)
|
|
576
|
+
return true;
|
|
577
|
+
if (typeof refName !== 'string')
|
|
578
|
+
return false;
|
|
579
|
+
// Azure echoes matchKind back with whatever casing the object was
|
|
580
|
+
// created with: a policy made through the portal returns 'Prefix'.
|
|
581
|
+
// Compared case-sensitively, a human's `refs/heads/` prefix policy
|
|
582
|
+
// stops looking like it covers the default branch and Redline
|
|
583
|
+
// stacks a second policy beside it.
|
|
584
|
+
const matchKind = entry['matchKind'];
|
|
585
|
+
return typeof matchKind === 'string' && matchKind.toLowerCase() === 'prefix'
|
|
586
|
+
? defaultRef.startsWith(refName)
|
|
587
|
+
: refName === defaultRef;
|
|
588
|
+
});
|
|
589
|
+
};
|
|
590
|
+
const describe = (config) => {
|
|
591
|
+
const displayName = config.settings['displayName'];
|
|
592
|
+
return typeof displayName === 'string'
|
|
593
|
+
? `"${displayName}" (policy ${config.id})`
|
|
594
|
+
: `policy ${config.id}`;
|
|
595
|
+
};
|
|
596
|
+
const repoPropertyOutcome = {
|
|
597
|
+
capability: 'repo-property',
|
|
598
|
+
status: 'unsupported',
|
|
599
|
+
detail: 'Azure DevOps has no repository properties — the central registry tracks this repo instead',
|
|
600
|
+
};
|
|
601
|
+
const existing = await client.request('GET', `/${proj}/_apis/policy/configurations`);
|
|
602
|
+
if (!isSuccess(existing.status)) {
|
|
603
|
+
// Denied or unsupported — never guess whether a Redline policy
|
|
604
|
+
// already exists, and never crash on it. Skip the write entirely;
|
|
605
|
+
// onboarding still continues below.
|
|
606
|
+
return {
|
|
607
|
+
outcomes: [outcome('merge-policy', existing.status, 'branch policies'), repoPropertyOutcome],
|
|
608
|
+
policy: null,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
const configs = parsePolicyConfigurations(existing.body);
|
|
612
|
+
if (configs === null) {
|
|
613
|
+
throw new RedlineError('host', 'Azure DevOps returned an unexpected shape for policy configurations');
|
|
614
|
+
}
|
|
615
|
+
const onBranch = configs.filter(coversDefaultBranch);
|
|
616
|
+
const gate = gateBuild;
|
|
617
|
+
const registeredGate = gate !== null && gate.registered ? gate : null;
|
|
618
|
+
// Security property: a blocking redline/gate Status policy with no
|
|
619
|
+
// pipeline able to publish that status blocks every pull request
|
|
620
|
+
// forever, so blocking survives only when something can actually queue
|
|
621
|
+
// the gate. Three ways it can: installGate has not run in this process
|
|
622
|
+
// (applyPolicy called standalone — the caller's choice stands), this
|
|
623
|
+
// run registered or confirmed the definition, or the repository already
|
|
624
|
+
// carries a Redline Build Validation policy from an earlier run. That
|
|
625
|
+
// last case is what keeps a token which cannot read build definitions
|
|
626
|
+
// from silently downgrading a working, enforcing gate to advisory.
|
|
627
|
+
const gateAlreadyEnforcing = onBranch.some(isRedlineBuildPolicy);
|
|
628
|
+
const blocking = policy.blocking && (gate === null || registeredGate !== null || gateAlreadyEnforcing);
|
|
629
|
+
const minimumReviewersTypeId = requiredTypeId(types, POLICY_TYPE_NAMES.minimumReviewers);
|
|
630
|
+
const commentsTypeId = requiredTypeId(types, POLICY_TYPE_NAMES.comments);
|
|
631
|
+
// types is guaranteed to carry every POLICY_TYPE_NAMES entry:
|
|
632
|
+
// resolvePolicyTypeIds always seeds it from POLICY_TYPE_FALLBACK first.
|
|
633
|
+
const wanted = [
|
|
634
|
+
{
|
|
635
|
+
typeId: minimumReviewersTypeId,
|
|
636
|
+
mine: isRedlineOwned,
|
|
637
|
+
oneSettingPerBranch: true,
|
|
638
|
+
what: 'the minimum reviewer count',
|
|
639
|
+
config: () => ({
|
|
640
|
+
type: { id: minimumReviewersTypeId },
|
|
641
|
+
isEnabled: true,
|
|
642
|
+
isBlocking: true,
|
|
643
|
+
settings: {
|
|
644
|
+
minimumApproverCount: policy.requiredApprovals,
|
|
645
|
+
creatorVoteCounts: false,
|
|
646
|
+
resetOnSourcePush: policy.dismissStaleReviews,
|
|
647
|
+
blockLastPusherVote: true,
|
|
648
|
+
displayName: `${REDLINE_POLICY_MARKER} minimum reviewers`,
|
|
649
|
+
scope,
|
|
650
|
+
},
|
|
651
|
+
}),
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
typeId: commentsTypeId,
|
|
655
|
+
mine: isRedlineOwned,
|
|
656
|
+
oneSettingPerBranch: true,
|
|
657
|
+
what: 'comment resolution',
|
|
658
|
+
config: () => ({
|
|
659
|
+
type: { id: commentsTypeId },
|
|
660
|
+
isEnabled: policy.requireThreadResolution,
|
|
661
|
+
isBlocking: policy.requireThreadResolution,
|
|
662
|
+
settings: { displayName: `${REDLINE_POLICY_MARKER} comment resolution`, scope },
|
|
663
|
+
}),
|
|
664
|
+
},
|
|
665
|
+
// The Build Validation policy is what actually queues the gate
|
|
666
|
+
// pipeline on a pull request (Azure Repos ignores YAML `pr:`
|
|
667
|
+
// triggers) — written only against a definition installGate confirmed
|
|
668
|
+
// or registered, never against a guess, and written BEFORE the Status
|
|
669
|
+
// policy so that a rejected write can still hold the gate advisory.
|
|
670
|
+
//
|
|
671
|
+
// DO NOT ADD `oneSettingPerBranch: true` HERE. Azure keys Build
|
|
672
|
+
// Validation policies by buildDefinitionId and several coexist on one
|
|
673
|
+
// branch, so a human's Build policy is a neighbour, not the same
|
|
674
|
+
// control. Backing off beside it would leave the repository with a
|
|
675
|
+
// blocking `redline/gate` Status policy (written below, after this
|
|
676
|
+
// entry) and nothing able to publish that status — every pull request
|
|
677
|
+
// blocked forever. The back-off branch in the loop below deliberately
|
|
678
|
+
// does not degrade `effectiveBlocking`, because this entry is the one
|
|
679
|
+
// case where it would matter and this entry must never reach it.
|
|
680
|
+
...(registeredGate === null
|
|
681
|
+
? []
|
|
682
|
+
: [
|
|
683
|
+
{
|
|
684
|
+
typeId: buildTypeId,
|
|
685
|
+
mine: isRedlineBuildPolicy,
|
|
686
|
+
runsTheGate: true,
|
|
687
|
+
what: 'the gate build',
|
|
688
|
+
config: (blocking) => ({
|
|
689
|
+
type: { id: buildTypeId },
|
|
690
|
+
isEnabled: true,
|
|
691
|
+
isBlocking: blocking,
|
|
692
|
+
settings: {
|
|
693
|
+
buildDefinitionId: registeredGate.definitionId,
|
|
694
|
+
displayName: AZURE_BUILD_POLICY_DISPLAY_NAME,
|
|
695
|
+
validDuration: 0,
|
|
696
|
+
queueOnSourceUpdateOnly: true,
|
|
697
|
+
scope,
|
|
698
|
+
},
|
|
699
|
+
}),
|
|
700
|
+
},
|
|
701
|
+
]),
|
|
702
|
+
{
|
|
703
|
+
typeId: statusTypeId,
|
|
704
|
+
mine: isRedlineStatusPolicy,
|
|
705
|
+
what: `the ${AZURE_STATUS_GENRE}/${AZURE_STATUS_NAME} status`,
|
|
706
|
+
config: (blocking) => ({
|
|
707
|
+
type: { id: statusTypeId },
|
|
708
|
+
isEnabled: true,
|
|
709
|
+
// Advisory is native on Azure: isBlocking mirrors the (possibly
|
|
710
|
+
// degraded) blocking choice directly, no rule needs omitting the
|
|
711
|
+
// way GitHub's does.
|
|
712
|
+
isBlocking: blocking,
|
|
713
|
+
settings: {
|
|
714
|
+
statusName: AZURE_STATUS_NAME,
|
|
715
|
+
statusGenre: AZURE_STATUS_GENRE,
|
|
716
|
+
authorId: null,
|
|
717
|
+
invalidateOnSourceUpdate: true,
|
|
718
|
+
displayName: `${REDLINE_POLICY_MARKER} gate status`,
|
|
719
|
+
scope,
|
|
720
|
+
},
|
|
721
|
+
}),
|
|
722
|
+
},
|
|
723
|
+
];
|
|
724
|
+
let effectiveBlocking = blocking;
|
|
725
|
+
const results = [];
|
|
726
|
+
for (const wantedPolicy of wanted) {
|
|
727
|
+
const sameType = onBranch.filter((c) => c.type.id === wantedPolicy.typeId);
|
|
728
|
+
const match = sameType.find(wantedPolicy.mine);
|
|
729
|
+
const humanOwned = match === undefined && wantedPolicy.oneSettingPerBranch === true ? sameType[0] : undefined;
|
|
730
|
+
if (humanOwned) {
|
|
731
|
+
results.push({
|
|
732
|
+
capability: 'merge-policy',
|
|
733
|
+
status: 'already',
|
|
734
|
+
detail: `branch policies — ${describe(humanOwned)} already sets ${wantedPolicy.what} on ` +
|
|
735
|
+
`${defaultRef} and carries no "${REDLINE_POLICY_MARKER}" marker, so it is human-owned ` +
|
|
736
|
+
`and was left untouched; Redline wrote none of its own`,
|
|
737
|
+
});
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
const config = wantedPolicy.config(effectiveBlocking);
|
|
741
|
+
const method = match ? 'PUT' : 'POST';
|
|
742
|
+
const path = match
|
|
743
|
+
? `/${proj}/_apis/policy/configurations/${match.id}`
|
|
744
|
+
: `/${proj}/_apis/policy/configurations`;
|
|
745
|
+
const res = await client.request(method, path, config);
|
|
746
|
+
// Parity with github/install.ts's 422 guard, for the same reason: a
|
|
747
|
+
// rejected payload is a Redline bug or a project-settings conflict,
|
|
748
|
+
// and reporting it as a capability outcome makes it `merge-policy:
|
|
749
|
+
// denied` — "an administrator must still enable: merge-policy", exit
|
|
750
|
+
// 0 — which sends an operator to check permissions that are already
|
|
751
|
+
// correct for a defect only Redline can fix.
|
|
752
|
+
if (res.status === 400) {
|
|
753
|
+
throw new RedlineError('host', `Azure DevOps rejected the Redline branch policy payload (HTTP 400 on ${method} ${path})`);
|
|
754
|
+
}
|
|
755
|
+
results.push(outcome('merge-policy', res.status, 'branch policies'));
|
|
756
|
+
if (wantedPolicy.runsTheGate === true && !isSuccess(res.status))
|
|
757
|
+
effectiveBlocking = false;
|
|
758
|
+
}
|
|
759
|
+
const mergePolicy = worstOutcome(results);
|
|
760
|
+
// "Nothing was refused", not "every write was a create". `already` is
|
|
761
|
+
// how a settled branch reports — a human-owned policy Redline
|
|
762
|
+
// deliberately left alone — and reading it as failure zeroed the policy
|
|
763
|
+
// the run reports, which then read back as no policy at all.
|
|
764
|
+
const nothingRefused = results.every((o) => o.status === 'applied' || o.status === 'already');
|
|
765
|
+
return {
|
|
766
|
+
outcomes: [mergePolicy, repoPropertyOutcome],
|
|
767
|
+
// The applied policy reports what was actually written: when nothing
|
|
768
|
+
// could queue the gate, blocking was degraded to advisory.
|
|
769
|
+
policy: nothingRefused ? { ...policy, blocking: effectiveBlocking } : null,
|
|
770
|
+
};
|
|
771
|
+
},
|
|
772
|
+
async installGate(ref, cwd, opts, check = false) {
|
|
773
|
+
const files = [];
|
|
774
|
+
refuseForeignGateFile(cwd, opts);
|
|
775
|
+
const pipeline = readFileSync(join(PACKAGE_ROOT, 'platforms/azure/gate-template.yml'), 'utf8')
|
|
776
|
+
.replace(/ADR_DIFF_THRESHOLD: \d+/, `ADR_DIFF_THRESHOLD: ${opts.adrDiffThreshold}`)
|
|
777
|
+
.replace(/FAIL_ON_DEPENDENCY_SEVERITY: \w+/, `FAIL_ON_DEPENDENCY_SEVERITY: ${opts.failOnDependencySeverity}`)
|
|
778
|
+
.replace(/SOFT_FAIL_LABELS: .*/, `SOFT_FAIL_LABELS: ${opts.softFailLabels.join(',')}`);
|
|
779
|
+
// `redlinegate@latest` inside a template installed across every
|
|
780
|
+
// onboarded repository means any npm publish changes org-wide gate
|
|
781
|
+
// behaviour with no pull request anywhere. Pin the version that wrote
|
|
782
|
+
// the file, so a CLI upgrade arrives as a reviewable sync PR.
|
|
783
|
+
const pinned = cliVersion === UNPUBLISHED_VERSION
|
|
784
|
+
? pipeline
|
|
785
|
+
: pipeline.replace('redlinegate@latest', `redlinegate@${cliVersion}`);
|
|
786
|
+
if (syncFile(cwd, '.azuredevops/redline-gate.yml', pinned, check)) {
|
|
787
|
+
files.push('.azuredevops/redline-gate.yml');
|
|
788
|
+
}
|
|
789
|
+
const template = readFileSync(join(PACKAGE_ROOT, 'templates/azure/pull_request_template.md'), 'utf8');
|
|
790
|
+
const prTemplate = syncPullRequestTemplate(cwd, '.azuredevops/pull_request_template.md', template, check);
|
|
791
|
+
files.push(...prTemplate.files);
|
|
792
|
+
if (check)
|
|
793
|
+
return { files, outcomes: [] };
|
|
794
|
+
// Azure Repos ignores the YAML `pr:` trigger, so writing the pipeline
|
|
795
|
+
// file alone runs nothing: the definition must be registered so the
|
|
796
|
+
// Build Validation policy (applyPolicy) can queue it on pull requests.
|
|
797
|
+
const gate = await ensureGateBuildDefinition(ref);
|
|
798
|
+
return {
|
|
799
|
+
files,
|
|
800
|
+
// No `labels` outcome here: Azure creates pull request labels on use
|
|
801
|
+
// rather than pre-declaring them, so the capability is only exercised
|
|
802
|
+
// when openPullRequest applies them — and it reports it there.
|
|
803
|
+
//
|
|
804
|
+
// One `gate` outcome, not two. The pipeline definition and the pull
|
|
805
|
+
// request template are both gate machinery, and emitting the capability
|
|
806
|
+
// twice is a trap for the next caller that folds outcomes per
|
|
807
|
+
// capability — worstOutcome keeps one and drops the other's detail.
|
|
808
|
+
// Folded here instead, where both details survive.
|
|
809
|
+
outcomes: [
|
|
810
|
+
{
|
|
811
|
+
capability: 'gate',
|
|
812
|
+
status: worstOutcome([
|
|
813
|
+
gate,
|
|
814
|
+
{
|
|
815
|
+
capability: 'gate',
|
|
816
|
+
status: prTemplate.changed ? 'applied' : 'already',
|
|
817
|
+
detail: prTemplate.detail,
|
|
818
|
+
},
|
|
819
|
+
]).status,
|
|
820
|
+
detail: `${gate.detail}; ${prTemplate.detail}`,
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
};
|
|
824
|
+
},
|
|
825
|
+
// Phase 1 boundary, reported honestly instead of half-done. GitHub takes
|
|
826
|
+
// team *slugs* in a CODEOWNERS file; Azure takes reviewer *identity
|
|
827
|
+
// GUIDs* in a policy. cli/commands/init.ts has only slugs, so every
|
|
828
|
+
// policy this used to POST named a reviewer id that does not exist on
|
|
829
|
+
// Azure — and it POSTed unconditionally, with no filter on the existing
|
|
830
|
+
// configurations, so each re-run of `redline init` added another blocking
|
|
831
|
+
// required-reviewer policy per rule. Until an identity lookup exists
|
|
832
|
+
// (Task 17) this reports `unsupported`: the capability does not exist on
|
|
833
|
+
// this host yet, which is not pending administrator work.
|
|
834
|
+
async ensureReviewOwnership(_ref, _cwd, rules) {
|
|
835
|
+
if (rules.length === 0)
|
|
836
|
+
return { files: [], outcomes: [] };
|
|
837
|
+
return {
|
|
838
|
+
files: [],
|
|
839
|
+
outcomes: [
|
|
840
|
+
{
|
|
841
|
+
capability: 'review-ownership',
|
|
842
|
+
status: 'unsupported',
|
|
843
|
+
detail: 'Azure DevOps required-reviewer policies take identity GUIDs, not team slugs — ' +
|
|
844
|
+
'set them by hand under Project settings > Repositories > Policies',
|
|
845
|
+
},
|
|
846
|
+
],
|
|
847
|
+
};
|
|
848
|
+
},
|
|
849
|
+
async openPullRequest(ref, cwd, change) {
|
|
850
|
+
const git = gitFor(cwd);
|
|
851
|
+
// `git commit -m` commits the WHOLE index, so anything the user staged
|
|
852
|
+
// before running redline would be swept into the onboarding PR.
|
|
853
|
+
// Refusing beats `commit -- <paths>`: a partial-index commit surprises
|
|
854
|
+
// in the opposite direction.
|
|
855
|
+
if (git.hasStagedChanges()) {
|
|
856
|
+
throw new RedlineError('usage', 'this repository already has staged changes', 'commit or unstage them first, then re-run — redline will not sweep them into its onboarding pull request');
|
|
857
|
+
}
|
|
858
|
+
const originalBranch = git.currentBranch();
|
|
859
|
+
git.checkoutNewBranch(change.branch);
|
|
860
|
+
let gitFailed = false;
|
|
861
|
+
try {
|
|
862
|
+
// Stage only what Redline itself wrote — never sweep in pre-existing
|
|
863
|
+
// dirty or untracked state from the working tree.
|
|
864
|
+
git.stagePaths(change.files);
|
|
865
|
+
if (!git.hasStagedChanges()) {
|
|
866
|
+
// Already onboarded and nothing changed: a legitimate no-op, not
|
|
867
|
+
// an error — the caller reports it instead of a pull request.
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
git.commit(change.title);
|
|
871
|
+
git.push(change.branch);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
gitFailed = true;
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
// Leave the operator on their own branch, never on redline/onboard.
|
|
879
|
+
// When a git step failed its error already says where the work sits,
|
|
880
|
+
// and a failed restore must not mask it.
|
|
881
|
+
try {
|
|
882
|
+
git.checkoutBranch(originalBranch);
|
|
883
|
+
}
|
|
884
|
+
catch (restoreError) {
|
|
885
|
+
if (!gitFailed)
|
|
886
|
+
throw restoreError;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const created = await client.request('POST', `/${project(ref)}/_apis/git/repositories/${repoId(ref)}/pullrequests`, {
|
|
890
|
+
sourceRefName: `refs/heads/${change.branch}`,
|
|
891
|
+
targetRefName: `refs/heads/${ref.defaultBranch}`,
|
|
892
|
+
title: change.title,
|
|
893
|
+
description: change.body,
|
|
894
|
+
});
|
|
895
|
+
const id = isSuccess(created.status) ? parseCreatedPullRequestId(created.body) : null;
|
|
896
|
+
if (id === null) {
|
|
897
|
+
throw new RedlineError('host', `could not open a pull request (HTTP ${created.status})`);
|
|
898
|
+
}
|
|
899
|
+
// Labels come after creation on Azure (there is no field on the create
|
|
900
|
+
// body), and the gate reads them: the onboarding pull request must
|
|
901
|
+
// carry `redline-sync` or the first gate run fails on a repository that
|
|
902
|
+
// has not adopted the standard yet. A refused label is still not worth
|
|
903
|
+
// losing the pull request over — it degrades into an outcome.
|
|
904
|
+
const labelOutcomes = [];
|
|
905
|
+
for (const label of change.labels) {
|
|
906
|
+
const applied = await client.request('POST', `/${project(ref)}/_apis/git/repositories/${repoId(ref)}/pullRequests/${id}/labels`, { name: label });
|
|
907
|
+
labelOutcomes.push(outcome('labels', applied.status, `pull request label "${label}"`));
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
number: id,
|
|
911
|
+
url: `https://dev.azure.com/${ref.org}/${project(ref)}/_git/${ref.repo}/pullrequest/${id}`,
|
|
912
|
+
...(labelOutcomes.length > 0 ? { outcomes: [worstOutcome(labelOutcomes)] } : {}),
|
|
913
|
+
};
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
//# sourceMappingURL=install.js.map
|