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,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<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. */
@@ -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<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 {
async getIssue(index) {
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)
},
}
}