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 { 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', () => {

View File

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

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
}