192 lines
13 KiB
Diff
192 lines
13 KiB
Diff
commit f3a966ea48b803cdced3f3f561d86c29d7878c25
|
|
Author: Jason Woltje <[email protected]>
|
|
Date: Mon Oct 19 07:57:37 2026 -0500
|
|
|
|
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])(?<!\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;
|
|
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');
|