From 9cedd8646e5ea1cce6ccfc4afaed34b149c348ea Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 19:32:06 -0400 Subject: [PATCH] feat: lifecycle inference from the issue timeline (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the board's Steeping / In-review columns (and the calibration actuals) from real gitea timeline events, replacing the three-column-only v0. core (@commitea/core): - inferLifecycle(issue, events, asOf): five-column inference — closed → done; open PR ref → review; commit ref → steeping; any triage signal → triage; else diagnosis. Earliest event of each kind fixes the stage timestamp. - Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the calibration fit (D3) learns from — and steepingDays (first commit → now) for the board age badge. - workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular. - normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline (label/milestone → triage, commit_ref → commit, pull_ref → pull, close, reopen), drop the rest. Paginated. app: - reconcile now fetches every issue's timeline and returns it keyed by number; threaded through the bridge → useBacklog → board/focus. - issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review columns, steeping-age `days` badge, focus-card steeping badge. Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely referenced in a PR body can read as In-review; distinguishing closing refs from mentions needs the PR link's state (later). Re-opening multi-segment actuals also deferred. Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts the board's Done column is populated from real events. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/live-backlog.spec.ts | 6 +- apps/desktop/src/main/gitea.ts | 11 +- .../src/components/shell/app-shell.tsx | 6 +- apps/desktop/src/renderer/src/global.d.ts | 4 +- apps/desktop/src/renderer/src/lib/backlog.ts | 62 +++++++--- .../src/renderer/src/lib/use-backlog.ts | 18 ++- packages/core/src/gitea/client.test.ts | 27 ++++- packages/core/src/gitea/client.ts | 37 ++++++ packages/core/src/index.ts | 17 ++- .../core/src/lifecycle/lifecycle-v0.test.ts | 97 ++++++++++++++- packages/core/src/lifecycle/lifecycle-v0.ts | 113 ++++++++++++++++-- 11 files changed, 361 insertions(+), 37 deletions(-) diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index be9d7f8..e430200 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -16,9 +16,13 @@ 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 diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 35fcecf..a443d30 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -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 = Object.fromEntries(timelineEntries) + return { configured: true, issues, milestones, deps, timelines } }) ipcMain.handle('gitea:getIssue', async (_event, index: number) => { diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 7ab83ed..6e2de90 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -86,8 +86,10 @@ export function AppShell() { const [issue, setIssue] = useState(null) const [readIds, setReadIds] = useState([]) 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 diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 468e19f..019d932 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -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 }> getIssue(index: number): Promise } diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts index 0a2dd90..4c35e0c 100644 --- a/apps/desktop/src/renderer/src/lib/backlog.ts +++ b/apps/desktop/src/renderer/src/lib/backlog.ts @@ -2,8 +2,10 @@ import { type DependencyEdge, forecast, type GiteaIssue, - inferColumnV0, + inferLifecycle, type LifecycleColumn, + type LifecycleEvent, + type LifecycleInference, schedule, type ScheduledItem, selectFocus, @@ -12,6 +14,13 @@ import { import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js' import { type BurnUpData, buildBurnUpData } from './dates.js' +type Timelines = Record + +/** Infer every issue's lifecycle once; callers index by issue number. */ +function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map { + return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)])) +} + const COLUMN_LABELS: Record = { diagnosis: 'Diagnosis', triage: 'Triage', @@ -27,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, + } + }), })) } @@ -54,9 +71,16 @@ export interface FocusView { later: FocusIssue | null } -function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { +function toFocusIssue(item: ScheduledItem | null, inf?: Map): 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[]) { @@ -99,8 +123,18 @@ export function forecastBacklog( * 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 { +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), + } } diff --git a/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts index 4f14ca5..17b4848 100644 --- a/apps/desktop/src/renderer/src/lib/use-backlog.ts +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -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 + } /** * 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' }, ) }) diff --git a/packages/core/src/gitea/client.test.ts b/packages/core/src/gitea/client.test.ts index f8dc005..989c23f 100644 --- a/packages/core/src/gitea/client.test.ts +++ b/packages/core/src/gitea/client.test.ts @@ -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) diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts index 0cc1441..794ec67 100644 --- a/packages/core/src/gitea/client.ts +++ b/packages/core/src/gitea/client.ts @@ -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 = { + 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 /** The issue indices this issue depends on (its blockers). */ getIssueDependencies(index: number): Promise + /** Normalized lifecycle events for one issue (all pages of its timeline). */ + getIssueTimeline(index: number): Promise } /** 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( + (page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`, + ) + return normalizeTimeline(raw) + }, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0d047f..2a097f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/lifecycle/lifecycle-v0.test.ts b/packages/core/src/lifecycle/lifecycle-v0.test.ts index 0f74f87..a288ee5 100644 --- a/packages/core/src/lifecycle/lifecycle-v0.test.ts +++ b/packages/core/src/lifecycle/lifecycle-v0.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/lifecycle/lifecycle-v0.ts b/packages/core/src/lifecycle/lifecycle-v0.ts index 936737d..a34613a 100644 --- a/packages/core/src/lifecycle/lifecycle-v0.ts +++ b/packages/core/src/lifecycle/lifecycle-v0.ts @@ -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): 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, + 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 } +}