diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 8334d54..b7d3224 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -19,6 +19,7 @@ import { type GiteaConfig, type GiteaLabel, type IssueChange, + isLabelChange, type LifecycleEvent, makeDirectiveEntry, parseCapacityConfig, @@ -263,19 +264,32 @@ export function registerGiteaIpc(): void { return client.getIssue(index) }) - // The write path (apply_changes). Additive label swaps, applied only after the - // renderer's propose-approve. Returns the plan + the freshly-read issue. + // The write path (apply_changes), applied only after the renderer's propose- + // 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) => { const client = getGiteaClient() 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 (plan.noop) return { ok: true as const, plan, issue: current } - const ids = await resolveLabelIds(client, plan.labels) - await client.setIssueLabels(change.issue, ids) - const issue = await client.getIssue(change.issue) - invalidateSnapshot() // the board + forecast must reflect the label change - return { ok: true as const, plan, issue } + + if (isLabelChange(change)) { + const current = await client.getIssue(change.issue) + const plan = planIssueChange(current.labels, change) + if (plan.noop) return { ok: true as const, plan, issue: current } + const ids = await resolveLabelIds(client, plan.labels) + await client.setIssueLabels(change.issue, ids) + 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. diff --git a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx index fb2f76d..a8cc758 100644 --- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -2,13 +2,12 @@ import React, { useState } from 'react' import { - describeChange, type EstimateLabel, ESTIMATE_LABELS, type IssueChange, - planIssueChange, type PriorityLabel, PRIORITY_LABELS, + summarizeChange, } from '@commitea/core' import { ISSUE_DETAIL, type IssueDetail, type IssueRef } from '../../data/fixtures.js' @@ -78,7 +77,7 @@ export function IssueScreen({ if (priority !== curPriority) 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 () => { if (!onApplyChange || pendingChanges.length === 0) return diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index d473b52..c5e01db 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -17,7 +17,8 @@ import type { /** The result of a write through the bridge. */ export type ApplyChangeResult = | { 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. */ export type CreateIssuesResult = diff --git a/packages/core/src/changes/apply-changes-v0.test.ts b/packages/core/src/changes/apply-changes-v0.test.ts index e1cea9e..7661fd3 100644 --- a/packages/core/src/changes/apply-changes-v0.test.ts +++ b/packages/core/src/changes/apply-changes-v0.test.ts @@ -1,6 +1,13 @@ 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', () => { it('swaps the estimate label, keeping non-axis labels', () => { @@ -77,3 +84,31 @@ describe('proposalsFor', () => { 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') + }) +}) diff --git a/packages/core/src/changes/apply-changes-v0.ts b/packages/core/src/changes/apply-changes-v0.ts index ccd529e..2f705d9 100644 --- a/packages/core/src/changes/apply-changes-v0.ts +++ b/packages/core/src/changes/apply-changes-v0.ts @@ -17,6 +17,15 @@ import { 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). */ @@ -29,7 +38,10 @@ export interface LabelPlan { 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 + +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 } @@ -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 * 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 onAxis = current.filter((l) => axis.includes(l)) const removed = onAxis.filter((l) => l !== target) @@ -58,6 +70,26 @@ export function describeChange(plan: LabelPlan): string { 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 diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts index 52aaeba..b2260e1 100644 --- a/packages/core/src/gitea/client.ts +++ b/packages/core/src/gitea/client.ts @@ -103,6 +103,10 @@ export interface GiteaClient { listLabels(): Promise /** Replace an issue's entire label set with the given label ids. Write. */ setIssueLabels(index: number, labelIds: number[]): Promise + /** Replace an issue's assignees (empty array unassigns); returns the updated issue. Write. */ + setIssueAssignees(index: number, logins: string[]): Promise + /** Set (or clear, with null) an issue's milestone by id; returns the updated issue. Write. */ + setIssueMilestone(index: number, milestoneId: number | null): Promise /** Open a new issue with a title, optional body, and label ids. Write. */ createIssue(input: { title: string; body?: string; labelIds?: number[] }): Promise /** 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 } }) }, + 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) { const raw = (await request('/issues', { method: 'POST', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f54a11d..7f3a3d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,8 +23,8 @@ export type { GiteaRequestInit, } from './gitea/types.js' -export { describeChange, planIssueChange, proposalsFor } from './changes/apply-changes-v0.js' -export type { ChangeProposal, IssueChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js' +export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js' +export type { ChangeProposal, IssueChange, LabelChange, LabelPlan, ProposeChangeArgs } from './changes/apply-changes-v0.js' export { inferColumnV0,