/** * 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 { 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 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 human diff, ready for approve-then-apply. */ export interface ChangeProposal { change: IssueChange /** One-line diff for the approve UI — always present ({@link summarizeChange}). */ summary: string /** The label plan, for label kinds only (absent for assign/milestone). */ plan?: LabelPlan issueTitle?: string } /** What the `propose_change` tool accepts — a target issue and the fields to set. */ export interface ProposeChangeArgs { issue: number estimate?: EstimateLabel priority?: PriorityLabel /** Login to assign, or null to unassign. Omit to leave assignee untouched. */ assignee?: string | null /** Milestone id to set, or null to clear. Omit to leave the milestone untouched. */ milestone?: number | null } /** Current state + lookups a proposal needs to skip noops and label the milestone. */ export interface ProposalContext { currentAssignee?: string | null currentMilestoneId?: number | null milestones?: { id: number; title: string }[] } 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. Label * axes (est/*, p/*) are validated and never invented; assign/milestone are * emitted only when they differ from the issue's current value (via `ctx`). Each * proposal carries a human `summary`; label kinds also carry the label plan. */ export function proposalsFor( args: ProposeChangeArgs, currentLabels: string[], issueTitle?: string, ctx: ProposalContext = {}, ): ChangeProposal[] { const out: ChangeProposal[] = [] const pushLabel = (change: LabelChange) => { const plan = planIssueChange(currentLabels, change) if (!plan.noop) out.push({ change, plan, summary: describeChange(plan), issueTitle }) } if (isEstimate(args.estimate)) pushLabel({ kind: 'reestimate', issue: args.issue, estimate: args.estimate }) if (isPriority(args.priority)) pushLabel({ kind: 'reprioritize', issue: args.issue, priority: args.priority }) if (args.assignee !== undefined && args.assignee !== (ctx.currentAssignee ?? null)) { const change: IssueChange = { kind: 'assign', issue: args.issue, assignee: args.assignee } out.push({ change, summary: summarizeChange(change), issueTitle }) } if (args.milestone !== undefined && args.milestone !== (ctx.currentMilestoneId ?? null)) { const milestoneTitle = ctx.milestones?.find((m) => m.id === args.milestone)?.title ?? null const change: IssueChange = { kind: 'remilestone', issue: args.issue, milestone: args.milestone, milestoneTitle } out.push({ change, summary: summarizeChange(change), issueTitle }) } return out }