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

@@ -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 }) => (
<div
@@ -56,7 +66,12 @@ export function BoardScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
active={tab} onChange={setTab}
/>
{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' }}>
{filtered.map((col) => (
<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)' }}>
<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>
)
) : tab === 'deps' ? (

View File

@@ -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<IssueRef | null>(null)
const [readIds, setReadIds] = useState<number[]>([])
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 <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
case 'board':
return <BoardScreen onOpenIssue={openIssue} />
return (
<BoardScreen
onOpenIssue={openIssue}
columns={boardColumns}
loading={backlog.status === 'loading'}
/>
)
case 'runway':
return (
<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
}