team 0.0.1 → 0.1.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 +310 -0
- package/dist/approve/approval.d.ts +38 -0
- package/dist/approve/approval.js +78 -0
- package/dist/approve/diff.d.ts +13 -0
- package/dist/approve/diff.js +47 -0
- package/dist/approve/fingerprint.d.ts +44 -0
- package/dist/approve/fingerprint.js +109 -0
- package/dist/args.d.ts +7 -0
- package/dist/args.js +22 -0
- package/dist/budgets/checks.d.ts +35 -0
- package/dist/budgets/checks.js +77 -0
- package/dist/budgets/gate.d.ts +20 -0
- package/dist/budgets/gate.js +96 -0
- package/dist/budgets/readings.d.ts +47 -0
- package/dist/budgets/readings.js +119 -0
- package/dist/budgets/run.d.ts +66 -0
- package/dist/budgets/run.js +119 -0
- package/dist/budgets/table.d.ts +24 -0
- package/dist/budgets/table.js +90 -0
- package/dist/caller.d.ts +48 -0
- package/dist/caller.js +126 -0
- package/dist/check/config.d.ts +51 -0
- package/dist/check/config.js +50 -0
- package/dist/check/git.d.ts +27 -0
- package/dist/check/git.js +97 -0
- package/dist/check/message.d.ts +27 -0
- package/dist/check/message.js +101 -0
- package/dist/check/run.d.ts +31 -0
- package/dist/check/run.js +77 -0
- package/dist/check/signature.d.ts +10 -0
- package/dist/check/signature.js +22 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +76 -0
- package/dist/clis.d.ts +7 -0
- package/dist/clis.js +9 -0
- package/dist/commands/add.d.ts +23 -0
- package/dist/commands/add.js +445 -0
- package/dist/commands/approve.d.ts +11 -0
- package/dist/commands/approve.js +146 -0
- package/dist/commands/check.d.ts +17 -0
- package/dist/commands/check.js +103 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +232 -0
- package/dist/commands/down.d.ts +37 -0
- package/dist/commands/down.js +258 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/remove.d.ts +23 -0
- package/dist/commands/remove.js +235 -0
- package/dist/commands/status.d.ts +41 -0
- package/dist/commands/status.js +147 -0
- package/dist/commands/up.d.ts +49 -0
- package/dist/commands/up.js +361 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +302 -0
- package/dist/commands/worktree.d.ts +12 -0
- package/dist/commands/worktree.js +388 -0
- package/dist/end/condition.d.ts +28 -0
- package/dist/end/condition.js +77 -0
- package/dist/file/current.d.ts +13 -0
- package/dist/file/current.js +53 -0
- package/dist/file/lines.d.ts +25 -0
- package/dist/file/lines.js +277 -0
- package/dist/file/load.d.ts +7 -0
- package/dist/file/load.js +90 -0
- package/dist/file/paths.d.ts +6 -0
- package/dist/file/paths.js +77 -0
- package/dist/file/secrets.d.ts +6 -0
- package/dist/file/secrets.js +54 -0
- package/dist/file/signature.d.ts +13 -0
- package/dist/file/signature.js +36 -0
- package/dist/file/types.d.ts +130 -0
- package/dist/file/types.js +1 -0
- package/dist/file/validate.d.ts +13 -0
- package/dist/file/validate.js +702 -0
- package/dist/file/write.d.ts +11 -0
- package/dist/file/write.js +13 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +227 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/io.d.ts +10 -0
- package/dist/io.js +1 -0
- package/dist/launch/deliver.d.ts +10 -0
- package/dist/launch/deliver.js +39 -0
- package/dist/launch/execute.d.ts +46 -0
- package/dist/launch/execute.js +284 -0
- package/dist/launch/plan.d.ts +185 -0
- package/dist/launch/plan.js +280 -0
- package/dist/launch/rules.d.ts +25 -0
- package/dist/launch/rules.js +36 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +24 -0
- package/dist/profiles/antigravity.yaml +66 -0
- package/dist/profiles/claude-code.yaml +69 -0
- package/dist/profiles/codex.yaml +77 -0
- package/dist/profiles/cursor.yaml +68 -0
- package/dist/profiles/index.d.ts +1 -0
- package/dist/profiles/index.js +1 -0
- package/dist/profiles/profile.d.ts +60 -0
- package/dist/profiles/profile.js +313 -0
- package/dist/profiles/quota.d.ts +22 -0
- package/dist/profiles/quota.js +56 -0
- package/dist/state.d.ts +59 -0
- package/dist/state.js +127 -0
- package/dist/status/compare.d.ts +26 -0
- package/dist/status/compare.js +110 -0
- package/dist/status/statusline.d.ts +9 -0
- package/dist/status/statusline.js +26 -0
- package/dist/store/store.d.ts +62 -0
- package/dist/store/store.js +116 -0
- package/dist/watch/check.d.ts +82 -0
- package/dist/watch/check.js +29 -0
- package/dist/watch/checks/approval.d.ts +2 -0
- package/dist/watch/checks/approval.js +16 -0
- package/dist/watch/checks/attention.d.ts +2 -0
- package/dist/watch/checks/attention.js +23 -0
- package/dist/watch/checks/budget.d.ts +2 -0
- package/dist/watch/checks/budget.js +141 -0
- package/dist/watch/checks/disk.d.ts +2 -0
- package/dist/watch/checks/disk.js +13 -0
- package/dist/watch/checks/extra.d.ts +2 -0
- package/dist/watch/checks/extra.js +18 -0
- package/dist/watch/checks/idle.d.ts +2 -0
- package/dist/watch/checks/idle.js +32 -0
- package/dist/watch/checks/load.d.ts +2 -0
- package/dist/watch/checks/load.js +11 -0
- package/dist/watch/checks/memory.d.ts +2 -0
- package/dist/watch/checks/memory.js +11 -0
- package/dist/watch/checks/missing.d.ts +2 -0
- package/dist/watch/checks/missing.js +12 -0
- package/dist/watch/checks/model-drift.d.ts +2 -0
- package/dist/watch/checks/model-drift.js +14 -0
- package/dist/watch/checks/swap-free.d.ts +2 -0
- package/dist/watch/checks/swap-free.js +12 -0
- package/dist/watch/checks/swap-growth.d.ts +2 -0
- package/dist/watch/checks/swap-growth.js +18 -0
- package/dist/watch/checks/team-idle.d.ts +2 -0
- package/dist/watch/checks/team-idle.js +18 -0
- package/dist/watch/checks/unsent.d.ts +2 -0
- package/dist/watch/checks/unsent.js +18 -0
- package/dist/watch/close.d.ts +19 -0
- package/dist/watch/close.js +40 -0
- package/dist/watch/dialect.d.ts +5 -0
- package/dist/watch/dialect.js +430 -0
- package/dist/watch/end.d.ts +11 -0
- package/dist/watch/end.js +17 -0
- package/dist/watch/machine.d.ts +38 -0
- package/dist/watch/machine.js +137 -0
- package/dist/watch/notify.d.ts +1 -0
- package/dist/watch/notify.js +15 -0
- package/dist/watch/pass.d.ts +30 -0
- package/dist/watch/pass.js +237 -0
- package/dist/watch/screen-core.d.ts +14 -0
- package/dist/watch/screen-core.js +320 -0
- package/dist/watch/screen-data.d.ts +65 -0
- package/dist/watch/screen-data.js +1 -0
- package/dist/watch/screen-file.d.ts +2 -0
- package/dist/watch/screen-file.js +269 -0
- package/dist/watch/screen.d.ts +20 -0
- package/dist/watch/screen.js +38 -0
- package/dist/worktree/place.d.ts +18 -0
- package/dist/worktree/place.js +126 -0
- package/dist/yaml.d.ts +30 -0
- package/dist/yaml.js +380 -0
- package/examples/checks/codex-quota +217 -0
- package/examples/team.yaml +76 -0
- package/package.json +29 -10
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { verdict } from "./readings.js";
|
|
2
|
+
const WINDOWS = ['session', 'daily', 'weekly'];
|
|
3
|
+
/** Rows for the accounts the budgets in force name, and for any reading still in the state. */
|
|
4
|
+
export function budgetTable(budgets, list, now) {
|
|
5
|
+
const staleAfterMs = budgets.staleAfter * 1000;
|
|
6
|
+
const groups = new Map();
|
|
7
|
+
for (const reading of list) {
|
|
8
|
+
const key = `${reading.account}\0${reading.window}`;
|
|
9
|
+
const group = groups.get(key) ?? [];
|
|
10
|
+
group.push(reading);
|
|
11
|
+
groups.set(key, group);
|
|
12
|
+
}
|
|
13
|
+
const rows = [];
|
|
14
|
+
const named = new Set();
|
|
15
|
+
for (const [key, group] of groups) {
|
|
16
|
+
const account = key.slice(0, key.indexOf('\0'));
|
|
17
|
+
const window = group[0]?.window ?? null;
|
|
18
|
+
named.add(account);
|
|
19
|
+
const reserve = reserveOf(budgets, account);
|
|
20
|
+
rows.push(rowOf(account, window, verdict(group, now, staleAfterMs, reserve), now, reserve));
|
|
21
|
+
}
|
|
22
|
+
for (const account of Object.keys(budgets.accounts)) {
|
|
23
|
+
if (!named.has(account))
|
|
24
|
+
rows.push(blank(account, reserveOf(budgets, account)));
|
|
25
|
+
}
|
|
26
|
+
return rows.sort(byAccount);
|
|
27
|
+
}
|
|
28
|
+
/** An account's reserve: a subscription's own, and none for anything else. */
|
|
29
|
+
export function reserveOf(budgets, account) {
|
|
30
|
+
const entry = budgets.accounts[account];
|
|
31
|
+
return entry?.kind === 'subscription' ? entry.reserve : null;
|
|
32
|
+
}
|
|
33
|
+
export function budgetLine(row) {
|
|
34
|
+
if (row.state === 'unknown' || row.left === null || row.used === null) {
|
|
35
|
+
return [row.account, row.window, 'unknown'].filter((part) => part).join(' ');
|
|
36
|
+
}
|
|
37
|
+
const reset = row.resetsIn === null ? 'resets unknown' : `resets in ${row.resetsIn}`;
|
|
38
|
+
const from = row.source === 'status_line' ? 'status line' : 'unknown source';
|
|
39
|
+
const state = row.inside && row.reserve !== null ? `${row.state}, inside reserve ${row.reserve}%` : row.state;
|
|
40
|
+
return `${row.account} ${row.window} left ${row.left}% used ${row.used}% ${reset} ${row.seat} changed ${row.age} ago ${from} ${state}`;
|
|
41
|
+
}
|
|
42
|
+
function rowOf(account, window, result, now, reserve) {
|
|
43
|
+
if (result.kind === 'unknown')
|
|
44
|
+
return { ...blank(account, reserve), window };
|
|
45
|
+
const reading = result.reading;
|
|
46
|
+
return {
|
|
47
|
+
account,
|
|
48
|
+
window: reading.window,
|
|
49
|
+
left: reading.left,
|
|
50
|
+
used: reading.used,
|
|
51
|
+
resetsIn: reading.resetsAt === null ? null : span(reading.resetsAt - now),
|
|
52
|
+
seat: reading.seat,
|
|
53
|
+
age: span(now - reading.changedAt),
|
|
54
|
+
source: 'status_line',
|
|
55
|
+
state: result.kind,
|
|
56
|
+
inside: reserve !== null && reading.left <= reserve,
|
|
57
|
+
reserve,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function blank(account, reserve) {
|
|
61
|
+
return {
|
|
62
|
+
account,
|
|
63
|
+
window: null,
|
|
64
|
+
left: null,
|
|
65
|
+
used: null,
|
|
66
|
+
resetsIn: null,
|
|
67
|
+
seat: null,
|
|
68
|
+
age: null,
|
|
69
|
+
source: null,
|
|
70
|
+
state: 'unknown',
|
|
71
|
+
inside: false,
|
|
72
|
+
reserve,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function byAccount(a, b) {
|
|
76
|
+
if (a.account !== b.account)
|
|
77
|
+
return a.account < b.account ? -1 : 1;
|
|
78
|
+
const rank = (window) => (window === null ? WINDOWS.length : WINDOWS.indexOf(window));
|
|
79
|
+
return rank(a.window) - rank(b.window);
|
|
80
|
+
}
|
|
81
|
+
function span(ms) {
|
|
82
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
83
|
+
const hours = Math.floor(minutes / 60);
|
|
84
|
+
const rest = minutes % 60;
|
|
85
|
+
if (hours && rest)
|
|
86
|
+
return `${hours}h${rest}m`;
|
|
87
|
+
if (hours)
|
|
88
|
+
return `${hours}h`;
|
|
89
|
+
return `${minutes}m`;
|
|
90
|
+
}
|
package/dist/caller.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TeamFile } from './file/types.ts';
|
|
2
|
+
import type { HerdrAgent } from './herdr.ts';
|
|
3
|
+
import type { Io } from './io.ts';
|
|
4
|
+
export type Caller = {
|
|
5
|
+
kind: 'owner';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'seat';
|
|
8
|
+
name: string;
|
|
9
|
+
pane: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'unplaced';
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
export type Process = {
|
|
15
|
+
pid: number;
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
export type CallerSources = {
|
|
19
|
+
ancestors(): Process[] | null;
|
|
20
|
+
agents(): HerdrAgent[] | null;
|
|
21
|
+
paneRootPid(pane: string): number | null;
|
|
22
|
+
env: Record<string, string | undefined>;
|
|
23
|
+
stdinIsTTY: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function placeCaller(sources: CallerSources): Caller;
|
|
26
|
+
export declare function isOwner(caller: Caller): boolean;
|
|
27
|
+
export declare function mayChangeTeam(caller: Caller, team: Pick<TeamFile, 'coordinator' | 'operator'>): boolean;
|
|
28
|
+
export declare function describeCaller(caller: Caller): string;
|
|
29
|
+
export type ReadProcess = (pid: number) => {
|
|
30
|
+
ppid: number;
|
|
31
|
+
name: string;
|
|
32
|
+
} | null;
|
|
33
|
+
export declare const readWithPs: ReadProcess;
|
|
34
|
+
export declare function parseStat(text: string): {
|
|
35
|
+
ppid: number;
|
|
36
|
+
name: string;
|
|
37
|
+
} | null;
|
|
38
|
+
export declare function readWithProc(pid: number, proc?: string): {
|
|
39
|
+
ppid: number;
|
|
40
|
+
name: string;
|
|
41
|
+
} | null;
|
|
42
|
+
export declare function processReader(platform?: string): ReadProcess;
|
|
43
|
+
export declare function readAncestors(pid?: number, read?: ReadProcess): Process[] | null;
|
|
44
|
+
export declare function callerOf(io: Pick<Io, 'env' | 'stdinIsTTY' | 'caller'>, session?: string): Caller;
|
|
45
|
+
export declare function currentCaller(io: {
|
|
46
|
+
env: Record<string, string | undefined>;
|
|
47
|
+
stdinIsTTY: boolean;
|
|
48
|
+
}, session?: string): Caller;
|
package/dist/caller.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { agentList, paneRootPid } from "./herdr.js";
|
|
5
|
+
import { CLI_PROCESSES } from "./clis.js";
|
|
6
|
+
export function placeCaller(sources) {
|
|
7
|
+
const ancestors = sources.ancestors();
|
|
8
|
+
// A walk that stopped early may have stopped below a herdr server: it places nobody.
|
|
9
|
+
if (!ancestors?.length)
|
|
10
|
+
return { kind: 'unplaced', reason: 'its parent processes can\'t be read to the top' };
|
|
11
|
+
const pids = new Set(ancestors.map((process) => process.pid));
|
|
12
|
+
if (ancestors.some((process) => process.name === 'herdr')) {
|
|
13
|
+
const agents = sources.agents();
|
|
14
|
+
if (!agents)
|
|
15
|
+
return { kind: 'unplaced', reason: 'it runs under herdr, and herdr doesn\'t answer' };
|
|
16
|
+
// The variable only says which pane to read first; the pids decide.
|
|
17
|
+
const hint = sources.env.HERDR_PANE_ID;
|
|
18
|
+
const ordered = [...agents].sort((a, b) => Number(b.pane === hint) - Number(a.pane === hint));
|
|
19
|
+
for (const agent of ordered) {
|
|
20
|
+
const root = sources.paneRootPid(agent.pane);
|
|
21
|
+
if (root === null || !pids.has(root))
|
|
22
|
+
continue;
|
|
23
|
+
if (agent.name)
|
|
24
|
+
return { kind: 'seat', name: agent.name, pane: agent.pane };
|
|
25
|
+
return { kind: 'unplaced', reason: `it runs in pane ${agent.pane}, whose agent has no herdr name` };
|
|
26
|
+
}
|
|
27
|
+
return { kind: 'unplaced', reason: 'it runs in a herdr pane without an agent' };
|
|
28
|
+
}
|
|
29
|
+
const cli = ancestors.find((process) => CLI_PROCESSES.includes(process.name));
|
|
30
|
+
if (cli)
|
|
31
|
+
return { kind: 'unplaced', reason: `it is run by an agent (${cli.name}) outside herdr` };
|
|
32
|
+
if (sources.env.AGENT_UNATTENDED)
|
|
33
|
+
return { kind: 'unplaced', reason: 'AGENT_UNATTENDED is set' };
|
|
34
|
+
if (!sources.stdinIsTTY)
|
|
35
|
+
return { kind: 'unplaced', reason: 'it doesn\'t run on a terminal' };
|
|
36
|
+
return { kind: 'owner' };
|
|
37
|
+
}
|
|
38
|
+
export function isOwner(caller) {
|
|
39
|
+
return caller.kind === 'owner';
|
|
40
|
+
}
|
|
41
|
+
// The owner, or the coordinator's or the operator's seat: who may change a running team.
|
|
42
|
+
export function mayChangeTeam(caller, team) {
|
|
43
|
+
if (caller.kind === 'owner')
|
|
44
|
+
return true;
|
|
45
|
+
return caller.kind === 'seat' && (caller.name === team.coordinator || caller.name === team.operator);
|
|
46
|
+
}
|
|
47
|
+
export function describeCaller(caller) {
|
|
48
|
+
if (caller.kind === 'owner')
|
|
49
|
+
return 'owner';
|
|
50
|
+
if (caller.kind === 'seat')
|
|
51
|
+
return caller.name;
|
|
52
|
+
return `unplaced (${caller.reason})`;
|
|
53
|
+
}
|
|
54
|
+
// A process's name, however the platform spells it: ps prints a path, /proc a bare name, and a
|
|
55
|
+
// login shell leads with a dash.
|
|
56
|
+
function processName(raw) {
|
|
57
|
+
return basename(raw).replace(/^-/, '');
|
|
58
|
+
}
|
|
59
|
+
export const readWithPs = (pid) => {
|
|
60
|
+
try {
|
|
61
|
+
const line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
62
|
+
const match = /^(\d+)\s+(.+)$/.exec(line);
|
|
63
|
+
return match ? { ppid: Number(match[1]), name: processName(match[2]) } : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// "10 (herdr) S 1 10 …", one line of /proc/<pid>/stat: the name in parentheses — it can hold
|
|
70
|
+
// spaces and parentheses, so the last of them closes it — then the state, then the parent.
|
|
71
|
+
export function parseStat(text) {
|
|
72
|
+
const open = text.indexOf('(');
|
|
73
|
+
const close = text.lastIndexOf(')');
|
|
74
|
+
if (open < 0 || close < open)
|
|
75
|
+
return null;
|
|
76
|
+
const after = text.slice(close + 1).trim().split(/\s+/);
|
|
77
|
+
const ppid = Number(after[1]);
|
|
78
|
+
if (!Number.isInteger(ppid) || ppid < 0)
|
|
79
|
+
return null;
|
|
80
|
+
const name = processName(text.slice(open + 1, close));
|
|
81
|
+
return name ? { ppid, name } : null;
|
|
82
|
+
}
|
|
83
|
+
// One process, from Linux's own table. `proc` is the /proc root, a parameter so a test can walk a
|
|
84
|
+
// captured one.
|
|
85
|
+
export function readWithProc(pid, proc = '/proc') {
|
|
86
|
+
try {
|
|
87
|
+
return parseStat(readFileSync(`${proc}/${pid}/stat`, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The table this platform keeps: Linux answers from /proc, every other system through ps.
|
|
94
|
+
export function processReader(platform = process.platform) {
|
|
95
|
+
return platform === 'linux' ? readWithProc : readWithPs;
|
|
96
|
+
}
|
|
97
|
+
// The parent processes of `pid`, nearest first, up to the system's first process. Null when a
|
|
98
|
+
// process on the way can't be read, or the chain is longer than any real one: a partial list
|
|
99
|
+
// could hide the herdr server above it.
|
|
100
|
+
export function readAncestors(pid = process.ppid, read = processReader()) {
|
|
101
|
+
const out = [];
|
|
102
|
+
let at = pid;
|
|
103
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
104
|
+
if (at <= 1)
|
|
105
|
+
return out;
|
|
106
|
+
const found = read(at);
|
|
107
|
+
if (!found)
|
|
108
|
+
return null;
|
|
109
|
+
out.push({ pid: at, name: found.name });
|
|
110
|
+
at = found.ppid;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// The caller of a command: the one a test handed in, or the one the processes show.
|
|
115
|
+
export function callerOf(io, session) {
|
|
116
|
+
return io.caller ?? currentCaller(io, session);
|
|
117
|
+
}
|
|
118
|
+
export function currentCaller(io, session) {
|
|
119
|
+
return placeCaller({
|
|
120
|
+
ancestors: () => readAncestors(),
|
|
121
|
+
agents: () => agentList(session),
|
|
122
|
+
paneRootPid: (pane) => paneRootPid(pane, session),
|
|
123
|
+
env: io.env,
|
|
124
|
+
stdinIsTTY: io.stdinIsTTY,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `check` needs from a validated team file: the `identity` section with
|
|
3
|
+
* its defaults applied, and the seats whose signatures are accepted.
|
|
4
|
+
*/
|
|
5
|
+
import type { Position, TeamFile } from '../file/types.ts';
|
|
6
|
+
/** One `display` and `role` the team has had, with the fields a template may use. */
|
|
7
|
+
export interface SeatIdentity {
|
|
8
|
+
display: string;
|
|
9
|
+
role: string;
|
|
10
|
+
model: string;
|
|
11
|
+
version: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SignatureRule {
|
|
14
|
+
template: string;
|
|
15
|
+
position: Position;
|
|
16
|
+
}
|
|
17
|
+
export interface CheckConfig {
|
|
18
|
+
/** Whether `forbiddenPublic` applies. */
|
|
19
|
+
public: boolean;
|
|
20
|
+
commits: SignatureRule & {
|
|
21
|
+
exemptMerge: boolean;
|
|
22
|
+
};
|
|
23
|
+
pullRequests: SignatureRule;
|
|
24
|
+
/** Author emails whose commits need no signature. */
|
|
25
|
+
humans: string[];
|
|
26
|
+
/** A commit: it and everything reachable from it are skipped. */
|
|
27
|
+
since?: string;
|
|
28
|
+
/** The file's patterns; the defaults are added by `forbiddenPatterns`. */
|
|
29
|
+
forbidden: string[];
|
|
30
|
+
forbiddenPublic: string[];
|
|
31
|
+
/** Every seat whose signature is accepted. */
|
|
32
|
+
ledger: SeatIdentity[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What `check` reads of a validated file. The accepted signatures are those
|
|
36
|
+
* of the file's seats and of the ledger: every seat the team has had, so the
|
|
37
|
+
* commits of a removed or temporary seat, or of a seat before a version
|
|
38
|
+
* change, still pass.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fromTeamFile(team: TeamFile, ledger?: readonly SeatIdentity[]): CheckConfig;
|
|
41
|
+
export interface ForbiddenPattern {
|
|
42
|
+
source: string;
|
|
43
|
+
regex: RegExp;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The patterns that apply to this project: the defaults (a validated file
|
|
47
|
+
* already lists them; they are never left out), the file's, and the public
|
|
48
|
+
* ones when the project is public. Throws on a pattern that isn't a
|
|
49
|
+
* regular expression.
|
|
50
|
+
*/
|
|
51
|
+
export declare function forbiddenPatterns(config: CheckConfig): ForbiddenPattern[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `check` needs from a validated team file: the `identity` section with
|
|
3
|
+
* its defaults applied, and the seats whose signatures are accepted.
|
|
4
|
+
*/
|
|
5
|
+
import { DEFAULT_FORBIDDEN } from "../file/signature.js";
|
|
6
|
+
import { mergeLedger } from "../store/store.js";
|
|
7
|
+
/**
|
|
8
|
+
* What `check` reads of a validated file. The accepted signatures are those
|
|
9
|
+
* of the file's seats and of the ledger: every seat the team has had, so the
|
|
10
|
+
* commits of a removed or temporary seat, or of a seat before a version
|
|
11
|
+
* change, still pass.
|
|
12
|
+
*/
|
|
13
|
+
export function fromTeamFile(team, ledger = []) {
|
|
14
|
+
const { identity } = team;
|
|
15
|
+
return {
|
|
16
|
+
public: team.visibility === 'public',
|
|
17
|
+
commits: {
|
|
18
|
+
template: identity.signature.commits.template,
|
|
19
|
+
position: identity.signature.commits.position,
|
|
20
|
+
exemptMerge: identity.signature.commits.exempt.includes('merge'),
|
|
21
|
+
},
|
|
22
|
+
pullRequests: identity.signature.pullRequests,
|
|
23
|
+
humans: identity.humans,
|
|
24
|
+
since: identity.since ?? undefined,
|
|
25
|
+
forbidden: identity.forbidden,
|
|
26
|
+
forbiddenPublic: identity.forbiddenPublic,
|
|
27
|
+
ledger: mergeLedger(ledger, team.seats),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The patterns that apply to this project: the defaults (a validated file
|
|
32
|
+
* already lists them; they are never left out), the file's, and the public
|
|
33
|
+
* ones when the project is public. Throws on a pattern that isn't a
|
|
34
|
+
* regular expression.
|
|
35
|
+
*/
|
|
36
|
+
export function forbiddenPatterns(config) {
|
|
37
|
+
const sources = [...DEFAULT_FORBIDDEN, ...config.forbidden, ...(config.public ? config.forbiddenPublic : [])];
|
|
38
|
+
const patterns = [];
|
|
39
|
+
for (const source of new Set(sources)) {
|
|
40
|
+
let regex;
|
|
41
|
+
try {
|
|
42
|
+
regex = new RegExp(source);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new Error(`forbidden pattern ${JSON.stringify(source)} is not a regular expression: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
patterns.push({ source, regex });
|
|
48
|
+
}
|
|
49
|
+
return patterns;
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A git command that failed, or a ref that can't be used. */
|
|
2
|
+
export declare class GitError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface Commit {
|
|
5
|
+
hash: string;
|
|
6
|
+
parents: string[];
|
|
7
|
+
authorEmail: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
message: string;
|
|
10
|
+
/** The lines of the final trailer block as git reads it; empty when it sees none. */
|
|
11
|
+
trailers: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface Selection {
|
|
14
|
+
/** The commits to check, newest first. */
|
|
15
|
+
commits: Commit[];
|
|
16
|
+
/** Commits of the range that `since` leaves out. */
|
|
17
|
+
skipped: number;
|
|
18
|
+
/** `since` as a commit hash, when one applies. */
|
|
19
|
+
since?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The commits `check` reads for `<ref>`: the range as given when it holds
|
|
23
|
+
* `..`, else that one commit, without what is reachable from `since`.
|
|
24
|
+
* An empty or unresolvable range is an error, and so is a `since` that isn't
|
|
25
|
+
* reachable from the range.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectCommits(cwd: string, ref: string, since?: string): Selection;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
/** A git command that failed, or a ref that can't be used. */
|
|
3
|
+
export class GitError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Set for every call, so the machine's git configuration can't change what is read. */
|
|
6
|
+
const CONFIGURATION = ['log.showSignature=false', 'i18n.logOutputEncoding=UTF-8', 'trailer.separators=:'].flatMap((setting) => ['-c', setting]);
|
|
7
|
+
function run(cwd, args) {
|
|
8
|
+
const result = spawnSync('git', [...CONFIGURATION, ...args], {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
12
|
+
});
|
|
13
|
+
if (result.error)
|
|
14
|
+
throw new GitError(`can't run git: ${result.error.message}`);
|
|
15
|
+
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
16
|
+
}
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
const result = run(cwd, args);
|
|
19
|
+
if (result.status !== 0) {
|
|
20
|
+
throw new GitError(result.stderr.trim() || `git ${args[0]} exited ${result.status}`);
|
|
21
|
+
}
|
|
22
|
+
return result.stdout;
|
|
23
|
+
}
|
|
24
|
+
function refuseOption(ref, what) {
|
|
25
|
+
if (ref === '' || ref.startsWith('-'))
|
|
26
|
+
throw new GitError(`${what} ${JSON.stringify(ref)} is not a ref`);
|
|
27
|
+
}
|
|
28
|
+
/** The revision arguments for `<ref>`: a range as given, or that one commit. */
|
|
29
|
+
function revision(cwd, ref) {
|
|
30
|
+
refuseOption(ref, 'the ref');
|
|
31
|
+
if (ref.includes('..'))
|
|
32
|
+
return ref;
|
|
33
|
+
const result = run(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]);
|
|
34
|
+
if (result.status !== 0)
|
|
35
|
+
throw new GitError(`${JSON.stringify(ref)} doesn't name a commit`);
|
|
36
|
+
return `${result.stdout.trim()}^!`;
|
|
37
|
+
}
|
|
38
|
+
/** The commits a range starts from: what `rev-parse` gives without a `^`. */
|
|
39
|
+
function tips(cwd, range) {
|
|
40
|
+
const result = run(cwd, ['rev-parse', '--revs-only', '--end-of-options', range]);
|
|
41
|
+
if (result.status !== 0 || result.stdout.trim() === '') {
|
|
42
|
+
throw new GitError(`the range ${JSON.stringify(range)} can't be resolved`);
|
|
43
|
+
}
|
|
44
|
+
return result.stdout.split('\n').filter((line) => line !== '' && !line.startsWith('^'));
|
|
45
|
+
}
|
|
46
|
+
const FIELDS = 6;
|
|
47
|
+
const FORMAT = ['%H', '%P', '%ae', '%s', '%B', '%(trailers)'].map((field) => `${field}%x00`).join('');
|
|
48
|
+
function parseLog(output) {
|
|
49
|
+
const fields = output.split('\0');
|
|
50
|
+
const commits = [];
|
|
51
|
+
for (let index = 0; index + FIELDS <= fields.length; index += FIELDS) {
|
|
52
|
+
const [hash, parents, authorEmail, subject, message, trailers] = fields.slice(index, index + FIELDS);
|
|
53
|
+
commits.push({
|
|
54
|
+
// Each record ends with a newline, which lands before the next hash.
|
|
55
|
+
hash: hash.trim(),
|
|
56
|
+
parents: parents.split(' ').filter(Boolean),
|
|
57
|
+
authorEmail,
|
|
58
|
+
subject,
|
|
59
|
+
message,
|
|
60
|
+
trailers: trailers.split('\n').filter((line) => line.trim() !== ''),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return commits;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The commits `check` reads for `<ref>`: the range as given when it holds
|
|
67
|
+
* `..`, else that one commit, without what is reachable from `since`.
|
|
68
|
+
* An empty or unresolvable range is an error, and so is a `since` that isn't
|
|
69
|
+
* reachable from the range.
|
|
70
|
+
*/
|
|
71
|
+
export function selectCommits(cwd, ref, since) {
|
|
72
|
+
const inside = run(cwd, ['rev-parse', '--git-dir']);
|
|
73
|
+
if (inside.status !== 0)
|
|
74
|
+
throw new GitError('not in a git repository');
|
|
75
|
+
const range = revision(cwd, ref);
|
|
76
|
+
const from = tips(cwd, range);
|
|
77
|
+
const total = Number(git(cwd, ['rev-list', '--count', '--end-of-options', range]).trim());
|
|
78
|
+
if (total === 0)
|
|
79
|
+
throw new GitError(`the range ${JSON.stringify(ref)} holds no commit`);
|
|
80
|
+
const revisions = [range];
|
|
81
|
+
let sinceHash;
|
|
82
|
+
if (since !== undefined) {
|
|
83
|
+
refuseOption(since, 'since');
|
|
84
|
+
const resolved = run(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${since}^{commit}`]);
|
|
85
|
+
if (resolved.status !== 0) {
|
|
86
|
+
throw new GitError(`since ${JSON.stringify(since)} doesn't name a commit here (a shallow clone doesn't hold the history it needs)`);
|
|
87
|
+
}
|
|
88
|
+
sinceHash = resolved.stdout.trim();
|
|
89
|
+
const reachable = from.some((tip) => run(cwd, ['merge-base', '--is-ancestor', sinceHash, tip]).status === 0);
|
|
90
|
+
if (!reachable) {
|
|
91
|
+
throw new GitError(`since ${JSON.stringify(since)} is not reachable from ${JSON.stringify(ref)}`);
|
|
92
|
+
}
|
|
93
|
+
revisions.push(`^${sinceHash}`);
|
|
94
|
+
}
|
|
95
|
+
const commits = parseLog(git(cwd, ['log', '--no-color', `--format=tformat:${FORMAT}`, '--end-of-options', ...revisions]));
|
|
96
|
+
return { commits, skipped: total - commits.length, since: sinceHash };
|
|
97
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ForbiddenPattern, SeatIdentity, SignatureRule } from './config.ts';
|
|
2
|
+
export type FindingKind = 'forbidden' | 'signature-missing' | 'signature-misplaced' | 'signature-in-prose' | 'signature-unknown';
|
|
3
|
+
export interface Finding {
|
|
4
|
+
kind: FindingKind;
|
|
5
|
+
message: string;
|
|
6
|
+
/** 1-based line of the commit message or PR body, when the finding has one. */
|
|
7
|
+
line?: number;
|
|
8
|
+
/** The offending line. */
|
|
9
|
+
text?: string;
|
|
10
|
+
}
|
|
11
|
+
/** The text's lines, trailing spaces trimmed. */
|
|
12
|
+
export declare function splitLines(text: string): string[];
|
|
13
|
+
/** Every line that matches a forbidden pattern. */
|
|
14
|
+
export declare function findForbidden(lines: readonly string[], patterns: readonly ForbiddenPattern[]): Finding[];
|
|
15
|
+
export interface SignatureInput {
|
|
16
|
+
lines: readonly string[];
|
|
17
|
+
rule: SignatureRule;
|
|
18
|
+
ledger: readonly SeatIdentity[];
|
|
19
|
+
/**
|
|
20
|
+
* For a commit: the lines of the final trailer block as git reads it, empty
|
|
21
|
+
* when git sees none. Absent for a PR body, where `trailer` means the last
|
|
22
|
+
* paragraph.
|
|
23
|
+
*/
|
|
24
|
+
trailers?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/** Empty when the text carries a signature of the team's at the position the rule says. */
|
|
27
|
+
export declare function checkSignature(input: SignatureInput): Finding[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { renderings, shape } from "./signature.js";
|
|
2
|
+
/** The text's lines, trailing spaces trimmed. */
|
|
3
|
+
export function splitLines(text) {
|
|
4
|
+
return text.split(/\r?\n/).map((line) => line.trimEnd());
|
|
5
|
+
}
|
|
6
|
+
/** Every line that matches a forbidden pattern. */
|
|
7
|
+
export function findForbidden(lines, patterns) {
|
|
8
|
+
const findings = [];
|
|
9
|
+
lines.forEach((text, index) => {
|
|
10
|
+
for (const pattern of patterns) {
|
|
11
|
+
if (pattern.regex.test(text)) {
|
|
12
|
+
findings.push({
|
|
13
|
+
kind: 'forbidden',
|
|
14
|
+
message: `forbidden pattern ${pattern.source}`,
|
|
15
|
+
line: index + 1,
|
|
16
|
+
text,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return findings;
|
|
22
|
+
}
|
|
23
|
+
/** The indexes of the last run of non-empty lines: [start, end). */
|
|
24
|
+
function lastParagraph(lines) {
|
|
25
|
+
let end = lines.length;
|
|
26
|
+
while (end > 0 && lines[end - 1] === '')
|
|
27
|
+
end--;
|
|
28
|
+
let start = end;
|
|
29
|
+
while (start > 0 && lines[start - 1] !== '')
|
|
30
|
+
start--;
|
|
31
|
+
return [start, end];
|
|
32
|
+
}
|
|
33
|
+
const TRAILER_LINE = /^[A-Za-z0-9-]+\s*:\s*\S/;
|
|
34
|
+
const WHERE = {
|
|
35
|
+
'last-line': { commit: 'the last line', pr: 'the last line' },
|
|
36
|
+
trailer: { commit: 'the final trailer block', pr: 'the last paragraph' },
|
|
37
|
+
anywhere: { commit: 'a line of its own', pr: 'a line of its own' },
|
|
38
|
+
};
|
|
39
|
+
/** Empty when the text carries a signature of the team's at the position the rule says. */
|
|
40
|
+
export function checkSignature(input) {
|
|
41
|
+
const { lines, rule, ledger, trailers } = input;
|
|
42
|
+
const accepted = renderings(rule.template, ledger);
|
|
43
|
+
const [start, end] = lastParagraph(lines);
|
|
44
|
+
const where = WHERE[rule.position][trailers ? 'commit' : 'pr'];
|
|
45
|
+
let candidates;
|
|
46
|
+
if (rule.position === 'last-line')
|
|
47
|
+
candidates = lines.slice(Math.max(end - 1, 0), end);
|
|
48
|
+
else if (rule.position === 'anywhere')
|
|
49
|
+
candidates = lines;
|
|
50
|
+
else
|
|
51
|
+
candidates = trailers ? trailers.map((line) => line.trimEnd()) : lines.slice(start, end);
|
|
52
|
+
if (candidates.some((line) => accepted.has(line)))
|
|
53
|
+
return [];
|
|
54
|
+
const signed = lines.findIndex((line) => accepted.has(line));
|
|
55
|
+
if (signed !== -1) {
|
|
56
|
+
const text = lines[signed];
|
|
57
|
+
const inLastParagraph = signed >= start && signed < end;
|
|
58
|
+
if (trailers && rule.position === 'trailer' && inLastParagraph) {
|
|
59
|
+
const prose = lines.slice(start, end).filter((line) => !TRAILER_LINE.test(line));
|
|
60
|
+
if (prose.length > 0) {
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
kind: 'signature-in-prose',
|
|
64
|
+
message: `the signature shares its paragraph with prose (${prose.map((line) => JSON.stringify(line)).join(', ')}), ` +
|
|
65
|
+
'so git reads no trailer block: put it in a paragraph of its own, with trailers only',
|
|
66
|
+
line: signed + 1,
|
|
67
|
+
text,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
kind: 'signature-misplaced',
|
|
75
|
+
message: `the signature is not in ${where}`,
|
|
76
|
+
line: signed + 1,
|
|
77
|
+
text,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const form = shape(rule.template);
|
|
82
|
+
const unknown = [];
|
|
83
|
+
lines.forEach((text, index) => {
|
|
84
|
+
if (form.test(text)) {
|
|
85
|
+
unknown.push({
|
|
86
|
+
kind: 'signature-unknown',
|
|
87
|
+
message: 'a signature no seat of this team has had (the model, the version and the role must all match one seat)',
|
|
88
|
+
line: index + 1,
|
|
89
|
+
text,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
if (unknown.length > 0)
|
|
94
|
+
return unknown;
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
kind: 'signature-missing',
|
|
98
|
+
message: `no signature: expected ${JSON.stringify(rule.template)} in ${where}`,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type CheckConfig } from './config.ts';
|
|
2
|
+
import { type Finding } from './message.ts';
|
|
3
|
+
export interface CheckOptions {
|
|
4
|
+
/** A folder of the repository to read. */
|
|
5
|
+
cwd: string;
|
|
6
|
+
/** A range when it holds `..`, else one commit. */
|
|
7
|
+
ref: string;
|
|
8
|
+
/** Overrides the file's `identity.since` for this run. */
|
|
9
|
+
since?: string;
|
|
10
|
+
/** A pull request's body, to check as well. */
|
|
11
|
+
pullRequestBody?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CommitReport {
|
|
14
|
+
hash: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
/** Why the commit needs no signature; its forbidden patterns are still checked. */
|
|
17
|
+
exempt?: 'merge' | 'human';
|
|
18
|
+
findings: Finding[];
|
|
19
|
+
}
|
|
20
|
+
export interface CheckReport {
|
|
21
|
+
commits: CommitReport[];
|
|
22
|
+
skipped: number;
|
|
23
|
+
since?: string;
|
|
24
|
+
/** Present when a PR body was checked. */
|
|
25
|
+
pullRequest?: Finding[];
|
|
26
|
+
ok: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Checks the commits of `ref`, and a PR body when one is given. Read only. */
|
|
29
|
+
export declare function runCheck(config: CheckConfig, options: CheckOptions): CheckReport;
|
|
30
|
+
/** The report as `check` prints it: each offending commit and line, then a summary. */
|
|
31
|
+
export declare function formatReport(report: CheckReport): string;
|