From 0ef8620f3f4d1804137c2e2fccf9eab391934eb1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 19 Oct 2026 07:57:37 -0500 Subject: [PATCH] ledger: count only stack issue numbers, a tracker-name skip and a --first-issue floor (#1665) --- packages/ledger/README.md | 10 +++-- packages/ledger/src/cli.mjs | 14 ++++--- packages/ledger/src/ledger.mjs | 16 +++++++- packages/ledger/tests/ledger.test.mjs | 54 ++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/ledger/README.md b/packages/ledger/README.md index 5fc4b1b65..f98fb6be7 100644 --- a/packages/ledger/README.md +++ b/packages/ledger/README.md @@ -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 diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs index 5534a3c4d..5047f989c 100644 --- a/packages/ledger/src/cli.mjs +++ b/packages/ledger/src/cli.mjs @@ -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. diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs index cd14d2a04..1267e53f9 100644 --- a/packages/ledger/src/ledger.mjs +++ b/packages/ledger/src/ledger.mjs @@ -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])(? 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; diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs index 5bd7f42aa..ebd3f2b84 100644 --- a/packages/ledger/tests/ledger.test.mjs +++ b/packages/ledger/tests/ledger.test.mjs @@ -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');