apply_changes: unified mutation tool — estimate/priority/assign/milestone, in-app + agent (#24) #53
@@ -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 }
|
||||||
const current = await client.getIssue(change.issue)
|
|
||||||
const plan = planIssueChange(current.labels, change)
|
if (isLabelChange(change)) {
|
||||||
if (plan.noop) return { ok: true as const, plan, issue: current }
|
const current = await client.getIssue(change.issue)
|
||||||
const ids = await resolveLabelIds(client, plan.labels)
|
const plan = planIssueChange(current.labels, change)
|
||||||
await client.setIssueLabels(change.issue, ids)
|
if (plan.noop) return { ok: true as const, plan, issue: current }
|
||||||
const issue = await client.getIssue(change.issue)
|
const ids = await resolveLabelIds(client, plan.labels)
|
||||||
invalidateSnapshot() // the board + forecast must reflect the label change
|
await client.setIssueLabels(change.issue, ids)
|
||||||
return { ok: true as const, plan, issue }
|
const issue = await client.getIssue(change.issue)
|
||||||
|
invalidateSnapshot()
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
3
apps/desktop/src/renderer/src/global.d.ts
vendored
3
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -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 =
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user