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