From 94199639f260270892b2c1de044809c7bdca428c Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 16:57:57 -0400 Subject: [PATCH] feat: read the real gitea backlog into the app (P1 thin slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app now displays its own live backlog instead of fixtures. First end of the sync loop — the tap-root (#1) grows list reads and a read path through the Electron main process. - @commitea/core: client gains `listIssues` (paginated, PRs excluded) and `listMilestones`; a `lifecycle-v0` mapper (closed→done, labelled/milestoned→triage, bare→diagnosis — steeping/review await event inference in P1-5). +10 unit tests. - main: gitea bridge over IPC (token stays in main, never the renderer); config from env / .env.local; gated off under COMMITEA_E2E so the committed e2e stays on fixtures. Preload exposes the typed bridge. - renderer: useBacklog() reconciles once on mount; issuesToBoardColumns shapes real issues into The pot. Board takes optional real columns + a loading state, falling back to demo fixtures when unconfigured. Verified: 14 e2e green (fixture mode) + a gated live spec that launches against the real repo — the board renders the actual 25 open + 9 closed issues (screenshot). SQLite mirror + reconcile-on-a-timer + lifecycle event inference are the next slices (#2/#3/#5). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/e2e/live-backlog.spec.ts | 29 ++++++++ apps/desktop/src/main/gitea.ts | 63 ++++++++++++++++ apps/desktop/src/main/index.ts | 3 + apps/desktop/src/preload/index.ts | 10 ++- .../src/components/screens/board-screen.tsx | 29 ++++++-- .../src/components/shell/app-shell.tsx | 12 +++- apps/desktop/src/renderer/src/global.d.ts | 17 +++++ apps/desktop/src/renderer/src/lib/backlog.ts | 39 ++++++++++ .../src/renderer/src/lib/use-backlog.ts | 40 +++++++++++ packages/core/src/gitea/client.test.ts | 44 ++++++++++++ packages/core/src/gitea/client.ts | 71 +++++++++++++++++-- packages/core/src/gitea/types.ts | 11 +++ packages/core/src/index.ts | 8 ++- .../core/src/lifecycle/lifecycle-v0.test.ts | 29 ++++++++ packages/core/src/lifecycle/lifecycle-v0.ts | 32 +++++++++ 15 files changed, 422 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/e2e/live-backlog.spec.ts create mode 100644 apps/desktop/src/main/gitea.ts create mode 100644 apps/desktop/src/renderer/src/global.d.ts create mode 100644 apps/desktop/src/renderer/src/lib/backlog.ts create mode 100644 apps/desktop/src/renderer/src/lib/use-backlog.ts create mode 100644 packages/core/src/lifecycle/lifecycle-v0.test.ts create mode 100644 packages/core/src/lifecycle/lifecycle-v0.ts diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts new file mode 100644 index 0000000..fdb62a5 --- /dev/null +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -0,0 +1,29 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { _electron as electron, expect, test } from '@playwright/test' + +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. +test.describe('live backlog', () => { + test('The pot shows real gitea issues', 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') + + 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 }) + + 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 new file mode 100644 index 0000000..17e58ce --- /dev/null +++ b/apps/desktop/src/main/gitea.ts @@ -0,0 +1,63 @@ +/** + * Main-process gitea bridge. All gitea traffic runs here — the token never + * reaches the renderer (which is CSP-locked to 'self' anyway). The renderer + * calls these over IPC (see preload). Config for the dogfood slice comes from + * the environment or the repo's .env.local; Settings/Onboarding wire it up + * properly later. + */ + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { createGiteaClient, type GiteaConfig } from '@commitea/core' +import { ipcMain } from 'electron' + +/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ +function loadEnvLocalToken(): string | undefined { + let dir = process.cwd() + for (let i = 0; i < 6; i++) { + try { + const txt = readFileSync(join(dir, '.env.local'), 'utf8') + const m = /^GITEA_TOKEN\s*=\s*(.+?)\s*$/m.exec(txt) + if (m) return m[1].trim() + } catch { + // not in this dir — keep walking up + } + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return undefined +} + +function resolveConfig(): GiteaConfig | null { + // E2E runs against fixtures — never hit the network from the test harness. + if (process.env.COMMITEA_E2E === '1') return null + const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken() + if (!token) return null + return { + baseUrl: process.env.GITEA_BASE_URL ?? 'https://gitea.stephenmann.io', + token, + owner: process.env.GITEA_OWNER ?? 'christian', + repo: process.env.GITEA_REPO ?? 'commitea', + } +} + +export function registerGiteaIpc(): void { + const config = resolveConfig() + const client = config ? createGiteaClient(config, fetch) : null + const repo = config ? `${config.owner}/${config.repo}` : null + + ipcMain.handle('gitea:status', () => ({ configured: !!config, repo })) + + ipcMain.handle('gitea:reconcile', async () => { + if (!client) return { configured: false, issues: [], milestones: [] } + const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) + return { configured: true, issues, milestones } + }) + + ipcMain.handle('gitea:getIssue', async (_event, index: number) => { + if (!client) return null + return client.getIssue(index) + }) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c8b9573..76c0500 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2,6 +2,8 @@ import { join } from 'node:path' import { BrowserWindow, app, shell } from 'electron' +import { registerGiteaIpc } from './gitea.js' + function createWindow(): void { const win = new BrowserWindow({ width: 1440, @@ -32,6 +34,7 @@ function createWindow(): void { } void app.whenReady().then(() => { + registerGiteaIpc() createWindow() app.on('activate', () => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cf4a6ed..0e846d7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,7 +1,15 @@ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' const api = { platform: process.platform, + gitea: { + /** Whether the main process has a gitea token + target repo configured. */ + status: () => ipcRenderer.invoke('gitea:status'), + /** Full read of the managed repo — every issue + milestone. */ + reconcile: () => ipcRenderer.invoke('gitea:reconcile'), + /** One issue by index, normalized (or null if unconfigured). */ + getIssue: (index: number) => ipcRenderer.invoke('gitea:getIssue', index), + }, } export type CommiteaApi = typeof api diff --git a/apps/desktop/src/renderer/src/components/screens/board-screen.tsx b/apps/desktop/src/renderer/src/components/screens/board-screen.tsx index c9d59e0..a51fcb9 100644 --- a/apps/desktop/src/renderer/src/components/screens/board-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/board-screen.tsx @@ -1,19 +1,29 @@ import React from 'react' -import { COLUMNS, type BoardIssue, type IssueRef } from '../../data/fixtures.js' +import { COLUMNS, type BoardColumn, type BoardIssue, type IssueRef } from '../../data/fixtures.js' import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js' import { EmptyState } from '../shell/states.js' import { GanttView } from './gantt-view.js' import { DepsGraph } from './deps-graph.js' -// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs -export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { +// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs. +// `columns` defaults to demo fixtures; the shell passes real reconciled data +// when gitea is configured. `loading` covers the first reconcile. +export function BoardScreen({ + onOpenIssue, + columns = COLUMNS, + loading = false, +}: { + onOpenIssue: (issue: IssueRef) => void + columns?: BoardColumn[] + loading?: boolean +}) { const [tab, setTab] = React.useState('board'); const [query, setQuery] = React.useState(''); const q = query.trim().toLowerCase(); - const filtered = COLUMNS.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues })); + const filtered = columns.map((c) => ({ ...c, issues: q ? c.issues.filter((i) => (i.title + ' #' + i.id).toLowerCase().includes(q)) : c.issues })); const anyMatch = filtered.some((c) => c.issues.length > 0); - const openCount = COLUMNS.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0); + const openCount = columns.reduce((n, c) => n + (c.id === 'done' ? 0 : c.issues.length), 0); const IssueCard = ({ issue }: { issue: BoardIssue }) => (
active={tab} onChange={setTab} /> {tab === 'board' ? ( - anyMatch ? ( + loading ? ( +
+ + Reconciling with gitea… +
+ ) : anyMatch ? (
{filtered.map((col) => (
@@ -71,7 +86,7 @@ export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => ) : (
n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} /> + line={`The pot holds ${columns.reduce((n, c) => n + c.issues.length, 0)} issues; none of them answer to “${query.trim()}”.`} />
) ) : tab === 'deps' ? ( diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 3f56c01..da1d538 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react' import logoIcon from '../../design/assets/logo-icon.png' import type { IssueRef } from '../../data/fixtures.js' +import { issuesToBoardColumns } from '../../lib/backlog.js' +import { useBacklog } from '../../lib/use-backlog.js' import { PrimitivesGallery } from '../gallery.js' import { BoardScreen } from '../screens/board-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js' @@ -83,6 +85,8 @@ export function AppShell() { const [offline, setOffline] = useState(false) const [issue, setIssue] = useState(null) const [readIds, setReadIds] = useState([]) + const backlog = useBacklog() + const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') @@ -157,7 +161,13 @@ export function AppShell() { case 'standup': return setView('focus')} onOpenIssue={openIssue} /> case 'board': - return + return ( + + ) case 'runway': return ( + reconcile(): Promise<{ configured: boolean; issues: GiteaIssue[]; milestones: GiteaMilestone[] }> + getIssue(index: number): Promise +} + +declare global { + interface Window { + commitea: { + platform: string + gitea: GiteaBridge + } + } +} diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts new file mode 100644 index 0000000..625f657 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/backlog.ts @@ -0,0 +1,39 @@ +import { type GiteaIssue, inferColumnV0, type LifecycleColumn } from '@commitea/core' + +import { type BoardColumn, type BoardIssue } from '../data/fixtures.js' + +const COLUMN_LABELS: Record = { + diagnosis: 'Diagnosis', + triage: 'Triage', + steeping: 'Steeping', + review: 'In review', + done: 'Done', +} + +const COLUMN_ORDER: LifecycleColumn[] = ['diagnosis', 'triage', 'steeping', 'review', 'done'] + +function initials(login: string): string { + return login.slice(0, 2).toUpperCase() +} + +/** + * Shape real gitea issues into the Board's five columns via lifecycle-v0. + * `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr` + * are likewise event-derived and omitted here. + */ +export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] { + return COLUMN_ORDER.map((key) => ({ + id: key, + label: COLUMN_LABELS[key], + issues: issues + .filter((i) => inferColumnV0(i) === key) + .map( + (i): BoardIssue => ({ + id: i.number, + title: i.title, + labels: i.labels, + who: i.assignee ? initials(i.assignee) : '·', + }), + ), + })) +} diff --git a/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts new file mode 100644 index 0000000..c9e5792 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react' + +import type { GiteaIssue, GiteaMilestone } from '@commitea/core' + +export type BacklogState = + | { status: 'loading' } + | { status: 'unconfigured' } + | { status: 'error'; message: string } + | { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[] } + +/** + * Reconcile the managed repo once on mount, through the main-process bridge. + * `unconfigured` means no token — the UI falls back to demo fixtures. Errors + * (network, bad token) surface as `error`. + */ +export function useBacklog(): BacklogState { + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + let alive = true + window.commitea.gitea + .reconcile() + .then((r) => { + if (!alive) return + setState( + r.configured + ? { status: 'ready', issues: r.issues, milestones: r.milestones } + : { status: 'unconfigured' }, + ) + }) + .catch((e: unknown) => { + if (alive) setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }) + }) + return () => { + alive = false + } + }, []) + + return state +} diff --git a/packages/core/src/gitea/client.test.ts b/packages/core/src/gitea/client.test.ts index 5e02528..f8dc005 100644 --- a/packages/core/src/gitea/client.test.ts +++ b/packages/core/src/gitea/client.test.ts @@ -107,6 +107,50 @@ describe('createGiteaClient.getIssue', () => { }) }) +describe('createGiteaClient.listIssues', () => { + /** Stub fetch that pages 50-at-a-time and records the paths it was asked for. */ + function pagedFetch(pages: unknown[][]): { fetch: FetchLike; paths: string[] } { + const paths: string[] = [] + const fetch: FetchLike = (url) => { + paths.push(url) + const page = Number(/[?&]page=(\d+)/.exec(url)?.[1] ?? '1') + const body = pages[page - 1] ?? [] + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(''), + }) + } + return { fetch, paths } + } + + it('walks every page until a short page ends it', async () => { + const full = Array.from({ length: 50 }, (_, i) => ({ ...RAW_ISSUE, number: i + 1 })) + const tail = [{ ...RAW_ISSUE, number: 51 }] + const { fetch, paths } = pagedFetch([full, tail]) + + const issues = await createGiteaClient(CONFIG, fetch).listIssues() + expect(issues).toHaveLength(51) + expect(paths).toHaveLength(2) // stopped after the short second page + expect(paths[0]).toContain('/issues?type=issues&state=all&page=1&limit=50') + }) + + it('excludes pull requests even if the API returns them', async () => { + const { fetch } = pagedFetch([ + [{ ...RAW_ISSUE, number: 1 }, { ...RAW_ISSUE, number: 2, pull_request: { url: 'x' } }], + ]) + const issues = await createGiteaClient(CONFIG, fetch).listIssues() + expect(issues.map((i) => i.number)).toEqual([1]) + }) + + it('passes the state filter through', async () => { + const { fetch, paths } = pagedFetch([[]]) + await createGiteaClient(CONFIG, fetch).listIssues({ state: 'open' }) + expect(paths[0]).toContain('state=open') + }) +}) + describe('normalizeIssue', () => { it('defaults missing labels/assignees/body to empty and maps a closed state', () => { const issue = normalizeIssue({ diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts index 02fe22c..7c4f35b 100644 --- a/packages/core/src/gitea/client.ts +++ b/packages/core/src/gitea/client.ts @@ -1,8 +1,9 @@ /** - * Gitea read client. One method for now — `getIssue` — proving the vertical - * slice end to end: config + injected fetch → typed, normalized `GiteaIssue` - * with scheduler facts precomputed. Later reconcile work (P1-4) layers list - * reads on the same `request` seam. + * Gitea read client. Config + injected fetch → typed, normalized domain + * objects with scheduler facts precomputed. `getIssue` proved the vertical + * slice (P1-1); `listIssues`/`listMilestones` are the reconcile reads (P1-4) + * layered on the same `request` seam. Pagination is handled here so callers + * get the full set. */ import { extractLabelFacts } from '../labels/label-schema.js' @@ -11,6 +12,7 @@ import { type FetchLike, type GiteaConfig, type GiteaIssue, + type GiteaMilestone, type GiteaMilestoneRef, } from './types.js' @@ -39,11 +41,31 @@ interface RawIssue { updated_at: string closed_at: string | null html_url: string + /** Present (non-null) when the row is actually a pull request. */ + pull_request?: unknown +} +interface RawMilestoneFull { + id: number + title: string + description: string | null + due_on: string | null + state: string + open_issues: number + closed_issues: number +} + +export interface ListIssuesOptions { + /** @default 'all' */ + state?: 'open' | 'closed' | 'all' } export interface GiteaClient { /** Fetch one issue by its per-repo index, normalized. */ getIssue(index: number): Promise + /** Fetch every issue (all pages), pull requests excluded. */ + listIssues(opts?: ListIssuesOptions): Promise + /** Fetch every milestone (all pages). */ + listMilestones(): Promise } /** Map raw gitea issue JSON to the normalized domain shape. Pure. */ @@ -71,6 +93,21 @@ export function normalizeIssue(raw: RawIssue): GiteaIssue { } } +/** Map a raw milestone resource to the normalized shape. Pure. */ +export function normalizeMilestone(raw: RawMilestoneFull): GiteaMilestone { + return { + id: raw.id, + title: raw.title, + description: raw.description ?? '', + dueOn: raw.due_on, + state: raw.state === 'closed' ? 'closed' : 'open', + openIssues: raw.open_issues, + closedIssues: raw.closed_issues, + } +} + +const PAGE_LIMIT = 50 + export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient { const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` @@ -89,9 +126,35 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi return res.json() } + /** Follow gitea's page-limit pagination until a short page is returned. */ + async function requestAll(build: (page: number) => string): Promise { + const out: T[] = [] + for (let page = 1; ; page++) { + const batch = (await request(build(page))) as T[] + out.push(...batch) + if (batch.length < PAGE_LIMIT) return out + } + } + return { async getIssue(index) { return normalizeIssue((await request(`/issues/${index}`)) as RawIssue) }, + + async listIssues(opts) { + const state = opts?.state ?? 'all' + const raw = await requestAll( + (page) => `/issues?type=issues&state=${state}&page=${page}&limit=${PAGE_LIMIT}`, + ) + // `type=issues` should exclude PRs, but guard anyway. + return raw.filter((r) => r.pull_request == null).map(normalizeIssue) + }, + + async listMilestones() { + const raw = await requestAll( + (page) => `/milestones?state=all&page=${page}&limit=${PAGE_LIMIT}`, + ) + return raw.map(normalizeMilestone) + }, } } diff --git a/packages/core/src/gitea/types.ts b/packages/core/src/gitea/types.ts index aaa323d..9712ff9 100644 --- a/packages/core/src/gitea/types.ts +++ b/packages/core/src/gitea/types.ts @@ -43,6 +43,17 @@ export interface GiteaMilestoneRef { dueOn: string | null } +/** A milestone resource with its open/closed counts. */ +export interface GiteaMilestone { + id: number + title: string + description: string + dueOn: string | null + state: 'open' | 'closed' + openIssues: number + closedIssues: number +} + /** * Normalized issue — camelCase, label names flattened, scheduler-facing * `facts` precomputed via `extractLabelFacts`. This is the domain shape the diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 388ae80..64ad3ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,14 +9,18 @@ export { } from './labels/label-schema.js' export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js' -export { createGiteaClient, normalizeIssue } from './gitea/client.js' -export type { GiteaClient } from './gitea/client.js' +export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js' +export type { GiteaClient, ListIssuesOptions } from './gitea/client.js' export { GiteaApiError } from './gitea/types.js' export type { FetchLike, GiteaConfig, GiteaHttpResponse, GiteaIssue, + GiteaMilestone, GiteaMilestoneRef, GiteaRequestInit, } from './gitea/types.js' + +export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js' +export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js' diff --git a/packages/core/src/lifecycle/lifecycle-v0.test.ts b/packages/core/src/lifecycle/lifecycle-v0.test.ts new file mode 100644 index 0000000..0f74f87 --- /dev/null +++ b/packages/core/src/lifecycle/lifecycle-v0.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { inferColumnV0 } from './lifecycle-v0.js' + +const base = { state: 'open' as const, labels: [] as string[], milestone: null } + +describe('inferColumnV0', () => { + it('closed issues are done', () => { + expect(inferColumnV0({ ...base, state: 'closed' })).toBe('done') + }) + + it('open + labelled is triage', () => { + expect(inferColumnV0({ ...base, labels: ['est/2d'] })).toBe('triage') + }) + + it('open + milestoned is triage', () => { + expect(inferColumnV0({ ...base, milestone: { id: 6, title: 'P1', dueOn: null } })).toBe('triage') + }) + + it('open + bare is diagnosis', () => { + expect(inferColumnV0(base)).toBe('diagnosis') + }) + + it('never guesses steeping or review in v0', () => { + // even a closed, labelled, milestoned issue resolves to a real column, never the event-only ones + const col = inferColumnV0({ ...base, labels: ['est/2d', 'p/1'], milestone: { id: 6, title: 'P1', dueOn: null } }) + expect(['steeping', 'review']).not.toContain(col) + }) +}) diff --git a/packages/core/src/lifecycle/lifecycle-v0.ts b/packages/core/src/lifecycle/lifecycle-v0.ts new file mode 100644 index 0000000..936737d --- /dev/null +++ b/packages/core/src/lifecycle/lifecycle-v0.ts @@ -0,0 +1,32 @@ +/** + * Lifecycle inference, v0 — the coarse column an issue sits in, derived from + * *only* what a single issues-list read gives us (state, labels, milestone). + * + * The real five-column inference (P1-5) needs the issue timeline: first + * branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy. + * Until that lands, v0 can only place three columns honestly; `steeping` and + * `review` stay empty rather than guess. Board renders all five columns and + * fills the middle two once the event stream is available. + */ + +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 in v0. + */ +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' +}