diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index fdb62a5..4442d48 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -7,23 +7,26 @@ const here = dirname(fileURLToPath(import.meta.url)) const MAIN = join(here, '..', 'out', 'main', 'index.js') // Opt-in only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process -// reconciles the real repo via .env.local, and asserts real issues render. +// reconciles the real repo via .env.local, and asserts real data renders. test.describe('live backlog', () => { - test('The pot shows real gitea issues', async () => { + test('The pot + Focus render real gitea data', async () => { test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) const win = await app.firstWindow() await win.waitForLoadState('domcontentloaded') + const rail = win.getByRole('navigation', { name: 'Primary' }) - await win.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: 'The pot' }).click() - // our filed dogfood issue #1 (now closed → Done column) — real gitea data - await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 15000 }) + // The pot — waiting here also lets the reconcile (issues + deps) complete + await rail.getByRole('button', { name: 'The pot' }).click() + await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 }) + 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 + // rationale phrases are emitted only by the scheduler, never by the demo fixture. + await rail.getByRole('button', { name: 'Morning service' }).click() + await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible() + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' }) - await win.screenshot({ - path: join(here, '.artifacts', 'screens', 'live-board.png'), - fullPage: true, - animations: 'disabled', - }) await app.close() }) }) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 17e58ce..35fcecf 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -51,9 +51,15 @@ export function registerGiteaIpc(): void { ipcMain.handle('gitea:status', () => ({ configured: !!config, repo })) ipcMain.handle('gitea:reconcile', async () => { - if (!client) return { configured: false, issues: [], milestones: [] } + if (!client) return { configured: false, issues: [], milestones: [], deps: [] } const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) - return { configured: true, issues, milestones } + // dependency edges among the open scope (the scheduler only plans what's left) + const open = issues.filter((i) => i.state === 'open') + const perIssue = await Promise.all( + 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 }))) + return { configured: true, issues, milestones, deps } }) ipcMain.handle('gitea:getIssue', async (_event, index: number) => { diff --git a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx index f20941a..d71a848 100644 --- a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx @@ -1,14 +1,23 @@ import React from 'react' import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js' +import { type FocusView } from '../../lib/backlog.js' import { BurnUpCone } from '../charts/chart.js' import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' /** * Morning service — the Now/Next/Later focus cards + the milestone burn-up cone. - * Data is fixture (data.js) until P2's scheduler + Monte Carlo feed it. + * `focus` (real scheduler output) overrides the demo fixture when gitea is + * configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice). */ -export function FocusScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { +export function FocusScreen({ + onOpenIssue, + focus, +}: { + onOpenIssue: (issue: IssueRef) => void + focus?: FocusView +}) { + const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later } const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
- - - + {view.now ? : null} + {view.next ? : null} + {view.later ? : null}
([]) const backlog = useBacklog() const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined + const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') @@ -157,7 +158,7 @@ export function AppShell() { const renderScreen = () => { switch (view) { case 'focus': - return + return case 'standup': return setView('focus')} onOpenIssue={openIssue} /> case 'board': diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 3537aa0..468e19f 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -1,9 +1,14 @@ -import type { GiteaIssue, GiteaMilestone } from '@commitea/core' +import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' /** The gitea bridge exposed by the preload over IPC (main-process backed). */ export interface GiteaBridge { status(): Promise<{ configured: boolean; repo: string | null }> - reconcile(): Promise<{ configured: boolean; issues: GiteaIssue[]; milestones: GiteaMilestone[] }> + reconcile(): Promise<{ + configured: boolean + issues: GiteaIssue[] + milestones: GiteaMilestone[] + deps: DependencyEdge[] + }> getIssue(index: number): Promise } diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts index 625f657..52a71b9 100644 --- a/apps/desktop/src/renderer/src/lib/backlog.ts +++ b/apps/desktop/src/renderer/src/lib/backlog.ts @@ -1,6 +1,14 @@ -import { type GiteaIssue, inferColumnV0, type LifecycleColumn } from '@commitea/core' +import { + type DependencyEdge, + type GiteaIssue, + inferColumnV0, + type LifecycleColumn, + schedule, + type ScheduledItem, + selectFocus, +} from '@commitea/core' -import { type BoardColumn, type BoardIssue } from '../data/fixtures.js' +import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js' const COLUMN_LABELS: Record = { diagnosis: 'Diagnosis', @@ -37,3 +45,35 @@ export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] { ), })) } + +export interface FocusView { + now: FocusIssue | null + next: FocusIssue | null + later: FocusIssue | null +} + +function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { + if (!item) return null + return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale } +} + +/** + * Run the deterministic scheduler over the open backlog and take the top three + * as Now/Next/Later. Estimates + priority come from label facts; dependency + * edges come from gitea's native issue dependencies. + */ +export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView { + const open = issues.filter((i) => i.state === 'open') + const plan = schedule( + open.map((i) => ({ + number: i.number, + title: i.title, + labels: i.labels, + estimateDays: i.facts.estimateDays, + priority: i.facts.priority, + })), + deps, + ) + const f = selectFocus(plan) + return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) } +} diff --git a/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts index c9e5792..4f14ca5 100644 --- a/apps/desktop/src/renderer/src/lib/use-backlog.ts +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react' -import type { GiteaIssue, GiteaMilestone } from '@commitea/core' +import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' export type BacklogState = | { status: 'loading' } | { status: 'unconfigured' } | { status: 'error'; message: string } - | { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[] } + | { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] } /** * Reconcile the managed repo once on mount, through the main-process bridge. @@ -24,7 +24,7 @@ export function useBacklog(): BacklogState { if (!alive) return setState( r.configured - ? { status: 'ready', issues: r.issues, milestones: r.milestones } + ? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps } : { status: 'unconfigured' }, ) }) diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts index 7c4f35b..0cc1441 100644 --- a/packages/core/src/gitea/client.ts +++ b/packages/core/src/gitea/client.ts @@ -66,6 +66,8 @@ export interface GiteaClient { listIssues(opts?: ListIssuesOptions): Promise /** Fetch every milestone (all pages). */ listMilestones(): Promise + /** The issue indices this issue depends on (its blockers). */ + getIssueDependencies(index: number): Promise } /** Map raw gitea issue JSON to the normalized domain shape. Pure. */ @@ -156,5 +158,10 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi ) return raw.map(normalizeMilestone) }, + + async getIssueDependencies(index) { + const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[] + return raw.map((d) => d.number) + }, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 64ad3ba..961fe39 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,3 +24,12 @@ export type { export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js' export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js' + +export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js' +export type { + DependencyEdge, + Focus, + SchedulableIssue, + ScheduledItem, + SchedulePlan, +} from './scheduler/scheduler-v0.js' diff --git a/packages/core/src/scheduler/scheduler-v0.test.ts b/packages/core/src/scheduler/scheduler-v0.test.ts new file mode 100644 index 0000000..c1ca239 --- /dev/null +++ b/packages/core/src/scheduler/scheduler-v0.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { type DependencyEdge, schedule, type SchedulableIssue, selectFocus } from './scheduler-v0.js' + +function issue(number: number, over: Partial = {}): 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() + }) +}) diff --git a/packages/core/src/scheduler/scheduler-v0.ts b/packages/core/src/scheduler/scheduler-v0.ts new file mode 100644 index 0000000..26192e4 --- /dev/null +++ b/packages/core/src/scheduler/scheduler-v0.ts @@ -0,0 +1,182 @@ +/** + * Deterministic scheduler, v0. Orders the open backlog by dependency topology, + * then priority, then estimate; lays it out on a single serial capacity and + * marks the critical path. The LLM never does this — it's plain code. + * + * v0 simplifications (each is a later slice, not a hack): + * - single serial worker; per-person capacity is #8. + * - point durations from estimate labels; Monte Carlo cones are #10. + * - a null estimate defaults to DEFAULT_ESTIMATE_DAYS. + * Dependencies on out-of-scope issues (closed/done, or not passed in) are + * treated as satisfied and dropped — you schedule what's left to do. + */ + +export const DEFAULT_ESTIMATE_DAYS = 2 + +export interface SchedulableIssue { + number: number + title: string + labels: string[] + /** From label facts; null when unestimated. */ + estimateDays: number | null + /** 1 (most urgent) … 4; null when unset. */ + priority: number | null +} + +export interface DependencyEdge { + issue: number + dependsOn: number +} + +export interface ScheduledItem { + number: number + title: string + labels: string[] + /** 0-based position in the plan. */ + order: number + /** Working-day offsets from now. */ + startDay: number + endDay: number + durationDays: number + /** In-scope dependencies (all still open, so all still blocking). */ + blockedBy: number[] + /** Issues that depend on this one. */ + blocks: number[] + /** On a longest-duration dependency chain. */ + critical: boolean + rationale: string +} + +export interface SchedulePlan { + items: ScheduledItem[] + /** A dependency cycle (issue numbers) if one was found — the plan is then empty. */ + cycle: number[] | null +} + +function durationOf(issue: SchedulableIssue): number { + return issue.estimateDays ?? DEFAULT_ESTIMATE_DAYS +} + +/** Best-first comparison among ready nodes: priority (1 first), then larger estimate, then number. */ +function readyRank(a: SchedulableIssue, b: SchedulableIssue): number { + const pa = a.priority ?? 99 + const pb = b.priority ?? 99 + if (pa !== pb) return pa - pb + const da = durationOf(a) + const db = durationOf(b) + if (da !== db) return db - da + return a.number - b.number +} + +function rationaleFor(item: { + critical: boolean + blockedBy: number[] + blocks: number[] + priority: number | null +}): string { + const blocksNote = item.blocks.length ? `unblocks ${item.blocks.map((n) => `#${n}`).join(', ')}` : '' + if (item.critical) return ['on the critical path', blocksNote].filter(Boolean).join(' · ') + if (item.blockedBy.length) return `waits on ${item.blockedBy.map((n) => `#${n}`).join(', ')}` + if (blocksNote) return blocksNote + if (item.priority != null) return `p/${item.priority} · ready` + return 'ready' +} + +export function schedule(issues: SchedulableIssue[], edges: DependencyEdge[]): SchedulePlan { + const byNumber = new Map(issues.map((i) => [i.number, i])) + + // keep only edges whose endpoints are both in scope + const scoped = edges.filter((e) => byNumber.has(e.issue) && byNumber.has(e.dependsOn)) + const deps = new Map() // issue → its dependencies + const dependents = new Map() // issue → who depends on it + for (const i of issues) { + deps.set(i.number, []) + dependents.set(i.number, []) + } + for (const e of scoped) { + deps.get(e.issue)!.push(e.dependsOn) + dependents.get(e.dependsOn)!.push(e.issue) + } + + // Kahn topo with priority tie-break + const indegree = new Map(issues.map((i) => [i.number, deps.get(i.number)!.length])) + const order: number[] = [] + const ready = issues.filter((i) => indegree.get(i.number) === 0) + while (ready.length) { + ready.sort(readyRank) + const next = ready.shift()! + order.push(next.number) + for (const dep of dependents.get(next.number)!) { + const d = indegree.get(dep)! - 1 + indegree.set(dep, d) + if (d === 0) ready.push(byNumber.get(dep)!) + } + } + + if (order.length < issues.length) { + // cycle: report the nodes that never became ready + const stuck = issues.filter((i) => indegree.get(i.number)! > 0).map((i) => i.number) + return { items: [], cycle: stuck } + } + + // longest-path (by duration) for critical-path marking + const longestThrough = new Map() // node → longest chain duration passing through it + const before = new Map() // longest duration of chain ending at node's start + for (const n of order) { + const depMax = Math.max(0, ...deps.get(n)!.map((d) => before.get(d)! + durationOf(byNumber.get(d)!))) + before.set(n, depMax) + } + const after = new Map() + for (const n of [...order].reverse()) { + const depMax = Math.max(0, ...dependents.get(n)!.map((d) => after.get(d)! + durationOf(byNumber.get(d)!))) + after.set(n, depMax) + } + let globalMax = 0 + for (const n of order) { + const total = before.get(n)! + durationOf(byNumber.get(n)!) + after.get(n)! + longestThrough.set(n, total) + globalMax = Math.max(globalMax, total) + } + + // serial layout + let cursor = 0 + const items: ScheduledItem[] = order.map((n, idx) => { + const issue = byNumber.get(n)! + const dur = durationOf(issue) + const start = cursor + cursor += dur + const blockedBy = deps.get(n)! + const blocks = dependents.get(n)! + const critical = globalMax > 0 && longestThrough.get(n) === globalMax + return { + number: n, + title: issue.title, + labels: issue.labels, + order: idx, + startDay: start, + endDay: cursor, + durationDays: dur, + blockedBy, + blocks, + critical, + rationale: rationaleFor({ critical, blockedBy, blocks, priority: issue.priority }), + } + }) + + return { items, cycle: null } +} + +export interface Focus { + now: ScheduledItem | null + next: ScheduledItem | null + later: ScheduledItem | null +} + +/** Now/Next/Later = the first three of the plan (already dependency- + priority-ordered). */ +export function selectFocus(plan: SchedulePlan): Focus { + return { + now: plan.items[0] ?? null, + next: plan.items[1] ?? null, + later: plan.items[2] ?? null, + } +}