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,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Central telemetry collector. Runs in the redline-metrics repo on a schedule and pulls
|
|
3
|
+
// review outcomes for merged PRs across the org.
|
|
4
|
+
//
|
|
5
|
+
// This replaces the previous per-repo telemetry workflow, which required a cross-repo
|
|
6
|
+
// write token to be stored as a secret in every onboarded repo. One central read token
|
|
7
|
+
// in one repo has a far smaller blast radius.
|
|
8
|
+
//
|
|
9
|
+
// Vendor-neutral by design: findings are attributed by reviewer login, so Copilot,
|
|
10
|
+
// Claude, Codex or a human reviewer are all measured the same way.
|
|
11
|
+
//
|
|
12
|
+
// SARIF ingestion: where a repository already runs a scanner, its code-scanning
|
|
13
|
+
// alerts are ingested alongside Redline's own findings and put through the same
|
|
14
|
+
// severity contract. Ingested findings are ALWAYS tagged `source: "sarif"` with the
|
|
15
|
+
// producing tool — a view that cannot tell them apart would tune Redline's rules on
|
|
16
|
+
// another tool's noise, which is the one way this makes things worse than not doing
|
|
17
|
+
// it. Redline never runs a scanner and never asks a repository to change which ones
|
|
18
|
+
// it runs; and per the roadmap's open question 4, ingested findings are MEASURED
|
|
19
|
+
// ONLY. They never gate a merge: gating on another tool's output makes Redline
|
|
20
|
+
// responsible for that tool's false positives.
|
|
21
|
+
//
|
|
22
|
+
// Env: GH_TOKEN (read access to org repos + PRs), ORG, [SINCE=YYYY-MM-DD], [DAYS=8],
|
|
23
|
+
// [OUT=data], [DRY_RUN=1], [SKIP_SARIF=1]
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
SEVERITIES,
|
|
29
|
+
loadRules,
|
|
30
|
+
parseFinding,
|
|
31
|
+
isReviewBot,
|
|
32
|
+
emptyBySeverity,
|
|
33
|
+
RESERVED_RULE_IDS,
|
|
34
|
+
} from './lib/rules.mjs';
|
|
35
|
+
import { readExemption } from './lib/exemptions.mjs';
|
|
36
|
+
import { ingestAlerts } from './lib/sarif.mjs';
|
|
37
|
+
|
|
38
|
+
const { GH_TOKEN, ORG, SINCE, DAYS = '8', OUT = 'data', DRY_RUN } = process.env;
|
|
39
|
+
if (!GH_TOKEN || !ORG) throw new Error('GH_TOKEN and ORG are required');
|
|
40
|
+
|
|
41
|
+
const since = SINCE ?? new Date(Date.now() - Number(DAYS) * 86400000).toISOString().slice(0, 10);
|
|
42
|
+
|
|
43
|
+
// The catalogue is optional here: the collector may run in the metrics repo, which has
|
|
44
|
+
// the scripts but not necessarily the standards. Without it, rule ids are still recorded,
|
|
45
|
+
// just not validated.
|
|
46
|
+
let rules = new Map();
|
|
47
|
+
try {
|
|
48
|
+
rules = loadRules();
|
|
49
|
+
} catch {
|
|
50
|
+
console.warn('standards/ not present — rule ids will be recorded without validation');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function graphql(query, variables) {
|
|
54
|
+
const res = await fetch('https://api.github.com/graphql', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${GH_TOKEN}`,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
Accept: 'application/vnd.github+json',
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({ query, variables }),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`graphql ${res.status}: ${await res.text()}`);
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
// Partial errors are common (a repo the token cannot see); only a total failure is fatal.
|
|
66
|
+
if (body.errors?.length && !body.data) throw new Error(JSON.stringify(body.errors));
|
|
67
|
+
if (body.errors?.length) console.warn(` ${body.errors.length} partial GraphQL error(s)`);
|
|
68
|
+
return body.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const QUERY = `
|
|
72
|
+
query($q: String!, $cursor: String) {
|
|
73
|
+
search(query: $q, type: ISSUE, first: 25, after: $cursor) {
|
|
74
|
+
pageInfo { hasNextPage endCursor }
|
|
75
|
+
nodes {
|
|
76
|
+
... on PullRequest {
|
|
77
|
+
number
|
|
78
|
+
mergedAt
|
|
79
|
+
additions
|
|
80
|
+
deletions
|
|
81
|
+
changedFiles
|
|
82
|
+
repository { nameWithOwner }
|
|
83
|
+
author { login }
|
|
84
|
+
title
|
|
85
|
+
body
|
|
86
|
+
# Lead time is first commit to merge. The first commit, not the branch
|
|
87
|
+
# creation: a branch that sat unused for a week did not take a week of
|
|
88
|
+
# lead time, and reporting that it did makes every team look slower than
|
|
89
|
+
# it is.
|
|
90
|
+
commits(first: 1) { nodes { commit { committedDate } } }
|
|
91
|
+
labels(first: 30) { nodes { name } }
|
|
92
|
+
reviewThreads(first: 100) {
|
|
93
|
+
nodes {
|
|
94
|
+
isResolved
|
|
95
|
+
isOutdated
|
|
96
|
+
comments(first: 1) { nodes { body author { login } path } }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}`;
|
|
103
|
+
|
|
104
|
+
function summarise(pr) {
|
|
105
|
+
const findings = emptyBySeverity();
|
|
106
|
+
const resolved = emptyBySeverity();
|
|
107
|
+
const stale = emptyBySeverity();
|
|
108
|
+
const reviewers = new Set();
|
|
109
|
+
// Per-rule outcomes are the point of the rule-id contract: a rule that fires a lot and
|
|
110
|
+
// is never acted on is noise, and that is only visible per rule, not per severity.
|
|
111
|
+
const byRule = new Map();
|
|
112
|
+
let untagged = 0;
|
|
113
|
+
let withoutRuleId = 0;
|
|
114
|
+
let unknownRuleId = 0;
|
|
115
|
+
let human = 0;
|
|
116
|
+
|
|
117
|
+
for (const thread of pr.reviewThreads?.nodes ?? []) {
|
|
118
|
+
const comment = thread.comments?.nodes?.[0];
|
|
119
|
+
if (!comment) continue;
|
|
120
|
+
const login = comment.author?.login ?? '';
|
|
121
|
+
if (!isReviewBot(login)) {
|
|
122
|
+
human += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
reviewers.add(login);
|
|
126
|
+
|
|
127
|
+
const parsed = parseFinding(comment.body ?? '');
|
|
128
|
+
if (!parsed.tagged) untagged += 1;
|
|
129
|
+
if (!parsed.hasRuleId) withoutRuleId += 1;
|
|
130
|
+
if (parsed.hasRuleId && rules.size && !rules.has(parsed.ruleId) && !RESERVED_RULE_IDS.has(parsed.ruleId)) {
|
|
131
|
+
unknownRuleId += 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const severity = parsed.severity.toLowerCase();
|
|
135
|
+
findings[severity] += 1;
|
|
136
|
+
if (thread.isResolved) resolved[severity] += 1;
|
|
137
|
+
// Unresolved and outdated: the author changed the code around it and nobody closed
|
|
138
|
+
// the thread. The strongest signal available that the finding was not worth acting on.
|
|
139
|
+
const ignored = !thread.isResolved && thread.isOutdated;
|
|
140
|
+
if (ignored) stale[severity] += 1;
|
|
141
|
+
|
|
142
|
+
const ruleId = parsed.ruleId ?? '(untagged)';
|
|
143
|
+
const entry = byRule.get(ruleId) ?? { fired: 0, resolved: 0, stale: 0, severity: parsed.severity };
|
|
144
|
+
entry.fired += 1;
|
|
145
|
+
if (thread.isResolved) entry.resolved += 1;
|
|
146
|
+
if (ignored) entry.stale += 1;
|
|
147
|
+
byRule.set(ruleId, entry);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const total = SEVERITIES.reduce((n, s) => n + findings[s.toLowerCase()], 0);
|
|
151
|
+
const totalResolved = SEVERITIES.reduce((n, s) => n + resolved[s.toLowerCase()], 0);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
repo: pr.repository.nameWithOwner,
|
|
155
|
+
pr: pr.number,
|
|
156
|
+
title: pr.title ?? '',
|
|
157
|
+
merged_at: pr.mergedAt,
|
|
158
|
+
// Absent where the API did not return it. Left absent rather than defaulted
|
|
159
|
+
// to the merge time, which would report a lead time of zero for every one of
|
|
160
|
+
// them and drag the median toward a number no team achieved.
|
|
161
|
+
first_commit_at: pr.commits?.nodes?.[0]?.commit?.committedDate ?? null,
|
|
162
|
+
author: pr.author?.login ?? null,
|
|
163
|
+
reviewers: [...reviewers],
|
|
164
|
+
labels: (pr.labels?.nodes ?? []).map((l) => l.name),
|
|
165
|
+
exempted: (pr.labels?.nodes ?? []).some((l) => ['redline-exempt', 'no-adr'].includes(l.name)),
|
|
166
|
+
// The structured exemption, when the pull request carried one. A label says
|
|
167
|
+
// a check was waived; this says who accepted what, why, and until when — the
|
|
168
|
+
// difference between an exemption and an opt-out, and the only version of it
|
|
169
|
+
// that can be audited or trended.
|
|
170
|
+
exemption: readExemption(pr.body),
|
|
171
|
+
size: { additions: pr.additions, deletions: pr.deletions, files: pr.changedFiles },
|
|
172
|
+
findings: { total, ...findings },
|
|
173
|
+
// Acted-on rate. total > 0 and acted_on near 0 means the review is being ignored,
|
|
174
|
+
// which is the noise signal the standards are tuned against.
|
|
175
|
+
outcomes: { resolved: totalResolved, resolved_by_severity: resolved, stale_by_severity: stale },
|
|
176
|
+
rules: Object.fromEntries(byRule),
|
|
177
|
+
human_review_threads: human,
|
|
178
|
+
untagged_findings: untagged,
|
|
179
|
+
findings_without_rule_id: withoutRuleId,
|
|
180
|
+
unknown_rule_ids: unknownRuleId,
|
|
181
|
+
collected_at: new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Code-scanning alerts per repository, fetched once and cached: several merged
|
|
186
|
+
// pull requests share a repository, and the alerts API is per-repo not per-PR.
|
|
187
|
+
const alertCache = new Map();
|
|
188
|
+
async function scannerFindings(repo) {
|
|
189
|
+
if (process.env.SKIP_SARIF) return { findings: [], problems: [] };
|
|
190
|
+
if (alertCache.has(repo)) return alertCache.get(repo);
|
|
191
|
+
|
|
192
|
+
let result = { findings: [], problems: [] };
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(
|
|
195
|
+
`https://api.github.com/repos/${repo}/code-scanning/alerts?per_page=100&state=open`,
|
|
196
|
+
{ headers: { authorization: `Bearer ${GH_TOKEN}`, accept: 'application/vnd.github+json', 'user-agent': 'redline' } }
|
|
197
|
+
);
|
|
198
|
+
// 404 means code scanning is not enabled here, which is a fact about the
|
|
199
|
+
// repository and not a failure of this run. 403 usually means the token
|
|
200
|
+
// cannot see security data — also not a failure worth stopping for.
|
|
201
|
+
if (response.ok) {
|
|
202
|
+
result = ingestAlerts(await response.json(), { where: repo });
|
|
203
|
+
} else if (response.status !== 404 && response.status !== 403) {
|
|
204
|
+
result = { findings: [], problems: [`${repo}: code-scanning alerts returned ${response.status}`] };
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
result = { findings: [], problems: [`${repo}: could not read code-scanning alerts (${error.message})`] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
alertCache.set(repo, result);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const records = [];
|
|
215
|
+
let cursor = null;
|
|
216
|
+
let page = 0;
|
|
217
|
+
do {
|
|
218
|
+
const data = await graphql(QUERY, { q: `org:${ORG} is:pr is:merged merged:>=${since}`, cursor });
|
|
219
|
+
const search = data.search;
|
|
220
|
+
for (const pr of search.nodes) {
|
|
221
|
+
if (!pr?.repository) continue;
|
|
222
|
+
const record = summarise(pr);
|
|
223
|
+
const scanner = await scannerFindings(record.repo);
|
|
224
|
+
for (const problem of scanner.problems) console.warn(` ${problem}`);
|
|
225
|
+
// Kept as its own key, never folded into `findings` or `rules`. Those two are
|
|
226
|
+
// Redline's own catalogue and drive rule tuning; mixing a scanner's rule ids
|
|
227
|
+
// into them is exactly the distortion this design exists to prevent.
|
|
228
|
+
//
|
|
229
|
+
// `repo_scoped` says what this number is: code-scanning alerts are a fact
|
|
230
|
+
// about the REPOSITORY, not about this pull request. Stamping the same count
|
|
231
|
+
// on every merged PR and then summing them multiplied the estate's ingested
|
|
232
|
+
// findings by the number of pull requests per repo — a repo with 12 alerts
|
|
233
|
+
// and 40 merges reported 480. The aggregate counts each repository once.
|
|
234
|
+
record.scanner = {
|
|
235
|
+
repo_scoped: true,
|
|
236
|
+
findings: scanner.findings.length,
|
|
237
|
+
by_tool: scanner.findings.reduce((acc, f) => {
|
|
238
|
+
acc[f.tool] = (acc[f.tool] ?? 0) + 1;
|
|
239
|
+
return acc;
|
|
240
|
+
}, {}),
|
|
241
|
+
by_severity: scanner.findings.reduce((acc, f) => {
|
|
242
|
+
acc[f.severity.toLowerCase()] = (acc[f.severity.toLowerCase()] ?? 0) + 1;
|
|
243
|
+
return acc;
|
|
244
|
+
}, {}),
|
|
245
|
+
};
|
|
246
|
+
records.push(record);
|
|
247
|
+
}
|
|
248
|
+
cursor = search.pageInfo.hasNextPage ? search.pageInfo.endCursor : null;
|
|
249
|
+
page += 1;
|
|
250
|
+
console.log(`page ${page}: ${records.length} PRs so far`);
|
|
251
|
+
// GitHub search caps at 1000 results per query. Past that the window is too wide;
|
|
252
|
+
// shorten DAYS rather than silently under-reporting.
|
|
253
|
+
if (records.length >= 1000) {
|
|
254
|
+
console.warn(`search result cap reached at ${records.length} PRs — narrow DAYS/SINCE, numbers are truncated`);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
} while (cursor);
|
|
258
|
+
|
|
259
|
+
if (DRY_RUN) {
|
|
260
|
+
console.log(JSON.stringify(records.slice(0, 3), null, 2));
|
|
261
|
+
console.log(`dry run: ${records.length} records, nothing written`);
|
|
262
|
+
process.exit(0);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Append idempotently: re-running for an overlapping window must not double-count.
|
|
266
|
+
mkdirSync(OUT, { recursive: true });
|
|
267
|
+
const byMonth = new Map();
|
|
268
|
+
for (const record of records) {
|
|
269
|
+
const month = (record.merged_at ?? '').slice(0, 7);
|
|
270
|
+
if (!month) continue;
|
|
271
|
+
if (!byMonth.has(month)) byMonth.set(month, []);
|
|
272
|
+
byMonth.get(month).push(record);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let written = 0;
|
|
276
|
+
for (const [month, monthRecords] of byMonth) {
|
|
277
|
+
const file = join(OUT, `${month}.jsonl`);
|
|
278
|
+
const existing = new Map();
|
|
279
|
+
if (existsSync(file)) {
|
|
280
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
281
|
+
if (!line.trim()) continue;
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(line);
|
|
284
|
+
existing.set(`${parsed.repo}#${parsed.pr}`, parsed);
|
|
285
|
+
} catch {
|
|
286
|
+
console.warn(` skipping unparseable line in ${file}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const record of monthRecords) existing.set(`${record.repo}#${record.pr}`, record);
|
|
291
|
+
|
|
292
|
+
const sorted = [...existing.values()].sort((a, b) =>
|
|
293
|
+
(a.merged_at ?? '').localeCompare(b.merged_at ?? '')
|
|
294
|
+
);
|
|
295
|
+
writeFileSync(file, sorted.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
296
|
+
written += monthRecords.length;
|
|
297
|
+
console.log(`${file}: ${sorted.length} total records (${monthRecords.length} from this run)`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
console.log(`collected ${written} records since ${since}`);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildBaseline, formatBaseline, measured, unavailable } from '../baseline.mjs';
|
|
4
|
+
|
|
5
|
+
const agg = (over = {}) => ({
|
|
6
|
+
findings: 100,
|
|
7
|
+
resolved: 70,
|
|
8
|
+
blocker: 20,
|
|
9
|
+
repos: 8,
|
|
10
|
+
untagged: 3,
|
|
11
|
+
exempted: 2,
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const input = (over = {}) => ({
|
|
16
|
+
aggregate: agg(),
|
|
17
|
+
windowDays: 90,
|
|
18
|
+
registry: { entries: new Array(10).fill({}) },
|
|
19
|
+
...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('the primary metric is resolved over fired', () => {
|
|
23
|
+
const baseline = buildBaseline(input());
|
|
24
|
+
|
|
25
|
+
assert.equal(baseline.primary.actedOnRate.value, 0.7);
|
|
26
|
+
assert.equal(baseline.primary.actedOnRate.available, true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('an unmeasurable figure is null with a reason, never zero', () => {
|
|
30
|
+
// A zero that means "nobody measured this" reads as a finding, and makes every
|
|
31
|
+
// later comparison look like progress that did not happen.
|
|
32
|
+
const baseline = buildBaseline(input({ registry: null }));
|
|
33
|
+
|
|
34
|
+
assert.equal(baseline.guardrails.coverage.onboarded.value, null);
|
|
35
|
+
assert.equal(baseline.guardrails.coverage.onboarded.available, false);
|
|
36
|
+
assert.match(baseline.guardrails.coverage.onboarded.reason, /registry\.json/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('coverage has no denominator when the register is unreadable', () => {
|
|
40
|
+
const baseline = buildBaseline(input({ registry: null }));
|
|
41
|
+
|
|
42
|
+
assert.equal(baseline.guardrails.coverage.ratio.available, false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('coverage is instrumented over onboarded, so partial coverage cannot read as health', () => {
|
|
46
|
+
const baseline = buildBaseline(input());
|
|
47
|
+
|
|
48
|
+
assert.equal(baseline.guardrails.coverage.ratio.value, 0.8);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('an empty estate has no coverage ratio rather than a divide by zero', () => {
|
|
52
|
+
const baseline = buildBaseline(input({ registry: { entries: [] } }));
|
|
53
|
+
|
|
54
|
+
assert.equal(baseline.guardrails.coverage.ratio.available, false);
|
|
55
|
+
assert.match(baseline.guardrails.coverage.ratio.reason, /no meaning yet/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('acted-on rate is absent, not zero, when nothing fired', () => {
|
|
59
|
+
const baseline = buildBaseline(input({ aggregate: agg({ findings: 0, resolved: 0 }) }));
|
|
60
|
+
|
|
61
|
+
assert.equal(baseline.primary.actedOnRate.available, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("Redline's own merge rate is absent until its pull requests are surveyed", () => {
|
|
65
|
+
const baseline = buildBaseline(input());
|
|
66
|
+
|
|
67
|
+
assert.equal(baseline.guardrails.ownPullRequestMergeRate.available, false);
|
|
68
|
+
assert.match(baseline.guardrails.ownPullRequestMergeRate.reason, /org read access/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Redline's own merge rate is computed once its pull requests are known", () => {
|
|
72
|
+
const baseline = buildBaseline(
|
|
73
|
+
input({ ownPullRequests: [{ merged: true }, { merged: true }, { merged: false }, { merged: false }] })
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
assert.equal(baseline.guardrails.ownPullRequestMergeRate.value, 0.5);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('no pull requests opened yet is absent, not a 0% merge rate', () => {
|
|
80
|
+
// 0% would say the estate is refusing Redline's changes. Nothing has been
|
|
81
|
+
// offered to it.
|
|
82
|
+
const baseline = buildBaseline(input({ ownPullRequests: [] }));
|
|
83
|
+
|
|
84
|
+
assert.equal(baseline.guardrails.ownPullRequestMergeRate.available, false);
|
|
85
|
+
assert.match(baseline.guardrails.ownPullRequestMergeRate.reason, /opened no pull requests/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('findings per week is derived from the window, not assumed weekly', () => {
|
|
89
|
+
const baseline = buildBaseline(input({ windowDays: 90 }));
|
|
90
|
+
|
|
91
|
+
assert.equal(Math.round(baseline.findings.perWeek.value * 100) / 100, 7.78);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('SARIF producers are counted per tool once surveyed', () => {
|
|
95
|
+
const baseline = buildBaseline(
|
|
96
|
+
input({ sarifProducers: { 'a/one': ['CodeQL'], 'a/two': ['CodeQL', 'Semgrep'] } })
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(baseline.sarifProducers.value, { CodeQL: 2, Semgrep: 1 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('cost per BLOCKER needs both halves and says which one is missing', () => {
|
|
103
|
+
const noSpend = buildBaseline(input());
|
|
104
|
+
assert.equal(noSpend.costPerBlockerCaught.available, false);
|
|
105
|
+
assert.match(noSpend.costPerBlockerCaught.reason, /spend unavailable/);
|
|
106
|
+
|
|
107
|
+
const noBlockers = buildBaseline(
|
|
108
|
+
input({ aggregate: agg({ blocker: 0 }), spend: { total: 100, currency: 'USD', grain: 'org' } })
|
|
109
|
+
);
|
|
110
|
+
assert.equal(noBlockers.costPerBlockerCaught.available, false);
|
|
111
|
+
assert.match(noBlockers.costPerBlockerCaught.reason, /nothing to divide/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('cost per BLOCKER is computed when both halves exist', () => {
|
|
115
|
+
const baseline = buildBaseline(
|
|
116
|
+
input({ spend: { total: 400, currency: 'USD', grain: 'org' } })
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
assert.equal(baseline.costPerBlockerCaught.value, 20);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the summary prints a reason for every absent figure, never a dash', () => {
|
|
123
|
+
const text = formatBaseline(buildBaseline(input({ registry: null })));
|
|
124
|
+
|
|
125
|
+
assert.match(text, /repositories onboarded\s+not available — .*registry\.json/);
|
|
126
|
+
assert.equal(text.includes(' — \n'), false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('measured and unavailable are the only two shapes', () => {
|
|
130
|
+
assert.deepEqual(measured(3), { value: 3, available: true });
|
|
131
|
+
assert.deepEqual(unavailable('why'), { value: null, available: false, reason: 'why' });
|
|
132
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { correlate, MIN_SAMPLE } from '../correlate.mjs';
|
|
4
|
+
|
|
5
|
+
const day = (n) => new Date(Date.UTC(2026, 7, n)).toISOString();
|
|
6
|
+
|
|
7
|
+
const pr = (over = {}) => ({
|
|
8
|
+
repo: 'acme/web',
|
|
9
|
+
title: 'feat: something',
|
|
10
|
+
merged_at: day(1),
|
|
11
|
+
rules: {},
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ignoring = (ruleId, count, over = {}) =>
|
|
16
|
+
pr({ rules: { [ruleId]: { severity: 'BLOCKER', fired: count, resolved: 0, stale: count } }, ...over });
|
|
17
|
+
|
|
18
|
+
test('an ignored finding followed by a revert in the same repo is counted', () => {
|
|
19
|
+
const result = correlate([
|
|
20
|
+
ignoring('react/effect-derived-state', MIN_SAMPLE, { merged_at: day(1) }),
|
|
21
|
+
pr({ merged_at: day(2), title: 'Revert "feat: something"' }),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const rule = result.rules.find((r) => r.ruleId === 'react/effect-derived-state');
|
|
25
|
+
assert.equal(rule?.followedByRemediation, MIN_SAMPLE);
|
|
26
|
+
assert.equal(rule?.rate, 1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a revert outside the window does not attach', () => {
|
|
30
|
+
const result = correlate(
|
|
31
|
+
[ignoring('react/x', MIN_SAMPLE, { merged_at: day(1) }), pr({ merged_at: day(28), title: 'Revert "x"' })],
|
|
32
|
+
{ windowDays: 7 }
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
assert.equal(result.rules[0]?.rate, 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a revert in another repository does not attach', () => {
|
|
39
|
+
const result = correlate([
|
|
40
|
+
ignoring('react/x', MIN_SAMPLE, { merged_at: day(1) }),
|
|
41
|
+
pr({ repo: 'acme/api', merged_at: day(2), title: 'Revert "x"' }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
assert.equal(result.rules[0]?.rate, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a rule below the sample threshold gets no rate, and says why', () => {
|
|
48
|
+
// The rate exists arithmetically and means nothing. Publishing it anyway is how
|
|
49
|
+
// a coincidence becomes a rule nobody can argue with.
|
|
50
|
+
const result = correlate([
|
|
51
|
+
ignoring('react/x', 3, { merged_at: day(1) }),
|
|
52
|
+
pr({ merged_at: day(2), title: 'Revert "x"' }),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
assert.equal(result.rules[0]?.rate, null);
|
|
56
|
+
assert.equal(result.rules[0]?.reportable, false);
|
|
57
|
+
assert.match(result.rules[0]?.reason ?? '', /at least 10 are needed/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a remediation's own ignored findings are not evidence about what it remedied", () => {
|
|
61
|
+
// Counting them would let one incident inflate every rule that fired on the fix.
|
|
62
|
+
const result = correlate([
|
|
63
|
+
pr({ merged_at: day(1), title: 'hotfix: patch', rules: { 'react/x': { severity: 'HIGH', stale: 5 } } }),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(result.rules, []);
|
|
67
|
+
assert.equal(result.unattributable, 5);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the verdict refuses when no rule reaches the threshold', () => {
|
|
71
|
+
// The honest output of a weak experiment is "we cannot say".
|
|
72
|
+
const result = correlate([ignoring('react/x', 2, { merged_at: day(1) })]);
|
|
73
|
+
|
|
74
|
+
assert.equal(result.verdict.reportable, false);
|
|
75
|
+
assert.match(result.verdict.reason, /too weak to report/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('nothing ignored is a good result, not a failed experiment', () => {
|
|
79
|
+
const result = correlate([pr()]);
|
|
80
|
+
|
|
81
|
+
assert.equal(result.verdict.reportable, false);
|
|
82
|
+
assert.match(result.verdict.reason, /good result, not a failed experiment/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a reportable verdict carries its caveat and says what was withheld', () => {
|
|
86
|
+
const result = correlate([
|
|
87
|
+
ignoring('react/reported', MIN_SAMPLE, { merged_at: day(1) }),
|
|
88
|
+
ignoring('react/withheld', 2, { merged_at: day(3) }),
|
|
89
|
+
pr({ merged_at: day(2), title: 'Revert "x"' }),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
assert.equal(result.verdict.reportable, true);
|
|
93
|
+
assert.equal(result.verdict.rulesReported, 1);
|
|
94
|
+
assert.equal(result.verdict.rulesWithheld, 1);
|
|
95
|
+
assert.match(result.verdict.caveat, /Correlation, not causation/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rules are ordered with the strongest signal first', () => {
|
|
99
|
+
// A five-day window puts the revert inside `strong`'s window and outside
|
|
100
|
+
// `weak`'s, so the two genuinely differ rather than tying at 1.
|
|
101
|
+
const result = correlate(
|
|
102
|
+
[
|
|
103
|
+
ignoring('react/weak', MIN_SAMPLE, { merged_at: day(1) }),
|
|
104
|
+
ignoring('react/strong', MIN_SAMPLE, { merged_at: day(10) }),
|
|
105
|
+
pr({ merged_at: day(11), title: 'Revert "x"' }),
|
|
106
|
+
],
|
|
107
|
+
{ windowDays: 5 }
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
assert.equal(result.rules[0]?.ruleId, 'react/strong');
|
|
111
|
+
assert.equal(result.rules[0]?.rate, 1);
|
|
112
|
+
assert.equal(result.rules[1]?.rate, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('an unreportable rule sorts below every reportable one, whatever its arithmetic rate', () => {
|
|
116
|
+
const result = correlate([
|
|
117
|
+
ignoring('react/tiny-but-perfect', 2, { merged_at: day(1) }),
|
|
118
|
+
ignoring('react/real', MIN_SAMPLE, { merged_at: day(1) }),
|
|
119
|
+
pr({ merged_at: day(2), title: 'Revert "x"' }),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
assert.equal(result.rules[0]?.ruleId, 'react/real');
|
|
123
|
+
assert.equal(result.rules[1]?.reportable, false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an empty history correlates nothing rather than dividing by zero', () => {
|
|
127
|
+
const result = correlate([]);
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(result.rules, []);
|
|
130
|
+
assert.equal(result.verdict.reportable, false);
|
|
131
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { changeFailureRate, deploymentFrequency, dora, leadTimes, median } from '../dora.mjs';
|
|
4
|
+
|
|
5
|
+
const at = (iso) => new Date(iso).toISOString();
|
|
6
|
+
const pr = (over = {}) => ({
|
|
7
|
+
repo: 'acme/web',
|
|
8
|
+
title: 'feat: something',
|
|
9
|
+
merged_at: at('2026-08-10T12:00:00Z'),
|
|
10
|
+
first_commit_at: at('2026-08-09T12:00:00Z'),
|
|
11
|
+
...over,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('lead time is first commit to merge, in hours', () => {
|
|
15
|
+
assert.deepEqual(leadTimes([pr()]), [24]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('a pull request with no first-commit timestamp is skipped, not counted as zero', () => {
|
|
19
|
+
// Zero lead time would drag the median toward a number no team achieved.
|
|
20
|
+
assert.deepEqual(leadTimes([pr({ first_commit_at: undefined })]), []);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a rebase that moves the commit past the merge is skipped, not reported negative', () => {
|
|
24
|
+
assert.deepEqual(leadTimes([pr({ first_commit_at: at('2026-08-11T12:00:00Z') })]), []);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('the median is reported, because a mean is dragged by one stale branch', () => {
|
|
28
|
+
assert.equal(median([1, 2, 3, 400]), 2.5);
|
|
29
|
+
assert.equal(median([1, 2, 3]), 2);
|
|
30
|
+
assert.equal(median([]), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a revert after a merge counts that merge as a failure', () => {
|
|
34
|
+
const result = changeFailureRate([
|
|
35
|
+
pr({ merged_at: at('2026-08-10T00:00:00Z') }),
|
|
36
|
+
pr({ merged_at: at('2026-08-11T00:00:00Z'), title: 'Revert "feat: something"' }),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
assert.equal(result.failures, 1);
|
|
40
|
+
assert.equal(result.total, 2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the revert itself is not counted as a failure of its own', () => {
|
|
44
|
+
// Otherwise every incident scores twice and the estate looks half-broken.
|
|
45
|
+
const result = changeFailureRate([
|
|
46
|
+
pr({ merged_at: at('2026-08-10T00:00:00Z') }),
|
|
47
|
+
pr({ merged_at: at('2026-08-11T00:00:00Z'), title: 'Revert "feat: something"' }),
|
|
48
|
+
pr({ merged_at: at('2026-08-12T00:00:00Z'), title: 'hotfix: patch it' }),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
assert.equal(result.failures, 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a revert outside the window does not attach to the merge', () => {
|
|
55
|
+
const result = changeFailureRate([
|
|
56
|
+
pr({ merged_at: at('2026-08-01T00:00:00Z') }),
|
|
57
|
+
pr({ merged_at: at('2026-08-20T00:00:00Z'), title: 'Revert "feat: something"' }),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
assert.equal(result.failures, 0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a revert in another repository does not attach', () => {
|
|
64
|
+
const result = changeFailureRate([
|
|
65
|
+
pr({ merged_at: at('2026-08-10T00:00:00Z') }),
|
|
66
|
+
pr({ repo: 'acme/api', merged_at: at('2026-08-11T00:00:00Z'), title: 'Revert "x"' }),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
assert.equal(result.failures, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the rate is null, not zero, when there is nothing to divide', () => {
|
|
73
|
+
const result = changeFailureRate([]);
|
|
74
|
+
|
|
75
|
+
assert.equal(result.rate, null);
|
|
76
|
+
assert.match(result.reason, /no merged pull requests/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the caveat travels with the number, because the number will be quoted', () => {
|
|
80
|
+
const result = changeFailureRate([pr()]);
|
|
81
|
+
|
|
82
|
+
assert.match(result.caveat, /floor, not the true rate/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('deployment frequency is unknown, not zero, without the deployments API', () => {
|
|
86
|
+
// Assuming one deploy per merge reports a trunk-based team and a
|
|
87
|
+
// quarterly-release team as identical — the exact distinction the metric draws.
|
|
88
|
+
const result = deploymentFrequency(null, 90);
|
|
89
|
+
|
|
90
|
+
assert.equal(result.perDay, null);
|
|
91
|
+
assert.match(result.reason, /unknown, not zero/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('deployment frequency is computed when deployments are supplied', () => {
|
|
95
|
+
assert.equal(deploymentFrequency(new Array(90).fill({}), 90).perDay, 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('MTTR is refused by name, so nobody wonders whether it was forgotten', () => {
|
|
99
|
+
const result = dora([pr()]);
|
|
100
|
+
|
|
101
|
+
assert.equal(result.meanTimeToRestore.value, null);
|
|
102
|
+
assert.match(result.meanTimeToRestore.reason, /incident feed/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('lead time reports a reason when nothing in the window carries a timestamp', () => {
|
|
106
|
+
const result = dora([pr({ first_commit_at: undefined })]);
|
|
107
|
+
|
|
108
|
+
assert.equal(result.leadTimeHours.median, null);
|
|
109
|
+
assert.match(result.leadTimeHours.reason, /first-commit timestamp/);
|
|
110
|
+
});
|