@mindlace/chreode 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -2
  3. package/dist/burn-in/run.d.ts +18 -0
  4. package/dist/burn-in/run.js +71 -0
  5. package/dist/burn-in/select.d.ts +30 -0
  6. package/dist/burn-in/select.js +78 -0
  7. package/dist/burn-in/step.d.ts +10 -0
  8. package/dist/burn-in/step.js +14 -0
  9. package/dist/cli.d.ts +37 -0
  10. package/dist/cli.js +121 -0
  11. package/dist/config/load.d.ts +5 -0
  12. package/dist/config/load.js +13 -0
  13. package/dist/config/types.d.ts +114 -0
  14. package/dist/config/types.js +19 -0
  15. package/dist/config/validate.d.ts +9 -0
  16. package/dist/config/validate.js +278 -0
  17. package/dist/flakes/gh.d.ts +5 -0
  18. package/dist/flakes/gh.js +74 -0
  19. package/dist/flakes/ledger.d.ts +53 -0
  20. package/dist/flakes/ledger.js +108 -0
  21. package/dist/flakes/log.d.ts +39 -0
  22. package/dist/flakes/log.js +89 -0
  23. package/dist/flakes/phase.d.ts +34 -0
  24. package/dist/flakes/phase.js +66 -0
  25. package/dist/flakes/report.d.ts +14 -0
  26. package/dist/flakes/report.js +45 -0
  27. package/dist/git/change-set.d.ts +18 -0
  28. package/dist/git/change-set.js +42 -0
  29. package/dist/git/merge.d.ts +21 -0
  30. package/dist/git/merge.js +33 -0
  31. package/dist/github/protect.d.ts +20 -0
  32. package/dist/github/protect.js +90 -0
  33. package/dist/github/status.d.ts +24 -0
  34. package/dist/github/status.js +54 -0
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +2 -0
  37. package/dist/lock/machine-lock.d.ts +40 -0
  38. package/dist/lock/machine-lock.js +76 -0
  39. package/dist/reporters/options.d.ts +17 -0
  40. package/dist/reporters/options.js +18 -0
  41. package/dist/reporters/playwright.d.ts +15 -0
  42. package/dist/reporters/playwright.js +47 -0
  43. package/dist/reporters/vitest.d.ts +15 -0
  44. package/dist/reporters/vitest.js +47 -0
  45. package/dist/run/exec.d.ts +8 -0
  46. package/dist/run/exec.js +15 -0
  47. package/dist/run/stderr.d.ts +22 -0
  48. package/dist/run/stderr.js +30 -0
  49. package/dist/run/step.d.ts +55 -0
  50. package/dist/run/step.js +182 -0
  51. package/dist/signoff/plan.d.ts +50 -0
  52. package/dist/signoff/plan.js +112 -0
  53. package/dist/signoff/preconditions.d.ts +12 -0
  54. package/dist/signoff/preconditions.js +59 -0
  55. package/dist/signoff/run.d.ts +41 -0
  56. package/dist/signoff/run.js +226 -0
  57. package/dist/signoff/tail.d.ts +32 -0
  58. package/dist/signoff/tail.js +55 -0
  59. package/package.json +92 -4
