@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.
- package/LICENSE +21 -0
- package/README.md +249 -2
- package/dist/burn-in/run.d.ts +18 -0
- package/dist/burn-in/run.js +71 -0
- package/dist/burn-in/select.d.ts +30 -0
- package/dist/burn-in/select.js +78 -0
- package/dist/burn-in/step.d.ts +10 -0
- package/dist/burn-in/step.js +14 -0
- package/dist/cli.d.ts +37 -0
- package/dist/cli.js +121 -0
- package/dist/config/load.d.ts +5 -0
- package/dist/config/load.js +13 -0
- package/dist/config/types.d.ts +114 -0
- package/dist/config/types.js +19 -0
- package/dist/config/validate.d.ts +9 -0
- package/dist/config/validate.js +278 -0
- package/dist/flakes/gh.d.ts +5 -0
- package/dist/flakes/gh.js +74 -0
- package/dist/flakes/ledger.d.ts +53 -0
- package/dist/flakes/ledger.js +108 -0
- package/dist/flakes/log.d.ts +39 -0
- package/dist/flakes/log.js +89 -0
- package/dist/flakes/phase.d.ts +34 -0
- package/dist/flakes/phase.js +66 -0
- package/dist/flakes/report.d.ts +14 -0
- package/dist/flakes/report.js +45 -0
- package/dist/git/change-set.d.ts +18 -0
- package/dist/git/change-set.js +42 -0
- package/dist/git/merge.d.ts +21 -0
- package/dist/git/merge.js +33 -0
- package/dist/github/protect.d.ts +20 -0
- package/dist/github/protect.js +90 -0
- package/dist/github/status.d.ts +24 -0
- package/dist/github/status.js +54 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/lock/machine-lock.d.ts +40 -0
- package/dist/lock/machine-lock.js +76 -0
- package/dist/reporters/options.d.ts +17 -0
- package/dist/reporters/options.js +18 -0
- package/dist/reporters/playwright.d.ts +15 -0
- package/dist/reporters/playwright.js +47 -0
- package/dist/reporters/vitest.d.ts +15 -0
- package/dist/reporters/vitest.js +47 -0
- package/dist/run/exec.d.ts +8 -0
- package/dist/run/exec.js +15 -0
- package/dist/run/stderr.d.ts +22 -0
- package/dist/run/stderr.js +30 -0
- package/dist/run/step.d.ts +55 -0
- package/dist/run/step.js +182 -0
- package/dist/signoff/plan.d.ts +50 -0
- package/dist/signoff/plan.js +112 -0
- package/dist/signoff/preconditions.d.ts +12 -0
- package/dist/signoff/preconditions.js +59 -0
- package/dist/signoff/run.d.ts +41 -0
- package/dist/signoff/run.js +226 -0
- package/dist/signoff/tail.d.ts +32 -0
- package/dist/signoff/tail.js +55 -0
- package/package.json +92 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One retried-then-passed test, as a reporter saw it. */
|
|
2
|
+
export interface FlakeEvent {
|
|
3
|
+
/** Issue-title bucket, e.g. 'e2e', 'unit', 'client'. */
|
|
4
|
+
kind: string;
|
|
5
|
+
runner: 'playwright' | 'vitest';
|
|
6
|
+
/** Runner project name ('' if none). */
|
|
7
|
+
project: string;
|
|
8
|
+
/** Repo-relative, '/'-separated. */
|
|
9
|
+
file: string;
|
|
10
|
+
/** Describe chain joined with ' › '. */
|
|
11
|
+
title: string;
|
|
12
|
+
/** At most ERROR_LINE_CAP lines, ANSI-stripped. */
|
|
13
|
+
firstAttemptError: string;
|
|
14
|
+
/** Playwright trace of the failed attempt. */
|
|
15
|
+
tracePath?: string;
|
|
16
|
+
/** CHREODE_HEAD when run under signoff. */
|
|
17
|
+
sha?: string;
|
|
18
|
+
/** ISO timestamp. */
|
|
19
|
+
at: string;
|
|
20
|
+
}
|
|
21
|
+
/** Repo-relative. Not under test-results/: Playwright wipes its outputDir at start and would
|
|
22
|
+
* delete events an earlier vitest step wrote. */
|
|
23
|
+
export declare const FLAKE_LOG_FILE = ".chreode/flakes.jsonl";
|
|
24
|
+
export declare const ERROR_LINE_CAP = 40;
|
|
25
|
+
export declare function flakeLogPath(env?: NodeJS.ProcessEnv, cwd?: string): string;
|
|
26
|
+
/** jsonl, append-only: sequential writer processes in one run never clobber each other. */
|
|
27
|
+
export declare function appendFlakeEvent(event: FlakeEvent, logPath: string): void;
|
|
28
|
+
export declare function readFlakeEvents(logPath: string): FlakeEvent[];
|
|
29
|
+
export declare function clearFlakeLog(logPath: string): void;
|
|
30
|
+
export declare function writeFlakeEvents(logPath: string, events: readonly FlakeEvent[]): void;
|
|
31
|
+
export declare function flakeKey(event: FlakeEvent): string;
|
|
32
|
+
/** One report per test per run: the same flake seen in two projects counts once. */
|
|
33
|
+
export declare function dedupeEvents(events: readonly FlakeEvent[]): FlakeEvent[];
|
|
34
|
+
export declare function truncateError(message: string | undefined): string;
|
|
35
|
+
/** The nearest ancestor holding `.git`: a directory in a checkout, a file in a worktree. */
|
|
36
|
+
export declare function findRepoRoot(start: string, exists?: (path: string) => boolean): string | null;
|
|
37
|
+
/** Relative to the git root found from the file itself, so a runner started in a subpackage
|
|
38
|
+
* still yields repo paths; '/'-separated. */
|
|
39
|
+
export declare function repoRelative(file: string, cwd?: string, exists?: (path: string) => boolean): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
3
|
+
import { stripAnsi } from '../run/stderr.js';
|
|
4
|
+
/** Repo-relative. Not under test-results/: Playwright wipes its outputDir at start and would
|
|
5
|
+
* delete events an earlier vitest step wrote. */
|
|
6
|
+
export const FLAKE_LOG_FILE = '.chreode/flakes.jsonl';
|
|
7
|
+
export const ERROR_LINE_CAP = 40;
|
|
8
|
+
export function flakeLogPath(env = process.env, cwd = process.cwd()) {
|
|
9
|
+
const fromEnv = env.CHREODE_FLAKE_LOG;
|
|
10
|
+
return fromEnv !== undefined && fromEnv !== '' ? fromEnv : join(cwd, FLAKE_LOG_FILE);
|
|
11
|
+
}
|
|
12
|
+
/** jsonl, append-only: sequential writer processes in one run never clobber each other. */
|
|
13
|
+
export function appendFlakeEvent(event, logPath) {
|
|
14
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
15
|
+
appendFileSync(logPath, `${JSON.stringify(event)}\n`);
|
|
16
|
+
}
|
|
17
|
+
const REQUIRED = ['kind', 'runner', 'project', 'file', 'title', 'firstAttemptError', 'at'];
|
|
18
|
+
function isFlakeEvent(v) {
|
|
19
|
+
if (typeof v !== 'object' || v === null)
|
|
20
|
+
return false;
|
|
21
|
+
const record = v;
|
|
22
|
+
return REQUIRED.every((key) => typeof record[key] === 'string');
|
|
23
|
+
}
|
|
24
|
+
export function readFlakeEvents(logPath) {
|
|
25
|
+
if (!existsSync(logPath))
|
|
26
|
+
return [];
|
|
27
|
+
const events = [];
|
|
28
|
+
for (const line of readFileSync(logPath, 'utf8').split('\n')) {
|
|
29
|
+
if (line.trim() === '')
|
|
30
|
+
continue;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(line);
|
|
33
|
+
if (isFlakeEvent(parsed))
|
|
34
|
+
events.push(parsed);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// An interrupted write: skip it rather than fail the report.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return events;
|
|
41
|
+
}
|
|
42
|
+
export function clearFlakeLog(logPath) {
|
|
43
|
+
rmSync(logPath, { force: true });
|
|
44
|
+
}
|
|
45
|
+
export function writeFlakeEvents(logPath, events) {
|
|
46
|
+
if (events.length === 0) {
|
|
47
|
+
clearFlakeLog(logPath);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
51
|
+
writeFileSync(logPath, events.map((e) => `${JSON.stringify(e)}\n`).join(''));
|
|
52
|
+
}
|
|
53
|
+
export function flakeKey(event) {
|
|
54
|
+
return `${event.kind}/${event.file}/${event.title}`;
|
|
55
|
+
}
|
|
56
|
+
/** One report per test per run: the same flake seen in two projects counts once. */
|
|
57
|
+
export function dedupeEvents(events) {
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
return events.filter((e) => {
|
|
60
|
+
const key = flakeKey(e);
|
|
61
|
+
if (seen.has(key))
|
|
62
|
+
return false;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function truncateError(message) {
|
|
68
|
+
if (message === undefined || message === '')
|
|
69
|
+
return '(first attempt error unavailable)';
|
|
70
|
+
return stripAnsi(message).split('\n').slice(0, ERROR_LINE_CAP).join('\n');
|
|
71
|
+
}
|
|
72
|
+
/** The nearest ancestor holding `.git`: a directory in a checkout, a file in a worktree. */
|
|
73
|
+
export function findRepoRoot(start, exists = existsSync) {
|
|
74
|
+
let dir = start;
|
|
75
|
+
for (;;) {
|
|
76
|
+
if (exists(join(dir, '.git')))
|
|
77
|
+
return dir;
|
|
78
|
+
const parent = dirname(dir);
|
|
79
|
+
if (parent === dir)
|
|
80
|
+
return null;
|
|
81
|
+
dir = parent;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Relative to the git root found from the file itself, so a runner started in a subpackage
|
|
85
|
+
* still yields repo paths; '/'-separated. */
|
|
86
|
+
export function repoRelative(file, cwd = process.cwd(), exists = existsSync) {
|
|
87
|
+
const root = findRepoRoot(dirname(file), exists) ?? cwd;
|
|
88
|
+
return relative(root, file).split(sep).join('/');
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResolvedFlakes } from '../config/types.js';
|
|
2
|
+
import { type FlakeLedgerDeps, type LedgerEntry, type LedgerResult, type RunContext } from './ledger.js';
|
|
3
|
+
export interface FlakePhaseInput {
|
|
4
|
+
logPath: string;
|
|
5
|
+
ctx: RunContext;
|
|
6
|
+
config: ResolvedFlakes;
|
|
7
|
+
ledger: FlakeLedgerDeps;
|
|
8
|
+
log: (m: string) => void;
|
|
9
|
+
warn: (m: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export type FlakePhaseOutcome = {
|
|
12
|
+
kind: 'none';
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'reported';
|
|
15
|
+
result: LedgerResult;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'gh-error';
|
|
18
|
+
message: string;
|
|
19
|
+
partial: LedgerResult;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Report the logged flakes to the ledger. Recorded events leave the log; on a GitHub failure
|
|
23
|
+
* the unrecorded ones stay for the next run (an outage must not block merging, and each event
|
|
24
|
+
* keeps its own sha and time). Escalations are returned, not acted on: the caller decides.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runFlakePhase(input: FlakePhaseInput): FlakePhaseOutcome;
|
|
27
|
+
/** The failure text for escalated flakes; each issue is listed once, at its highest count. */
|
|
28
|
+
export declare function escalationMessage(escalations: readonly LedgerEntry[], config: ResolvedFlakes): string;
|
|
29
|
+
/**
|
|
30
|
+
* The single place a phase outcome becomes a pass/fail decision: the failure message when any
|
|
31
|
+
* flake escalated (including ones recorded before a GitHub error), otherwise undefined.
|
|
32
|
+
* Signoff and `flake-report` both call this.
|
|
33
|
+
*/
|
|
34
|
+
export declare function escalationFailure(outcome: FlakePhaseOutcome, config: ResolvedFlakes): string | undefined;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { LedgerError, processFlakeEvents } from './ledger.js';
|
|
2
|
+
import { clearFlakeLog, readFlakeEvents, writeFlakeEvents } from './log.js';
|
|
3
|
+
function printResult(result, log) {
|
|
4
|
+
for (const f of result.filed)
|
|
5
|
+
log(`[chreode] flake filed: ${f.url} ${f.title}`);
|
|
6
|
+
for (const b of result.bumped) {
|
|
7
|
+
log(`[chreode] flake seen again (${b.count} occurrences): ${b.url} ${b.title}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Report the logged flakes to the ledger. Recorded events leave the log; on a GitHub failure
|
|
12
|
+
* the unrecorded ones stay for the next run (an outage must not block merging, and each event
|
|
13
|
+
* keeps its own sha and time). Escalations are returned, not acted on: the caller decides.
|
|
14
|
+
*/
|
|
15
|
+
export function runFlakePhase(input) {
|
|
16
|
+
const events = readFlakeEvents(input.logPath);
|
|
17
|
+
if (events.length === 0)
|
|
18
|
+
return { kind: 'none' };
|
|
19
|
+
input.log(`\n[chreode] flakes: ${events.length} retried-then-passed result(s) recorded`);
|
|
20
|
+
let result;
|
|
21
|
+
try {
|
|
22
|
+
result = processFlakeEvents(events, input.ctx, input.config, input.ledger);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (!(err instanceof LedgerError))
|
|
26
|
+
throw err;
|
|
27
|
+
printResult(err.partial, input.log);
|
|
28
|
+
writeFlakeEvents(input.logPath, err.remaining);
|
|
29
|
+
input.warn(`[chreode] flakes: a GitHub call failed (${err.message}); kept ` +
|
|
30
|
+
`${err.remaining.length} event(s) in ${input.logPath} for the next run`);
|
|
31
|
+
return { kind: 'gh-error', message: err.message, partial: err.partial };
|
|
32
|
+
}
|
|
33
|
+
clearFlakeLog(input.logPath);
|
|
34
|
+
printResult(result, input.log);
|
|
35
|
+
return { kind: 'reported', result };
|
|
36
|
+
}
|
|
37
|
+
function dedupeEscalations(escalations) {
|
|
38
|
+
const byIssue = new Map();
|
|
39
|
+
for (const e of escalations) {
|
|
40
|
+
const seen = byIssue.get(e.number);
|
|
41
|
+
if (!seen || e.count > seen.count)
|
|
42
|
+
byIssue.set(e.number, e);
|
|
43
|
+
}
|
|
44
|
+
return [...byIssue.values()];
|
|
45
|
+
}
|
|
46
|
+
/** The failure text for escalated flakes; each issue is listed once, at its highest count. */
|
|
47
|
+
export function escalationMessage(escalations, config) {
|
|
48
|
+
const unique = dedupeEscalations(escalations);
|
|
49
|
+
return [
|
|
50
|
+
`\n[chreode] FAILED: ${unique.length} flake(s) reached ${config.threshold} occurrences:`,
|
|
51
|
+
...unique.map((e) => ` ${e.url} (${e.count} occurrences) ${e.title}`),
|
|
52
|
+
`[chreode] Fix each one (playbook: ${config.playbookUrl}), or label its issue`,
|
|
53
|
+
`[chreode] ${config.snoozeLabel} to defer it deliberately.`
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The single place a phase outcome becomes a pass/fail decision: the failure message when any
|
|
58
|
+
* flake escalated (including ones recorded before a GitHub error), otherwise undefined.
|
|
59
|
+
* Signoff and `flake-report` both call this.
|
|
60
|
+
*/
|
|
61
|
+
export function escalationFailure(outcome, config) {
|
|
62
|
+
if (outcome.kind === 'none')
|
|
63
|
+
return undefined;
|
|
64
|
+
const result = outcome.kind === 'reported' ? outcome.result : outcome.partial;
|
|
65
|
+
return result.escalations.length > 0 ? escalationMessage(result.escalations, config) : undefined;
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ResolvedConfig } from '../config/types.js';
|
|
2
|
+
import { type Exec } from '../run/exec.js';
|
|
3
|
+
import type { FlakeLedgerDeps } from './ledger.js';
|
|
4
|
+
export interface FlakeReportDeps {
|
|
5
|
+
exec: Exec;
|
|
6
|
+
ledger: FlakeLedgerDeps;
|
|
7
|
+
logPath: string;
|
|
8
|
+
log: (m: string) => void;
|
|
9
|
+
warn: (m: string) => void;
|
|
10
|
+
error: (m: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/** `chreode flake-report`: the signoff flake phase on its own, for hosted CI or by hand.
|
|
13
|
+
* Without a `flakes` block it uses the defaults: running it is the opt-in. */
|
|
14
|
+
export declare function runFlakeReport(config: ResolvedConfig, overrides?: Partial<FlakeReportDeps>): number;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolveFlakes } from '../config/types.js';
|
|
2
|
+
import { defaultExec } from '../run/exec.js';
|
|
3
|
+
import { ghLedgerDeps } from './gh.js';
|
|
4
|
+
import { flakeLogPath } from './log.js';
|
|
5
|
+
import { escalationFailure, runFlakePhase } from './phase.js';
|
|
6
|
+
/** `chreode flake-report`: the signoff flake phase on its own, for hosted CI or by hand.
|
|
7
|
+
* Without a `flakes` block it uses the defaults: running it is the opt-in. */
|
|
8
|
+
export function runFlakeReport(config, overrides = {}) {
|
|
9
|
+
const d = {
|
|
10
|
+
exec: defaultExec,
|
|
11
|
+
ledger: ghLedgerDeps(),
|
|
12
|
+
logPath: flakeLogPath(),
|
|
13
|
+
log: (m) => console.log(m),
|
|
14
|
+
warn: (m) => console.warn(m),
|
|
15
|
+
error: (m) => console.error(m),
|
|
16
|
+
...overrides
|
|
17
|
+
};
|
|
18
|
+
const flakes = config.flakes ?? resolveFlakes({});
|
|
19
|
+
const sha = d.exec('git', ['rev-parse', 'HEAD']).trim();
|
|
20
|
+
let branch = 'HEAD';
|
|
21
|
+
try {
|
|
22
|
+
branch = d.exec('git', ['symbolic-ref', '-q', '--short', 'HEAD']).trim();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Detached, e.g. a CI checkout of a sha.
|
|
26
|
+
}
|
|
27
|
+
const outcome = runFlakePhase({
|
|
28
|
+
logPath: d.logPath,
|
|
29
|
+
ctx: { branch, sha },
|
|
30
|
+
config: flakes,
|
|
31
|
+
ledger: d.ledger,
|
|
32
|
+
log: d.log,
|
|
33
|
+
warn: d.warn
|
|
34
|
+
});
|
|
35
|
+
if (outcome.kind === 'none') {
|
|
36
|
+
d.log('[chreode] flakes: no flakes recorded');
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const failure = escalationFailure(outcome, flakes);
|
|
40
|
+
if (failure !== undefined) {
|
|
41
|
+
d.error(failure);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Exec } from '../run/exec.js';
|
|
2
|
+
/** Successful signoffs are recorded as notes under refs/notes/chreode. */
|
|
3
|
+
export declare const NOTES_REF = "chreode";
|
|
4
|
+
export interface BaseInfo {
|
|
5
|
+
base: string;
|
|
6
|
+
head: string;
|
|
7
|
+
source: 'note' | 'merge-base';
|
|
8
|
+
}
|
|
9
|
+
export declare function hasNote(sha: string, exec?: Exec): boolean;
|
|
10
|
+
/** The nearest commit on this branch (not on origin/<baseBranch>) carrying a signoff note;
|
|
11
|
+
* else the merge-base with origin/<baseBranch>. */
|
|
12
|
+
export declare function resolveBase(baseBranch: string, exec?: Exec): BaseInfo;
|
|
13
|
+
export declare function resolveRef(ref: string, exec?: Exec): string;
|
|
14
|
+
/** Paths changed between base and head. --no-renames lists both sides of a rename, so a
|
|
15
|
+
* file moved out of a rule's reach still fires that rule; deletions are included. */
|
|
16
|
+
export declare function committedPaths(base: string, head: string, exec?: Exec): string[];
|
|
17
|
+
export declare function isWorktree(exec?: Exec): boolean;
|
|
18
|
+
export declare function writeNote(sha: string, ranLabels: readonly string[], exec?: Exec): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defaultExec, lines } from '../run/exec.js';
|
|
2
|
+
/** Successful signoffs are recorded as notes under refs/notes/chreode. */
|
|
3
|
+
export const NOTES_REF = 'chreode';
|
|
4
|
+
export function hasNote(sha, exec = defaultExec) {
|
|
5
|
+
try {
|
|
6
|
+
exec('git', ['notes', `--ref=${NOTES_REF}`, 'show', sha]);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** The nearest commit on this branch (not on origin/<baseBranch>) carrying a signoff note;
|
|
14
|
+
* else the merge-base with origin/<baseBranch>. */
|
|
15
|
+
export function resolveBase(baseBranch, exec = defaultExec) {
|
|
16
|
+
const head = exec('git', ['rev-parse', 'HEAD']).trim();
|
|
17
|
+
const upstream = `origin/${baseBranch}`;
|
|
18
|
+
for (const sha of lines(exec('git', ['rev-list', '--topo-order', 'HEAD', `^${upstream}`]))) {
|
|
19
|
+
if (hasNote(sha, exec))
|
|
20
|
+
return { base: sha, head, source: 'note' };
|
|
21
|
+
}
|
|
22
|
+
return { base: exec('git', ['merge-base', 'HEAD', upstream]).trim(), head, source: 'merge-base' };
|
|
23
|
+
}
|
|
24
|
+
export function resolveRef(ref, exec = defaultExec) {
|
|
25
|
+
return exec('git', ['rev-parse', '--verify', `${ref}^{commit}`]).trim();
|
|
26
|
+
}
|
|
27
|
+
/** Paths changed between base and head. --no-renames lists both sides of a rename, so a
|
|
28
|
+
* file moved out of a rule's reach still fires that rule; deletions are included. */
|
|
29
|
+
export function committedPaths(base, head, exec = defaultExec) {
|
|
30
|
+
if (base === head)
|
|
31
|
+
return [];
|
|
32
|
+
const out = exec('git', ['diff', '--name-only', '--no-renames', '-z', `${base}..${head}`]);
|
|
33
|
+
return out.split('\0').filter((p) => p !== '');
|
|
34
|
+
}
|
|
35
|
+
export function isWorktree(exec = defaultExec) {
|
|
36
|
+
const [gitDir, commonDir] = lines(exec('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir']));
|
|
37
|
+
return gitDir !== commonDir;
|
|
38
|
+
}
|
|
39
|
+
export function writeNote(sha, ranLabels, exec = defaultExec) {
|
|
40
|
+
const summary = ranLabels.length > 0 ? ranLabels.join(' ') : 'no-op';
|
|
41
|
+
exec('git', ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-m', summary, sha]);
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Exec } from '../run/exec.js';
|
|
2
|
+
export type MergeOutcome = {
|
|
3
|
+
kind: 'skipped';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'up-to-date';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'merged';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'conflict';
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'failed';
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Fold the latest origin/<baseBranch> into the branch so what is signed off is what will
|
|
16
|
+
* land. Merge, never rebase: existing SHAs (and their notes) survive and the push stays a
|
|
17
|
+
* fast-forward. On conflict the merge is left in progress for the user to resolve.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mergeBaseBranch(baseBranch: string, opts: {
|
|
20
|
+
noMerge: boolean;
|
|
21
|
+
}, exec?: Exec, warn?: (m: string) => void): MergeOutcome;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defaultExec } from '../run/exec.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fold the latest origin/<baseBranch> into the branch so what is signed off is what will
|
|
4
|
+
* land. Merge, never rebase: existing SHAs (and their notes) survive and the push stays a
|
|
5
|
+
* fast-forward. On conflict the merge is left in progress for the user to resolve.
|
|
6
|
+
*/
|
|
7
|
+
export function mergeBaseBranch(baseBranch, opts, exec = defaultExec, warn = (m) => console.warn(m)) {
|
|
8
|
+
if (opts.noMerge)
|
|
9
|
+
return { kind: 'skipped' };
|
|
10
|
+
const upstream = `origin/${baseBranch}`;
|
|
11
|
+
try {
|
|
12
|
+
exec('git', ['fetch', 'origin', baseBranch]);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
warn(`[chreode] git fetch origin ${baseBranch} failed (${err.message.trim()}); ` +
|
|
16
|
+
`continuing with the local view of ${upstream}`);
|
|
17
|
+
}
|
|
18
|
+
if (exec('git', ['rev-list', `HEAD..${upstream}`]).trim() === '')
|
|
19
|
+
return { kind: 'up-to-date' };
|
|
20
|
+
try {
|
|
21
|
+
exec('git', ['merge', '--no-edit', upstream]);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
try {
|
|
25
|
+
exec('git', ['rev-parse', '-q', '--verify', 'MERGE_HEAD']);
|
|
26
|
+
return { kind: 'conflict' };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { kind: 'failed', message: err.message.trim() };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { kind: 'merged' };
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config/types.js';
|
|
2
|
+
import { type Exec } from '../run/exec.js';
|
|
3
|
+
import { type RepoInfo } from './status.js';
|
|
4
|
+
export declare const DEFAULT_BRANCH_PROTECTION: Record<string, unknown>;
|
|
5
|
+
export declare const DEFAULT_REPO_SETTINGS: Record<string, unknown>;
|
|
6
|
+
/** Defaults shallow-merged with config.protection; the status context is always required. */
|
|
7
|
+
export declare function protectionPayloads(config: ResolvedConfig): {
|
|
8
|
+
branch: Record<string, unknown>;
|
|
9
|
+
repo: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
export interface ProtectDeps {
|
|
12
|
+
exec: Exec;
|
|
13
|
+
log: (m: string) => void;
|
|
14
|
+
repo: () => RepoInfo;
|
|
15
|
+
}
|
|
16
|
+
/** Idempotently require the signoff status on the base branch and set squash-only merges. */
|
|
17
|
+
export declare function runProtect(config: ResolvedConfig, opts: {
|
|
18
|
+
dryRun: boolean;
|
|
19
|
+
repo: RepoInfo | null;
|
|
20
|
+
}, overrides?: Partial<ProtectDeps>): number;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isRecord } from '../config/validate.js';
|
|
5
|
+
import { defaultExec } from '../run/exec.js';
|
|
6
|
+
import { repoInfo } from './status.js';
|
|
7
|
+
export const DEFAULT_BRANCH_PROTECTION = {
|
|
8
|
+
enforce_admins: true,
|
|
9
|
+
required_pull_request_reviews: {
|
|
10
|
+
dismiss_stale_reviews: true,
|
|
11
|
+
require_code_owner_reviews: false,
|
|
12
|
+
required_approving_review_count: 0
|
|
13
|
+
},
|
|
14
|
+
restrictions: null,
|
|
15
|
+
allow_force_pushes: false,
|
|
16
|
+
allow_deletions: false,
|
|
17
|
+
required_linear_history: true,
|
|
18
|
+
required_conversation_resolution: false
|
|
19
|
+
};
|
|
20
|
+
export const DEFAULT_REPO_SETTINGS = {
|
|
21
|
+
allow_squash_merge: true,
|
|
22
|
+
allow_merge_commit: false,
|
|
23
|
+
allow_rebase_merge: false,
|
|
24
|
+
squash_merge_commit_title: 'PR_TITLE',
|
|
25
|
+
squash_merge_commit_message: 'PR_BODY',
|
|
26
|
+
delete_branch_on_merge: true
|
|
27
|
+
};
|
|
28
|
+
/** Defaults shallow-merged with config.protection; the status context is always required. */
|
|
29
|
+
export function protectionPayloads(config) {
|
|
30
|
+
const override = config.protection.branch ?? {};
|
|
31
|
+
const checks = isRecord(override.required_status_checks) ? override.required_status_checks : {};
|
|
32
|
+
const extra = Array.isArray(checks.contexts)
|
|
33
|
+
? checks.contexts.filter((c) => typeof c === 'string')
|
|
34
|
+
: [];
|
|
35
|
+
return {
|
|
36
|
+
branch: {
|
|
37
|
+
...DEFAULT_BRANCH_PROTECTION,
|
|
38
|
+
...override,
|
|
39
|
+
required_status_checks: {
|
|
40
|
+
strict: true,
|
|
41
|
+
...checks,
|
|
42
|
+
contexts: [...new Set([config.statusContext, ...extra])]
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
repo: { ...DEFAULT_REPO_SETTINGS, ...(config.protection.repo ?? {}) }
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Idempotently require the signoff status on the base branch and set squash-only merges. */
|
|
49
|
+
export function runProtect(config, opts, overrides = {}) {
|
|
50
|
+
const d = {
|
|
51
|
+
exec: defaultExec,
|
|
52
|
+
log: (m) => console.log(m),
|
|
53
|
+
repo: () => repoInfo(defaultExec),
|
|
54
|
+
...overrides
|
|
55
|
+
};
|
|
56
|
+
let repo = opts.repo;
|
|
57
|
+
if (repo === null) {
|
|
58
|
+
try {
|
|
59
|
+
repo = d.repo();
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (!opts.dryRun)
|
|
63
|
+
throw err;
|
|
64
|
+
d.log('[chreode] gh repo view failed; showing payloads for <owner>/<repo>');
|
|
65
|
+
repo = { owner: '<owner>', name: '<repo>' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const { branch, repo: settings } = protectionPayloads(config);
|
|
69
|
+
const apply = (method, path, body) => {
|
|
70
|
+
if (opts.dryRun) {
|
|
71
|
+
d.log(`[dry-run] ${method} ${path}\n${JSON.stringify(body, null, 2)}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const dir = mkdtempSync(join(tmpdir(), 'chreode-protect-'));
|
|
75
|
+
const file = join(dir, 'payload.json');
|
|
76
|
+
writeFileSync(file, JSON.stringify(body));
|
|
77
|
+
try {
|
|
78
|
+
d.exec('gh', ['api', '-X', method, path, '--input', file]);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const target = `${repo.owner}/${repo.name}`;
|
|
85
|
+
d.log(`[chreode] protecting ${target} ${config.baseBranch} (dry-run=${opts.dryRun})`);
|
|
86
|
+
apply('PUT', `repos/${target}/branches/${config.baseBranch}/protection`, branch);
|
|
87
|
+
apply('PATCH', `repos/${target}`, settings);
|
|
88
|
+
d.log(`[chreode] ${config.baseBranch} now requires ${config.statusContext}`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Exec } from '../run/exec.js';
|
|
2
|
+
/** GitHub rejects longer descriptions with a 422; truncate rather than fail the post. */
|
|
3
|
+
export declare const STATUS_DESCRIPTION_MAX = 140;
|
|
4
|
+
/** What made a run less than a full signoff; every part is named in the status. */
|
|
5
|
+
export interface Disclosure {
|
|
6
|
+
/** Steps dropped by --skip. */
|
|
7
|
+
skipped?: readonly string[];
|
|
8
|
+
/** Policed steps that passed only because --allow-stderr waived their stderr. */
|
|
9
|
+
allowedStderr?: readonly string[];
|
|
10
|
+
/** The --base ref, when the diff did not start from the last signoff. */
|
|
11
|
+
base?: string | null;
|
|
12
|
+
}
|
|
13
|
+
/** Honest about --skip, --allow-stderr and --base, so a partial run cannot wear the same
|
|
14
|
+
* green as a full one. */
|
|
15
|
+
export declare function statusDescription(signer: string, disclosure: Disclosure): string;
|
|
16
|
+
export interface RepoInfo {
|
|
17
|
+
owner: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function parseRepo(s: string): RepoInfo;
|
|
21
|
+
export declare function repoInfo(exec?: Exec): RepoInfo;
|
|
22
|
+
export declare function statusArgs(repo: RepoInfo, sha: string, context: string, description: string): string[];
|
|
23
|
+
export declare function postStatus(repo: RepoInfo, sha: string, context: string, description: string, exec?: Exec): void;
|
|
24
|
+
export declare function prUrl(exec?: Exec): string | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { defaultExec } from '../run/exec.js';
|
|
2
|
+
/** GitHub rejects longer descriptions with a 422; truncate rather than fail the post. */
|
|
3
|
+
export const STATUS_DESCRIPTION_MAX = 140;
|
|
4
|
+
/** Honest about --skip, --allow-stderr and --base, so a partial run cannot wear the same
|
|
5
|
+
* green as a full one. */
|
|
6
|
+
export function statusDescription(signer, disclosure) {
|
|
7
|
+
const { skipped = [], allowedStderr = [], base = null } = disclosure;
|
|
8
|
+
const parts = [
|
|
9
|
+
...(skipped.length > 0 ? [`skipped: ${skipped.join(', ')}`] : []),
|
|
10
|
+
...(allowedStderr.length > 0 ? [`allowed stderr: ${allowedStderr.join(', ')}`] : []),
|
|
11
|
+
...(base !== null ? [`base=${base}`] : [])
|
|
12
|
+
];
|
|
13
|
+
const desc = parts.length === 0
|
|
14
|
+
? `signed off by ${signer}`
|
|
15
|
+
: `signed off by ${signer} (${parts.join('; ')})`;
|
|
16
|
+
if (desc.length <= STATUS_DESCRIPTION_MAX)
|
|
17
|
+
return desc;
|
|
18
|
+
return `${desc.slice(0, STATUS_DESCRIPTION_MAX - 3)}...`;
|
|
19
|
+
}
|
|
20
|
+
export function parseRepo(s) {
|
|
21
|
+
const m = /^([\w.-]+)\/([\w.-]+)$/.exec(s);
|
|
22
|
+
if (!m)
|
|
23
|
+
throw new Error(`repo must look like owner/name, got "${s}"`);
|
|
24
|
+
return { owner: m[1], name: m[2] };
|
|
25
|
+
}
|
|
26
|
+
export function repoInfo(exec = defaultExec) {
|
|
27
|
+
const parsed = JSON.parse(exec('gh', ['repo', 'view', '--json', 'owner,name']));
|
|
28
|
+
return { owner: parsed.owner.login, name: parsed.name };
|
|
29
|
+
}
|
|
30
|
+
export function statusArgs(repo, sha, context, description) {
|
|
31
|
+
return [
|
|
32
|
+
'api',
|
|
33
|
+
'-X',
|
|
34
|
+
'POST',
|
|
35
|
+
`repos/${repo.owner}/${repo.name}/statuses/${sha}`,
|
|
36
|
+
'-f',
|
|
37
|
+
'state=success',
|
|
38
|
+
'-f',
|
|
39
|
+
`context=${context}`,
|
|
40
|
+
'-f',
|
|
41
|
+
`description=${description}`
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
export function postStatus(repo, sha, context, description, exec = defaultExec) {
|
|
45
|
+
exec('gh', statusArgs(repo, sha, context, description));
|
|
46
|
+
}
|
|
47
|
+
export function prUrl(exec = defaultExec) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(exec('gh', ['pr', 'view', '--json', 'url'])).url;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { defineConfig } from './config/types.js';
|
|
2
|
+
export { burnInStep, type BurnInStepOptions } from './burn-in/step.js';
|
|
3
|
+
export type { AllowlistEntry, BurnInConfig, BurnInRunner, ChreodeConfig, FlakesConfig, Matcher, ProtectionConfig, RuleConfig, StepConfig, StepContext } from './config/types.js';
|
|
4
|
+
export type { FlakeEvent } from './flakes/log.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare const LOCK_STALE_MS = 30000;
|
|
2
|
+
export declare const LOCK_UPDATE_MS = 10000;
|
|
3
|
+
export declare const LOCK_POLL_MS = 2000;
|
|
4
|
+
export declare const LOCK_MAX_WAIT_MS: number;
|
|
5
|
+
export type Release = () => Promise<void>;
|
|
6
|
+
export interface MachineLock {
|
|
7
|
+
/** Resolves to a release fn once held; while held elsewhere, logs once and polls. */
|
|
8
|
+
acquire(): Promise<Release>;
|
|
9
|
+
}
|
|
10
|
+
export interface RetryingLockDeps {
|
|
11
|
+
/** One attempt; must reject with an `ELOCKED`-coded error when held. */
|
|
12
|
+
tryLock: () => Promise<Release>;
|
|
13
|
+
label: string;
|
|
14
|
+
lockPath: string;
|
|
15
|
+
pollMs: number;
|
|
16
|
+
maxWaitMs: number;
|
|
17
|
+
now: () => number;
|
|
18
|
+
sleep: (ms: number) => Promise<void>;
|
|
19
|
+
log: (msg: string) => void;
|
|
20
|
+
/** One-line remedy appended to the timeout error. */
|
|
21
|
+
hint?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Poll-until-free around a one-shot lock. All I/O injected so the retry/log/timeout logic
|
|
24
|
+
* is testable without the filesystem or real timers. */
|
|
25
|
+
export declare function createRetryingLock(deps: RetryingLockDeps): MachineLock;
|
|
26
|
+
/** proper-lockfile at a fixed path, with stale-steal. */
|
|
27
|
+
export declare function fileLock(opts: {
|
|
28
|
+
label: string;
|
|
29
|
+
lockPath: string;
|
|
30
|
+
staleMs?: number;
|
|
31
|
+
updateMs?: number;
|
|
32
|
+
pollMs?: number;
|
|
33
|
+
maxWaitMs?: number;
|
|
34
|
+
hint?: string;
|
|
35
|
+
log?: (msg: string) => void;
|
|
36
|
+
}): MachineLock;
|
|
37
|
+
/** `CHREODE_LOCK_PATH` exists for test isolation: chreode's own suite runs `chreode signoff`
|
|
38
|
+
* while an outer signoff holds the real lock. */
|
|
39
|
+
export declare function defaultLockPath(env?: NodeJS.ProcessEnv): string;
|
|
40
|
+
export declare function signoffLock(env?: NodeJS.ProcessEnv): MachineLock;
|