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:
Croissant Le Doux
2026-07-08 20:14:47 -04:00
parent a901b7f855
commit 6198f21d9a
13 changed files with 384 additions and 17 deletions

View File

@@ -13,6 +13,7 @@ import {
type FetchLike,
type GiteaConfig,
type GiteaIssue,
type GiteaLabel,
type GiteaMilestone,
type GiteaMilestoneRef,
} from './types.js'
@@ -98,6 +99,10 @@ export interface GiteaClient {
getIssueDependencies(index: number): Promise<number[]>
/** Normalized lifecycle events for one issue (all pages of its timeline). */
getIssueTimeline(index: number): Promise<LifecycleEvent[]>
/** Every label defined on the repo (id + name), for name→id resolution. */
listLabels(): Promise<GiteaLabel[]>
/** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void>
}
/** Map raw gitea issue JSON to the normalized domain shape. Pure. */
@@ -144,18 +149,24 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
const apiBase = `${config.baseUrl.replace(/\/+$/, '')}/api/v1`
const repoBase = `${apiBase}/repos/${config.owner}/${config.repo}`
async function request(path: string): Promise<unknown> {
async function request(path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
const method = init?.method ?? 'GET'
const hasBody = init?.body !== undefined
const res = await fetchImpl(`${repoBase}${path}`, {
method,
headers: {
Authorization: `token ${config.token}`,
Accept: 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
},
body: hasBody ? JSON.stringify(init!.body) : undefined,
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, `GET ${path} failed (${res.status})`, body)
throw new GiteaApiError(res.status, `${method} ${path} failed (${res.status})`, body)
}
return res.json()
// writes may reply 204 No Content
return res.status === 204 ? null : res.json()
}
/** Follow gitea's page-limit pagination until a short page is returned. */
@@ -200,5 +211,16 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
)
return normalizeTimeline(raw)
},
async listLabels() {
const raw = await requestAll<{ id: number; name: string }>(
(page) => `/labels?page=${page}&limit=${PAGE_LIMIT}`,
)
return raw.map((l) => ({ id: l.id, name: l.name }))
},
async setIssueLabels(index, labelIds) {
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
},
}
}