Files
commitea/apps/desktop/scripts/dogfood-report.ts
Croissant Le Doux 3873e652f2 Gantt bars use the capacity-aware lane layout (match the forecast)
ganttView ran schedule() — the single-serial-worker layout — while the Monte
Carlo forecast ran scheduleWithCapacity() over the real lanes. So the bars you
saw didn't match what was forecast (serial 1.0/day vs the team's actual lanes).

Switch ganttView to scheduleWithCapacity(open, deps, workers): startDay/endDay
now come from the same lane layout each forecast trial uses, and `who` shows the
lane an issue actually landed on (falling back to assignee, then blank). The
dogfood harness had the same split — updated it to match and to print the lane
per row.

Verified: typecheck clean, 14 fixture e2e green, live-onboarding e2e renders the
real capacity-aware Gantt with no uncaught errors.

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

143 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,
scheduleWithCapacity,
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 = scheduleWithCapacity(toSchedulable(open), edges, workers)
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 lane = it.worker ? ` [${it.worker}]` : ''
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)}${lane} ${it.title.slice(0, 38)}${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)
})