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:
Croissant Le Doux
2026-07-08 19:32:06 -04:00
parent ba3d9df88e
commit b9b21450e7
11 changed files with 361 additions and 37 deletions

View File

@@ -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)

View File

@@ -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)
},
}
}

View File

@@ -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 {

View File

@@ -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)
})
})

View File

@@ -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 (MonFri) 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 }
}