Fill the board's Steeping / In-review columns (and the calibration actuals) from real gitea timeline events, replacing the three-column-only v0. core (@commitea/core): - inferLifecycle(issue, events, asOf): five-column inference — closed → done; open PR ref → review; commit ref → steeping; any triage signal → triage; else diagnosis. Earliest event of each kind fixes the stage timestamp. - Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the calibration fit (D3) learns from — and steepingDays (first commit → now) for the board age badge. - workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular. - normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline (label/milestone → triage, commit_ref → commit, pull_ref → pull, close, reopen), drop the rest. Paginated. app: - reconcile now fetches every issue's timeline and returns it keyed by number; threaded through the bridge → useBacklog → board/focus. - issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review columns, steeping-age `days` badge, focus-card steeping badge. Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely referenced in a PR body can read as In-review; distinguishing closing refs from mentions needs the PR link's state (later). Re-opening multi-segment actuals also deferred. Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts the board's Done column is populated from real events. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
3.0 KiB
TypeScript
75 lines
3.0 KiB
TypeScript
/**
|
|
* 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, type LifecycleEvent } 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: [], deps: [], timelines: {} }
|
|
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
|
// dependency edges among the open scope (the scheduler only plans what's left)
|
|
const open = issues.filter((i) => i.state === 'open')
|
|
const perIssue = await Promise.all(
|
|
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
|
|
)
|
|
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
|
|
// lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals)
|
|
const timelineEntries = await Promise.all(
|
|
issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const),
|
|
)
|
|
const timelines: Record<number, LifecycleEvent[]> = Object.fromEntries(timelineEntries)
|
|
return { configured: true, issues, milestones, deps, timelines }
|
|
})
|
|
|
|
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
|
if (!client) return null
|
|
return client.getIssue(index)
|
|
})
|
|
}
|