Files
commitea/packages/core/src/changes/apply-changes-v0.ts
Croissant Le Doux 72dcd396f8 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>
2026-07-09 11:31:26 -04:00

137 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 }
| { 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). */
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
}
/** 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 }
}
/**
* 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: LabelChange): 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}`
}
/**
* 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
plan: LabelPlan
issueTitle?: string
}
/** What the `propose_change` tool accepts — a target issue and the axes to set. */
export interface ProposeChangeArgs {
issue: number
estimate?: EstimateLabel
priority?: PriorityLabel
}
function isEstimate(v: unknown): v is EstimateLabel {
return typeof v === 'string' && (ESTIMATE_LABELS as readonly string[]).includes(v)
}
function isPriority(v: unknown): v is PriorityLabel {
return typeof v === 'string' && (PRIORITY_LABELS as readonly string[]).includes(v)
}
/**
* Build the concrete, non-noop proposals for a `propose_change` request against
* an issue's current labels. Invalid or unchanged axes are dropped — the agent
* proposes only real changes, and never a label outside the est/* · p/* axes.
*/
export function proposalsFor(
args: ProposeChangeArgs,
currentLabels: string[],
issueTitle?: string,
): ChangeProposal[] {
const out: ChangeProposal[] = []
if (isEstimate(args.estimate)) {
const change: IssueChange = { kind: 'reestimate', issue: args.issue, estimate: args.estimate }
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle })
}
if (isPriority(args.priority)) {
const change: IssueChange = { kind: 'reprioritize', issue: args.issue, priority: args.priority }
const plan = planIssueChange(currentLabels, change)
if (!plan.noop) out.push({ change, plan, issueTitle })
}
return out
}