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>
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
/**
|
||
* 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<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 }
|
||
}
|