ledger: count issue-tagged commits and repo seat messages (#1506)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# Ledger
|
||||
|
||||
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
|
||||
request through `scripts/gitea-api.sh`, and repo seats' Pi session logs.
|
||||
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
|
||||
|
||||
```sh
|
||||
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12
|
||||
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json
|
||||
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
|
||||
node --test packages/ledger/tests/
|
||||
```
|
||||
|
||||
Dates include both endpoints in UTC. Omitted `--until` means today in UTC.
|
||||
The checkout containing this package supplies the sources, not the shell's cwd.
|
||||
No install, build, service restart, or configuration change is needed.
|
||||
|
||||
## Counting rules
|
||||
|
||||
- Git uses committer timestamps and every commit reachable from local `refactor`,
|
||||
including merges. It reads subjects only. A repeated `#N` in one subject counts
|
||||
once. A commit naming two issues counts for both rows but once in total commits.
|
||||
Untagged commits do not count. Issue numbers are literal references to
|
||||
`mosaicstack/stack`, with no attempt to remap archived repositories' numbers.
|
||||
This can associate historical references with unrelated same-number issues.
|
||||
- Table 1 includes issues with a tagged commit or a `closed_at` in range.
|
||||
Opened is `created_at`; hours open is `closed_at - created_at`, rounded to one
|
||||
decimal, or `open` if not closed. It is not age as of `--until`. Reopen history
|
||||
is unavailable from the issue-list response. Median hours open uses only
|
||||
issues closed in range and rounds after computing the median.
|
||||
- Follow-ups are `max(commits in range - 1, 0)` per issue, not a lifetime count
|
||||
and not a quality assessment. Follow-ups per issue divides their sum by all
|
||||
Table 1 rows, including close-only rows.
|
||||
- Table 2 counts user-message entries in `.pi/state/<seat>/sessions/*.jsonl`
|
||||
where `<seat>` is a real directory in `agents/`. All matching files count;
|
||||
duplicated entries in copied logs are not deduplicated. No transcript content
|
||||
leaves the parser. Assistant messages and logs outside repo seats do not count.
|
||||
Symlink source directories are refused and symlink files are not followed.
|
||||
- The first text line alone classifies a message. A bracketed addressing
|
||||
preamble whose source session is `control-board` is board; any other valid
|
||||
addressing preamble is agent; otherwise human. This is a format count, not
|
||||
proof of who typed the message. Text blocks are joined with newlines.
|
||||
The entry timestamp is used, falling back to the message timestamp.
|
||||
- Seats with no in-range user messages are omitted. Issue seats come from `#N`
|
||||
mentions anywhere in in-range user text, including quoted text.
|
||||
- Human messages per closed issue divides Table 2's human sum by issues closed
|
||||
in range. A zero denominator with human messages is `unknown`; a truly empty
|
||||
report has zero totals. JSON keeps numeric values as numbers; text displays
|
||||
ratios and durations with one decimal. Titles truncate to 48 characters in
|
||||
text only. Missing evidence is the literal string `unknown`.
|
||||
|
||||
## One Gitea call and missing evidence
|
||||
|
||||
The client requests issues updated since the start date, all states, first page,
|
||||
limit 50. This includes issues closed in range, even if later updated. Gitea caps
|
||||
responses at 50; a full page fails rather than silently reporting partial totals.
|
||||
Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
|
||||
issue not returned by the updated-since query still has a row, with unknown
|
||||
metadata. This is the cost of the brief's one-call boundary.
|
||||
|
||||
Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git
|
||||
or session evidence. Malformed JSONL, including a partially written last line,
|
||||
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
|
||||
credentials, API, payload, or completeness failure. The CLI never prints API
|
||||
error bodies or reads authentication files itself. `--no-issues` makes no API
|
||||
call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
|
||||
median duration, and human-per-closed ratio. It cannot invent close-only rows.
|
||||
|
||||
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
|
||||
repository scripts directory is appended to PATH for the issue request.
|
||||
Tests use only temporary repositories, logs, and fake API tools, with no real
|
||||
credentials or network. The helper regression stubs Node before any credential
|
||||
read and checks successful GET, successful POST, and failed HTTP status.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Gate D is Jason's: run the requested week, choose a number to move next week,
|
||||
and write the sentence and number into `docs/plans/CURRENT.md`. Automated tests
|
||||
and publication do not pass that gate. Remove the package to stop using it;
|
||||
there is no persistent ledger state to migrate or restore.
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dateRange, readCommits, readIssues, readSessions, summarize, formatTable, SourceError } from './ledger.mjs';
|
||||
|
||||
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues]';
|
||||
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
|
||||
let since, until, json = false, noIssues = false;
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const flag = args[i];
|
||||
if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
|
||||
seen.add(flag);
|
||||
if (flag === '--help') { console.log(usage); return; }
|
||||
if (flag === '--json') json = true;
|
||||
else if (flag === '--no-issues') noIssues = true;
|
||||
else if (flag === '--since' || flag === '--until') {
|
||||
const value = args[++i];
|
||||
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`);
|
||||
if (flag === '--since') since = value; else until = value;
|
||||
} else throw new SourceError('Unknown option; ' + usage);
|
||||
}
|
||||
if (!since) throw new SourceError(usage);
|
||||
const range = dateRange(since, until);
|
||||
const commits = readCommits(root, range);
|
||||
// Fixture tools may be placed first on PATH. The repository client is the
|
||||
// default without requiring installation or reading auth material here.
|
||||
const priorPath = process.env.PATH;
|
||||
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
|
||||
let issues;
|
||||
try { issues = noIssues ? null : readIssues(root, range); }
|
||||
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
|
||||
const sessions = await readSessions(root, range);
|
||||
const report = summarize(range, commits, issues, sessions);
|
||||
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
|
||||
return report;
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main(process.argv.slice(2)).catch(error => {
|
||||
console.error(error instanceof SourceError ? error.message : 'Ledger failed: cannot read source evidence');
|
||||
process.exitCode = error.exitCode ?? 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, lstat } from 'node:fs/promises';
|
||||
import { createInterface } from 'node:readline';
|
||||
import path from 'node:path';
|
||||
|
||||
const DAY = 86400000;
|
||||
export const UNKNOWN = 'unknown';
|
||||
export class SourceError extends Error {
|
||||
constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
|
||||
}
|
||||
|
||||
export function dateRange(since, until = new Date().toISOString().slice(0, 10)) {
|
||||
const parse = value => {
|
||||
const ms = Date.parse(`${value}T00:00:00Z`);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(ms) ||
|
||||
new Date(ms).toISOString().slice(0, 10) !== value) {
|
||||
throw new SourceError('Dates must be valid YYYY-MM-DD values');
|
||||
}
|
||||
return ms;
|
||||
};
|
||||
const start = parse(since), end = parse(until) + DAY;
|
||||
if (end <= start) throw new SourceError('--until must not precede --since');
|
||||
return { since, until, start, end };
|
||||
}
|
||||
const inRange = (value, range) => {
|
||||
const ms = typeof value === 'number' ? value : Date.parse(value);
|
||||
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
|
||||
};
|
||||
export const issueNumbers = text => [...new Set(
|
||||
[...text.matchAll(/(?:^|[^\w])#([1-9]\d*)\b/g)].map(m => Number(m[1]))
|
||||
)].filter(Number.isSafeInteger);
|
||||
|
||||
export function readCommits(root, range) {
|
||||
let output;
|
||||
try {
|
||||
// Filter ourselves: git --since can prune history at an out-of-order date.
|
||||
output = execFileSync('git', ['log', 'refactor', '--format=%H%x00%ct%x00%s'],
|
||||
{ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch { throw new SourceError('Cannot read local refactor git log'); }
|
||||
return output.split('\n').filter(Boolean).flatMap(line => {
|
||||
const [hash, seconds, subject] = line.split('\0');
|
||||
const issues = issueNumbers(subject ?? '');
|
||||
return inRange(Number(seconds) * 1000, range) && issues.length ? [{ hash, issues }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function readIssues(root, range, tool = 'gitea-api.sh') {
|
||||
let output;
|
||||
try {
|
||||
// Updated-since includes closes in range. No until filter: later updates must
|
||||
// not hide a close in range. One page only, as required by the brief.
|
||||
output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=50&page=1`],
|
||||
{ cwd: root, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (error) {
|
||||
// Never echo an arbitrary API response or stderr that might contain secrets.
|
||||
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
|
||||
error.code === 'ENOENT' ? 'gitea-api.sh unavailable' : 'credential or Gitea request failure';
|
||||
throw new SourceError(`Issues unavailable: ${reason}; use --no-issues for unknown issue metrics`, 2);
|
||||
}
|
||||
let issues;
|
||||
try { issues = JSON.parse(output); } catch { throw new SourceError('Issues unavailable: invalid Gitea JSON', 2); }
|
||||
if (!Array.isArray(issues) || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
|
||||
typeof i.title !== 'string' || !Number.isFinite(Date.parse(i.created_at)) ||
|
||||
!(i.closed_at === null || Number.isFinite(Date.parse(i.closed_at))))) {
|
||||
throw new SourceError('Issues unavailable: invalid Gitea issue records', 2);
|
||||
}
|
||||
if (issues.length >= 50) throw new SourceError('Issues unavailable: full 50-row page may be incomplete; one-call limit forbids pagination. Narrow --since or use --no-issues', 2);
|
||||
if (new Set(issues.map(i => i.number)).size !== issues.length) throw new SourceError('Issues unavailable: duplicate issue numbers', 2);
|
||||
return issues.filter(i => !i.pull_request);
|
||||
}
|
||||
|
||||
export function messageText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) return content.filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
|
||||
return '';
|
||||
}
|
||||
export function messageKind(text) {
|
||||
const firstLine = text.split(/\r?\n/, 1)[0];
|
||||
const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
|
||||
return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent';
|
||||
}
|
||||
async function directories(dir, optional = false) {
|
||||
try {
|
||||
if (!(await lstat(dir)).isDirectory()) throw new SourceError('Session source must be a real directory');
|
||||
return await readdir(dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (optional && error.code === 'ENOENT') return [];
|
||||
throw new SourceError(`Cannot read ledger directory: ${dir}`);
|
||||
}
|
||||
}
|
||||
export async function readSessions(root, range) {
|
||||
const rows = [];
|
||||
const mentions = new Map();
|
||||
// No symlink traversal, no fleet paths, no transcript content in the report.
|
||||
const agents = (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const state = path.join(root, '.pi', 'state');
|
||||
// Check every source ancestor, not only the leaf directory.
|
||||
if (!(await directories(path.join(root, '.pi'), true)).length) return { rows, mentions };
|
||||
await directories(state, true);
|
||||
for (const agent of agents) {
|
||||
const seatRoot = path.join(state, agent.name);
|
||||
await directories(seatRoot, true);
|
||||
const dir = path.join(seatRoot, 'sessions');
|
||||
const files = (await directories(dir, true)).filter(e => e.isFile() && e.name.endsWith('.jsonl'));
|
||||
const row = { seat: agent.name, board: 0, agent: 0, human: 0 };
|
||||
for (const file of files) {
|
||||
const input = createReadStream(path.join(dir, file.name));
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
let lineNumber = 0;
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
lineNumber++;
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try { entry = JSON.parse(line); }
|
||||
catch { throw new SourceError(`Malformed session JSON: ${agent.name}/${file.name}:${lineNumber}`); }
|
||||
if (entry?.type !== 'message' || entry.message?.role !== 'user') continue;
|
||||
const stamp = entry.timestamp ?? entry.message.timestamp;
|
||||
if (!Number.isFinite(typeof stamp === 'number' ? stamp : Date.parse(stamp))) {
|
||||
throw new SourceError(`Invalid user-message timestamp: ${agent.name}/${file.name}:${lineNumber}`);
|
||||
}
|
||||
if (!inRange(stamp, range)) continue;
|
||||
const text = messageText(entry.message.content);
|
||||
row[messageKind(text)]++;
|
||||
for (const number of issueNumbers(text)) {
|
||||
if (!mentions.has(number)) mentions.set(number, new Set());
|
||||
mentions.get(number).add(agent.name);
|
||||
}
|
||||
}
|
||||
} finally { lines.close(); input.destroy(); }
|
||||
}
|
||||
if (row.board + row.agent + row.human) rows.push(row);
|
||||
}
|
||||
return { rows, mentions };
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10;
|
||||
function duration(issue) {
|
||||
if (!issue) return UNKNOWN;
|
||||
if (!issue.closed_at) return 'open';
|
||||
const hours = (Date.parse(issue.closed_at) - Date.parse(issue.created_at)) / 3600000;
|
||||
return hours >= 0 ? hours : UNKNOWN;
|
||||
}
|
||||
export function summarize(range, commits, issues, sessions) {
|
||||
const byNumber = new Map((issues ?? []).map(i => [i.number, i]));
|
||||
const counts = new Map();
|
||||
for (const commit of commits) for (const n of commit.issues) counts.set(n, (counts.get(n) ?? 0) + 1);
|
||||
const closed = (issues ?? []).filter(i => i.closed_at && inRange(i.closed_at, range));
|
||||
const touched = new Set([...counts.keys(), ...closed.map(i => i.number)]);
|
||||
const rows = [...touched].sort((a, b) => a - b).map(number => {
|
||||
const issue = byNumber.get(number), count = counts.get(number) ?? 0;
|
||||
const hours = duration(issue);
|
||||
return { issue: number, title: issue?.title ?? UNKNOWN, opened: issue?.created_at ?? UNKNOWN,
|
||||
hoursOpen: typeof hours === 'number' ? round(hours) : hours, commits: count,
|
||||
followUps: Math.max(0, count - 1), seats: [...(sessions.mentions.get(number) ?? [])].sort() };
|
||||
});
|
||||
const hours = closed.map(duration).sort((a, b) => a - b);
|
||||
const middle = Math.floor(hours.length / 2);
|
||||
const median = hours.includes(UNKNOWN) ? UNKNOWN : hours.length ?
|
||||
round(hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2) : 0;
|
||||
const human = sessions.rows.reduce((sum, r) => sum + r.human, 0);
|
||||
return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows,
|
||||
totals: { issuesClosed: issues === null ? UNKNOWN : closed.length,
|
||||
medianHoursOpen: issues === null ? UNKNOWN : median, commits: commits.length,
|
||||
followUpsPerIssue: rows.length ? round(rows.reduce((sum, r) => sum + r.followUps, 0) / rows.length) : 0,
|
||||
humanMessagesPerClosedIssue: issues === null ? UNKNOWN : closed.length ? round(human / closed.length) : human ? UNKNOWN : 0 } };
|
||||
}
|
||||
const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
||||
const decimal = value => typeof value === 'number' ? value.toFixed(1) : value;
|
||||
export function totalsLine(t) {
|
||||
return `Totals: issues closed ${t.issuesClosed} | median hours open ${decimal(t.medianHoursOpen)} | commits ${t.commits} | follow-ups per issue ${decimal(t.followUpsPerIssue)} | human messages per closed issue ${decimal(t.humanMessagesPerClosedIssue)}`;
|
||||
}
|
||||
export function formatTable(report) {
|
||||
return [`Ledger ${report.since} through ${report.until} UTC`,
|
||||
'Issue | Title | Opened | Hours open | Commits | Follow-ups | Seats',
|
||||
...report.issues.map(r => [`#${r.issue}`, clean(r.title).slice(0, 48), r.opened,
|
||||
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
|
||||
'', 'Seat | Board | Agent | Human',
|
||||
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
|
||||
'', totalsLine(report.totals)].join('\n');
|
||||
}
|
||||
@@ -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}`));
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user