apply_changes: unify the mutation tool with assign + milestone (#24)

The write path handled only the est/* and p/* label axes. Extend it to the two
mutations dogfooding proved were missing — assignee and milestone — behind one
unified change model:

- IssueChange gains `assign` and `remilestone` kinds. `planIssueChange` is now
  typed to LabelChange (the label kinds only); `isLabelChange` narrows, and a new
  pure `summarizeChange(change, currentLabels?)` gives one confirm line for ANY
  kind (label kinds delegate to describeChange; field writes describe directly).
- GiteaClient gains `setIssueAssignees` / `setIssueMilestone` (PATCH /issues/{n},
  returning the updated issue; milestone 0 clears).
- The main `gitea:applyChange` handler branches: label swaps resolve ids +
  setIssueLabels as before; field writes call the new client methods. Snapshot
  invalidated either way so board + forecast catch up. ApplyChangeResult.plan is
  now optional (absent for field writes).
- issue-screen uses summarizeChange for its diff preview.

Tests: +5 covering isLabelChange, and summarizeChange for assign/unassign,
milestone set-by-title / by-id / removal, and label delegation. Full core suite
132 green; core + desktop typecheck clean.

Follow-up (rest of #24): surface assignee/milestone pickers in the Adjust dialog
and extend the agent's propose_change tool so Reginald can propose them too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-09 11:31:26 -04:00
parent 62a521e7eb
commit 72dcd396f8
7 changed files with 118 additions and 19 deletions

View File

@@ -17,6 +17,15 @@ import {
export type IssueChange =
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | null }
| { kind: 'assign'; issue: number; assignee: string | null }
| { kind: 'remilestone'; issue: number; milestone: number | null; milestoneTitle?: string | null }
/** The label axes are the only kinds planned as a label swap; the rest are field writes. */
export function isLabelChange(
change: IssueChange,
): change is Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }> {
return change.kind === 'reestimate' || change.kind === 'reprioritize'
}
export interface LabelPlan {
/** The full resulting label-name set (order: kept labels, then the new axis label). */
@@ -29,7 +38,10 @@ export interface LabelPlan {
noop: boolean
}
function axisFor(change: IssueChange): { labels: readonly string[]; target: string | null } {
/** A change that resolves to a label swap (the only kind `planIssueChange` accepts). */
export type LabelChange = Extract<IssueChange, { kind: 'reestimate' | 'reprioritize' }>
function axisFor(change: LabelChange): { labels: readonly string[]; target: string | null } {
return change.kind === 'reestimate'
? { labels: ESTIMATE_LABELS, target: change.estimate }
: { labels: PRIORITY_LABELS, target: change.priority }
@@ -40,7 +52,7 @@ function axisFor(change: IssueChange): { labels: readonly string[]; target: stri
* 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 {
export function planIssueChange(current: string[], change: LabelChange): LabelPlan {
const { labels: axis, target } = axisFor(change)
const onAxis = current.filter((l) => axis.includes(l))
const removed = onAxis.filter((l) => l !== target)
@@ -58,6 +70,26 @@ export function describeChange(plan: LabelPlan): string {
return `${from}${to}`
}
/**
* One human-readable confirm line for ANY change kind — the unified describe for
* the propose-approve UI. Label kinds delegate to {@link describeChange} (so they
* need the issue's current labels); assign/milestone describe the field write
* directly. Pure; no network.
*/
export function summarizeChange(change: IssueChange, currentLabels: string[] = []): string {
switch (change.kind) {
case 'reestimate':
case 'reprioritize':
return describeChange(planIssueChange(currentLabels, change))
case 'assign':
return change.assignee ? `assign → @${change.assignee}` : 'unassign'
case 'remilestone':
return change.milestone == null
? 'remove from milestone'
: `milestone → ${change.milestoneTitle ?? `#${change.milestone}`}`
}
}
/** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */
export interface ChangeProposal {
change: IssueChange