ledger: count issue-tagged commits and repo seat messages (#1506)

This commit is contained in:
2026-09-12 12:04:15 -05:00
parent db0d784bd8
commit cd0aa5fbb3
12 changed files with 677 additions and 7 deletions
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const helper = fileURLToPath(new URL('../../../scripts/gitea-api.sh', import.meta.url));
for (const [method, status, expected] of [['GET', '200', 0], ['POST', '201', 0], ['GET', '403', 1]]) {
test(`real helper ${method} HTTP ${status} preserves exit ${expected} without credentials`, t => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-helper-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
function tool(name, content) { const p = path.join(dir, name); writeFileSync(p, '#!/usr/bin/env bash\n' + content); chmodSync(p, 0o755); }
// Stub node before the helper can read credential material. No auth file exists.
tool('node', 'printf %s https://git.mosaicstack.dev\n');
tool('git', 'printf %s https://git.mosaicstack.dev/mosaicstack/stack.git\n');
tool('curl', 'while (($#)); do if [[ "$1" == -o ]]; then shift; out="$1"; fi; shift; done\nprintf "[]" > "$out"\nprintf %s "$FAKE_HTTP"\n');
const r = spawnSync('bash', [helper, method, 'repos/mosaicstack/stack/issues', ...(method === 'POST' ? ['{}'] : [])], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, MOSAIC_GITEA_CREDENTIAL_FILE: `${dir}/nonexistent`, FAKE_HTTP: status } });
assert.equal(r.status, expected, r.stderr); assert.equal(r.stdout, '[]'); assert.match(r.stderr, new RegExp(`HTTP ${status}`));
});
}
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync, spawnSync } from 'node:child_process';
import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../src/ledger.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
const range = dateRange('2026-09-06', '2026-09-12');
const fixtureIssues = [
{ number: 1, title: 'First issue', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T12:00:00Z' },
{ number: 2, title: 'Second issue', created_at: '2026-09-06T00:00:00Z', closed_at: null },
];
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const put = (name, data) => { const p = path.join(root, name); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, data); return p; };
const git = (args, date = '2026-09-07T00:00:00Z') => execFileSync('git', args, { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, stdio: 'pipe' });
git(['init', '-b', 'refactor']); git(['config', 'user.email', '[email protected]']); git(['config', 'user.name', 'Fixture']);
const commit = (subject, date, body) => git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', subject, ...(body ? ['-m', body] : [])], date);
commit('first #1'); commit('first #2'); commit('follow-up #2'); commit('untagged', undefined, 'body only #3');
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
execFileSync('chmod', ['+x', api]);
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
return { root, put, commit, run, entry, logs };
}
test('fixture git subjects only, follow-ups and three session kinds', t => {
const f = fixture(t), result = f.run(['--json']);
assert.equal(result.status, 0, result.stderr);
const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues.map(x => [x.issue, x.commits, x.followUps, x.hoursOpen, x.seats]), [[1, 1, 0, 36, ['alice']], [2, 2, 1, 'open', ['alice']]]);
assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
assert.deepEqual(r.totals, { issuesClosed: 1, medianHoursOpen: 36, commits: 3, followUpsPerIssue: 0.5, humanMessagesPerClosedIssue: 1 });
const calls = readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(calls.length, 1); assert.equal(calls[0][0], 'GET');
assert.match(calls[0][1], /^repos\/mosaicstack\/stack\/issues\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=1$/);
});
test('text and JSON carry same numbers, open and truncated title', t => {
const f = fixture(t); f.put('issues.json', JSON.stringify([{ ...fixtureIssues[0], title: 'x'.repeat(100) }, fixtureIssues[1]]));
const text = f.run(), json = f.run(['--json']);
assert.equal(text.status, 0, text.stderr); const r = JSON.parse(json.stdout);
assert.ok(text.stdout.includes(totalsLine(r.totals)));
assert.match(text.stdout, /#1 \| x{48} \| .* \| 36.0 \| 1 \| 0 \| alice/);
assert.match(text.stdout, /#2 \| Second issue \| .* \| open \| 2 \| 1 \| alice/);
assert.match(text.stdout, /alice \| 1 \| 1 \| 1/);
});
test('missing credentials exit 2, no-issues never calls API and shows unknown', t => {
const f = fixture(t); const bad = f.run([], { API_FAIL: '3' });
assert.equal(bad.status, 2); assert.match(bad.stderr, /credentials missing, unreadable, or invalid/); assert.doesNotMatch(bad.stderr, /secret must/); assert.equal(bad.stdout, '');
f.put('calls.jsonl', ''); const skip = f.run(['--no-issues'], { API_FAIL: '3' });
assert.equal(skip.status, 0, skip.stderr); assert.match(skip.stdout, /#1 \| unknown \| unknown \| unknown/);
assert.match(skip.stdout, /issues closed unknown/); assert.equal(readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8'), '');
});
test('empty range gives no rows and zero totals', t => {
const f = fixture(t);
f.put('issues.json', '[]');
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
});
test('inclusive UTC dates, first-line preamble only, role and seat boundaries', t => {
const f = fixture(t);
f.put('.pi/state/alice/sessions/one.jsonl', [f.entry('start #1', '2026-09-06T00:00:00Z'), f.entry('end #2', '2026-09-12T23:59:59.999Z'), f.entry('outside', '2026-09-13T00:00:00Z'), f.entry('human\n[host:control-board -> host:alice] quoted')].map(JSON.stringify).join('\n'));
f.put('.pi/state/not-a-seat/sessions/one.jsonl', JSON.stringify(f.entry('ignored')));
f.commit('at end #2', '2026-09-12T23:59:59Z'); f.commit('outside #2', '2026-09-13T00:00:00Z');
const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.equal(r.seats[0].human, 3); assert.equal(r.totals.commits, 4);
});
test('close-only issue included, even median, missing metadata stays unknown', t => {
const f = fixture(t);
f.put('issues.json', JSON.stringify([fixtureIssues[0], { number: 3, title: 'close only', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-08T12:00:00Z' }]));
const r = JSON.parse(f.run(['--json']).stdout);
assert.equal(r.issues[1].hoursOpen, 'unknown'); assert.equal(r.issues[2].commits, 0); assert.equal(r.totals.medianHoursOpen, 48); assert.equal(r.totals.issuesClosed, 2);
});
test('unique commits but per-issue links count multiple tags once each', t => {
const f = fixture(t); f.commit('both #1 #2 #2'); const r = JSON.parse(f.run(['--json']).stdout);
assert.equal(r.totals.commits, 4); assert.deepEqual(r.issues.map(x => x.commits), [2, 3]);
});
test('page cap refuses rather than silently undercounting', t => {
const f = fixture(t); f.put('issues.json', JSON.stringify(Array.from({ length: 50 }, (_, i) => ({ ...fixtureIssues[0], number: i + 1 }))));
const result = f.run(); assert.equal(result.status, 2); assert.match(result.stderr, /full 50-row page/); assert.equal(result.stdout, '');
});
for (const payload of ['not JSON', '{}', '[{"number":1}]']) test(`bad API payload ${payload} refuses`, t => {
const f = fixture(t); f.put('issues.json', payload); const r = f.run(); assert.equal(r.status, 2); assert.equal(r.stdout, '');
});
test('partial or malformed session log refuses with location, not content', t => {
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
});
test('no sessions is an empty table; symlink source refuses', t => {
const f = fixture(t); rmSync(path.join(f.root, '.pi'), { recursive: true });
assert.deepEqual(JSON.parse(f.run(['--json']).stdout).seats, []);
symlinkSync(path.join(f.root, 'agents'), path.join(f.root, '.pi'));
const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /Cannot read ledger directory/);
});
test('reads only refactor even when another branch is checked out', t => {
const f = fixture(t); execFileSync('git', ['checkout', '-b', 'other'], { cwd: f.root, stdio: 'pipe' }); f.commit('other #1');
assert.equal(JSON.parse(f.run(['--json']).stdout).totals.commits, 3);
});
test('invalid dates, reverse dates and duplicate options refuse', t => {
assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06'));
const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1);
});
test('preamble parsing and issue number boundaries', () => {
assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board');
assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent');
assert.equal(messageKind('human\n[h:control-board -> h:seat] hi'), 'human');
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
});
test('no closed issues with human messages means undefined ratio, not invented zero', () => {
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
});