Compare commits
4 Commits
feat/offli
...
d98921033e
| Author | SHA1 | Date | |
|---|---|---|---|
| d98921033e | |||
|
|
b9b21450e7 | ||
| 153a3e85a1 | |||
|
|
ba3d9df88e |
@@ -16,15 +16,22 @@ test.describe('live backlog', () => {
|
||||
await win.waitForLoadState('domcontentloaded')
|
||||
const rail = win.getByRole('navigation', { name: 'Primary' })
|
||||
|
||||
// The pot — waiting here also lets the reconcile (issues + deps) complete
|
||||
// The pot — waiting here also lets the reconcile (issues + deps + timelines) complete
|
||||
await rail.getByRole('button', { name: 'The pot' }).click()
|
||||
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
|
||||
// Lifecycle inference (#5): columns come from the real event stream — merged
|
||||
// work lands in Done, so that column is non-empty (proves timelines drove it,
|
||||
// not the three-column fallback which would still show closed issues in Done).
|
||||
await expect(win.getByText('Done', { exact: true })).toBeVisible()
|
||||
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
|
||||
|
||||
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These
|
||||
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
|
||||
await rail.getByRole('button', { name: 'Morning service' }).click()
|
||||
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
|
||||
// Real Monte Carlo cone — this headline is emitted only for real forecasts.
|
||||
await expect(win.getByText(/80% of the open backlog lands by/)).toBeVisible()
|
||||
await expect(win.getByText(/Cold-start priors/)).toBeVisible()
|
||||
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
|
||||
|
||||
await app.close()
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { createGiteaClient, type GiteaConfig } from '@commitea/core'
|
||||
import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
|
||||
@@ -51,7 +51,7 @@ export function registerGiteaIpc(): void {
|
||||
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
|
||||
|
||||
ipcMain.handle('gitea:reconcile', async () => {
|
||||
if (!client) return { configured: false, issues: [], milestones: [], deps: [] }
|
||||
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
|
||||
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||
// dependency edges among the open scope (the scheduler only plans what's left)
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
@@ -59,7 +59,12 @@ export function registerGiteaIpc(): void {
|
||||
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
|
||||
)
|
||||
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
|
||||
return { configured: true, issues, milestones, deps }
|
||||
// lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
|
||||
const timelineEntries = await Promise.all(
|
||||
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
|
||||
)
|
||||
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
|
||||
return { configured: true, issues, milestones, deps, timelines }
|
||||
})
|
||||
|
||||
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { BurnUpData } from '../../lib/dates.js'
|
||||
|
||||
/**
|
||||
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. The
|
||||
* fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the
|
||||
* shapes (cone from today, 80% band, actual polyline, today rule) are final.
|
||||
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. When a
|
||||
* `data` prop is supplied the cone is drawn from real Monte Carlo output
|
||||
* (#10); without it, the fixed sample paths below stand in (demo mode). The
|
||||
* shapes (cone from today, 80% band, today rule) are final.
|
||||
*/
|
||||
|
||||
export interface BurnUpConeProps {
|
||||
width?: number
|
||||
height?: number
|
||||
/** Real forecast geometry. When present it replaces the demo sample paths. */
|
||||
data?: BurnUpData
|
||||
}
|
||||
|
||||
export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) {
|
||||
export function BurnUpCone({ width = 640, height = 220, data }: BurnUpConeProps) {
|
||||
if (data) return <BurnUpConeReal width={width} height={height} data={data} />
|
||||
const pad = { l: 34, r: 96, t: 16, b: 26 }
|
||||
const W = width - pad.l - pad.r
|
||||
const H = height - pad.t - pad.b
|
||||
@@ -89,6 +95,62 @@ export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Data-driven twin of BurnUpCone — same visual grammar, real forecast geometry. */
|
||||
function BurnUpConeReal({ width, height, data }: { width: number; height: number; data: BurnUpData }) {
|
||||
const pad = { l: 34, r: 96, t: 16, b: 26 }
|
||||
const W = width - pad.l - pad.r
|
||||
const H = height - pad.t - pad.b
|
||||
const x = (day: number) => pad.l + (day / data.horizonDays) * W
|
||||
const y = (frac: number) => pad.t + (1 - frac) * H
|
||||
|
||||
const hi = data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`).join(' ')
|
||||
const lo = data.band.map((p) => `${x(p.loDay)},${y(p.fraction)}`).join(' ')
|
||||
const mid = data.band.map((p) => `${x(p.midDay)},${y(p.fraction)}`).join(' ')
|
||||
const cone = [
|
||||
...data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`),
|
||||
...[...data.band].reverse().map((p) => `${x(p.loDay)},${y(p.fraction)}`),
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
|
||||
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
|
||||
))}
|
||||
{/* scope */}
|
||||
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
|
||||
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||
scope · {data.scope} {data.scope === 1 ? 'issue' : 'issues'}
|
||||
</text>
|
||||
{/* cone */}
|
||||
<polygon points={cone} fill="var(--cone-fill)" />
|
||||
<polyline points={hi} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||
<polyline points={lo} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||
<polyline points={mid} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
|
||||
{/* origin = today, 0% of remaining scope */}
|
||||
<circle cx={x(0)} cy={y(0)} r="3.5" fill="var(--cone-actual)" />
|
||||
{/* today rule */}
|
||||
<line x1={x(0)} x2={x(0)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
|
||||
<text x={x(0) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
|
||||
today
|
||||
</text>
|
||||
{/* 80% band label */}
|
||||
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
|
||||
80%
|
||||
</text>
|
||||
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
|
||||
{data.rangeLabel}
|
||||
</text>
|
||||
{/* x labels */}
|
||||
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||
{data.startLabel}
|
||||
</text>
|
||||
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||
{data.endLabel}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RunwayBarMilestone {
|
||||
tone: 'ok' | 'warn'
|
||||
pos: number
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
||||
import { type FocusView } from '../../lib/backlog.js'
|
||||
import { type ForecastView, type FocusView } from '../../lib/backlog.js'
|
||||
import { BurnUpCone } from '../charts/chart.js'
|
||||
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
||||
|
||||
/**
|
||||
* Morning service — the Now/Next/Later focus cards + the milestone burn-up cone.
|
||||
* `focus` (real scheduler output) overrides the demo fixture when gitea is
|
||||
* configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice).
|
||||
* Morning service — the Now/Next/Later focus cards + the burn-up cone.
|
||||
* `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures
|
||||
* when gitea is configured; both fall back to the handoff demo otherwise.
|
||||
*/
|
||||
export function FocusScreen({
|
||||
onOpenIssue,
|
||||
focus,
|
||||
forecast,
|
||||
}: {
|
||||
onOpenIssue: (issue: IssueRef) => void
|
||||
focus?: FocusView
|
||||
forecast?: ForecastView
|
||||
}) {
|
||||
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
||||
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
||||
@@ -94,13 +96,21 @@ export function FocusScreen({
|
||||
</div>
|
||||
|
||||
<Card
|
||||
overline="Milestone · Beta"
|
||||
title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>}
|
||||
overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
|
||||
title={
|
||||
forecast ? (
|
||||
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
|
||||
) : (
|
||||
<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>
|
||||
)
|
||||
}
|
||||
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
|
||||
>
|
||||
<BurnUpCone />
|
||||
<BurnUpCone data={forecast?.cone} />
|
||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||
The cone has narrowed since Friday. I’m quietly pleased.
|
||||
{forecast
|
||||
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — the cone tightens as the team closes work.`
|
||||
: 'The cone has narrowed since Friday. I’m quietly pleased.'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
|
||||
import logoIcon from '../../design/assets/logo-icon.png'
|
||||
import type { IssueRef } from '../../data/fixtures.js'
|
||||
import { issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
|
||||
import { forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
|
||||
import { useBacklog } from '../../lib/use-backlog.js'
|
||||
import { PrimitivesGallery } from '../gallery.js'
|
||||
import { BoardScreen } from '../screens/board-screen.js'
|
||||
@@ -86,8 +86,12 @@ export function AppShell() {
|
||||
const [issue, setIssue] = useState<IssueRef | null>(null)
|
||||
const [readIds, setReadIds] = useState<number[]>([])
|
||||
const backlog = useBacklog()
|
||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
||||
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined
|
||||
const boardColumns =
|
||||
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
|
||||
const focus =
|
||||
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
|
||||
const forecast =
|
||||
backlog.status === 'ready' ? (forecastBacklog(backlog.issues, backlog.deps) ?? undefined) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
@@ -158,7 +162,7 @@ export function AppShell() {
|
||||
const renderScreen = () => {
|
||||
switch (view) {
|
||||
case 'focus':
|
||||
return <FocusScreen onOpenIssue={openIssue} focus={focus} />
|
||||
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
|
||||
case 'standup':
|
||||
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
||||
case 'board':
|
||||
|
||||
4
apps/desktop/src/renderer/src/global.d.ts
vendored
4
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
|
||||
|
||||
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
|
||||
export interface GiteaBridge {
|
||||
@@ -8,6 +8,8 @@ export interface GiteaBridge {
|
||||
issues: GiteaIssue[]
|
||||
milestones: GiteaMilestone[]
|
||||
deps: DependencyEdge[]
|
||||
/** Normalized lifecycle events keyed by issue number. */
|
||||
timelines: Record<number, LifecycleEvent[]>
|
||||
}>
|
||||
getIssue(index: number): Promise<GiteaIssue | null>
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import {
|
||||
type DependencyEdge,
|
||||
forecast,
|
||||
type GiteaIssue,
|
||||
inferColumnV0,
|
||||
inferLifecycle,
|
||||
type LifecycleColumn,
|
||||
type LifecycleEvent,
|
||||
type LifecycleInference,
|
||||
schedule,
|
||||
type ScheduledItem,
|
||||
selectFocus,
|
||||
} from '@commitea/core'
|
||||
|
||||
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
||||
import { type BurnUpData, buildBurnUpData } from './dates.js'
|
||||
|
||||
type Timelines = Record<number, LifecycleEvent[]>
|
||||
|
||||
/** Infer every issue's lifecycle once; callers index by issue number. */
|
||||
function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map<number, LifecycleInference> {
|
||||
return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)]))
|
||||
}
|
||||
|
||||
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||
diagnosis: 'Diagnosis',
|
||||
@@ -25,24 +36,32 @@ function initials(login: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape real gitea issues into the Board's five columns via lifecycle-v0.
|
||||
* `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr`
|
||||
* are likewise event-derived and omitted here.
|
||||
* Shape real gitea issues into the Board's five columns via lifecycle inference
|
||||
* (#5). Steeping / In-review are now populated from the event stream (first
|
||||
* commit ref → steeping, first PR ref → review); the `days` badge is the
|
||||
* steeping age in working days.
|
||||
*/
|
||||
export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] {
|
||||
export function issuesToBoardColumns(
|
||||
issues: GiteaIssue[],
|
||||
timelines: Timelines = {},
|
||||
asOf: Date = new Date(),
|
||||
): BoardColumn[] {
|
||||
const inf = inferAll(issues, timelines, asOf)
|
||||
return COLUMN_ORDER.map((key) => ({
|
||||
id: key,
|
||||
label: COLUMN_LABELS[key],
|
||||
issues: issues
|
||||
.filter((i) => inferColumnV0(i) === key)
|
||||
.map(
|
||||
(i): BoardIssue => ({
|
||||
.filter((i) => inf.get(i.number)!.column === key)
|
||||
.map((i): BoardIssue => {
|
||||
const li = inf.get(i.number)!
|
||||
return {
|
||||
id: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
who: i.assignee ? initials(i.assignee) : '·',
|
||||
}),
|
||||
),
|
||||
days: li.steepingDays != null ? `${li.steepingDays}d` : undefined,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -52,9 +71,51 @@ export interface FocusView {
|
||||
later: FocusIssue | null
|
||||
}
|
||||
|
||||
function toFocusIssue(item: ScheduledItem | null): FocusIssue | null {
|
||||
function toFocusIssue(item: ScheduledItem | null, inf?: Map<number, LifecycleInference>): FocusIssue | null {
|
||||
if (!item) return null
|
||||
return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale }
|
||||
const steepingDays = inf?.get(item.number)?.steepingDays ?? null
|
||||
return {
|
||||
id: item.number,
|
||||
title: item.title,
|
||||
labels: item.labels,
|
||||
rationale: item.rationale,
|
||||
steeping: steepingDays != null ? `${steepingDays}d` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toSchedulable(issues: GiteaIssue[]) {
|
||||
return issues
|
||||
.filter((i) => i.state === 'open')
|
||||
.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
estimateDays: i.facts.estimateDays,
|
||||
priority: i.facts.priority,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface ForecastView {
|
||||
scope: number
|
||||
cone: BurnUpData
|
||||
p80Label: string
|
||||
rangeLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored
|
||||
* burn-up cone. Returns null when there's nothing to forecast (no open scope) —
|
||||
* the UI then falls back to the demo cone. `today` is injectable for tests.
|
||||
*/
|
||||
export function forecastBacklog(
|
||||
issues: GiteaIssue[],
|
||||
deps: DependencyEdge[],
|
||||
today: Date = new Date(),
|
||||
): ForecastView | null {
|
||||
const f = forecast(toSchedulable(issues), deps)
|
||||
const cone = buildBurnUpData(f, today)
|
||||
if (!cone) return null
|
||||
return { scope: f.scope, cone, p80Label: cone.p80Label, rangeLabel: cone.rangeLabel }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,18 +123,18 @@ function toFocusIssue(item: ScheduledItem | null): FocusIssue | null {
|
||||
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
||||
* edges come from gitea's native issue dependencies.
|
||||
*/
|
||||
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView {
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
const plan = schedule(
|
||||
open.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
estimateDays: i.facts.estimateDays,
|
||||
priority: i.facts.priority,
|
||||
})),
|
||||
deps,
|
||||
)
|
||||
export function scheduleFocus(
|
||||
issues: GiteaIssue[],
|
||||
deps: DependencyEdge[],
|
||||
timelines: Timelines = {},
|
||||
asOf: Date = new Date(),
|
||||
): FocusView {
|
||||
const plan = schedule(toSchedulable(issues), deps)
|
||||
const f = selectFocus(plan)
|
||||
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) }
|
||||
const inf = inferAll(issues, timelines, asOf)
|
||||
return {
|
||||
now: toFocusIssue(f.now, inf),
|
||||
next: toFocusIssue(f.next, inf),
|
||||
later: toFocusIssue(f.later, inf),
|
||||
}
|
||||
}
|
||||
|
||||
70
apps/desktop/src/renderer/src/lib/dates.ts
Normal file
70
apps/desktop/src/renderer/src/lib/dates.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Forecast } from '@commitea/core'
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
/** Advance `base` by whole working days (skipping Sat/Sun). Fractions round to nearest day. */
|
||||
export function addWorkingDays(base: Date, workingDays: number): Date {
|
||||
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate())
|
||||
let remaining = Math.max(0, Math.round(workingDays))
|
||||
while (remaining > 0) {
|
||||
d.setDate(d.getDate() + 1)
|
||||
const day = d.getDay()
|
||||
if (day !== 0 && day !== 6) remaining -= 1
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** "Mar 6" */
|
||||
export function formatShort(d: Date): string {
|
||||
return `${MONTHS[d.getMonth()]} ${d.getDate()}`
|
||||
}
|
||||
|
||||
/** "Feb 24 – Mar 6" (collapses the month when both ends share it → "Mar 3–12"). */
|
||||
export function formatRange(lo: Date, hi: Date): string {
|
||||
if (lo.getMonth() === hi.getMonth()) return `${MONTHS[lo.getMonth()]} ${lo.getDate()}–${hi.getDate()}`
|
||||
return `${formatShort(lo)} – ${formatShort(hi)}`
|
||||
}
|
||||
|
||||
export interface BurnUpBandPoint {
|
||||
fraction: number
|
||||
loDay: number
|
||||
midDay: number
|
||||
hiDay: number
|
||||
}
|
||||
|
||||
/** Chart-ready geometry + calendar labels derived from a Monte Carlo forecast. */
|
||||
export interface BurnUpData {
|
||||
scope: number
|
||||
horizonDays: number
|
||||
band: BurnUpBandPoint[]
|
||||
p80Day: number
|
||||
p80Label: string
|
||||
rangeLabel: string
|
||||
startLabel: string
|
||||
endLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a working-day forecast onto a calendar-anchored burn-up cone. `today`
|
||||
* anchors day 0; the cone emanates from (today, 0) and widens to the right.
|
||||
* The historical "actual" polyline behind today awaits lifecycle events (#5).
|
||||
*/
|
||||
export function buildBurnUpData(f: Forecast, today: Date): BurnUpData | null {
|
||||
if (f.scope === 0 || f.curve.length === 0) return null
|
||||
const last = f.curve[f.curve.length - 1]
|
||||
const horizonDays = Math.max(1, Math.ceil(last.p90Day * 1.05))
|
||||
const band: BurnUpBandPoint[] = [
|
||||
{ fraction: 0, loDay: 0, midDay: 0, hiDay: 0 },
|
||||
...f.curve.map((p) => ({ fraction: p.fraction, loDay: p.p10Day, midDay: p.p50Day, hiDay: p.p90Day })),
|
||||
]
|
||||
return {
|
||||
scope: f.scope,
|
||||
horizonDays,
|
||||
band,
|
||||
p80Day: f.p80Day,
|
||||
p80Label: formatShort(addWorkingDays(today, f.p80Day)),
|
||||
rangeLabel: formatRange(addWorkingDays(today, f.p50Day), addWorkingDays(today, last.p90Day)),
|
||||
startLabel: formatShort(today),
|
||||
endLabel: formatShort(addWorkingDays(today, horizonDays)),
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
|
||||
|
||||
export type BacklogState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] }
|
||||
| {
|
||||
status: 'ready'
|
||||
issues: GiteaIssue[]
|
||||
milestones: GiteaMilestone[]
|
||||
deps: DependencyEdge[]
|
||||
timelines: Record<number, LifecycleEvent[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the managed repo once on mount, through the main-process bridge.
|
||||
@@ -24,7 +30,13 @@ export function useBacklog(): BacklogState {
|
||||
if (!alive) return
|
||||
setState(
|
||||
r.configured
|
||||
? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps }
|
||||
? {
|
||||
status: 'ready',
|
||||
issues: r.issues,
|
||||
milestones: r.milestones,
|
||||
deps: r.deps,
|
||||
timelines: r.timelines,
|
||||
}
|
||||
: { status: 'unconfigured' },
|
||||
)
|
||||
})
|
||||
|
||||
100
packages/core/src/forecast/forecast-v0.test.ts
Normal file
100
packages/core/src/forecast/forecast-v0.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js'
|
||||
import { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast-v0.js'
|
||||
|
||||
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
|
||||
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
|
||||
}
|
||||
|
||||
describe('priorForEstimate', () => {
|
||||
it('returns the exact prior for a bucket day count', () => {
|
||||
expect(priorForEstimate(3)).toBe(COLD_START_PRIORS[3])
|
||||
expect(priorForEstimate(8)).toBe(COLD_START_PRIORS[8])
|
||||
})
|
||||
|
||||
it('snaps a non-bucket estimate to the nearest bucket', () => {
|
||||
expect(priorForEstimate(4)).toBe(COLD_START_PRIORS[3]) // tie → smaller bucket
|
||||
expect(priorForEstimate(6)).toBe(COLD_START_PRIORS[5])
|
||||
expect(priorForEstimate(100)).toBe(COLD_START_PRIORS[8])
|
||||
})
|
||||
|
||||
it('every prior is pessimistic (median actual runs longer than the estimate)', () => {
|
||||
for (const p of Object.values(COLD_START_PRIORS)) expect(p.mu).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forecast', () => {
|
||||
const scope = [
|
||||
issue(1, { estimateDays: 2 }),
|
||||
issue(2, { estimateDays: 3 }),
|
||||
issue(3, { estimateDays: 1 }),
|
||||
]
|
||||
|
||||
it('is reproducible: same seed → identical result', () => {
|
||||
const a = forecast(scope, [], { trials: 500, seed: 42 })
|
||||
const b = forecast(scope, [], { trials: 500, seed: 42 })
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
|
||||
it('percentiles are ordered p50 <= p80 <= p95', () => {
|
||||
const f = forecast(scope, [], { trials: 3000 })
|
||||
expect(f.p50Day).toBeLessThanOrEqual(f.p80Day)
|
||||
expect(f.p80Day).toBeLessThanOrEqual(f.p95Day)
|
||||
})
|
||||
|
||||
it('the burn-up curve is monotonic in both fraction and day', () => {
|
||||
const f = forecast(scope, [], { trials: 3000 })
|
||||
expect(f.curve).toHaveLength(3)
|
||||
for (let i = 1; i < f.curve.length; i++) {
|
||||
expect(f.curve[i].fraction).toBeGreaterThan(f.curve[i - 1].fraction)
|
||||
expect(f.curve[i].p50Day).toBeGreaterThan(f.curve[i - 1].p50Day)
|
||||
}
|
||||
expect(f.curve.at(-1)!.fraction).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('lo/mid/hi are ordered within each cone point', () => {
|
||||
const f = forecast(scope, [], { trials: 3000 })
|
||||
for (const pt of f.curve) {
|
||||
expect(pt.p10Day).toBeLessThanOrEqual(pt.p50Day)
|
||||
expect(pt.p50Day).toBeLessThanOrEqual(pt.p90Day)
|
||||
}
|
||||
})
|
||||
|
||||
it('the median landing runs longer than the raw estimate sum (pessimism)', () => {
|
||||
const rawSum = 2 + 3 + 1
|
||||
const f = forecast(scope, [], { trials: 4000 })
|
||||
expect(f.p50Day).toBeGreaterThan(rawSum)
|
||||
})
|
||||
|
||||
it('reports scope and cold-start honestly', () => {
|
||||
const f = forecast(scope, [], { trials: 100 })
|
||||
expect(f.scope).toBe(3)
|
||||
expect(f.coldStart).toBe(true)
|
||||
})
|
||||
|
||||
it('a dependency cycle yields an empty, zeroed forecast', () => {
|
||||
const edges: DependencyEdge[] = [
|
||||
{ issue: 1, dependsOn: 2 },
|
||||
{ issue: 2, dependsOn: 1 },
|
||||
]
|
||||
const f = forecast([issue(1), issue(2)], edges, { trials: 100 })
|
||||
expect(f.scope).toBe(0)
|
||||
expect(f.curve).toEqual([])
|
||||
expect(f.p80Day).toBe(0)
|
||||
})
|
||||
|
||||
it('an empty scope forecasts nothing', () => {
|
||||
const f = forecast([], [], { trials: 100 })
|
||||
expect(f.scope).toBe(0)
|
||||
expect(f.curve).toEqual([])
|
||||
})
|
||||
|
||||
it('unestimated issues fall back to the default-estimate prior', () => {
|
||||
const f = forecast([issue(1, { estimateDays: null }), issue(2, { estimateDays: null })], [], {
|
||||
trials: 500,
|
||||
})
|
||||
expect(f.scope).toBe(2)
|
||||
expect(f.p50Day).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
165
packages/core/src/forecast/forecast-v0.ts
Normal file
165
packages/core/src/forecast/forecast-v0.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Monte Carlo forecast, v0. The LLM never does this — it's plain, seeded,
|
||||
* reproducible code (evidence-based scheduling). It samples an actual duration
|
||||
* per open issue from a lognormal prior, walks the scheduler's deterministic
|
||||
* order on a single serial worker, and reads percentiles off the resulting
|
||||
* completion-day distribution.
|
||||
*
|
||||
* v0 simplifications (each a later slice, not a hack):
|
||||
* - single serial worker; per-person capacity + parallelism is #8.
|
||||
* - cold-start priors only; the team's empirical calibration fit lands at
|
||||
* n >= 20 closed-with-estimate issues (#5 supplies the actuals).
|
||||
* - forecast covers remaining (open) scope from today forward; the historical
|
||||
* burn-up "actual" polyline needs lifecycle event dates (#5).
|
||||
*/
|
||||
|
||||
import {
|
||||
type DependencyEdge,
|
||||
schedule,
|
||||
type SchedulableIssue,
|
||||
} from '../scheduler/scheduler-v0.js'
|
||||
|
||||
export interface LognormalPrior {
|
||||
/** Median log-ratio: sampled median duration = estimate * e^mu. */
|
||||
mu: number
|
||||
/** Spread of log(actual / estimate). */
|
||||
sigma: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Code-resident cold-start priors (D3). Lognormal on log(actual / estimate):
|
||||
* sampled actual = estimateDays * exp(N(mu, sigma)). mu > 0 encodes the honest
|
||||
* fact that actuals run long; sigma shrinks as tickets grow (a snag doubles a
|
||||
* 1-day task but barely dents an 8-day one). The team's fitted model replaces
|
||||
* these at n >= 20 (see calibration model in pm-state.md).
|
||||
*/
|
||||
export const COLD_START_PRIORS: Record<number, LognormalPrior> = {
|
||||
1: { mu: 0.25, sigma: 0.55 },
|
||||
2: { mu: 0.22, sigma: 0.48 },
|
||||
3: { mu: 0.2, sigma: 0.44 },
|
||||
5: { mu: 0.18, sigma: 0.4 },
|
||||
8: { mu: 0.16, sigma: 0.36 },
|
||||
}
|
||||
|
||||
const PRIOR_BUCKETS = [1, 2, 3, 5, 8]
|
||||
|
||||
/** Nearest estimate bucket (ties resolve to the smaller bucket). */
|
||||
export function priorForEstimate(days: number): LognormalPrior {
|
||||
let best = PRIOR_BUCKETS[0]
|
||||
for (const b of PRIOR_BUCKETS) {
|
||||
if (Math.abs(b - days) < Math.abs(best - days)) best = b
|
||||
}
|
||||
return COLD_START_PRIORS[best]
|
||||
}
|
||||
|
||||
export interface ForecastOptions {
|
||||
/** Simulation trials. More = smoother tails, linear cost. */
|
||||
trials?: number
|
||||
/** PRNG seed. Fixed by default so a forecast is reproducible. */
|
||||
seed?: number
|
||||
}
|
||||
|
||||
export interface BurnUpPoint {
|
||||
/** Cumulative fraction of remaining scope complete (0 < f <= 1]. */
|
||||
fraction: number
|
||||
/** Working-day offset from today at the p10 / p50 / p90 of reaching it. */
|
||||
p10Day: number
|
||||
p50Day: number
|
||||
p90Day: number
|
||||
}
|
||||
|
||||
export interface Forecast {
|
||||
/** Open issues in scope (== scheduled count). */
|
||||
scope: number
|
||||
trials: number
|
||||
/** true while code priors drive the sim; false once the empirical fit is in (v1). */
|
||||
coldStart: boolean
|
||||
/** Working-day offsets from today for the whole scope landing. */
|
||||
p50Day: number
|
||||
p80Day: number
|
||||
p95Day: number
|
||||
/** Burn-up cone, one point per scheduled issue, fraction ascending. */
|
||||
curve: BurnUpPoint[]
|
||||
}
|
||||
|
||||
const DEFAULT_TRIALS = 2000
|
||||
const DEFAULT_SEED = 0x9e3779b9
|
||||
|
||||
/** mulberry32 — small, fast, seedable PRNG (no reliance on Math.random). */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Box–Muller standard normal from a uniform PRNG. */
|
||||
function standardNormal(rng: () => number): number {
|
||||
let u1 = rng()
|
||||
const u2 = rng()
|
||||
if (u1 < 1e-12) u1 = 1e-12
|
||||
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
|
||||
}
|
||||
|
||||
function percentile(sortedAsc: number[], q: number): number {
|
||||
const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(q * (sortedAsc.length - 1))))
|
||||
return sortedAsc[idx]
|
||||
}
|
||||
|
||||
/**
|
||||
* Monte Carlo over the deterministic schedule order. Order is fixed (it's
|
||||
* derived from estimate labels + dependencies, not sampled), so only durations
|
||||
* vary across trials — the cone stretches, it never reorders.
|
||||
*/
|
||||
export function forecast(
|
||||
issues: SchedulableIssue[],
|
||||
edges: DependencyEdge[],
|
||||
options: ForecastOptions = {},
|
||||
): Forecast {
|
||||
const trials = options.trials ?? DEFAULT_TRIALS
|
||||
const seed = options.seed ?? DEFAULT_SEED
|
||||
const order = schedule(issues, edges).items // empty when a dependency cycle exists
|
||||
const n = order.length
|
||||
if (n === 0) {
|
||||
return { scope: 0, trials, coldStart: true, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] }
|
||||
}
|
||||
|
||||
const priors = order.map((it) => priorForEstimate(it.durationDays))
|
||||
const rng = mulberry32(seed)
|
||||
|
||||
// endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t.
|
||||
const endByRank: number[][] = Array.from({ length: n }, () => new Array<number>(trials))
|
||||
for (let t = 0; t < trials; t++) {
|
||||
let cursor = 0
|
||||
for (let k = 0; k < n; k++) {
|
||||
const p = priors[k]
|
||||
const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng))
|
||||
cursor += sampled
|
||||
endByRank[k][t] = cursor
|
||||
}
|
||||
}
|
||||
|
||||
const curve: BurnUpPoint[] = endByRank.map((row, k) => {
|
||||
const sorted = [...row].sort((a, b) => a - b)
|
||||
return {
|
||||
fraction: (k + 1) / n,
|
||||
p10Day: percentile(sorted, 0.1),
|
||||
p50Day: percentile(sorted, 0.5),
|
||||
p90Day: percentile(sorted, 0.9),
|
||||
}
|
||||
})
|
||||
|
||||
const total = [...endByRank[n - 1]].sort((a, b) => a - b)
|
||||
return {
|
||||
scope: n,
|
||||
trials,
|
||||
coldStart: true,
|
||||
p50Day: percentile(total, 0.5),
|
||||
p80Day: percentile(total, 0.8),
|
||||
p95Day: percentile(total, 0.95),
|
||||
curve,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createGiteaClient, normalizeIssue } from './client.js'
|
||||
import { createGiteaClient, normalizeIssue, normalizeTimeline } from './client.js'
|
||||
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
|
||||
|
||||
const CONFIG: GiteaConfig = {
|
||||
@@ -44,6 +44,31 @@ function stubFetch(body: unknown, status = 200): {
|
||||
return { fetch, calls }
|
||||
}
|
||||
|
||||
describe('normalizeTimeline', () => {
|
||||
it('maps known gitea event types to lifecycle signals and drops the rest', () => {
|
||||
const events = normalizeTimeline([
|
||||
{ type: 'label', created_at: '2026-01-06T09:00:00Z' },
|
||||
{ type: 'milestone', created_at: '2026-01-06T09:05:00Z' },
|
||||
{ type: 'commit_ref', created_at: '2026-01-07T12:00:00Z' },
|
||||
{ type: 'pull_ref', created_at: '2026-01-08T12:00:00Z' },
|
||||
{ type: 'comment', created_at: '2026-01-08T13:00:00Z' }, // dropped
|
||||
{ type: 'add_dependency', created_at: '2026-01-08T14:00:00Z' }, // dropped
|
||||
{ type: 'close', created_at: '2026-01-12T09:00:00Z' },
|
||||
])
|
||||
expect(events).toEqual([
|
||||
{ type: 'triage', at: '2026-01-06T09:00:00Z' },
|
||||
{ type: 'triage', at: '2026-01-06T09:05:00Z' },
|
||||
{ type: 'commit', at: '2026-01-07T12:00:00Z' },
|
||||
{ type: 'pull', at: '2026-01-08T12:00:00Z' },
|
||||
{ type: 'close', at: '2026-01-12T09:00:00Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips events missing a timestamp', () => {
|
||||
expect(normalizeTimeline([{ type: 'commit_ref', created_at: '' }])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGiteaClient.getIssue', () => {
|
||||
it('returns a normalized, typed issue from canned JSON', async () => {
|
||||
const { fetch } = stubFetch(RAW_ISSUE)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* get the full set.
|
||||
*/
|
||||
|
||||
import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js'
|
||||
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||
import {
|
||||
GiteaApiError,
|
||||
@@ -54,6 +55,33 @@ interface RawMilestoneFull {
|
||||
closed_issues: number
|
||||
}
|
||||
|
||||
/** The subset of a gitea timeline comment we read. */
|
||||
interface RawTimelineComment {
|
||||
type: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */
|
||||
const TIMELINE_TYPE_MAP: Record<string, LifecycleEventType> = {
|
||||
label: 'triage',
|
||||
milestone: 'triage',
|
||||
assignees: 'triage',
|
||||
commit_ref: 'commit',
|
||||
pull_ref: 'pull',
|
||||
close: 'close',
|
||||
reopen: 'reopen',
|
||||
}
|
||||
|
||||
/** Map a raw gitea timeline to normalized lifecycle events. Pure. */
|
||||
export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] {
|
||||
const out: LifecycleEvent[] = []
|
||||
for (const c of raw) {
|
||||
const type = TIMELINE_TYPE_MAP[c.type]
|
||||
if (type && c.created_at) out.push({ type, at: c.created_at })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface ListIssuesOptions {
|
||||
/** @default 'all' */
|
||||
state?: 'open' | 'closed' | 'all'
|
||||
@@ -68,6 +96,8 @@ export interface GiteaClient {
|
||||
listMilestones(): Promise<GiteaMilestone[]>
|
||||
/** The issue indices this issue depends on (its blockers). */
|
||||
getIssueDependencies(index: number): Promise<number[]>
|
||||
/** Normalized lifecycle events for one issue (all pages of its timeline). */
|
||||
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
|
||||
}
|
||||
|
||||
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
|
||||
@@ -163,5 +193,12 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
|
||||
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
|
||||
return raw.map((d) => d.number)
|
||||
},
|
||||
|
||||
async getIssueTimeline(index) {
|
||||
const raw = await requestAll<RawTimelineComment>(
|
||||
(page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`,
|
||||
)
|
||||
return normalizeTimeline(raw)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export {
|
||||
} from './labels/label-schema.js'
|
||||
export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js'
|
||||
|
||||
export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js'
|
||||
export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js'
|
||||
export type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
|
||||
export { GiteaApiError } from './gitea/types.js'
|
||||
export type {
|
||||
@@ -22,8 +22,19 @@ export type {
|
||||
GiteaRequestInit,
|
||||
} from './gitea/types.js'
|
||||
|
||||
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
|
||||
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'
|
||||
export {
|
||||
inferColumnV0,
|
||||
inferLifecycle,
|
||||
LIFECYCLE_COLUMNS,
|
||||
workingDaysBetween,
|
||||
} from './lifecycle/lifecycle-v0.js'
|
||||
export type {
|
||||
LifecycleColumn,
|
||||
LifecycleEvent,
|
||||
LifecycleEventType,
|
||||
LifecycleInference,
|
||||
LifecycleStages,
|
||||
} from './lifecycle/lifecycle-v0.js'
|
||||
|
||||
export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
|
||||
export type {
|
||||
@@ -33,3 +44,6 @@ export type {
|
||||
ScheduledItem,
|
||||
SchedulePlan,
|
||||
} from './scheduler/scheduler-v0.js'
|
||||
|
||||
export { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast/forecast-v0.js'
|
||||
export type { BurnUpPoint, Forecast, ForecastOptions, LognormalPrior } from './forecast/forecast-v0.js'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { inferColumnV0 } from './lifecycle-v0.js'
|
||||
import {
|
||||
inferColumnV0,
|
||||
inferLifecycle,
|
||||
type LifecycleEvent,
|
||||
workingDaysBetween,
|
||||
} from './lifecycle-v0.js'
|
||||
|
||||
const base = { state: 'open' as const, labels: [] as string[], milestone: null }
|
||||
|
||||
@@ -27,3 +32,93 @@ describe('inferColumnV0', () => {
|
||||
expect(['steeping', 'review']).not.toContain(col)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workingDaysBetween', () => {
|
||||
it('counts weekdays in [start, end), excluding weekends', () => {
|
||||
// Mon 2026-01-05 → Mon 2026-01-12 spans a full week = 5 working days
|
||||
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-12'))).toBe(5)
|
||||
})
|
||||
|
||||
it('is 0 for same day or reversed', () => {
|
||||
expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-05'))).toBe(0)
|
||||
expect(workingDaysBetween(new Date('2026-01-12'), new Date('2026-01-05'))).toBe(0)
|
||||
})
|
||||
|
||||
it('skips a weekend inside the interval', () => {
|
||||
// Fri 2026-01-09 → Mon 2026-01-12: only Fri counts (Sat/Sun excluded) = 1
|
||||
expect(workingDaysBetween(new Date('2026-01-09'), new Date('2026-01-12'))).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inferLifecycle', () => {
|
||||
const openBase = { ...base, createdAt: '2026-01-05T09:00:00Z', closedAt: null }
|
||||
const asOf = new Date('2026-01-14T09:00:00Z')
|
||||
|
||||
const ev = (type: LifecycleEvent['type'], at: string): LifecycleEvent => ({ type, at })
|
||||
|
||||
it('bare open issue with no events is diagnosis', () => {
|
||||
const inf = inferLifecycle(openBase, [], asOf)
|
||||
expect(inf.column).toBe('diagnosis')
|
||||
expect(inf.stages.opened).toBe(openBase.createdAt)
|
||||
expect(inf.actualWorkingDays).toBeNull()
|
||||
expect(inf.steepingDays).toBeNull()
|
||||
})
|
||||
|
||||
it('a triage event (or a current label) moves it to triage', () => {
|
||||
expect(inferLifecycle(openBase, [ev('triage', '2026-01-06T09:00:00Z')], asOf).column).toBe('triage')
|
||||
expect(inferLifecycle({ ...openBase, labels: ['p/1'] }, [], asOf).column).toBe('triage')
|
||||
})
|
||||
|
||||
it('a commit ref moves an open issue to steeping and counts its working age', () => {
|
||||
const inf = inferLifecycle(
|
||||
{ ...openBase, labels: ['est/2d'] },
|
||||
[ev('triage', '2026-01-05T10:00:00Z'), ev('commit', '2026-01-07T12:00:00Z')],
|
||||
asOf,
|
||||
)
|
||||
expect(inf.column).toBe('steeping')
|
||||
expect(inf.stages.steeping).toBe('2026-01-07T12:00:00Z')
|
||||
// Wed 2026-01-07 → Wed 2026-01-14 = 5 working days
|
||||
expect(inf.steepingDays).toBe(5)
|
||||
})
|
||||
|
||||
it('a pull ref outranks a commit ref → review', () => {
|
||||
const inf = inferLifecycle(
|
||||
openBase,
|
||||
[ev('commit', '2026-01-07T12:00:00Z'), ev('pull', '2026-01-08T12:00:00Z')],
|
||||
asOf,
|
||||
)
|
||||
expect(inf.column).toBe('review')
|
||||
expect(inf.steepingDays).toBeNull() // only steeping issues carry an age
|
||||
})
|
||||
|
||||
it('closed → done, with actual working time from work-start to close', () => {
|
||||
const inf = inferLifecycle(
|
||||
{ ...openBase, state: 'closed', closedAt: '2026-01-12T09:00:00Z' },
|
||||
[ev('commit', '2026-01-07T09:00:00Z'), ev('pull', '2026-01-08T09:00:00Z'), ev('close', '2026-01-12T09:00:00Z')],
|
||||
asOf,
|
||||
)
|
||||
expect(inf.column).toBe('done')
|
||||
expect(inf.stages.done).toBe('2026-01-12T09:00:00Z')
|
||||
// Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days
|
||||
expect(inf.actualWorkingDays).toBe(3)
|
||||
})
|
||||
|
||||
it('takes the earliest event of each kind for stage timestamps', () => {
|
||||
const inf = inferLifecycle(
|
||||
openBase,
|
||||
[ev('commit', '2026-01-09T09:00:00Z'), ev('commit', '2026-01-07T09:00:00Z')],
|
||||
asOf,
|
||||
)
|
||||
expect(inf.stages.steeping).toBe('2026-01-07T09:00:00Z')
|
||||
})
|
||||
|
||||
it('falls back to opened when a closed issue has no work-start signal', () => {
|
||||
const inf = inferLifecycle(
|
||||
{ ...openBase, state: 'closed', closedAt: '2026-01-08T09:00:00Z' },
|
||||
[ev('close', '2026-01-08T09:00:00Z')],
|
||||
asOf,
|
||||
)
|
||||
// Mon 2026-01-05 → Thu 2026-01-08 = Mon,Tue,Wed = 3 working days
|
||||
expect(inf.actualWorkingDays).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Lifecycle inference, v0 — the coarse column an issue sits in, derived from
|
||||
* *only* what a single issues-list read gives us (state, labels, milestone).
|
||||
* Lifecycle inference.
|
||||
*
|
||||
* The real five-column inference (P1-5) needs the issue timeline: first
|
||||
* branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy.
|
||||
* Until that lands, v0 can only place three columns honestly; `steeping` and
|
||||
* `review` stay empty rather than guess. Board renders all five columns and
|
||||
* fills the middle two once the event stream is available.
|
||||
* `inferColumnV0` places the coarse three columns from a single issues-list
|
||||
* read (state/labels/milestone). `inferLifecycle` (#5) adds the issue timeline:
|
||||
* first commit ref → Steeping, first PR ref → In review, close → Done — and
|
||||
* derives the estimate-vs-actual working time that feeds calibration (D3).
|
||||
*/
|
||||
|
||||
import type { GiteaIssue } from '../gitea/types.js'
|
||||
@@ -23,10 +21,109 @@ export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [
|
||||
|
||||
/**
|
||||
* Closed → done. Open with any human intent applied (a label or a milestone)
|
||||
* → triage. Open and bare → diagnosis. Never returns steeping/review in v0.
|
||||
* → triage. Open and bare → diagnosis. Never returns steeping/review — that's
|
||||
* `inferLifecycle`, which reads the event stream.
|
||||
*/
|
||||
export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
|
||||
if (issue.state === 'closed') return 'done'
|
||||
const hasIntent = issue.labels.length > 0 || issue.milestone !== null
|
||||
return hasIntent ? 'triage' : 'diagnosis'
|
||||
}
|
||||
|
||||
/** A timeline signal, normalized from gitea's raw event stream. */
|
||||
export type LifecycleEventType = 'triage' | 'commit' | 'pull' | 'close' | 'reopen'
|
||||
|
||||
export interface LifecycleEvent {
|
||||
type: LifecycleEventType
|
||||
/** ISO timestamp. */
|
||||
at: string
|
||||
}
|
||||
|
||||
/** Timestamps for the stages an issue has reached (absent = not yet reached). */
|
||||
export interface LifecycleStages {
|
||||
opened: string
|
||||
/** First label / milestone / assignment. */
|
||||
triaged?: string
|
||||
/** First commit referencing the issue — work began. */
|
||||
steeping?: string
|
||||
/** First PR referencing the issue — in review. */
|
||||
review?: string
|
||||
/** Closed. */
|
||||
done?: string
|
||||
}
|
||||
|
||||
export interface LifecycleInference {
|
||||
column: LifecycleColumn
|
||||
stages: LifecycleStages
|
||||
/**
|
||||
* Working days from work-start (steeping → triaged → opened, first available)
|
||||
* to done. Only for closed issues — this is the "actual" calibration learns
|
||||
* from. null while open.
|
||||
*/
|
||||
actualWorkingDays: number | null
|
||||
/** Working days the issue has been steeping (first commit → asOf); null unless currently steeping. */
|
||||
steepingDays: number | null
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
/**
|
||||
* Whole working days (Mon–Fri) in the half-open interval [start, end). Same day
|
||||
* or reversed → 0. Day-granular by design — estimates are in days.
|
||||
*/
|
||||
export function workingDaysBetween(start: Date, end: Date): number {
|
||||
const s = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())
|
||||
const e = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate())
|
||||
if (e <= s) return 0
|
||||
let count = 0
|
||||
for (let t = s; t < e; t += DAY_MS) {
|
||||
const dow = new Date(t).getUTCDay()
|
||||
if (dow !== 0 && dow !== 6) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* The five-column inference. Column reflects the furthest stage still in play:
|
||||
* closed → done; else an open PR ref → review; else a commit ref → steeping;
|
||||
* else any triage signal (or a current label/milestone) → triage; else
|
||||
* diagnosis. Stage timestamps are the earliest event of each kind.
|
||||
*/
|
||||
export function inferLifecycle(
|
||||
issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone' | 'createdAt' | 'closedAt'>,
|
||||
events: LifecycleEvent[],
|
||||
asOf: Date,
|
||||
): LifecycleInference {
|
||||
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at))
|
||||
const firstOf = (type: LifecycleEventType) => sorted.find((e) => e.type === type)?.at
|
||||
|
||||
const stages: LifecycleStages = { opened: issue.createdAt }
|
||||
const triagedAt = firstOf('triage')
|
||||
const steepingAt = firstOf('commit')
|
||||
const reviewAt = firstOf('pull')
|
||||
if (triagedAt) stages.triaged = triagedAt
|
||||
if (steepingAt) stages.steeping = steepingAt
|
||||
if (reviewAt) stages.review = reviewAt
|
||||
const doneAt = issue.state === 'closed' ? (issue.closedAt ?? firstOf('close')) : undefined
|
||||
if (doneAt) stages.done = doneAt
|
||||
|
||||
let column: LifecycleColumn
|
||||
if (issue.state === 'closed') column = 'done'
|
||||
else if (reviewAt) column = 'review'
|
||||
else if (steepingAt) column = 'steeping'
|
||||
else if (triagedAt || issue.labels.length > 0 || issue.milestone !== null) column = 'triage'
|
||||
else column = 'diagnosis'
|
||||
|
||||
let actualWorkingDays: number | null = null
|
||||
if (stages.done) {
|
||||
const start = stages.steeping ?? stages.triaged ?? stages.opened
|
||||
actualWorkingDays = workingDaysBetween(new Date(start), new Date(stages.done))
|
||||
}
|
||||
|
||||
let steepingDays: number | null = null
|
||||
if (column === 'steeping' && stages.steeping) {
|
||||
steepingDays = workingDaysBetween(new Date(stages.steeping), asOf)
|
||||
}
|
||||
|
||||
return { column, stages, actualWorkingDays, steepingDays }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user