apply_changes: unified mutation tool — estimate/priority/assign/milestone, in-app + agent (#24) #53

Merged
christian merged 3 commits from feat/apply-changes-assign-milestone into main 2026-07-09 19:23:49 +00:00
26 changed files with 160 additions and 3492 deletions
Showing only changes of commit 72dcd396f8 - Show all commits

View File

@@ -19,6 +19,7 @@ import {
type GiteaConfig, type GiteaConfig,
type GiteaLabel, type GiteaLabel,
type IssueChange, type IssueChange,
isLabelChange,
type LifecycleEvent, type LifecycleEvent,
makeDirectiveEntry, makeDirectiveEntry,
parseCapacityConfig, parseCapacityConfig,
@@ -263,19 +264,32 @@ export function registerGiteaIpc(): void {
return client.getIssue(index) return client.getIssue(index)
}) })
// The write path (apply_changes). Additive label swaps, applied only after the // The write path (apply_changes), applied only after the renderer's propose-
// renderer's propose-approve. Returns the plan + the freshly-read issue. // approve. Label swaps (est/p) return the plan; field writes (assign, milestone)
// return the freshly-read issue directly. Either way the snapshot is invalidated
// so the board + forecast reflect the change.
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => { ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
const client = getGiteaClient() const client = getGiteaClient()
if (!client) return { ok: false as const, reason: 'unconfigured' as const } if (!client) return { ok: false as const, reason: 'unconfigured' as const }
if (isLabelChange(change)) {
const current = await client.getIssue(change.issue) const current = await client.getIssue(change.issue)
const plan = planIssueChange(current.labels, change) const plan = planIssueChange(current.labels, change)
if (plan.noop) return { ok: true as const, plan, issue: current } if (plan.noop) return { ok: true as const, plan, issue: current }
const ids = await resolveLabelIds(client, plan.labels) const ids = await resolveLabelIds(client, plan.labels)
await client.setIssueLabels(change.issue, ids) await client.setIssueLabels(change.issue, ids)
const issue = await client.getIssue(change.issue) const issue = await client.getIssue(change.issue)
invalidateSnapshot() // the board + forecast must reflect the label change invalidateSnapshot()
return { ok: true as const, plan, issue } return { ok: true as const, plan, issue }
}
// Field writes — the client returns the updated issue.
const issue =
change.kind === 'assign'
? await client.setIssueAssignees(change.issue, change.assignee ? [change.assignee] : [])
: await client.setIssueMilestone(change.issue, change.milestone)
invalidateSnapshot()
return { ok: true as const, issue }
}) })
// capture_work filing: open each approved issue with its est/* + p/* labels. // capture_work filing: open each approved issue with its est/* + p/* labels.

View File

@@ -2,13 +2,12 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
describeChange,
type EstimateLabel, type EstimateLabel,
ESTIMATE_LABELS, ESTIMATE_LABELS,
type IssueChange, type IssueChange,
planIssueChange,
type PriorityLabel, type PriorityLabel,
PRIORITY_LABELS, PRIORITY_LABELS,
summarizeChange,
} from '@commitea/core' } from '@commitea/core'
import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js'
@@ -78,7 +77,7 @@ export function IssueScreen({
if (priority !== curPriority) if (priority !== curPriority)
pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null }) pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null })
const diffs = pendingChanges.map((c) => describeChange(planIssueChange(labels, c))) const diffs = pendingChanges.map((c) => summarizeChange(c, labels))
const apply = async () => { const apply = async () => {
if (!onApplyChange || pendingChanges.length === 0) return if (!onApplyChange || pendingChanges.length === 0) return

View File

@@ -17,7 +17,8 @@ import type {
/** The result of a write through the bridge. */ /** The result of a write through the bridge. */
export type ApplyChangeResult = export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' } | { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue } // `plan` is present for label swaps (est/p); absent for field writes (assign, milestone).
| { ok: true; plan?: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */ /** The result of filing captured issues. */
export type CreateIssuesResult = export type CreateIssuesResult =

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange, proposalsFor } from './apply-changes-v0.js' import {
describeChange,
isLabelChange,
type IssueChange,
planIssueChange,
proposalsFor,
summarizeChange,
} from './apply-changes-v0.js'
describe('planIssueChange', () => { describe('planIssueChange', () => {
it('swaps the estimate label, keeping non-axis labels', () => { it('swaps the estimate label, keeping non-axis labels', () => {
@@ -77,3 +84,31 @@ describe('proposalsFor', () => {
expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([]) expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([])
}) })
}) })
describe('unified change model (assign + milestone)', () => {
it('isLabelChange narrows label kinds only', () => {
expect(isLabelChange({ kind: 'reestimate', issue: 1, estimate: 'est/2d' })).toBe(true)
expect(isLabelChange({ kind: 'reprioritize', issue: 1, priority: 'p/1' })).toBe(true)
expect(isLabelChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe(false)
expect(isLabelChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe(false)
})
it('summarizeChange describes an assignment and an unassignment', () => {
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: 'christian' })).toBe('assign → @christian')
expect(summarizeChange({ kind: 'assign', issue: 1, assignee: null })).toBe('unassign')
})
it('summarizeChange describes a milestone set (by title) and removal', () => {
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3, milestoneTitle: 'P5 — Dogfood' })).toBe(
'milestone → P5 — Dogfood',
)
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: 3 })).toBe('milestone → #3')
expect(summarizeChange({ kind: 'remilestone', issue: 1, milestone: null })).toBe('remove from milestone')
})
it('summarizeChange delegates label kinds to describeChange (needs current labels)', () => {
const change: IssueChange = { kind: 'reestimate', issue: 1, estimate: 'est/5d' }
expect(summarizeChange(change, ['est/2d'])).toBe('est/2d → est/5d')
expect(summarizeChange(change, ['est/5d'])).toBe('no change')
})
})

