/** * Lifecycle inference. * * `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' export type LifecycleColumn = 'diagnosis' | 'triage' | 'steeping' | 'review' | 'done' export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [ 'diagnosis', 'triage', 'steeping', 'review', 'done', ] /** * Closed → done. Open with any human intent applied (a label or a milestone) * → 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 } }