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:
54
packages/core/src/changes/apply-changes-v0.test.ts
Normal file
54
packages/core/src/changes/apply-changes-v0.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { describeChange, type IssueChange, planIssueChange } from './apply-changes-v0.js'
|
||||
|
||||
describe('planIssueChange', () => {
|
||||
it('swaps the estimate label, keeping non-axis labels', () => {
|
||||
const plan = planIssueChange(['est/2d', 'p/1', 'backend'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' })
|
||||
expect(plan.removed).toEqual(['est/2d'])
|
||||
expect(plan.added).toEqual(['est/5d'])
|
||||
expect(plan.labels).toEqual(['p/1', 'backend', 'est/5d'])
|
||||
expect(plan.noop).toBe(false)
|
||||
})
|
||||
|
||||
it('adds an estimate when none was set', () => {
|
||||
const plan = planIssueChange(['p/2'], { kind: 'reestimate', issue: 1, estimate: 'est/1d' })
|
||||
expect(plan.removed).toEqual([])
|
||||
expect(plan.added).toEqual(['est/1d'])
|
||||
expect(plan.labels).toEqual(['p/2', 'est/1d'])
|
||||
})
|
||||
|
||||
it('clears the axis when the target is null', () => {
|
||||
const plan = planIssueChange(['est/3d', 'p/1'], { kind: 'reprioritize', issue: 1, priority: null })
|
||||
expect(plan.removed).toEqual(['p/1'])
|
||||
expect(plan.added).toEqual([])
|
||||
expect(plan.labels).toEqual(['est/3d'])
|
||||
})
|
||||
|
||||
it('is a noop when the target already holds the axis alone', () => {
|
||||
const plan = planIssueChange(['est/2d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/2d' })
|
||||
expect(plan.noop).toBe(true)
|
||||
expect(plan.labels).toEqual(['p/1', 'est/2d'])
|
||||
})
|
||||
|
||||
it('cleans up a duplicated axis down to the target', () => {
|
||||
// two est/* labels — the change collapses to one
|
||||
const plan = planIssueChange(['est/2d', 'est/5d', 'p/1'], { kind: 'reestimate', issue: 1, estimate: 'est/5d' })
|
||||
expect(plan.removed).toEqual(['est/2d'])
|
||||
expect(plan.added).toEqual([]) // est/5d already present
|
||||
expect(plan.labels).toEqual(['p/1', 'est/5d'])
|
||||
expect(plan.noop).toBe(false)
|
||||
})
|
||||
|
||||
it('reprioritize only touches the priority axis', () => {
|
||||
const plan = planIssueChange(['est/2d', 'p/3'], { kind: 'reprioritize', issue: 1, priority: 'p/1' })
|
||||
expect(plan.labels).toEqual(['est/2d', 'p/1'])
|
||||
})
|
||||
|
||||
it('describeChange renders the diff', () => {
|
||||
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
|
||||
expect(describeChange(planIssueChange(['est/2d'], change))).toBe('est/2d → est/5d')
|
||||
expect(describeChange(planIssueChange(['p/1'], { kind: 'reprioritize', issue: 1, priority: null }))).toBe('p/1 → ∅')
|
||||
expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change')
|
||||
})
|
||||
})
|
||||
59
packages/core/src/changes/apply-changes-v0.ts
Normal file
59
packages/core/src/changes/apply-changes-v0.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Apply-changes, v0 — the write path's planning half. Estimates and priority
|
||||
* live as exclusive label axes (`est/*`, `p/*`); a change swaps the axis label.
|
||||
* This computes the resulting label set + a human-readable diff *purely*, so the
|
||||
* UI can show a propose-approve consequence before the write and tests can pin
|
||||
* the semantics. The actual PUT (name→id resolution + network) is the bridge's
|
||||
* job — additive here, never assumed.
|
||||
*/
|
||||
|
||||
import {
|
||||
type EstimateLabel,
|
||||
ESTIMATE_LABELS,
|
||||
type PriorityLabel,
|
||||
PRIORITY_LABELS,
|
||||
} from '../labels/label-schema.js'
|
||||
|
||||
export type IssueChange =
|
||||
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
|
||||
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null }
|
||||
|
||||
export interface LabelPlan {
|
||||
/** The full resulting label-name set (order: kept labels, then the new axis label). */
|
||||
labels: string[]
|
||||
/** Axis labels being added (0 or 1). */
|
||||
added: string[]
|
||||
/** Axis labels being removed (includes clearing a duplicated axis). */
|
||||
removed: string[]
|
||||
/** true when the change would leave the labels unchanged. */
|
||||
noop: boolean
|
||||
}
|
||||
|
||||
function axisFor(change: IssueChange): { labels: readonly string[]; target: string | null } {
|
||||
return change.kind === 'reestimate'
|
||||
? { labels: ESTIMATE_LABELS, target: change.estimate }
|
||||
: { labels: PRIORITY_LABELS, target: change.priority }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the label mutation for a single change. Removes every label on the
|
||||
* change's axis except the target, adds the target if absent. Setting the axis
|
||||
* to null clears it. Cleans up a duplicated axis (two `est/*`) as a side effect.
|
||||
*/
|
||||
export function planIssueChange(current: string[], change: IssueChange): LabelPlan {
|
||||
const { labels: axis, target } = axisFor(change)
|
||||
const onAxis = current.filter((l) => axis.includes(l))
|
||||
const removed = onAxis.filter((l) => l !== target)
|
||||
const added = target && !current.includes(target) ? [target] : []
|
||||
const kept = current.filter((l) => !axis.includes(l))
|
||||
const labels = target ? [...kept, target] : kept
|
||||
return { labels, added, removed, noop: added.length === 0 && removed.length === 0 }
|
||||
}
|
||||
|
||||
/** A short "est/2d → est/3d" (or "+p/1" / "−est/5d") summary for the confirm UI. */
|
||||
export function describeChange(plan: LabelPlan): string {
|
||||
if (plan.noop) return 'no change'
|
||||
const from = plan.removed.length ? plan.removed.join(', ') : '∅'
|
||||
const to = plan.added.length ? plan.added.join(', ') : '∅'
|
||||
return `${from} → ${to}`
|
||||
}
|
||||
Reference in New Issue
Block a user