@@ -0,0 +1,13 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createJiti } from 'jiti';
3
+ import { ConfigError, validateConfig } from './validate.js';
4
+ export const CONFIG_FILE = 'chreode.config.ts';
5
+ /** Load and validate a config file. jiti transpiles TypeScript on the fly, so consumers
6
+ * need no build step and no tsx. */
7
+ export async function loadConfig(file) {
8
+ if (!existsSync(file))
9
+ throw new ConfigError([`no config file at ${file}`]);
10
+ const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false });
11
+ const mod = await jiti.import(file, { default: true });
12
+ return validateConfig(mod);
13
+ }
@@ -0,0 +1,114 @@
1
+ /** A path matcher: a picomatch glob (dotfiles match) or a RegExp tested against the
2
+ * repo-relative POSIX path. */
3
+ export type Matcher = string | RegExp;
4
+ /** What a step's `run` function and `if` predicate see. */
5
+ export interface StepContext {
6
+ base: string;
7
+ head: string;
8
+ paths: readonly string[];
9
+ triggers: readonly string[];
10
+ isWorktree: boolean;
11
+ }
12
+ /** First matching rule wins. `triggers: []` means "matched, nothing to run". */
13
+ export interface RuleConfig {
14
+ match: Matcher | Matcher[];
15
+ triggers: string[];
16
+ }
17
+ export interface StepConfig {
18
+ /** Unique, no whitespace. Used by --skip, the note and the status description. */
19
+ label: string;
20
+ run: string | ((ctx: StepContext) => string);
21
+ /** Planned when any listed trigger fired. */
22
+ when: string[];
23
+ if?: (ctx: StepContext) => boolean;
24
+ /** Dropped when any listed step label is also going to run. */
25
+ supersededBy?: string[];
26
+ /** Capture stderr; a clean exit with non-allowlisted stderr fails the step. */
27
+ policeStderr?: boolean;
28
+ /** Hard wall-clock ceiling; the step's process group is SIGKILLed past it. */
29
+ timeoutMs?: number;
30
+ }
31
+ export interface AllowlistEntry {
32
+ /** A stderr line (ANSI-stripped) matching this pattern is benign. */
33
+ pattern: RegExp;
34
+ /** Why this noise is unavoidable. Required: allowlisting is a reviewed decision. */
35
+ reason: string;
36
+ }
37
+ export interface ProtectionConfig {
38
+ /** Shallow-merged over the default branch-protection payload. */
39
+ branch?: Record<string, unknown>;
40
+ /** Shallow-merged over the default repository merge settings. */
41
+ repo?: Record<string, unknown>;
42
+ }
43
+ /** Presence turns flake tracking on: retried-then-passed tests become GitHub issues. */
44
+ export interface FlakesConfig {
45
+ /** Occurrences (the issue itself counts as one) at which an unsnoozed flake fails signoff. */
46
+ threshold?: number;
47
+ /** An issue carrying this label never escalates. */
48
+ snoozeLabel?: string;
49
+ /** Linked from every filed issue. */
50
+ playbookUrl?: string;
51
+ }
52
+ export interface BurnInRunner {
53
+ /** For output. */
54
+ name: string;
55
+ match: Matcher | Matcher[];
56
+ ignore?: Matcher | Matcher[];
57
+ /** A string runs once; an array runs each in sequence (for runners with no repeat flag). */
58
+ command: (files: string[], repeats: number) => string | string[];
59
+ }
60
+ export interface BurnInConfig {
61
+ repeats?: number;
62
+ /** Changed test files beyond this many are listed as skipped, never silently dropped. */
63
+ maxFiles?: number;
64
+ runners: BurnInRunner[];
65
+ }
66
+ export interface ResolvedFlakes {
67
+ threshold: number;
68
+ snoozeLabel: string;
69
+ playbookUrl: string;
70
+ }
71
+ export interface ResolvedBurnIn {
72
+ repeats: number;
73
+ maxFiles: number;
74
+ runners: BurnInRunner[];
75
+ }
76
+ export interface ChreodeConfig {
77
+ baseBranch?: string;
78
+ statusContext?: string;
79
+ rules?: RuleConfig[];
80
+ steps: StepConfig[];
81
+ /** Runs only when the merge of origin/<baseBranch> actually changed the branch. */
82
+ afterMerge?: string;
83
+ stderrAllowlist?: AllowlistEntry[];
84
+ protection?: ProtectionConfig;
85
+ flakes?: FlakesConfig;
86
+ burnIn?: BurnInConfig;
87
+ }
88
+ export interface ResolvedStep extends StepConfig {
89
+ supersededBy: string[];
90
+ policeStderr: boolean;
91
+ timeoutMs: number;
92
+ }
93
+ export interface ResolvedConfig {
94
+ baseBranch: string;
95
+ statusContext: string;
96
+ rules: RuleConfig[];
97
+ steps: ResolvedStep[];
98
+ afterMerge: string | null;
99
+ stderrAllowlist: AllowlistEntry[];
100
+ protection: ProtectionConfig;
101
+ flakes: ResolvedFlakes | null;
102
+ burnIn: ResolvedBurnIn | null;
103
+ }
104
+ export declare const DEFAULT_BASE_BRANCH = "main";
105
+ export declare const DEFAULT_STATUS_CONTEXT = "chreode/signoff";
106
+ export declare const DEFAULT_TIMEOUT_MS: number;
107
+ export declare const DEFAULT_FLAKE_THRESHOLD = 3;
108
+ export declare const DEFAULT_SNOOZE_LABEL = "flake-snoozed";
109
+ export declare const DEFAULT_PLAYBOOK_URL = "https://github.com/mindlace/chreode/blob/main/docs/flakes.md";
110
+ export declare const DEFAULT_BURN_IN_REPEATS = 5;
111
+ export declare const DEFAULT_BURN_IN_MAX_FILES = 12;
112
+ export declare function resolveFlakes(config: FlakesConfig): ResolvedFlakes;
113
+ /** Identity helper so `chreode.config.ts` gets types. */
114
+ export declare function defineConfig(config: ChreodeConfig): ChreodeConfig;
@@ -0,0 +1,19 @@
1
+ export const DEFAULT_BASE_BRANCH = 'main';
2
+ export const DEFAULT_STATUS_CONTEXT = 'chreode/signoff';
3
+ export const DEFAULT_TIMEOUT_MS = 10 * 60_000;
4
+ export const DEFAULT_FLAKE_THRESHOLD = 3;
5
+ export const DEFAULT_SNOOZE_LABEL = 'flake-snoozed';
6
+ export const DEFAULT_PLAYBOOK_URL = 'https://github.com/mindlace/chreode/blob/main/docs/flakes.md';
7
+ export const DEFAULT_BURN_IN_REPEATS = 5;
8
+ export const DEFAULT_BURN_IN_MAX_FILES = 12;
9
+ export function resolveFlakes(config) {
10
+ return {
11
+ threshold: config.threshold ?? DEFAULT_FLAKE_THRESHOLD,
12
+ snoozeLabel: config.snoozeLabel ?? DEFAULT_SNOOZE_LABEL,
13
+ playbookUrl: config.playbookUrl ?? DEFAULT_PLAYBOOK_URL
14
+ };
15
+ }
16
+ /** Identity helper so `chreode.config.ts` gets types. */
17
+ export function defineConfig(config) {
18
+ return config;
19
+ }
@@ -0,0 +1,9 @@
1
+ import { type ResolvedConfig } from './types.js';
2
+ export declare class ConfigError extends Error {
3
+ readonly problems: string[];
4
+ constructor(problems: string[]);
5
+ }
6
+ /** Cross-realm safe: a config evaluated by jiti may not share this realm's RegExp. */
7
+ export declare function isRegExp(v: unknown): v is RegExp;
8
+ export declare function isRecord(v: unknown): v is Record<string, unknown>;
9
+ export declare function validateConfig(raw: unknown): ResolvedConfig;
@@ -0,0 +1,278 @@
1
+ import { DEFAULT_BASE_BRANCH, DEFAULT_BURN_IN_MAX_FILES, DEFAULT_BURN_IN_REPEATS, DEFAULT_STATUS_CONTEXT, DEFAULT_TIMEOUT_MS, resolveFlakes } from './types.js';
2
+ export class ConfigError extends Error {
3
+ problems;
4
+ constructor(problems) {
5
+ super(`invalid chreode config:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
6
+ this.problems = problems;
7
+ this.name = 'ConfigError';
8
+ }
9
+ }
10
+ const TOP_KEYS = new Set([
11
+ 'baseBranch',
12
+ 'statusContext',
13
+ 'rules',
14
+ 'steps',
15
+ 'afterMerge',
16
+ 'stderrAllowlist',
17
+ 'protection',
18
+ 'flakes',
19
+ 'burnIn'
20
+ ]);
21
+ const RULE_KEYS = new Set(['match', 'triggers']);
22
+ const STEP_KEYS = new Set([
23
+ 'label',
24
+ 'run',
25
+ 'when',
26
+ 'if',
27
+ 'supersededBy',
28
+ 'policeStderr',
29
+ 'timeoutMs'
30
+ ]);
31
+ const ALLOW_KEYS = new Set(['pattern', 'reason']);
32
+ const PROTECTION_KEYS = new Set(['branch', 'repo']);
33
+ const FLAKES_KEYS = new Set(['threshold', 'snoozeLabel', 'playbookUrl']);
34
+ const BURN_IN_KEYS = new Set(['repeats', 'maxFiles', 'runners']);
35
+ const RUNNER_KEYS = new Set(['name', 'match', 'ignore', 'command']);
36
+ const MATCHER_PROBLEM = 'must be a glob string, a RegExp, or a non-empty array of them';
37
+ /** Cross-realm safe: a config evaluated by jiti may not share this realm's RegExp. */
38
+ export function isRegExp(v) {
39
+ return Object.prototype.toString.call(v) === '[object RegExp]';
40
+ }
41
+ export function isRecord(v) {
42
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && !isRegExp(v);
43
+ }
44
+ function isNonEmptyString(v) {
45
+ return typeof v === 'string' && v.length > 0;
46
+ }
47
+ function isStringArray(v) {
48
+ return Array.isArray(v) && v.every(isNonEmptyString);
49
+ }
50
+ function isMatcher(v) {
51
+ return isNonEmptyString(v) || isRegExp(v);
52
+ }
53
+ function isMatcherList(v) {
54
+ return Array.isArray(v) ? v.length > 0 && v.every(isMatcher) : isMatcher(v);
55
+ }
56
+ function isPositiveInteger(v) {
57
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
58
+ }
59
+ function unknownKeys(obj, allowed, where, problems) {
60
+ for (const key of Object.keys(obj)) {
61
+ if (!allowed.has(key))
62
+ problems.push(`${where}: unknown key "${key}"`);
63
+ }
64
+ }
65
+ function validateRules(raw, problems) {
66
+ if (raw === undefined)
67
+ return [];
68
+ if (!Array.isArray(raw)) {
69
+ problems.push('config.rules: must be an array');
70
+ return [];
71
+ }
72
+ raw.forEach((rule, i) => {
73
+ const where = `rules[${i}]`;
74
+ if (!isRecord(rule)) {
75
+ problems.push(`${where}: must be an object`);
76
+ return;
77
+ }
78
+ unknownKeys(rule, RULE_KEYS, where, problems);
79
+ if (!isMatcherList(rule.match))
80
+ problems.push(`${where}.match: ${MATCHER_PROBLEM}`);
81
+ if (!Array.isArray(rule.triggers) || !rule.triggers.every(isNonEmptyString)) {
82
+ problems.push(`${where}.triggers: must be an array of trigger names ([] means "matched, run nothing")`);
83
+ }
84
+ });
85
+ return raw;
86
+ }
87
+ function validateSteps(raw, problems) {
88
+ if (!Array.isArray(raw)) {
89
+ problems.push('config.steps: must be an array');
90
+ return [];
91
+ }
92
+ const labels = new Set();
93
+ const out = [];
94
+ raw.forEach((step, i) => {
95
+ const where = `steps[${i}]`;
96
+ if (!isRecord(step)) {
97
+ problems.push(`${where}: must be an object`);
98
+ return;
99
+ }
100
+ unknownKeys(step, STEP_KEYS, where, problems);
101
+ const label = step.label;
102
+ if (typeof label !== 'string' || !/^\S+$/.test(label)) {
103
+ problems.push(`${where}.label: must be non-empty with no whitespace`);
104
+ }
105
+ else if (labels.has(label)) {
106
+ problems.push(`${where}.label: duplicate label "${label}"`);
107
+ }
108
+ else {
109
+ labels.add(label);
110
+ }
111
+ if (!isNonEmptyString(step.run) && typeof step.run !== 'function') {
112
+ problems.push(`${where}.run: must be a non-empty string or a function`);
113
+ }
114
+ if (!isStringArray(step.when) || step.when.length === 0) {
115
+ problems.push(`${where}.when: must be a non-empty array of trigger names`);
116
+ }
117
+ if (step.if !== undefined && typeof step.if !== 'function') {
118
+ problems.push(`${where}.if: must be a function`);
119
+ }
120
+ if (step.supersededBy !== undefined && !isStringArray(step.supersededBy)) {
121
+ problems.push(`${where}.supersededBy: must be an array of step labels`);
122
+ }
123
+ if (step.policeStderr !== undefined && typeof step.policeStderr !== 'boolean') {
124
+ problems.push(`${where}.policeStderr: must be a boolean`);
125
+ }
126
+ const t = step.timeoutMs;
127
+ if (t !== undefined && (typeof t !== 'number' || !Number.isFinite(t) || t <= 0)) {
128
+ problems.push(`${where}.timeoutMs: must be a positive number`);
129
+ }
130
+ out.push({
131
+ ...step,
132
+ supersededBy: isStringArray(step.supersededBy) ? step.supersededBy : [],
133
+ policeStderr: step.policeStderr === true,
134
+ timeoutMs: typeof t === 'number' && t > 0 ? t : DEFAULT_TIMEOUT_MS
135
+ });
136
+ });
137
+ for (const step of out) {
138
+ for (const target of step.supersededBy) {
139
+ if (target === step.label) {
140
+ problems.push(`step "${step.label}": supersededBy names itself`);
141
+ }
142
+ else if (!labels.has(target)) {
143
+ problems.push(`step "${step.label}": supersededBy names unknown step "${target}"`);
144
+ }
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+ function validateAllowlist(raw, problems) {
150
+ if (raw === undefined)
151
+ return [];
152
+ if (!Array.isArray(raw)) {
153
+ problems.push('config.stderrAllowlist: must be an array');
154
+ return [];
155
+ }
156
+ raw.forEach((entry, i) => {
157
+ const where = `stderrAllowlist[${i}]`;
158
+ if (!isRecord(entry)) {
159
+ problems.push(`${where}: must be an object`);
160
+ return;
161
+ }
162
+ unknownKeys(entry, ALLOW_KEYS, where, problems);
163
+ if (!isRegExp(entry.pattern))
164
+ problems.push(`${where}.pattern: must be a RegExp`);
165
+ if (!isNonEmptyString(entry.reason))
166
+ problems.push(`${where}.reason: must be a non-empty string`);
167
+ });
168
+ return raw;
169
+ }
170
+ function validateProtection(raw, problems) {
171
+ if (raw === undefined)
172
+ return {};
173
+ if (!isRecord(raw)) {
174
+ problems.push('config.protection: must be an object');
175
+ return {};
176
+ }
177
+ unknownKeys(raw, PROTECTION_KEYS, 'protection', problems);
178
+ for (const key of ['branch', 'repo']) {
179
+ if (raw[key] !== undefined && !isRecord(raw[key])) {
180
+ problems.push(`protection.${key}: must be an object`);
181
+ }
182
+ }
183
+ return raw;
184
+ }
185
+ function validateFlakes(raw, problems) {
186
+ if (raw === undefined)
187
+ return null;
188
+ if (!isRecord(raw)) {
189
+ problems.push('config.flakes: must be an object ({} turns flake tracking on with the defaults)');
190
+ return null;
191
+ }
192
+ unknownKeys(raw, FLAKES_KEYS, 'flakes', problems);
193
+ if (raw.threshold !== undefined && !isPositiveInteger(raw.threshold)) {
194
+ problems.push('flakes.threshold: must be a positive integer');
195
+ }
196
+ for (const key of ['snoozeLabel', 'playbookUrl']) {
197
+ if (raw[key] !== undefined && !isNonEmptyString(raw[key])) {
198
+ problems.push(`flakes.${key}: must be a non-empty string`);
199
+ }
200
+ }
201
+ return resolveFlakes(raw);
202
+ }
203
+ function validateBurnIn(raw, problems) {
204
+ if (raw === undefined)
205
+ return null;
206
+ if (!isRecord(raw)) {
207
+ problems.push('config.burnIn: must be an object');
208
+ return null;
209
+ }
210
+ unknownKeys(raw, BURN_IN_KEYS, 'burnIn', problems);
211
+ for (const key of ['repeats', 'maxFiles']) {
212
+ if (raw[key] !== undefined && !isPositiveInteger(raw[key])) {
213
+ problems.push(`burnIn.${key}: must be a positive integer`);
214
+ }
215
+ }
216
+ const runners = raw.runners;
217
+ if (!Array.isArray(runners) || runners.length === 0) {
218
+ problems.push('burnIn.runners: must be a non-empty array');
219
+ }
220
+ else {
221
+ runners.forEach((runner, i) => {
222
+ const where = `burnIn.runners[${i}]`;
223
+ if (!isRecord(runner)) {
224
+ problems.push(`${where}: must be an object`);
225
+ return;
226
+ }
227
+ unknownKeys(runner, RUNNER_KEYS, where, problems);
228
+ if (!isNonEmptyString(runner.name))
229
+ problems.push(`${where}.name: must be a non-empty string`);
230
+ if (!isMatcherList(runner.match))
231
+ problems.push(`${where}.match: ${MATCHER_PROBLEM}`);
232
+ if (runner.ignore !== undefined && !isMatcherList(runner.ignore)) {
233
+ problems.push(`${where}.ignore: ${MATCHER_PROBLEM}`);
234
+ }
235
+ if (typeof runner.command !== 'function') {
236
+ problems.push(`${where}.command: must be a function (files, repeats) => string | string[]`);
237
+ }
238
+ });
239
+ }
240
+ return {
241
+ repeats: raw.repeats ?? DEFAULT_BURN_IN_REPEATS,
242
+ maxFiles: raw.maxFiles ?? DEFAULT_BURN_IN_MAX_FILES,
243
+ runners: Array.isArray(runners) ? runners : []
244
+ };
245
+ }
246
+ export function validateConfig(raw) {
247
+ if (!isRecord(raw)) {
248
+ throw new ConfigError([
249
+ 'config must be an object (does chreode.config.ts `export default defineConfig({ ... })`?)'
250
+ ]);
251
+ }
252
+ const problems = [];
253
+ unknownKeys(raw, TOP_KEYS, 'config', problems);
254
+ for (const key of ['baseBranch', 'statusContext', 'afterMerge']) {
255
+ if (raw[key] !== undefined && !isNonEmptyString(raw[key])) {
256
+ problems.push(`config.${key}: must be a non-empty string`);
257
+ }
258
+ }
259
+ const rules = validateRules(raw.rules, problems);
260
+ const steps = validateSteps(raw.steps, problems);
261
+ const stderrAllowlist = validateAllowlist(raw.stderrAllowlist, problems);
262
+ const protection = validateProtection(raw.protection, problems);
263
+ const flakes = validateFlakes(raw.flakes, problems);
264
+ const burnIn = validateBurnIn(raw.burnIn, problems);
265
+ if (problems.length > 0)
266
+ throw new ConfigError(problems);
267
+ return {
268
+ baseBranch: raw.baseBranch ?? DEFAULT_BASE_BRANCH,
269
+ statusContext: raw.statusContext ?? DEFAULT_STATUS_CONTEXT,
270
+ rules,
271
+ steps,
272
+ afterMerge: raw.afterMerge ?? null,
273
+ stderrAllowlist,
274
+ protection,
275
+ flakes,
276
+ burnIn
277
+ };
278
+ }
@@ -0,0 +1,5 @@
1
+ import { type Exec } from '../run/exec.js';
2
+ import { type FlakeLedgerDeps } from './ledger.js';
3
+ /** The ledger's GitHub side over `gh`. Every call is argv (no shell), so test titles and
4
+ * error text reach GitHub verbatim. */
5
+ export declare function ghLedgerDeps(exec?: Exec): FlakeLedgerDeps;
@@ -0,0 +1,74 @@
1
+ import { defaultExec, lines } from '../run/exec.js';
2
+ import { FLAKE_LABEL, OCCURRENCE_MARKER } from './ledger.js';
3
+ const LABEL_DESCRIPTION = 'A test that failed, then passed on retry (chreode)';
4
+ const LABEL_COLOR = 'D93F0B';
5
+ /** The ledger's GitHub side over `gh`. Every call is argv (no shell), so test titles and
6
+ * error text reach GitHub verbatim. */
7
+ export function ghLedgerDeps(exec = defaultExec) {
8
+ let labelReady = false;
9
+ const ensureLabel = () => {
10
+ if (labelReady)
11
+ return;
12
+ const labels = JSON.parse(exec('gh', ['label', 'list', '--limit', '1000', '--json', 'name']));
13
+ if (!labels.some((l) => l.name === FLAKE_LABEL)) {
14
+ exec('gh', [
15
+ 'label',
16
+ 'create',
17
+ FLAKE_LABEL,
18
+ '--description',
19
+ LABEL_DESCRIPTION,
20
+ '--color',
21
+ LABEL_COLOR
22
+ ]);
23
+ }
24
+ labelReady = true;
25
+ };
26
+ return {
27
+ listOpenFlakeIssues() {
28
+ // A label filter, not a title search: it does not depend on search-index lag.
29
+ const raw = JSON.parse(exec('gh', [
30
+ 'issue',
31
+ 'list',
32
+ '--label',
33
+ FLAKE_LABEL,
34
+ '--state',
35
+ 'open',
36
+ '--limit',
37
+ '1000',
38
+ '--json',
39
+ 'number,title,labels,url'
40
+ ]));
41
+ return raw.map((i) => ({
42
+ number: i.number,
43
+ title: i.title,
44
+ url: i.url,
45
+ labels: i.labels.map((l) => l.name)
46
+ }));
47
+ },
48
+ countOccurrenceComments(issue) {
49
+ const raw = JSON.parse(exec('gh', ['issue', 'view', String(issue), '--json', 'comments']));
50
+ return raw.comments.filter((c) => c.body.includes(OCCURRENCE_MARKER)).length;
51
+ },
52
+ createIssue(title, body) {
53
+ ensureLabel();
54
+ const out = exec('gh', [
55
+ 'issue',
56
+ 'create',
57
+ '--title',
58
+ title,
59
+ '--body',
60
+ body,
61
+ '--label',
62
+ FLAKE_LABEL
63
+ ]);
64
+ const url = lines(out).at(-1) ?? '';
65
+ const m = /\/issues\/(\d+)$/.exec(url);
66
+ if (!m)
67
+ throw new Error(`gh issue create printed no issue URL: ${out.trim()}`);
68
+ return { number: Number(m[1]), title, url, labels: [FLAKE_LABEL] };
69
+ },
70
+ commentIssue(issue, body) {
71
+ exec('gh', ['issue', 'comment', String(issue), '--body', body]);
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,53 @@
1
+ import type { ResolvedFlakes } from '../config/types.js';
2
+ import { type FlakeEvent } from './log.js';
3
+ export declare const FLAKE_LABEL = "flake";
4
+ export declare const OCCURRENCE_MARKER = "<!-- chreode:flake-occurrence -->";
5
+ export declare const ISSUE_TITLE_MAX = 256;
6
+ export interface RunContext {
7
+ branch: string;
8
+ sha: string;
9
+ }
10
+ export interface FlakeIssue {
11
+ number: number;
12
+ title: string;
13
+ url: string;
14
+ labels: string[];
15
+ }
16
+ /** The GitHub side, injected: see gh.ts for the real one. */
17
+ export interface FlakeLedgerDeps {
18
+ /** Open issues labelled FLAKE_LABEL. */
19
+ listOpenFlakeIssues(): FlakeIssue[];
20
+ /** Comments on the issue that carry OCCURRENCE_MARKER. */
21
+ countOccurrenceComments(issue: number): number;
22
+ /** Creates an issue labelled FLAKE_LABEL. */
23
+ createIssue(title: string, body: string): FlakeIssue;
24
+ commentIssue(issue: number, body: string): void;
25
+ }
26
+ export interface LedgerEntry {
27
+ number: number;
28
+ title: string;
29
+ url: string;
30
+ /** Occurrences so far: 1 (the issue) + marker comments. */
31
+ count: number;
32
+ }
33
+ export interface LedgerResult {
34
+ filed: LedgerEntry[];
35
+ bumped: LedgerEntry[];
36
+ escalations: LedgerEntry[];
37
+ }
38
+ export declare function firstLine(err: unknown): string;
39
+ /** A GitHub call failed partway: `partial` was recorded, `remaining` was not. */
40
+ export declare class LedgerError extends Error {
41
+ readonly partial: LedgerResult;
42
+ readonly remaining: FlakeEvent[];
43
+ constructor(partial: LedgerResult, remaining: FlakeEvent[], cause: unknown);
44
+ }
45
+ export declare function issueTitle(event: FlakeEvent): string;
46
+ export declare function issueBody(event: FlakeEvent, ctx: RunContext, config: ResolvedFlakes): string;
47
+ export declare function occurrenceBody(event: FlakeEvent, ctx: RunContext): string;
48
+ /**
49
+ * One open issue per flaking test (exact title match); each later sighting adds a marker
50
+ * comment. An issue at `threshold` occurrences without the snooze label is an escalation.
51
+ * Any GitHub failure throws LedgerError naming what was and was not recorded.
52
+ */
53
+ export declare function processFlakeEvents(events: readonly FlakeEvent[], ctx: RunContext, config: ResolvedFlakes, deps: FlakeLedgerDeps): LedgerResult;
@@ -0,0 +1,108 @@
1
+ import { dedupeEvents } from './log.js';
2
+ export const FLAKE_LABEL = 'flake';
3
+ export const OCCURRENCE_MARKER = '<!-- chreode:flake-occurrence -->';
4
+ export const ISSUE_TITLE_MAX = 256;
5
+ export function firstLine(err) {
6
+ return (err instanceof Error ? err.message : String(err)).split('\n')[0] ?? '';
7
+ }
8
+ /** A GitHub call failed partway: `partial` was recorded, `remaining` was not. */
9
+ export class LedgerError extends Error {
10
+ partial;
11
+ remaining;
12
+ constructor(partial, remaining, cause) {
13
+ super(firstLine(cause), { cause });
14
+ this.partial = partial;
15
+ this.remaining = remaining;
16
+ this.name = 'LedgerError';
17
+ }
18
+ }
19
+ export function issueTitle(event) {
20
+ return `flake(${event.kind}): ${event.file} › ${event.title}`.slice(0, ISSUE_TITLE_MAX).trimEnd();
21
+ }
22
+ function longestRun(text) {
23
+ return Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
24
+ }
25
+ /** Inline code span that survives backticks in `text`; keeps @mentions and #refs inert. */
26
+ function codeSpan(text) {
27
+ const fence = '`'.repeat(longestRun(text) + 1);
28
+ const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';
29
+ return `${fence}${pad}${text}${pad}${fence}`;
30
+ }
31
+ /** Fenced block whose fence is longer than any backtick run inside (minimum 3). */
32
+ function codeFence(text) {
33
+ const fence = '`'.repeat(Math.max(3, longestRun(text) + 1));
34
+ return [fence, text, fence];
35
+ }
36
+ function details(event, ctx) {
37
+ const where = event.project === '' ? event.runner : `${event.runner}, project ${event.project}`;
38
+ return [
39
+ `- **Test:** ${codeSpan(`${event.file} › ${event.title}`)} (${where})`,
40
+ `- **Observed:** ${event.at} on \`${ctx.branch}\` @ ${event.sha ?? ctx.sha}`,
41
+ ...(event.tracePath ? [`- **First-attempt trace:** \`${event.tracePath}\``] : []),
42
+ '',
43
+ '**First-attempt error:**',
44
+ ...codeFence(event.firstAttemptError)
45
+ ].join('\n');
46
+ }
47
+ export function issueBody(event, ctx, config) {
48
+ return [
49
+ 'Filed by chreode flake tracking: this test failed, then passed on retry.',
50
+ '',
51
+ details(event, ctx),
52
+ '',
53
+ `A flake is a bug, not noise. Diagnose it with the playbook: ${config.playbookUrl}`,
54
+ `At ${config.threshold} recorded occurrences it fails signoff until fixed; label this issue`,
55
+ `\`${config.snoozeLabel}\` to defer that deliberately.`
56
+ ].join('\n');
57
+ }
58
+ export function occurrenceBody(event, ctx) {
59
+ return [OCCURRENCE_MARKER, '', 'Flaked again.', '', details(event, ctx)].join('\n');
60
+ }
61
+ /**
62
+ * One open issue per flaking test (exact title match); each later sighting adds a marker
63
+ * comment. An issue at `threshold` occurrences without the snooze label is an escalation.
64
+ * Any GitHub failure throws LedgerError naming what was and was not recorded.
65
+ */
66
+ export function processFlakeEvents(events, ctx, config, deps) {
67
+ const result = { filed: [], bumped: [], escalations: [] };
68
+ const unique = dedupeEvents(events);
69
+ if (unique.length === 0)
70
+ return result;
71
+ let open;
72
+ try {
73
+ open = [...deps.listOpenFlakeIssues()];
74
+ }
75
+ catch (err) {
76
+ throw new LedgerError(result, unique, err);
77
+ }
78
+ for (const [i, event] of unique.entries()) {
79
+ try {
80
+ const title = issueTitle(event);
81
+ const existing = open.find((candidate) => candidate.title === title);
82
+ let entry;
83
+ let labels;
84
+ if (existing === undefined) {
85
+ const created = deps.createIssue(title, issueBody(event, ctx, config));
86
+ // A later event whose title truncates to the same string bumps this issue.
87
+ open.push(created);
88
+ entry = { number: created.number, title, url: created.url, count: 1 };
89
+ labels = created.labels;
90
+ result.filed.push(entry);
91
+ }
92
+ else {
93
+ const prior = deps.countOccurrenceComments(existing.number);
94
+ deps.commentIssue(existing.number, occurrenceBody(event, ctx));
95
+ entry = { number: existing.number, title, url: existing.url, count: prior + 2 };
96
+ labels = existing.labels;
97
+ result.bumped.push(entry);
98
+ }
99
+ if (entry.count >= config.threshold && !labels.includes(config.snoozeLabel)) {
100
+ result.escalations.push(entry);
101
+ }
102
+ }
103
+ catch (err) {
104
+ throw new LedgerError(result, unique.slice(i), err);
105
+ }
106
+ }
107
+ return result;
108
+ }