Surfaces the assign/milestone mutations end-to-end so they're usable in-app and by the agent — the rest of #24. Agent path: - propose_change tool + system prompt now accept `assignee` (login/null) and `milestone` (id/null). ProposeChangeArgs + proposalsFor extended: a new ProposalContext (current assignee/milestone + milestones list) lets a proposal skip no-ops and label the milestone. ChangeProposal gains an always-present `summary` (plan is now label-only) — chat-panel, use-chat, and the model executor render `summary`, so non-label proposals display correctly. Dialog path: - Client `listCollaborators()` (prepends the repo owner — /collaborators omits them, so a solo-owner repo still has an assignable person). New `gitea:collaborators` bridge. The Adjust dialog gains Assignee + Milestone pickers (current values from the reconciled backlog); pending assign/remilestone changes flow through the existing apply path. Tests: +4 core (assign/milestone proposals with no-op skip; collaborators owner-prepend + no-double-add). 138 core green; core + desktop typecheck clean; 14 fixture e2e green; live-backlog now drives the pickers on real data. Fixed stale P2 refs in live-backlog (P2 is shipped → correctly off the runway). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
6.7 KiB
TypeScript
159 lines
6.7 KiB
TypeScript
/**
|
||
* 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 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
|
||
}
|