CommiTea now recommends its own next unit of work from the live backlog. - @commitea/core: `schedule()` — dependency topo-sort with priority + estimate tie-breaks, single serial capacity, cycle detection, and critical-path marking; `selectFocus()` takes the top three. Pure, deterministic; the LLM does none of this. +11 tests (39 in core). Client gains `getIssueDependencies`. - main: reconcile also fetches native issue dependencies for the open scope and returns edges. - renderer: `scheduleFocus()` maps real issues+deps→Now/Next/Later; Focus renders scheduler output (fixture fallback when unconfigured). v0 scope (each a later slice): single serial worker (per-person capacity #8), point durations (Monte Carlo cone #10), estimate-only (calibration #5). Verified: 14 e2e green (fixtures) + gated live spec — the board shows the real 25 open + 9 closed, and Focus picks #2 ChangeSource (critical path) as Now. Screenshots confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
3.9 KiB
TypeScript
100 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { type DependencyEdge, schedule, type SchedulableIssue, selectFocus } from './scheduler-v0.js'
|
|
|
|
function issue(number: number, over: Partial<SchedulableIssue> = {}): SchedulableIssue {
|
|
return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over }
|
|
}
|
|
|
|
describe('schedule', () => {
|
|
it('lays open issues out serially with cumulative start/finish', () => {
|
|
const plan = schedule([issue(1, { estimateDays: 2 }), issue(2, { estimateDays: 3 })], [])
|
|
expect(plan.cycle).toBeNull()
|
|
const byN = Object.fromEntries(plan.items.map((i) => [i.number, i]))
|
|
// total span = sum of durations, no overlap (single serial worker)
|
|
const spans = plan.items.map((i) => [i.startDay, i.endDay])
|
|
expect(spans).toContainEqual([0, expect.any(Number)])
|
|
expect(byN[1].durationDays + byN[2].durationDays).toBe(5)
|
|
expect(Math.max(...plan.items.map((i) => i.endDay))).toBe(5)
|
|
})
|
|
|
|
it('respects dependencies: a dependency is scheduled before its dependent', () => {
|
|
// #2 depends on #1
|
|
const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }]
|
|
const plan = schedule([issue(2, { priority: 1 }), issue(1, { priority: 4 })], edges)
|
|
const order = plan.items.map((i) => i.number)
|
|
expect(order.indexOf(1)).toBeLessThan(order.indexOf(2))
|
|
// #2 records #1 as a blocker; #1 records #2 as blocked
|
|
const two = plan.items.find((i) => i.number === 2)!
|
|
expect(two.blockedBy).toEqual([1])
|
|
const one = plan.items.find((i) => i.number === 1)!
|
|
expect(one.blocks).toEqual([2])
|
|
})
|
|
|
|
it('breaks ties among ready roots by priority, then estimate', () => {
|
|
const plan = schedule(
|
|
[issue(1, { priority: 3 }), issue(2, { priority: 1 }), issue(3, { priority: 2 })],
|
|
[],
|
|
)
|
|
expect(plan.items.map((i) => i.number)).toEqual([2, 3, 1])
|
|
})
|
|
|
|
it('detects a dependency cycle instead of looping', () => {
|
|
const edges: DependencyEdge[] = [
|
|
{ issue: 1, dependsOn: 2 },
|
|
{ issue: 2, dependsOn: 1 },
|
|
]
|
|
const plan = schedule([issue(1), issue(2)], edges)
|
|
expect(plan.items).toHaveLength(0)
|
|
expect(plan.cycle).not.toBeNull()
|
|
expect(plan.cycle!.sort()).toEqual([1, 2])
|
|
})
|
|
|
|
it('drops dependencies on out-of-scope (closed) issues', () => {
|
|
// #2 depends on #9 which isn't in scope → treated as satisfied
|
|
const plan = schedule([issue(2)], [{ issue: 2, dependsOn: 9 }])
|
|
expect(plan.cycle).toBeNull()
|
|
expect(plan.items.find((i) => i.number === 2)!.blockedBy).toEqual([])
|
|
})
|
|
|
|
it('marks the longest dependency chain critical', () => {
|
|
// chain 1→2→3 (each 3d = 9d) vs standalone 4 (2d): 1,2,3 critical, 4 not
|
|
const edges: DependencyEdge[] = [
|
|
{ issue: 2, dependsOn: 1 },
|
|
{ issue: 3, dependsOn: 2 },
|
|
]
|
|
const issues = [
|
|
issue(1, { estimateDays: 3 }),
|
|
issue(2, { estimateDays: 3 }),
|
|
issue(3, { estimateDays: 3 }),
|
|
issue(4, { estimateDays: 2 }),
|
|
]
|
|
const plan = schedule(issues, edges)
|
|
const crit = plan.items.filter((i) => i.critical).map((i) => i.number).sort()
|
|
expect(crit).toEqual([1, 2, 3])
|
|
expect(plan.items.find((i) => i.number === 4)!.critical).toBe(false)
|
|
})
|
|
|
|
it('defaults a null estimate to DEFAULT_ESTIMATE_DAYS', () => {
|
|
const plan = schedule([issue(1, { estimateDays: null })], [])
|
|
expect(plan.items[0].durationDays).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('selectFocus', () => {
|
|
it('picks the first three of the ordered plan as now/next/later', () => {
|
|
const plan = schedule([issue(1, { priority: 1 }), issue(2, { priority: 2 }), issue(3, { priority: 3 })], [])
|
|
const focus = selectFocus(plan)
|
|
expect(focus.now?.number).toBe(1)
|
|
expect(focus.next?.number).toBe(2)
|
|
expect(focus.later?.number).toBe(3)
|
|
})
|
|
|
|
it('returns nulls past the end of a short plan', () => {
|
|
const focus = selectFocus(schedule([issue(1)], []))
|
|
expect(focus.now?.number).toBe(1)
|
|
expect(focus.next).toBeNull()
|
|
expect(focus.later).toBeNull()
|
|
})
|
|
})
|