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:10:32 -05:00
1 parent c34deedf92
commit 0ef8620f3f
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
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 --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 --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 --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 --t3-db /tmp/fixture.sqlite
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-queue 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`, - Git uses committer timestamps and every commit reachable from local `refactor`,
including merges. It reads subjects only. A repeated `#N` in one subject counts 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. 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 Untagged commits do not count. The ledger counts a `#N` in a subject or a
`mosaicstack/stack`, with no attempt to remap archived repositories' numbers. message as a `mosaicstack/stack` issue only when N is at least 1495, the
This can associate historical references with unrelated same-number issues. 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. - 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 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 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 #!/usr/bin/env node
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { readT3, defaultT3Path } from './t3.mjs';
import { readQueue, readSeatsDir, issueStates, queueChecks, formatQueue, checkSeatName } from './queue-checks.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)), '../../..')) { 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 exempt = new Set();
const seen = new Set(); const seen = new Set();
for (let i = 0; i < args.length; i++) { 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') { else if (flag === '--t3-db') {
t3Db = args[++i]; t3Db = args[++i];
if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path'); 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') { } else if (flag === '--since' || flag === '--until') {
const value = args[++i]; const value = args[++i];
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`); 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; } 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. // 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 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 refs = stackRefs(commits, mergeSources(await readSessions(root, range), t3), first);
const report = summarize(range, commits, issues, sessions); const report = summarize(range, refs.commits, issues, refs.sessions);
report.issueSource = noIssues ? { read: false } : { read: true, pages }; report.issueSource = noIssues ? { read: false } : { read: true, pages };
report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false }; report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false };
// The queue section prints above the weekly table. // 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); const ms = typeof value === 'number' ? value : Date.parse(value);
return Number.isFinite(ms) && ms >= range.start && ms < range.end; 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( 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); )].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) { export function readCommits(root, range) {
let output; let output;
+52 -2
View File
@@ -6,7 +6,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { execFileSync, spawnSync } from 'node:child_process'; 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'; import { readT3 } from '../src/t3.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src'); 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' } }]; 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'); 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. // 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 }; return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
} }
test('fixture git subjects only, follow-ups and three session kinds', t => { 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.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]); 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', () => { 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)'; 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'); assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent');