feat: read the real gitea backlog into the app (P1 thin slice)

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) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 16:57:57 -04:00
parent 0fc53d03be
commit 94199639f2
15 changed files with 422 additions and 15 deletions

View File

@@ -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()
})
})

View File

@@ -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)
})
}

View File

@@ -2,6 +2,8 @@ import { join } from 'node:path'
import { BrowserWindow, app, shell } from 'electron' import { BrowserWindow, app, shell } from 'electron'
import { registerGiteaIpc } from './gitea.js'
function createWindow(): void { function createWindow(): void {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1440, width: 1440,
@@ -32,6 +34,7 @@ function createWindow(): void {
} }
void app.whenReady().then(() => { void app.whenReady().then(() => {
registerGiteaIpc()
createWindow() createWindow()
app.on('activate', () => { app.on('activate', () => {

View File

@@ -1,7 +1,15 @@
import { contextBridge } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
const api = { const api = {
platform: process.platform, 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 export type CommiteaApi = typeof api

View File

@@ -1,19 +1,29 @@
import React from 'react' 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 { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js'
import { EmptyState } from '../shell/states.js' import { EmptyState } from '../shell/states.js'
import { GanttView } from './gantt-view.js' import { GanttView } from './gantt-view.js'
import { DepsGraph } from './deps-graph.js' import { DepsGraph } from './deps-graph.js'
// Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs // Board — kanban over inferred lifecycle, with Gantt/Dependencies stubs.
export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { // `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 [tab, setTab] = React.useState('board');
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const q = query.trim().toLowerCase(); 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 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 }) => ( const IssueCard = ({ issue }: { issue: BoardIssue }) => (
<div <div
@@ -56,7 +66,12 @@ export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
active={tab} onChange={setTab} active={tab} onChange={setTab}
/> />
{tab === 'board' ? ( {tab === 'board' ? (
anyMatch ? ( loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--ink-3)' }}>
<Icon name="loader-circle" size={16} />
<span style={{ font: 'var(--text-small)' }}>Reconciling with gitea</span>
</div>
) : anyMatch ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, alignItems: 'start', flex: 1, minHeight: 0, overflow: 'auto' }}>
{filtered.map((col) => ( {filtered.map((col) => (
<div key={col.id} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div key={col.id} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -71,7 +86,7 @@ export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
) : ( ) : (
<div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}> <div style={{ flex: 1, border: '1px dashed var(--line-2)', borderRadius: 'var(--radius-3)' }}>
<EmptyState icon="search" title="Nothing by that name" <EmptyState icon="search" title="Nothing by that name"
line={`The pot holds ${COLUMNS.reduce((n, c) => 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()}”.`} />
</div> </div>
) )
) : tab === 'deps' ? ( ) : tab === 'deps' ? (

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
import logoIcon from '../../design/assets/logo-icon.png' import logoIcon from '../../design/assets/logo-icon.png'
import type { IssueRef } from '../../data/fixtures.js' 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 { PrimitivesGallery } from '../gallery.js'
import { BoardScreen } from '../screens/board-screen.js' import { BoardScreen } from '../screens/board-screen.js'
import { CalibrationScreen } from '../screens/calibration-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js'
@@ -83,6 +85,8 @@ export function AppShell() {
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
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 boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
@@ -157,7 +161,13 @@ export function AppShell() {
case 'standup': case 'standup':
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} /> return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board': case 'board':
return <BoardScreen onOpenIssue={openIssue} /> return (
<BoardScreen
onOpenIssue={openIssue}
columns={boardColumns}
loading={backlog.status === 'loading'}
/>
)
case 'runway': case 'runway':
return ( return (
<RunwayScreen <RunwayScreen

View File

@@ -0,0 +1,17 @@
import type { 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[] }>
getIssue(index: number): Promise<GiteaIssue | null>
}
declare global {
interface Window {
commitea: {
platform: string
gitea: GiteaBridge
}
}
}

View File

@@ -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<LifecycleColumn, string> = {
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) : '·',
}),
),
}))
}

View File

@@ -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<BacklogState>({ 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
}

View File

@@ -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', () => { describe('normalizeIssue', () => {
it('defaults missing labels/assignees/body to empty and maps a closed state', () => { it('defaults missing labels/assignees/body to empty and maps a closed state', () => {
const issue = normalizeIssue({ const issue = normalizeIssue({

View File

@@ -1,8 +1,9 @@
/** /**
* Gitea read client. One method for now — `getIssue` — proving the vertical * Gitea read client. Config + injected fetch → typed, normalized domain
* slice end to end: config + injected fetch → typed, normalized `GiteaIssue` * objects with scheduler facts precomputed. `getIssue` proved the vertical
* with scheduler facts precomputed. Later reconcile work (P1-4) layers list * slice (P1-1); `listIssues`/`listMilestones` are the reconcile reads (P1-4)
* reads on the same `request` seam. * layered on the same `request` seam. Pagination is handled here so callers
* get the full set.
*/ */
import { extractLabelFacts } from '../labels/label-schema.js' import { extractLabelFacts } from '../labels/label-schema.js'
@@ -11,6 +12,7 @@ import {
type FetchLike, type FetchLike,
type GiteaConfig, type GiteaConfig,
type GiteaIssue, type GiteaIssue,
type GiteaMilestone,
type GiteaMilestoneRef, type GiteaMilestoneRef,
} from './types.js' } from './types.js'
@@ -39,11 +41,31 @@ interface RawIssue {
updated_at: string updated_at: string
closed_at: string | null closed_at: string | null
html_url: string 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 { export interface GiteaClient {
/** Fetch one issue by its per-repo index, normalized. */ /** Fetch one issue by its per-repo index, normalized. */
getIssue(index: number): Promise<GiteaIssue> getIssue(index: number): Promise<GiteaIssue>
/** Fetch every issue (all pages), pull requests excluded. */
listIssues(opts?: ListIssuesOptions): Promise<GiteaIssue[]>
/** Fetch every milestone (all pages). */
listMilestones(): Promise<GiteaMilestone[]>
} }
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */ /** 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 { export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): GiteaClient {
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1` const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}` const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
@@ -89,9 +126,35 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
return res.json() return res.json()
} }
/** Follow gitea's page-limit pagination until a short page is returned. */
async function requestAll<T>(build: (page: number) => string): Promise<T[]> {
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 { return {
async getIssue(index) { async getIssue(index) {
return normalizeIssue((await request(`/issues/${index}`)) as RawIssue) return normalizeIssue((await request(`/issues/${index}`)) as RawIssue)
}, },
async listIssues(opts) {
const state = opts?.state ?? 'all'
const raw = await requestAll<RawIssue>(
(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<RawMilestoneFull>(
(page) => `/milestones?state=all&page=${page}&limit=${PAGE_LIMIT}`,
)
return raw.map(normalizeMilestone)
},
} }
} }

View File

@@ -43,6 +43,17 @@ export interface GiteaMilestoneRef {
dueOn: string | null 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 * Normalized issue — camelCase, label names flattened, scheduler-facing
* `facts` precomputed via `extractLabelFacts`. This is the domain shape the * `facts` precomputed via `extractLabelFacts`. This is the domain shape the

View File

@@ -9,14 +9,18 @@ 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 } from './gitea/client.js' export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js'
export type { GiteaClient } 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 {
FetchLike, FetchLike,
GiteaConfig, GiteaConfig,
GiteaHttpResponse, GiteaHttpResponse,
GiteaIssue, GiteaIssue,
GiteaMilestone,
GiteaMilestoneRef, GiteaMilestoneRef,
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js'
export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js'

View File

@@ -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)
})
})

View File

@@ -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<GiteaIssue, 'state' | 'labels' | 'milestone'>): LifecycleColumn {
if (issue.state === 'closed') return 'done'
const hasIntent = issue.labels.length > 0 || issue.milestone !== null
return hasIntent ? 'triage' : 'diagnosis'
}