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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/commands/redline-init.md +54 -0
  4. package/commands/redline-verify.md +69 -0
  5. package/dist/bin/redline.js +605 -0
  6. package/dist/bin/redline.js.map +1 -0
  7. package/dist/commands/exempt.js +45 -0
  8. package/dist/commands/exempt.js.map +1 -0
  9. package/dist/commands/init.js +615 -0
  10. package/dist/commands/init.js.map +1 -0
  11. package/dist/commands/policy.js +25 -0
  12. package/dist/commands/policy.js.map +1 -0
  13. package/dist/commands/remove.js +427 -0
  14. package/dist/commands/remove.js.map +1 -0
  15. package/dist/commands/review.js +72 -0
  16. package/dist/commands/review.js.map +1 -0
  17. package/dist/commands/sync.js +42 -0
  18. package/dist/commands/sync.js.map +1 -0
  19. package/dist/commands/verify.js +478 -0
  20. package/dist/commands/verify.js.map +1 -0
  21. package/dist/config/redline-json.js +187 -0
  22. package/dist/config/redline-json.js.map +1 -0
  23. package/dist/core/errors.js +25 -0
  24. package/dist/core/errors.js.map +1 -0
  25. package/dist/core/git.js +152 -0
  26. package/dist/core/git.js.map +1 -0
  27. package/dist/core/log.js +22 -0
  28. package/dist/core/log.js.map +1 -0
  29. package/dist/core/severity.js +16 -0
  30. package/dist/core/severity.js.map +1 -0
  31. package/dist/core/version.js +15 -0
  32. package/dist/core/version.js.map +1 -0
  33. package/dist/detect/scan.js +79 -0
  34. package/dist/detect/scan.js.map +1 -0
  35. package/dist/detect/stack.js +71 -0
  36. package/dist/detect/stack.js.map +1 -0
  37. package/dist/enforce/ladder.js +148 -0
  38. package/dist/enforce/ladder.js.map +1 -0
  39. package/dist/exempt/parse.js +86 -0
  40. package/dist/exempt/parse.js.map +1 -0
  41. package/dist/metrics/options.js +257 -0
  42. package/dist/metrics/options.js.map +1 -0
  43. package/dist/metrics/run.js +72 -0
  44. package/dist/metrics/run.js.map +1 -0
  45. package/dist/platforms/azure/client.js +53 -0
  46. package/dist/platforms/azure/client.js.map +1 -0
  47. package/dist/platforms/azure/index.js +69 -0
  48. package/dist/platforms/azure/index.js.map +1 -0
  49. package/dist/platforms/azure/install.js +917 -0
  50. package/dist/platforms/azure/install.js.map +1 -0
  51. package/dist/platforms/azure/policy-types.js +70 -0
  52. package/dist/platforms/azure/policy-types.js.map +1 -0
  53. package/dist/platforms/azure/verify.js +309 -0
  54. package/dist/platforms/azure/verify.js.map +1 -0
  55. package/dist/platforms/detect.js +40 -0
  56. package/dist/platforms/detect.js.map +1 -0
  57. package/dist/platforms/github/client.js +49 -0
  58. package/dist/platforms/github/client.js.map +1 -0
  59. package/dist/platforms/github/index.js +54 -0
  60. package/dist/platforms/github/index.js.map +1 -0
  61. package/dist/platforms/github/install.js +558 -0
  62. package/dist/platforms/github/install.js.map +1 -0
  63. package/dist/platforms/github/push.js +102 -0
  64. package/dist/platforms/github/push.js.map +1 -0
  65. package/dist/platforms/github/remote.js +36 -0
  66. package/dist/platforms/github/remote.js.map +1 -0
  67. package/dist/platforms/github/verify.js +366 -0
  68. package/dist/platforms/github/verify.js.map +1 -0
  69. package/dist/platforms/http.js +58 -0
  70. package/dist/platforms/http.js.map +1 -0
  71. package/dist/platforms/pull-request-templates.js +157 -0
  72. package/dist/platforms/pull-request-templates.js.map +1 -0
  73. package/dist/platforms/remote.js +21 -0
  74. package/dist/platforms/remote.js.map +1 -0
  75. package/dist/platforms/resolve.js +69 -0
  76. package/dist/platforms/resolve.js.map +1 -0
  77. package/dist/platforms/shape.js +19 -0
  78. package/dist/platforms/shape.js.map +1 -0
  79. package/dist/platforms/types.js +24 -0
  80. package/dist/platforms/types.js.map +1 -0
  81. package/dist/policy/checks.js +91 -0
  82. package/dist/policy/checks.js.map +1 -0
  83. package/dist/policy/diff.js +50 -0
  84. package/dist/policy/diff.js.map +1 -0
  85. package/dist/registry/discover.js +74 -0
  86. package/dist/registry/discover.js.map +1 -0
  87. package/dist/registry/serialize.js +41 -0
  88. package/dist/registry/serialize.js.map +1 -0
  89. package/dist/registry/types.js +2 -0
  90. package/dist/registry/types.js.map +1 -0
  91. package/dist/remove/host.js +294 -0
  92. package/dist/remove/host.js.map +1 -0
  93. package/dist/render/commands.js +139 -0
  94. package/dist/render/commands.js.map +1 -0
  95. package/dist/render/manifest.js +82 -0
  96. package/dist/render/manifest.js.map +1 -0
  97. package/dist/render/markers.js +166 -0
  98. package/dist/render/markers.js.map +1 -0
  99. package/dist/render/profile.js +23 -0
  100. package/dist/render/profile.js.map +1 -0
  101. package/dist/render/standards.js +169 -0
  102. package/dist/render/standards.js.map +1 -0
  103. package/dist/render/vendors.js +199 -0
  104. package/dist/render/vendors.js.map +1 -0
  105. package/dist/review/engines/api.js +95 -0
  106. package/dist/review/engines/api.js.map +1 -0
  107. package/dist/review/engines/embedded.js +20 -0
  108. package/dist/review/engines/embedded.js.map +1 -0
  109. package/dist/review/engines/types.js +2 -0
  110. package/dist/review/engines/types.js.map +1 -0
  111. package/dist/review/glob.js +29 -0
  112. package/dist/review/glob.js.map +1 -0
  113. package/dist/review/prompt.js +45 -0
  114. package/dist/review/prompt.js.map +1 -0
  115. package/dist/review/rules.js +25 -0
  116. package/dist/review/rules.js.map +1 -0
  117. package/dist/review/schema.js +84 -0
  118. package/dist/review/schema.js.map +1 -0
  119. package/dist/review/scope.js +27 -0
  120. package/dist/review/scope.js.map +1 -0
  121. package/dist/sarif/map.js +58 -0
  122. package/dist/sarif/map.js.map +1 -0
  123. package/dist/sarif/parse.js +88 -0
  124. package/dist/sarif/parse.js.map +1 -0
  125. package/dist/sarif/types.js +2 -0
  126. package/dist/sarif/types.js.map +1 -0
  127. package/dist/sync/host.js +21 -0
  128. package/dist/sync/host.js.map +1 -0
  129. package/dist/sync/plan.js +47 -0
  130. package/dist/sync/plan.js.map +1 -0
  131. package/dist/sync/render.js +57 -0
  132. package/dist/sync/render.js.map +1 -0
  133. package/dist/sync/run.js +118 -0
  134. package/dist/sync/run.js.map +1 -0
  135. package/dist/verify/host.js +52 -0
  136. package/dist/verify/host.js.map +1 -0
  137. package/dist/verify/remote.js +146 -0
  138. package/dist/verify/remote.js.map +1 -0
  139. package/package.json +52 -0
  140. package/platforms/azure/gate-template.yml +105 -0
  141. package/rulesets/redline-org-ruleset.json +46 -0
  142. package/rulesets/redline-ruleset.json +49 -0
  143. package/scripts/assign-rule-ids.mjs +129 -0
  144. package/scripts/build-baseline.mjs +124 -0
  145. package/scripts/build-correlation.mjs +74 -0
  146. package/scripts/build-dashboard.mjs +584 -0
  147. package/scripts/build-digest.mjs +164 -0
  148. package/scripts/build-inbox.mjs +133 -0
  149. package/scripts/build-registry.mjs +42 -0
  150. package/scripts/build-roi.mjs +181 -0
  151. package/scripts/check-pins.mjs +88 -0
  152. package/scripts/collect-telemetry.mjs +300 -0
  153. package/scripts/lib/__tests__/baseline.test.mjs +132 -0
  154. package/scripts/lib/__tests__/correlate.test.mjs +131 -0
  155. package/scripts/lib/__tests__/dora.test.mjs +110 -0
  156. package/scripts/lib/__tests__/exemptions.test.mjs +85 -0
  157. package/scripts/lib/__tests__/metrics-scanner.test.mjs +65 -0
  158. package/scripts/lib/__tests__/sarif.test.mjs +125 -0
  159. package/scripts/lib/__tests__/spend.test.mjs +64 -0
  160. package/scripts/lib/baseline.mjs +156 -0
  161. package/scripts/lib/correlate.mjs +125 -0
  162. package/scripts/lib/dora.mjs +133 -0
  163. package/scripts/lib/exemptions.mjs +79 -0
  164. package/scripts/lib/metrics.mjs +172 -0
  165. package/scripts/lib/rules.mjs +107 -0
  166. package/scripts/lib/sarif.mjs +108 -0
  167. package/scripts/lib/spend.mjs +69 -0
  168. package/scripts/measure-context.mjs +101 -0
  169. package/scripts/render-self.mjs +18 -0
  170. package/scripts/score-seeds.mjs +294 -0
  171. package/scripts/validate.mjs +394 -0
  172. package/standards/core.md +124 -0
  173. package/standards/manifest.json +214 -0
  174. package/standards/stacks/csharp.md +29 -0
  175. package/standards/stacks/go.md +30 -0
  176. package/standards/stacks/java.md +30 -0
  177. package/standards/stacks/javascript.md +42 -0
  178. package/standards/stacks/kotlin.md +27 -0
  179. package/standards/stacks/microservices.md +37 -0
  180. package/standards/stacks/nodejs.md +34 -0
  181. package/standards/stacks/python.md +29 -0
  182. package/standards/stacks/react-native.md +29 -0
  183. package/standards/stacks/react.md +46 -0
  184. package/standards/stacks/swift.md +26 -0
  185. package/standards/stacks/terraform.md +27 -0
  186. package/templates/CODEOWNERS +29 -0
  187. package/templates/azure/pull_request_template.md +72 -0
  188. package/templates/github/pull_request_template.md +72 -0
  189. package/templates/redline.yml +35 -0
  190. package/templates/repo-context.md +64 -0
  191. package/workflows/dashboard.yml +119 -0
  192. package/workflows/inbox.yml +97 -0
  193. package/workflows/redline-collect.yml +61 -0
  194. package/workflows/redline-gate.yml +301 -0
  195. package/workflows/redline-sync.yml +79 -0
  196. package/workflows/seed-canary.yml +231 -0
  197. package/workflows/verify-onboarding.yml +115 -0
  198. 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