View File

@@ -17,6 +17,15 @@ import {
export type IssueChange = export type IssueChange =
| { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null } | { kind: 'reestimate'; issue: number; estimate: EstimateLabel | null }
| { kind: 'reprioritize'; issue: number; priority: PriorityLabel | 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 { export interface LabelPlan {
/** The full resulting label-name set (order: kept labels, then the new axis label). */ /** The full resulting label-name set (order: kept labels, then the new axis label). */
@@ -29,7 +38,10 @@ export interface LabelPlan {
noop: boolean 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' return change.kind === 'reestimate'
? { labels: ESTIMATE_LABELS, target: change.estimate } ? { labels: ESTIMATE_LABELS, target: change.estimate }
: { labels: PRIORITY_LABELS, target: change.priority } : { 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 * 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. * 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 { labels: axis, target } = axisFor(change)
const onAxis = current.filter((l) => axis.includes(l)) const onAxis = current.filter((l) => axis.includes(l))
const removed = onAxis.filter((l) => l !== target) const removed = onAxis.filter((l) => l !== target)
@@ -58,6 +70,26 @@ export function describeChange(plan: LabelPlan): string {
return `${from}${to}` 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. */ /** A change the agent proposes: the concrete op + its diff, ready for approve-then-apply. */
export interface ChangeProposal { export interface ChangeProposal {
change: IssueChange change: IssueChange

View File

@@ -103,6 +103,10 @@ export interface GiteaClient {
listLabels(): Promise<GiteaLabel[]> listLabels(): Promise<GiteaLabel[]>
/** Replace an issue's entire label set with the given label ids. Write. */ /** Replace an issue's entire label set with the given label ids. Write. */
setIssueLabels(index: number, labelIds: number[]): Promise<void> setIssueLabels(index: number, labelIds: number[]): Promise<void>
/** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */
setIssueAssignees(index: number, logins: string[]): Promise<GiteaIssue>
/** Set (or clear, with null) an issue's milestone by id; returns the updated issue. Write. */
setIssueMilestone(index: number, milestoneId: number | null): Promise<GiteaIssue>
/** Open a new issue with a title, optional body, and label ids. Write. */ /** Open a new issue with a title, optional body, and label ids. Write. */
createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue> createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise<GiteaIssue>
/** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */ /** Read a repo file's base64 content + blob sha; null if it (or the repo) is absent. */
@@ -229,6 +233,20 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi
await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } }) await request(`/issues/${index}/labels`, { method: 'PUT', body: { labels: labelIds } })
}, },
async setIssueAssignees(index, logins) {
const raw = (await request(`/issues/${index}`, { method: 'PATCH', body: { assignees: logins } })) as RawIssue
return normalizeIssue(raw)
},
async setIssueMilestone(index, milestoneId) {
// gitea's EditIssueOption takes a milestone id; 0 clears it.
const raw = (await request(`/issues/${index}`, {
method: 'PATCH',
body: { milestone: milestoneId ?? 0 },
})) as RawIssue
return normalizeIssue(raw)
},
async createIssue(input) { async createIssue(input) {
const raw = (await request('/issues', { const raw = (await request('/issues', {
method: 'POST', method: 'POST',

View File

@@ -23,8 +23,8 @@ export type {
GiteaRequestInit, GiteaRequestInit,
} from './gitea/types.js' } from './gitea/types.js'
export { describeChange, planIssueChange, proposalsFor } from './changes/apply-changes-v0.js' export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js'
export type { ChangeProposal, IssueChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js' export type { ChangeProposal, IssueChange, LabelChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js'
export { export {
inferColumnV0, inferColumnV0,