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:
@@ -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' ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
17
apps/desktop/src/renderer/src/global.d.ts
vendored
Normal file
17
apps/desktop/src/renderer/src/global.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/desktop/src/renderer/src/lib/backlog.ts
Normal file
39
apps/desktop/src/renderer/src/lib/backlog.ts
Normal 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) : '·',
|
||||
}),
|
||||
),
|
||||
}))
|
||||
}
|
||||
40
apps/desktop/src/renderer/src/lib/use-backlog.ts
Normal file
40
apps/desktop/src/renderer/src/lib/use-backlog.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user