P1-5: lifecycle inference from the issue timeline (#5) #39
@@ -16,9 +16,13 @@ test.describe('live backlog', () => {
|
|||||||
await win.waitForLoadState('domcontentloaded')
|
await win.waitForLoadState('domcontentloaded')
|
||||||
const rail = win.getByRole('navigation', { name: 'Primary' })
|
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 rail.getByRole('button', { name: 'The pot' }).click()
|
||||||
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
|
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' })
|
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
|
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
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'
|
import { ipcMain } from 'electron'
|
||||||
|
|
||||||
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
|
/** 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:status', () => ({ configured: !!config, repo }))
|
||||||
|
|
||||||
ipcMain.handle('gitea:reconcile', async () => {
|
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()])
|
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||||
// dependency edges among the open scope (the scheduler only plans what's left)
|
// dependency edges among the open scope (the scheduler only plans what's left)
|
||||||
const open = issues.filter((i) => i.state === 'open')
|
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) })),
|
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 })))
|
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) => {
|
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ export function AppShell() {
|
|||||||
const [issue, setIssue] = useState<IssueRef | null>(null)
|
const [issue, setIssue] = useState<IssueRef | null>(null)
|
||||||
const [readIds, setReadIds] = useState<number[]>([])
|
const [readIds, setReadIds] = useState<number[]>([])
|
||||||
const backlog = useBacklog()
|
const backlog = useBacklog()
|
||||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
const boardColumns =
|
||||||
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined
|
backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined
|
||||||
|
const focus =
|
||||||
|
backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined
|
||||||
const forecast =
|
const forecast =
|
||||||
backlog.status === 'ready' ? (forecastBacklog(backlog.issues, backlog.deps) ?? undefined) : undefined
|
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). */
|
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
|
||||||
export interface GiteaBridge {
|
export interface GiteaBridge {
|
||||||
@@ -8,6 +8,8 @@ export interface GiteaBridge {
|
|||||||
issues: GiteaIssue[]
|
issues: GiteaIssue[]
|
||||||
milestones: GiteaMilestone[]
|
milestones: GiteaMilestone[]
|
||||||
deps: DependencyEdge[]
|
deps: DependencyEdge[]
|
||||||
|
/** Normalized lifecycle events keyed by issue number. */
|
||||||
|
timelines: Record<number, LifecycleEvent[]>
|
||||||
}>
|
}>
|
||||||
getIssue(index: number): Promise<GiteaIssue | null>
|
getIssue(index: number): Promise<GiteaIssue | null>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import {
|
|||||||
type DependencyEdge,
|
type DependencyEdge,
|
||||||
forecast,
|
forecast,
|
||||||
type GiteaIssue,
|
type GiteaIssue,
|
||||||
inferColumnV0,
|
inferLifecycle,
|
||||||
type LifecycleColumn,
|
type LifecycleColumn,
|
||||||
|
type LifecycleEvent,
|
||||||
|
type LifecycleInference,
|
||||||
schedule,
|
schedule,
|
||||||
type ScheduledItem,
|
type ScheduledItem,
|
||||||
selectFocus,
|
selectFocus,
|
||||||
@@ -12,6 +14,13 @@ import {
|
|||||||
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
||||||
import { type BurnUpData, buildBurnUpData } from './dates.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> = {
|
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||||
diagnosis: 'Diagnosis',
|
diagnosis: 'Diagnosis',
|
||||||
triage: 'Triage',
|
triage: 'Triage',
|
||||||
@@ -27,24 +36,32 @@ function initials(login: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape real gitea issues into the Board's five columns via lifecycle-v0.
|
* Shape real gitea issues into the Board's five columns via lifecycle inference
|
||||||
* `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr`
|
* (#5). Steeping / In-review are now populated from the event stream (first
|
||||||
* are likewise event-derived and omitted here.
|
* 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) => ({
|
return COLUMN_ORDER.map((key) => ({
|
||||||
id: key,
|
id: key,
|
||||||
label: COLUMN_LABELS[key],
|
label: COLUMN_LABELS[key],
|
||||||
issues: issues
|
issues: issues
|
||||||
.filter((i) => inferColumnV0(i) === key)
|
.filter((i) => inf.get(i.number)!.column === key)
|
||||||
.map(
|
.map((i): BoardIssue => {
|
||||||
(i): BoardIssue => ({
|
const li = inf.get(i.number)!
|
||||||
|
return {
|
||||||
id: i.number,
|
id: i.number,
|
||||||
title: i.title,
|
title: i.title,
|
||||||
labels: i.labels,
|
labels: i.labels,
|
||||||
who: i.assignee ? initials(i.assignee) : '·',
|
who: i.assignee ? initials(i.assignee) : '·',
|
||||||
}),
|
days: li.steepingDays != null ? `${li.steepingDays}d` : undefined,
|
||||||
),
|
}
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +71,16 @@ export interface FocusView {
|
|||||||
later: FocusIssue | null
|
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
|
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[]) {
|
function toSchedulable(issues: GiteaIssue[]) {
|
||||||
@@ -99,8 +123,18 @@ export function forecastBacklog(
|
|||||||
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
||||||
* edges come from gitea's native issue dependencies.
|
* 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 plan = schedule(toSchedulable(issues), deps)
|
||||||
const f = selectFocus(plan)
|
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 { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
|
||||||
|
|
||||||
export type BacklogState =
|
export type BacklogState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'unconfigured' }
|
| { status: 'unconfigured' }
|
||||||
| { status: 'error'; message: string }
|
| { 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.
|
* Reconcile the managed repo once on mount, through the main-process bridge.
|
||||||
@@ -24,7 +30,13 @@ export function useBacklog(): BacklogState {
|
|||||||
if (!alive) return
|
if (!alive) return
|
||||||
setState(
|
setState(
|
||||||
r.configured
|
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' },
|
: { status: 'unconfigured' },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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'
|
import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js'
|
||||||
|
|
||||||
const CONFIG: GiteaConfig = {
|
const CONFIG: GiteaConfig = {
|
||||||
@@ -44,6 +44,31 @@ function stubFetch(body: unknown, status = 200): {
|
|||||||
return { fetch, calls }
|
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', () => {
|
describe('createGiteaClient.getIssue', () => {
|
||||||
it('returns a normalized, typed issue from canned JSON', async () => {
|
it('returns a normalized, typed issue from canned JSON', async () => {
|
||||||
const { fetch } = stubFetch(RAW_ISSUE)
|
const { fetch } = stubFetch(RAW_ISSUE)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* get the full set.
|
* get the full set.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js'
|
||||||
import { extractLabelFacts } from '../labels/label-schema.js'
|
import { extractLabelFacts } from '../labels/label-schema.js'
|
||||||
import {
|
import {
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
@@ -54,6 +55,33 @@ interface RawMilestoneFull {
|
|||||||
closed_issues: number
|
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 {
|
export interface ListIssuesOptions {
|
||||||
/** @default 'all' */
|
/** @default 'all' */
|
||||||
state?: 'open' | 'closed' | 'all'
|
state?: 'open' | 'closed' | 'all'
|
||||||
@@ -68,6 +96,8 @@ export interface GiteaClient {
|
|||||||
listMilestones(): Promise<GiteaMilestone[]>
|
listMilestones(): Promise<GiteaMilestone[]>
|
||||||
/** The issue indices this issue depends on (its blockers). */
|
/** The issue indices this issue depends on (its blockers). */
|
||||||
getIssueDependencies(index: number): Promise<number[]>
|
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. */
|
/** 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 }[]
|
const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[]
|
||||||
return raw.map((d) => d.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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export {
|
|||||||
} from './labels/label-schema.js'
|
} from './labels/label-schema.js'
|
||||||
export type { EstimateLabel, LabelFacts, PriorityLabel } 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 type { GiteaClient, ListIssuesOptions } from './gitea/client.js'
|
||||||
export { GiteaApiError } from './gitea/types.js'
|
export { GiteaApiError } from './gitea/types.js'
|
||||||
export type {
|
export type {
|
||||||
@@ -22,8 +22,19 @@ export type {
|
|||||||
GiteaRequestInit,
|
GiteaRequestInit,
|
||||||
} from './gitea/types.js'
|
} from './gitea/types.js'
|
||||||
|
|
||||||
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
|
export {
|
||||||
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'
|
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 { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js'
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 }
|
const base = { state: 'open' as const, labels: [] as string[], milestone: null }
|
||||||
|
|
||||||
@@ -27,3 +32,93 @@ describe('inferColumnV0', () => {
|
|||||||
expect(['steeping', 'review']).not.toContain(col)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Lifecycle inference, v0 — the coarse column an issue sits in, derived from
|
* Lifecycle inference.
|
||||||
* *only* what a single issues-list read gives us (state, labels, milestone).
|
|
||||||
*
|
*
|
||||||
* The real five-column inference (P1-5) needs the issue timeline: first
|
* `inferColumnV0` places the coarse three columns from a single issues-list
|
||||||
* branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy.
|
* read (state/labels/milestone). `inferLifecycle` (#5) adds the issue timeline:
|
||||||
* Until that lands, v0 can only place three columns honestly; `steeping` and
|
* first commit ref → Steeping, first PR ref → In review, close → Done — and
|
||||||
* `review` stay empty rather than guess. Board renders all five columns and
|
* derives the estimate-vs-actual working time that feeds calibration (D3).
|
||||||
* fills the middle two once the event stream is available.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GiteaIssue } from '../gitea/types.js'
|
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)
|
* 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 {
|
export function inferColumnV0(issue: Pick<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
|
||||||
if (issue.state === 'closed') return 'done'
|
if (issue.state === 'closed') return 'done'
|
||||||
const hasIntent = issue.labels.length > 0 || issue.milestone !== null
|
const hasIntent = issue.labels.length > 0 || issue.milestone !== null
|
||||||
return hasIntent ? 'triage' : 'diagnosis'
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user