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'
+}