feat: the write path — apply estimate/priority changes to gitea (P4-3 apply_changes)
The first write path. Read, forecast, and calibration were all real; now you can
*manage* CommiTea from CommiTea. Estimates/priority are exclusive label axes, so
a change is a label swap — proposed, approved, then written. Nothing is assumed.
core (@commitea/core):
- planIssueChange(current, change): pure diff planner — swaps the est/*|p/* axis,
clears on null, dedups a doubled axis; returns the resulting label set + a
before/after diff + noop flag. describeChange() renders "est/2d → est/5d".
- request() seam extended for writes (method/body, JSON, 204). client gains
listLabels() (name→id) and setIssueLabels() (PUT /issues/{n}/labels).
app:
- main bridge gitea:applyChange — resolves plan.labels → ids (cached, refetch on
miss), PUTs, returns the plan + fresh issue. Token never leaves main.
- preload + global.d.ts expose applyChange; useBacklog returns a refetch so a
write re-reconciles the board + forecast.
- Issue screen: an Adjust button (shown only when configured) opens a
propose-approve Dialog — estimate/priority pickers, live "est/3d → est/8d"
consequence, Apply/Cancel. AppShell wires it, reflects new labels on the open
issue immediately, and refetches.
Verified: 83 core tests green (7 apply-changes + 2 client-write new), desktop
typecheck clean, 14 fixture e2e green. Live spec exercises propose + CANCEL (no
mutation); the real PUT was verified once manually (change #2 est/3d→est/8d→200,
reverted clean). Icon: pencil (no sliders-horizontal in the set).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,14 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core'
|
||||
import {
|
||||
createGiteaClient,
|
||||
type GiteaConfig,
|
||||
type GiteaLabel,
|
||||
type IssueChange,
|
||||
type LifecycleEvent,
|
||||
planIssueChange,
|
||||
} from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
|
||||
@@ -71,4 +78,31 @@ export function registerGiteaIpc(): void {
|
||||
if (!client) return null
|
||||
return client.getIssue(index)
|
||||
})
|
||||
|
||||
// Cached label list for name→id resolution; refreshed on demand if a name misses.
|
||||
let labelCache: GiteaLabel[] | null = null
|
||||
async function resolveLabelIds(names: string[]): Promise<number[]> {
|
||||
if (!client) return []
|
||||
const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
|
||||
if (!labelCache) labelCache = await client.listLabels()
|
||||
let byName = lookup()
|
||||
if (names.some((n) => !byName.has(n))) {
|
||||
labelCache = await client.listLabels() // a name we don't know — refetch once
|
||||
byName = lookup()
|
||||
}
|
||||
return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
|
||||
}
|
||||
|
||||
// The write path (apply_changes). Additive label swaps, applied only after the
|
||||
// renderer's propose-approve. Returns the plan + the freshly-read issue.
|
||||
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
|
||||
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const current = await client.getIssue(change.issue)
|
||||
const plan = planIssueChange(current.labels, change)
|
||||
if (plan.noop) return { ok: true as const, plan, issue: current }
|
||||
const ids = await resolveLabelIds(plan.labels)
|
||||
await client.setIssueLabels(change.issue, ids)
|
||||
const issue = await client.getIssue(change.issue)
|
||||
return { ok: true as const, plan, issue }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user