From 72dcd396f84fa6a71bab14b6ffe1bb4edc9957ad Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 11:31:26 -0400 Subject: [PATCH 1/2] apply_changes: unify the mutation tool with assign + milestone (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path handled only the est/* and p/* label axes. Extend it to the two mutations dogfooding proved were missing — assignee and milestone — behind one unified change model: - IssueChange gains `assign` and `remilestone` kinds. `planIssueChange` is now typed to LabelChange (the label kinds only); `isLabelChange` narrows, and a new pure `summarizeChange(change, currentLabels?)` gives one confirm line for ANY kind (label kinds delegate to describeChange; field writes describe directly). - GiteaClient gains `setIssueAssignees` / `setIssueMilestone` (PATCH /issues/{n}, returning the updated issue; milestone 0 clears). - The main `gitea:applyChange` handler branches: label swaps resolve ids + setIssueLabels as before; field writes call the new client methods. Snapshot invalidated either way so board + forecast catch up. ApplyChangeResult.plan is now optional (absent for field writes). - issue-screen uses summarizeChange for its diff preview. Tests: +5 covering isLabelChange, and summarizeChange for assign/unassign, milestone set-by-title / by-id / removal, and label delegation. Full core suite 132 green; core + desktop typecheck clean. Follow-up (rest of #24): surface assignee/milestone pickers in the Adjust dialog and extend the agent's propose_change tool so Reginald can propose them too. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/gitea.ts | 34 ++++++++++++----- .../src/components/screens/issue-screen.tsx | 5 +-- apps/desktop/src/renderer/src/global.d.ts | 3 +- .../core/src/changes/apply-changes-v0.test.ts | 37 ++++++++++++++++++- packages/core/src/changes/apply-changes-v0.ts | 36 +++++++++++++++++- packages/core/src/gitea/client.ts | 18 +++++++++ packages/core/src/index.ts | 4 +- 7 files changed, 118 insertions(+), 19 deletions(-) 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, From 4fc5b902c49b05a82fb705c967b2eec2f391fd4b Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Thu, 9 Jul 2026 11:54:03 -0400 Subject: [PATCH 2/2] apply_changes UI: assign + milestone in the dialog and Reginald's tool (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/live-backlog.spec.ts | 21 ++++--- apps/desktop/src/main/gitea.ts | 11 ++++ apps/desktop/src/main/model.ts | 14 ++++- apps/desktop/src/preload/index.ts | 2 + .../src/components/screens/issue-screen.tsx | 61 +++++++++++++++++-- .../src/components/shell/app-shell.tsx | 14 +++++ .../src/components/shell/chat-panel.tsx | 4 +- apps/desktop/src/renderer/src/global.d.ts | 1 + apps/desktop/src/renderer/src/lib/use-chat.ts | 3 +- packages/core/src/agent/agent-tools.ts | 12 ++-- .../core/src/changes/apply-changes-v0.test.ts | 22 ++++++- packages/core/src/changes/apply-changes-v0.ts | 48 +++++++++++---- packages/core/src/gitea/client.test.ts | 16 +++++ packages/core/src/gitea/client.ts | 15 +++++ packages/core/src/index.ts | 9 ++- 15 files changed, 215 insertions(+), 38 deletions(-) diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index 4362ea2..b610aac 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -11,6 +11,7 @@ const MAIN = join(here, '..', 'out', 'main', 'index.js') test.describe('live backlog', () => { test('The pot + Focus render real gitea data', async () => { test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test') + test.setTimeout(90_000) const app = await electron.launch({ args: [MAIN], env: { ...process.env } }) const win = await app.firstWindow() await win.waitForLoadState('domcontentloaded') @@ -42,16 +43,17 @@ test.describe('live backlog', () => { win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/), ).toBeVisible() // Real per-milestone forecasts — these milestone names come from gitea, not the - // fixture (which lists Beta / Pilot-ready / v1.0). - await expect(win.getByText(/P2 — Scheduler/)).toBeVisible() + // fixture (Beta / Pilot-ready / v1.0). The runway shows milestones with open + // scope; P5 is active (P2 is fully shipped, so it's correctly omitted). + await expect(win.getByText(/P5 — Dogfood/)).toBeVisible() // Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.) await expect(win.getByText('christian', { exact: true })).toBeVisible() await expect(win.getByText(/pd\/day/).first()).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' }) - // Milestone drill-in — clicking a real milestone opens its real detail - await win.getByText(/P2 — Scheduler/).click() - await expect(win.getByRole('heading', { name: 'P2 — Scheduler + Monte Carlo' })).toBeVisible() + // Milestone drill-in — clicking a real (active) milestone opens its real detail + await win.getByText(/P5 — Dogfood/).click() + await expect(win.getByRole('heading', { name: 'P5 — Dogfood + polish' })).toBeVisible() await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' }) await rail.getByRole('button', { name: 'Runway' }).click() @@ -68,10 +70,15 @@ test.describe('live backlog', () => { await win.getByRole('main').getByRole('link').first().click() await expect(win.getByText(/· stephen\/commitea/)).toBeVisible() await win.getByRole('button', { name: 'Adjust' }).click() - await expect(win.getByText('Adjust estimate & priority')).toBeVisible() + await expect(win.getByText('Adjust issue')).toBeVisible() await win.getByRole('combobox').first().selectOption('est/8d') - await expect(win.getByText('Proposed label change')).toBeVisible() + await expect(win.getByText('Proposed change')).toBeVisible() await expect(win.getByText(/est\/8d/).last()).toBeVisible() + // the unified tool also drives assignee + milestone — the pickers render and diff + await expect(win.getByText('Assignee', { exact: true })).toBeVisible() + await expect(win.getByText('Milestone', { exact: true })).toBeVisible() + await win.getByRole('combobox').nth(2).selectOption('') // Assignee → Unassigned + await expect(win.getByText('unassign', { exact: true })).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' }) await win.getByRole('button', { name: 'Cancel' }).click() // no mutation diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index b7d3224..1bafee0 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -333,6 +333,17 @@ export function registerGiteaIpc(): void { } }) + // Assignable people (repo collaborators) for the Adjust dialog's assignee picker. + ipcMain.handle('gitea:collaborators', async () => { + const client = getGiteaClient() + if (!client) return [] + try { + return await client.listCollaborators() + } catch { + return [] + } + }) + // ---- config (team onboarding) ---- ipcMain.handle('config:get', () => publicConfig()) diff --git a/apps/desktop/src/main/model.ts b/apps/desktop/src/main/model.ts index a4c7711..7883448 100644 --- a/apps/desktop/src/main/model.ts +++ b/apps/desktop/src/main/model.ts @@ -13,7 +13,6 @@ import { type ChangeProposal, type ChatMessage, createChatClient, - describeChange, type ModelRouter, type ProjectView, proposalsFor, @@ -130,10 +129,19 @@ export function registerModelIpc(): void { const a = (args ?? {}) as ProposeChangeArgs const issue = await client.getIssue(a.issue).catch(() => null) if (!issue) return { error: `issue #${a.issue} not found` } - const built = proposalsFor(a, issue.labels, issue.title) + // Milestone titles + current assignee/milestone let a proposal label the + // milestone and skip a no-op assign/move. + const milestones = (a.milestone !== undefined ? await client.listMilestones().catch(() => []) : []).map( + (m) => ({ id: m.id, title: m.title }), + ) + const built = proposalsFor(a, issue.labels, issue.title, { + currentAssignee: issue.assignee, + currentMilestoneId: issue.milestone?.id ?? null, + milestones, + }) proposals.push(...built) return built.length - ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) } + ? { proposed: built.map((p) => ({ issue: a.issue, diff: p.summary })) } : { proposed: [], note: 'no change — already at that value' } } if (name === 'record_directive') { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cd8c2c9..b780fee 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -15,6 +15,8 @@ const api = { applyChange: (change: unknown) => ipcRenderer.invoke('gitea:applyChange', change), /** File a set of captured issues with their est/* + p/* labels. */ createIssues: (issues: unknown) => ipcRenderer.invoke('gitea:createIssues', issues), + /** Assignable people (repo collaborators) for the assignee picker. */ + collaborators: () => ipcRenderer.invoke('gitea:collaborators'), }, pmstate: { /** Read the directive ledger from the pm-state repo. */ 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 a8cc758..acbae4a 100644 --- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -21,12 +21,20 @@ export function IssueScreen({ onOpenIssue, canWrite = false, onApplyChange, + collaborators = [], + milestones = [], + currentAssignee = null, + currentMilestoneId = null, }: { issue: IssueRef onBack: () => void onOpenIssue: (issue: IssueRef) => void canWrite?: boolean onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }> + collaborators?: { login: string; name: string }[] + milestones?: { id: number; title: string }[] + currentAssignee?: string | null + currentMilestoneId?: number | null }) { const det: IssueDetail = ISSUE_DETAIL[issue.id] || { state: 'triage', @@ -59,23 +67,38 @@ export function IssueScreen({ const labels = issue.labels ?? [] const curEstimate = labels.find((l) => l.startsWith('est/')) ?? '' const curPriority = labels.find((l) => l.startsWith('p/')) ?? '' + const curAssignee = currentAssignee ?? '' + const curMilestone = currentMilestoneId != null ? String(currentMilestoneId) : '' const [adjustOpen, setAdjustOpen] = useState(false) const [estimate, setEstimate] = useState(curEstimate) const [priority, setPriority] = useState(curPriority) + const [assignee, setAssignee] = useState(curAssignee) + const [milestone, setMilestone] = useState(curMilestone) const [applying, setApplying] = useState(false) const openAdjust = () => { setEstimate(curEstimate) setPriority(curPriority) + setAssignee(curAssignee) + setMilestone(curMilestone) setAdjustOpen(true) } - // the concrete changes this dialog would apply, one per axis that differs + // the concrete changes this dialog would apply, one per field that differs const pendingChanges: IssueChange[] = [] if (estimate !== curEstimate) pendingChanges.push({ kind: 'reestimate', issue: issue.id, estimate: (estimate || null) as EstimateLabel | null }) if (priority !== curPriority) pendingChanges.push({ kind: 'reprioritize', issue: issue.id, priority: (priority || null) as PriorityLabel | null }) + if (assignee !== curAssignee) + pendingChanges.push({ kind: 'assign', issue: issue.id, assignee: assignee || null }) + if (milestone !== curMilestone) + pendingChanges.push({ + kind: 'remilestone', + issue: issue.id, + milestone: milestone ? Number(milestone) : null, + milestoneTitle: milestones.find((m) => String(m.id) === milestone)?.title ?? null, + }) const diffs = pendingChanges.map((c) => summarizeChange(c, labels)) @@ -92,6 +115,14 @@ export function IssueScreen({ const estOptions = [{ value: '', label: NONE }, ...ESTIMATE_LABELS.map((l) => ({ value: l, label: l }))] const prioOptions = [{ value: '', label: NONE }, ...PRIORITY_LABELS.map((l) => ({ value: l, label: l }))] + const assigneeOptions = [ + { value: '', label: 'Unassigned' }, + ...collaborators.map((c) => ({ value: c.login, label: c.name })), + ] + const milestoneOptions = [ + { value: '', label: NONE }, + ...milestones.map((m) => ({ value: String(m.id), label: m.title })), + ] return (
@@ -132,7 +163,7 @@ export function IssueScreen({ setAdjustOpen(false)} - title="Adjust estimate & priority" + title="Adjust issue" footer={ <>
+ {(collaborators.length > 0 || milestones.length > 0) && ( +
+ {collaborators.length > 0 ? ( + setMilestone(e.target.value)} + /> + ) : null} +
+ )}
{pendingChanges.length === 0 ? (

- No change yet — pick a different estimate or priority. + No change yet — pick a different estimate, priority, assignee, or milestone.

) : ( <>

- Proposed label change + Proposed change

{diffs.map((d) => (
@@ -174,7 +225,7 @@ export function IssueScreen({
))}

- Writes the label to gitea and re-runs the plan. Nothing else changes. + Writes to gitea and re-runs the plan. Nothing else changes.

)} diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index c7e8187..a81101d 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -101,6 +101,7 @@ export function AppShell() { const [milestoneId, setMilestoneId] = useState(null) const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking') const [pubConfig, setPubConfig] = useState(null) + const [collaborators, setCollaborators] = useState<{ login: string; name: string }[]>([]) const [backlog, refetchBacklog] = useBacklog() const capacityMembers = useCapacity() const workers = capacityWorkers(capacityMembers) @@ -144,8 +145,17 @@ export function AppShell() { .then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect')) .catch(() => setGate('connect')) window.commitea.config.get().then(setPubConfig).catch(() => {}) + // Assignable people for the Adjust dialog's assignee picker (empty when demo/unconfigured). + window.commitea.gitea.collaborators().then(setCollaborators).catch(() => {}) }, []) + // The open issue's current assignee + milestone (from the reconciled backlog), + // so the Adjust dialog can preselect and diff them. Milestones list feeds its picker. + const currentIssue = + backlog.status === 'ready' && issue ? backlog.issues.find((i) => i.number === issue.id) : undefined + const milestoneOptions = + backlog.status === 'ready' ? backlog.milestones.map((m) => ({ id: m.id, title: m.title })) : [] + const openIssue = (ref: IssueRef) => { if (view !== 'issue') setPrevView(view) setIssue(ref) @@ -286,6 +296,10 @@ export function AppShell() { onOpenIssue={openIssue} canWrite={backlog.status === 'ready'} onApplyChange={applyChange} + collaborators={collaborators} + milestones={milestoneOptions} + currentAssignee={currentIssue?.assignee ?? null} + currentMilestoneId={currentIssue?.milestone?.id ?? null} /> ) : null case 'states': diff --git a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx index d396d13..f614251 100644 --- a/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shell/chat-panel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react' -import { describeChange, type IssueChange } from '@commitea/core' +import type { IssueChange } from '@commitea/core' import { useChat } from '../../lib/use-chat.js' import { Button, Icon, IconButton } from '../ui/index.js' @@ -127,7 +127,7 @@ export function ChatPanel({ onOpenDirectives, offline, onApplyChange }: ChatPane
Proposed · #{p.change.issue}
-
{describeChange(p.plan)}
+
{p.summary}