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>
This commit is contained in:
141
apps/desktop/scripts/dogfood-report.ts
Normal file
141
apps/desktop/scripts/dogfood-report.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
Reference in New Issue
Block a user