ledger: count only stack issue numbers, a tracker-name skip and a --first-issue floor (#1665)

This commit is contained in:
jason.woltje committed 2026-10-19 08:24:06 -05:00
1 parent 54786df03a
commit efb51169de
4 files changed
+83 -11

No files matched your search

+7 -3
View File
@@ -11,6 +11,7 @@ No board changes, data-root writes, fleet reads, transcript output, or scheduler
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 packages/ledger/src/cli.mjs --since 2026-09-06 --first-issue 1
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3
node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-queue
@@ -27,9 +28,12 @@ No install, build, service restart, or configuration change is needed.
- 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.
Untagged commits do not count. The ledger counts a `#N` in a subject or a
message as a `mosaicstack/stack` issue only when N is at least 1495, the
conversion issue (`--first-issue N` moves that floor), and the word right
before it isn't `Vikunja`, `P19`, `SetSpark` or `Launchpad`. A commit whose
numbers all fall out counts as untagged. Issues Gitea lists as closed in range
are counted whatever their number.
- 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
+9 -5
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
import { dateRange, readCommits, readIssues, readSessions, mergeSources, stackRefs, summarize, formatTable, FIRST_ISSUE, SourceError } from './ledger.mjs';
import { readT3, defaultT3Path } from './t3.mjs';
import { readQueue, readSeatsDir, issueStates, queueChecks, formatQueue, checkSeatName } from './queue-checks.mjs';
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH] [--no-queue | --unsupported-runtime SEAT ...]';
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--first-issue N] [--no-t3 | --t3-db PATH] [--no-queue | --unsupported-runtime SEAT ...]';
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
let since, until, t3Db, json = false, noIssues = false, noT3 = false, noQueue = false;
let since, until, t3Db, first = FIRST_ISSUE, json = false, noIssues = false, noT3 = false, noQueue = false;
const exempt = new Set();
const seen = new Set();
for (let i = 0; i < args.length; i++) {
@@ -29,6 +29,10 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
else if (flag === '--t3-db') {
t3Db = args[++i];
if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path');
} else if (flag === '--first-issue') {
const value = args[++i];
if (!/^[1-9]\d*$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) throw new SourceError('--first-issue requires a positive integer');
first = Number(value);
} else if (flag === '--since' || flag === '--until') {
const value = args[++i];
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`);
@@ -54,8 +58,8 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath(
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
// T3 is on by default. A missing or unreadable database refuses the report.
const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false });
const sessions = mergeSources(await readSessions(root, range), t3);
const report = summarize(range, commits, issues, sessions);
const refs = stackRefs(commits, mergeSources(await readSessions(root, range), t3), first);
const report = summarize(range, refs.commits, issues, refs.sessions);
report.issueSource = noIssues ? { read: false } : { read: true, pages };
report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false };
// The queue section prints above the weekly table.
+15 -1
View File
@@ -26,9 +26,23 @@ export const inRange = (value, range) => {
const ms = typeof value === 'number' ? value : Date.parse(value);
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
};
// A #N right after one of these tracker names, with one space between, is that
// tracker's number. `Gitea #N` and `issue #N` stay counted.
export const issueNumbers = text => [...new Set(
[...text.matchAll(/(?:^|[^\w])#([1-9]\d*)\b/g)].map(m => Number(m[1]))
[...text.matchAll(/(?:^|[^\w])(?<!\b(?:vikunja|p19|setspark|launchpad) )#([1-9]\d*)\b/gi)].map(m => Number(m[1]))
)].filter(Number.isSafeInteger);
// Stack issues start at #1495, the conversion issue. A lower #N in a subject or
// message cites the archived v1 repositories or another tracker.
export const FIRST_ISSUE = 1495;
// Drops numbers below first from commit issues and session mentions. A commit
// left with none counts as untagged. Gitea's closed issues are not filtered.
export function stackRefs(commits, sessions, first = FIRST_ISSUE) {
const keep = n => n >= first;
return {
commits: commits.flatMap(c => { const issues = c.issues.filter(keep); return issues.length ? [{ ...c, issues }] : []; }),
sessions: { ...sessions, mentions: new Map([...sessions.mentions].filter(([n]) => keep(n))) },
};
}
export function readCommits(root, range) {
let output;
+52 -2
View File
@@ -6,7 +6,7 @@ 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';
import { dateRange, messageKind, issueNumbers, stackRefs, totalsLine, summarize } from '../src/ledger.mjs';
import { readT3 } from '../src/t3.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
@@ -80,7 +80,9 @@ else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8'));
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');
// These tests cover the weekly table; queue-checks.test.mjs covers the queue section.
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', '--no-queue', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
// The fixture numbers sit below the stack floor, so runs pass --first-issue 1
// unless a test asks for the default floor with { floor: [] }.
const run = (args = [], env = {}, { floor = ['--first-issue', '1'] } = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', '--no-queue', ...floor, ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
}
test('fixture git subjects only, follow-ups and three session kinds', t => {
@@ -234,6 +236,54 @@ test('preamble parsing and issue number boundaries', () => {
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
});
test('tracker names skip their numbers; the floor drops the rest of the foreign refs', () => {
// A tracker name then the number, any case, one space between.
assert.deepEqual(issueNumbers('Vikunja #259'), []);
assert.deepEqual(issueNumbers('P19 #252'), []);
assert.deepEqual(issueNumbers('Vikunja P19 #232'), []);
assert.deepEqual(issueNumbers('SetSpark #35 and Launchpad #36'), []);
assert.deepEqual(issueNumbers('vikunja #1600 p19 #1601 SETSPARK #1602'), []);
assert.deepEqual(issueNumbers('xvikunja #1600 Vikunja #1601'), [1600, 1601]);
// A range after a tracker name: the second number has no name before it.
assert.deepEqual(issueNumbers('P19 #219-#222'), [222]);
// Bare foreign numbers pass the skip; the floor drops them.
assert.deepEqual(issueNumbers('#237 proxy'), [237]);
assert.deepEqual(issueNumbers('Q61a #19'), [19]);
// Stack issues after another name stay counted.
assert.deepEqual(issueNumbers('Gitea #1578'), [1578]);
assert.deepEqual(issueNumbers('issue #1600'), [1600]);
assert.deepEqual(issueNumbers('#1603, Vikunja #259'), [1603]);
const commits = [{ hash: 'a', issues: [222, 1600] }, { hash: 'b', issues: [237] }, { hash: 'c', issues: [1494, 1495] }];
const sessions = { rows: [], mentions: new Map([[19, new Set(['bob'])], [1600, new Set(['alice'])]]) };
const refs = stackRefs(commits, sessions);
assert.deepEqual(refs.commits, [{ hash: 'a', issues: [1600] }, { hash: 'c', issues: [1495] }]);
assert.deepEqual([...refs.sessions.mentions.keys()], [1600]);
assert.deepEqual(stackRefs(commits, sessions, 1).commits, commits);
});
test('the default floor counts only stack issues in the table, commit total and seats', t => {
const f = fixture(t);
f.commit('land #1600, P19 #219-#222'); f.commit('Gitea #1601 and Vikunja #1602'); f.commit('P19 #252 only');
f.put('.pi/state/bob/sessions/b.jsonl', [f.entry('Vikunja #1601 then #1600'), f.entry('Q61a #19')].map(x => JSON.stringify(x)).join('\n') + '\n');
const result = f.run(['--json'], {}, { floor: [] });
assert.equal(result.status, 0, result.stderr);
const r = JSON.parse(result.stdout);
// #1 stays as a close-only row from Gitea, but alice's "do #1" no longer names it.
assert.deepEqual(r.issues.map(x => [x.issue, x.commits, x.seats]), [[1, 0, []], [1600, 1, ['bob']], [1601, 1, []]]);
assert.equal(r.totals.commits, 2);
assert.equal(r.totals.followUpsPerIssue, 0);
// With the floor at 1 the bare #222 from the range counts again; tracker names still skip.
const all = JSON.parse(f.run(['--json']).stdout);
assert.deepEqual(all.issues.map(x => x.issue), [1, 2, 222, 1600, 1601]);
assert.equal(all.totals.commits, 5);
});
test('--first-issue refuses a missing, zero or non-integer value', t => {
const f = fixture(t);
for (const bad of [[], ['0'], ['1.5'], ['abc'], ['-3'], ['--json'], ['99999999999999999']]) {
const r = f.run(['--first-issue', ...bad], {}, { floor: [] });
assert.equal(r.status, 1, bad.join(' ')); assert.match(r.stderr, /^--first-issue requires a positive integer$/m);
}
assert.match(f.run(['--first-issue', '7'], {}, { floor: [] }).stdout, /^Ledger 2026-09-06 through 2026-09-12 UTC$/m);
});
test('T3 header: agent, or board from control-board; anything short of the full header is human', () => {
const sage = 'sage (1ef1e4f8-3ead-4208-beca-38f9f1add079)', filbert = 'filbert (9cb9731e-a10f-4c8f-a212-c4fa1f5f4731)';
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent');