feat: lifecycle inference from the issue timeline (#5)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -86,8 +86,10 @@ 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
|
||||
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -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<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',
|
||||
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<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[]) {
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user