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:
@@ -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 } })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user