Files
commitea/apps/desktop/scripts/dogfood-report.ts
Croissant Le Doux 63f0ea1735 Dogfood harness: run CommiTea's engine on its own backlog (#31)
scripts/dogfood-report.ts drives the real @commitea/core engine (schedule +
capacity-aware Monte Carlo forecast + per-milestone runway) against the live
christian/commitea backlog and prints the project report each screen derives.
Read-only; `yarn tsx scripts/dogfood-report.ts`. Adds tsx as a devDependency.

First run surfaced two real gaps the fixtures hid: every open issue was
unassigned (so capacity load-balanced work onto the slow half-time lane and the
standup plan-per-person was empty) and no milestone had a due date (so Runway
couldn't judge on-track/at-risk). Both were fixed as PM actions on the repo via
the write path — all open issues assigned, milestone due dates synthesized from
the forecast — so the app's own numbers are now honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:53:28 -04:00

142 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dogfood (#31): run CommiTea's own deterministic engine against the live
* christian/commitea backlog and print the project report each screen derives —
* board, critical path, schedule, Monte Carlo forecast, per-milestone runway.
*
* This is the core brain (schedule + forecast + capacity), the exact code the
* desktop app runs. Read-only. Run: yarn tsx scripts/dogfood-report.ts
*/
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
createGiteaClient,
forecast,
inferLifecycle,
parseCapacityConfig,
capacityPerWorkday,
schedule,
type GiteaIssue,
type DependencyEdge,
type Worker,
} from '@commitea/core'
const here = dirname(fileURLToPath(import.meta.url))
function token(): string {
let dir = here
for (let i = 0; i < 6; i++) {
try {
const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(readFileSync(join(dir, '.env.local'), 'utf8'))
if (m) return m[1].trim()
} catch {
/* keep walking up */
}
dir = dirname(dir)
}
throw new Error('no GITEA_TOKEN in any .env.local up the tree')
}
/** working-day offset → calendar date (skip Sat/Sun), then a short label. */
function addWorkingDays(base: Date, n: number): Date {
const d = new Date(base)
let left = Math.ceil(n)
while (left > 0) {
d.setDate(d.getDate() + 1)
const day = d.getDay()
if (day !== 0 && day !== 6) left--
}
return d
}
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
async function main() {
const client = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea', token: token() },
fetch,
)
const all = await client.listIssues({ state: 'all' })
const open = all.filter((i) => i.state === 'open')
const closed = all.filter((i) => i.state === 'closed')
// Real dependency edges (open issues only — the scheduler drops out-of-scope endpoints anyway).
const edges: DependencyEdge[] = []
for (const i of open) {
for (const dep of await client.getIssueDependencies(i.number)) edges.push({ issue: i.number, dependsOn: dep })
}
// Capacity lanes from the pm-state repo (a separate repo, exactly like the app's
// pmStateClient — NOT the managed repo).
const pmClient = createGiteaClient(
{ baseUrl: 'https://gitea.stephenmann.io', owner: 'christian', repo: 'commitea-pm-state', token: token() },
fetch,
)
let workers: Worker[] = []
try {
const f = await pmClient.getFile('capacity/members.json')
if (f) {
const members = parseCapacityConfig(JSON.parse(Buffer.from(f.contentBase64, 'base64').toString('utf8')))
workers = members.map((m) => ({ person: m.person, speed: capacityPerWorkday(m) }))
}
} catch {
/* no capacity → single serial worker */
}
const toSchedulable = (issues: GiteaIssue[]) =>
issues.map((i) => ({
number: i.number,
title: i.title,
labels: i.labels,
estimateDays: i.facts.estimateDays,
priority: i.facts.priority,
assignee: i.assignee,
}))
const plan = schedule(toSchedulable(open), edges)
const today = new Date()
const f = forecast(toSchedulable(open), edges, { workers })
const bar = '─'.repeat(64)
console.log(`\n${bar}\n CommiTea, on itself — ${fmt(today)} ${today.getFullYear()}\n${bar}`)
console.log(` Board: ${open.length} open · ${closed.length} done`)
console.log(` Team: ${workers.length ? workers.map((w) => `${w.person} (${w.speed.toFixed(2)}/day)`).join(', ') : 'single serial worker'}`)
const unassigned = open.filter((i) => !i.assignee).length
if (unassigned) console.log(`${unassigned}/${open.length} open issues have no assignee`)
console.log(`\n Schedule (dependency + priority order, ${workers.length || 1} lane${workers.length === 1 ? '' : 's'}):`)
for (const it of plan.items) {
const crit = it.critical ? ' ★crit' : ''
const blocks = it.blocks.length ? ` blocks ${it.blocks.map((b) => '#' + b).join(',')}` : ''
console.log(
` #${String(it.number).padEnd(3)} ${fmt(addWorkingDays(today, it.startDay))}${fmt(addWorkingDays(today, it.endDay))}${crit.padEnd(7)} ${it.title.slice(0, 40)}${blocks}`,
)
}
if (plan.cycle) console.log(` ⚠ dependency cycle: ${plan.cycle.join(' → ')}`)
const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
console.log(`\n Forecast (Monte Carlo, ${f.trials} trials, ${f.coldStart ? 'cold-start priors' : 'fitted'}):`)
console.log(` whole backlog lands p50 ${fmt(addWorkingDays(today, f.p50Day))} · p80 ${fmt(addWorkingDays(today, f.p80Day))} · p90 ${fmt(addWorkingDays(today, p90))}`)
// Per-milestone runway.
const ms = await client.listMilestones()
console.log(`\n Runway (per open milestone):`)
for (const m of ms.filter((x) => x.state === 'open')) {
const scope = open.filter((i) => i.milestone?.id === m.id)
if (!scope.length) continue
const mf = forecast(toSchedulable(scope), edges, { workers })
const mp90 = mf.curve.length ? mf.curve[mf.curve.length - 1].p90Day : mf.p95Day
const due = m.dueOn ? fmt(new Date(m.dueOn)) : 'no due date'
console.log(
` ${m.title.padEnd(28)} ${scope.length} open · p50 ${fmt(addWorkingDays(today, mf.p50Day))}p90 ${fmt(addWorkingDays(today, mp90))} (due: ${due})`,
)
}
console.log(`\n${bar}\n`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})