diff --git a/handlers/work_item_update.ts b/handlers/work_item_update.ts new file mode 100644 index 0000000..5a12566 --- /dev/null +++ b/handlers/work_item_update.ts @@ -0,0 +1,87 @@ +// work_item_update — adapter-dispatching shell (#287). +// Sister to handlers/work_item.ts: that one creates issues/PRs; this one +// updates an existing issue's title, body, labels, assignees, milestone, or a +// single H2 section of the body. Subprocess and platform branching live in +// lib/adapters/work-item-update-{github,gitlab}.ts; this handler is purely the +// schema validation + envelope shaping. +// +// Section-level patches: when patch.body_section is provided, the adapter +// reads the current issue body, splices the section, and sends the resulting +// full body to the platform CLI's edit/update sub-command. This preserves all +// other sections verbatim — the AC contract for #287. + +import { z } from 'zod'; +import type { HandlerDef } from '../types.js'; +import { getAdapter } from '../lib/adapters/index.js'; + +const issueRefSchema = z + .string() + .regex(/^(?:[A-Za-z0-9._/-]+#)?\d+$|^#\d+$/, 'issue_ref must match `#N` or `owner/repo#N`'); + +const patchSchema = z + .object({ + title: z.string().min(1).optional(), + body: z.string().optional(), + body_section: z + .object({ + heading: z.string().min(1, 'body_section.heading must be non-empty'), + content: z.string(), + }) + .optional(), + labels: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional(), + milestone: z.string().optional(), + }) + .refine( + (p) => + p.title !== undefined || + p.body !== undefined || + p.body_section !== undefined || + p.labels !== undefined || + p.assignees !== undefined || + p.milestone !== undefined, + { message: 'patch must include at least one field' }, + ) + .refine((p) => !(p.body !== undefined && p.body_section !== undefined), { + message: 'patch.body and patch.body_section are mutually exclusive', + }); + +const inputSchema = z.object({ + issue_ref: issueRefSchema, + patch: patchSchema, + dry_run: z.boolean().optional(), + repo: z + .string() + .regex(/^[a-zA-Z0-9._/-]+\/[a-zA-Z0-9._-]+$/, 'repo must be owner/repo format') + .optional(), +}); + +function envelope(payload: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }; +} + +const workItemUpdateHandler: HandlerDef = { + name: 'work_item_update', + description: + 'Update an existing GitHub or GitLab issue (title, body, body_section, labels, assignees, milestone). Section-level patches preserve all other H2 sections. Use dry_run:true to preview without committing. Sister tool to `work_item` (which is create-only).', + inputSchema, + async execute(rawArgs: unknown) { + let args; + try { + args = inputSchema.parse(rawArgs); + } catch (err) { + return envelope({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + + const adapter = getAdapter({ repo: args.repo }); + const result = await adapter.workItemUpdate(args); + + if ('platform_unsupported' in result) { + return envelope({ ok: false, platform_unsupported: true, error: result.hint }); + } + if (!result.ok) return envelope({ ok: false, error: result.error, code: result.code }); + return envelope({ ok: true, ...result.data }); + }, +}; + +export default workItemUpdateHandler; diff --git a/lib/adapters/github.ts b/lib/adapters/github.ts index 2ae08c7..6280012 100644 --- a/lib/adapters/github.ts +++ b/lib/adapters/github.ts @@ -40,6 +40,7 @@ import { prMergeWaitGithub } from './pr-merge-wait-github.js'; import { prStatusGithub } from './pr-status-github.js'; import { prWaitCiGithub } from './pr-wait-ci-github.js'; import { workItemGithub } from './work-item-github.js'; +import { workItemUpdateGithub } from './work-item-update-github.js'; const stubMethod = async (_args: unknown) => ({ platform_unsupported: true as const, @@ -64,6 +65,7 @@ export const githubAdapter: PlatformAdapter = { labelCreate: labelCreateGithub, labelList: labelListGithub, workItem: workItemGithub, + workItemUpdate: workItemUpdateGithub, ibm: stubMethod, epicSubIssues: stubMethod, specGet: stubMethod, diff --git a/lib/adapters/gitlab.ts b/lib/adapters/gitlab.ts index f9675f2..72fdbf7 100644 --- a/lib/adapters/gitlab.ts +++ b/lib/adapters/gitlab.ts @@ -41,6 +41,7 @@ import { prMergeWaitGitlab } from './pr-merge-wait-gitlab.js'; import { prStatusGitlab } from './pr-status-gitlab.js'; import { prWaitCiGitlab } from './pr-wait-ci-gitlab.js'; import { workItemGitlab } from './work-item-gitlab.js'; +import { workItemUpdateGitlab } from './work-item-update-gitlab.js'; const stubMethod = async (_args: unknown) => ({ platform_unsupported: true as const, @@ -65,6 +66,7 @@ export const gitlabAdapter: PlatformAdapter = { labelCreate: labelCreateGitlab, labelList: labelListGitlab, workItem: workItemGitlab, + workItemUpdate: workItemUpdateGitlab, ibm: stubMethod, epicSubIssues: stubMethod, specGet: stubMethod, diff --git a/lib/adapters/types.test.ts b/lib/adapters/types.test.ts index 72ffae8..9dac8c6 100644 --- a/lib/adapters/types.test.ts +++ b/lib/adapters/types.test.ts @@ -86,6 +86,10 @@ describe('PlatformAdapter contract', () => { // after this lands the `handlers/` tree has zero // `gitlabApi*` importers, unblocking Phase 3 Story 3.1 // (delete the pre-retrofit GitLab helper module). + // #287: workItemUpdate — sister to workItem (Story 2.17) but + // for updating an existing issue (title, body, + // body_section, labels, assignees, milestone) instead of + // creating one. New tool, not a migration. const MIGRATED_METHODS = new Set([ 'prCreate', 'prDiff', @@ -110,6 +114,7 @@ describe('PlatformAdapter contract', () => { 'labelList', 'resolveBranchSha', 'workItem', + 'workItemUpdate', 'createBranch', 'findExistingPr', 'fetchCiTrustSignal', diff --git a/lib/adapters/types.ts b/lib/adapters/types.ts index 1a0b8e3..4ee9310 100644 --- a/lib/adapters/types.ts +++ b/lib/adapters/types.ts @@ -608,6 +608,63 @@ export interface WorkItemResponse { url: string; number: number; } + +/** + * `work_item_update` args/response (#287). Update an existing GitHub issue or + * GitLab issue via the appropriate platform CLI. + * + * `issue_ref` is parsed by `parseIssueRef` (`#N` or `owner/repo#N`). Adapters + * use the parsed `(owner, repo, number)` triple plus an optional `repo` arg + * to compose the cross-repo `--repo` / `-R` flag. + * + * `patch` is a partial mutation: + * - `title` — replace the title + * - `body` — replace the FULL body + * - `body_section` — replace ONE H2 section (the H2 heading is matched after + * `normalizeHeading`); preserves all other sections verbatim. Mutually + * exclusive with `body` (handler validates and rejects with both). + * - `labels` — replace the FULL label set on the issue + * - `assignees` — replace the FULL assignee set + * - `milestone` — set the milestone (GitHub only; on GitLab the adapter + * returns `platform_unsupported`). + * + * `dry_run` returns the proposed patch without invoking the platform CLI. + * + * Cross-platform asymmetry (R-03): + * - `milestone` is a GitHub concept (`gh issue edit --milestone`). GitLab's + * equivalent is "milestone" too, BUT the work-item-update adapter on + * GitLab returns `platform_unsupported` for `milestone` because GitLab's + * `glab issue update --milestone` requires the milestone *id* and group/ + * project resolution that this thin tool does not own. Callers needing + * GitLab milestone updates should call `glab` directly today. + */ +export interface WorkItemUpdatePatch { + title?: string; + body?: string; + body_section?: { heading: string; content: string }; + labels?: string[]; + assignees?: string[]; + milestone?: string; +} + +export interface WorkItemUpdateArgs { + issue_ref: string; + patch: WorkItemUpdatePatch; + dry_run?: boolean; + repo?: string; +} + +export interface WorkItemUpdateResponse { + url: string; + number: number; + /** True when this call was a dry-run; no platform CLI was invoked. */ + dry_run: boolean; + /** The fields that were (or would be) updated. */ + updated_fields: string[]; + /** When `body_section` was used, the resulting full body (recomputed). */ + resolved_body?: string; +} + export type IbmArgs = unknown; export type IbmResponse = unknown; export type EpicSubIssuesArgs = unknown; @@ -846,6 +903,9 @@ export interface PlatformAdapter { labelCreate(args: LabelCreateArgs): Promise>; labelList(args: LabelListArgs): Promise>; workItem(args: WorkItemArgs): Promise>; + workItemUpdate( + args: WorkItemUpdateArgs, + ): Promise>; ibm(args: IbmArgs): Promise>; epicSubIssues(args: EpicSubIssuesArgs): Promise>; @@ -917,6 +977,7 @@ export const PLATFORM_ADAPTER_METHODS = [ 'labelCreate', 'labelList', 'workItem', + 'workItemUpdate', 'ibm', 'epicSubIssues', 'specGet', diff --git a/lib/adapters/work-item-update-github.test.ts b/lib/adapters/work-item-update-github.test.ts new file mode 100644 index 0000000..ce4634f --- /dev/null +++ b/lib/adapters/work-item-update-github.test.ts @@ -0,0 +1,378 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import type { AdapterResult, WorkItemUpdateResponse } from './types.ts'; + +// Subprocess-boundary tests for the GitHub work_item_update adapter (#287). +// Argv strictness per `lesson_origin_ops_pitfalls.md`: gh takes `--repo` +// (long flag) and `--body ` inline; NOT glab's `-R` / `--description`. + +interface ThrowableError extends Error { + stderr?: string; + stdout?: string; + status?: number; +} + +let execRegistry: Array<{ match: string; respond: string | (() => string) }> = []; +let execCalls: string[] = []; + +function unquote(cmd: string): string { + return cmd.replace(/'([^']*)'/g, '$1'); +} + +const mockExecSync = mock((cmd: string, _opts?: unknown) => { + execCalls.push(cmd); + const flat = unquote(cmd); + if ( + flat.includes('gh issue edit') && + (/\s-R\s/.test(flat) || /--description/.test(flat)) + ) { + const err = new Error('FAIL: gh issue edit invoked with glab-style flags') as ThrowableError; + err.status = 127; + throw err; + } + for (const { match, respond } of execRegistry) { + if (cmd.includes(match) || flat.includes(match)) { + return typeof respond === 'function' ? respond() : respond; + } + } + const err = new Error(`Unexpected exec: ${cmd}`) as ThrowableError; + err.stderr = `Unexpected exec: ${cmd}`; + err.status = 127; + throw err; +}); + +mock.module('child_process', () => ({ execSync: mockExecSync })); + +const { workItemUpdateGithub } = await import('./work-item-update-github.ts'); + +function on(match: string, respond: string | (() => string)): void { + execRegistry.push({ match, respond }); +} + +function expectOk( + r: AdapterResult, +): asserts r is { ok: true; data: WorkItemUpdateResponse } { + if (!('ok' in r) || !r.ok) { + throw new Error(`expected ok result, got ${JSON.stringify(r)}`); + } +} + +function expectErr( + r: AdapterResult, +): asserts r is { ok: false; error: string; code: string } { + if (!('ok' in r) || r.ok) { + throw new Error(`expected error result, got ${JSON.stringify(r)}`); + } +} + +function findCall(needle: string): string { + return execCalls.find((c) => c.includes(needle) || unquote(c).includes(needle)) ?? ''; +} + +beforeEach(() => { + execRegistry = []; + execCalls = []; +}); + +describe('workItemUpdateGithub — subprocess boundary', () => { + // ---- title-only patch ---- + + test("title-only patch: emits gh issue edit with --title and no --body", async () => { + on('gh issue edit', 'https://github.com/org/repo/issues/42\n'); + + const result = await workItemUpdateGithub({ + issue_ref: '#42', + patch: { title: 'New title' }, + }); + expectOk(result); + + const call = findCall('gh issue edit'); + expect(call).toContain("'gh' 'issue' 'edit' '42'"); + expect(call).toContain("'--title' 'New title'"); + expect(call).not.toContain('--body'); + expect(result.data.number).toBe(42); + expect(result.data.dry_run).toBe(false); + expect(result.data.updated_fields).toEqual(['title']); + }); + + // ---- body-only patch (no pre-fetch needed) ---- + + test('body-only patch: emits --body and skips the pre-fetch view', async () => { + on('gh issue edit', 'https://github.com/org/repo/issues/42\n'); + + const result = await workItemUpdateGithub({ + issue_ref: '#42', + patch: { body: 'completely new body' }, + }); + expectOk(result); + + expect(execCalls.some((c) => unquote(c).includes('gh issue view'))).toBe(false); + const call = findCall('gh issue edit'); + expect(call).toContain("'--body' 'completely new body'"); + expect(result.data.updated_fields).toEqual(['body']); + }); + + // ---- repo flag forwarding ---- + + test('--repo forwarded for cross-repo update', async () => { + on('gh issue edit', 'https://github.com/foo/bar/issues/9\n'); + + await workItemUpdateGithub({ + issue_ref: '#9', + patch: { title: 'X' }, + repo: 'foo/bar', + }); + + const call = findCall('gh issue edit'); + expect(call).toContain("'--repo' 'foo/bar'"); + }); + + test('rejects malformed repo slug with typed error', async () => { + const result = await workItemUpdateGithub({ + issue_ref: '#1', + patch: { title: 'x' }, + repo: 'not a slug', + }); + expectErr(result); + expect(result.code).toBe('invalid_repo_slug'); + }); + + // ---- issue_ref parsing ---- + + test('parses owner/repo#N issue_ref', async () => { + on('gh issue edit', 'https://github.com/org/repo/issues/123\n'); + + const result = await workItemUpdateGithub({ + issue_ref: 'org/repo#123', + patch: { title: 'X' }, + }); + expectOk(result); + expect(result.data.number).toBe(123); + }); + + test('rejects unparseable issue_ref', async () => { + const result = await workItemUpdateGithub({ + issue_ref: 'not-an-issue', + patch: { title: 'x' }, + }); + expectErr(result); + expect(result.code).toBe('invalid_issue_ref'); + }); + + // ---- labels: replacement-via-add/remove diff ---- + + test('labels patch: pre-fetches current labels and emits add/remove diff', async () => { + on( + 'gh issue view', + JSON.stringify({ + number: 7, + url: 'https://github.com/org/repo/issues/7', + body: '## Summary\nbody\n', + labels: [{ name: 'old-keep' }, { name: 'old-drop' }], + assignees: [], + }), + ); + on('gh issue edit', 'https://github.com/org/repo/issues/7\n'); + + const result = await workItemUpdateGithub({ + issue_ref: '#7', + patch: { labels: ['old-keep', 'new-add'] }, + }); + expectOk(result); + + const call = findCall('gh issue edit'); + expect(call).toContain("'--add-label' 'new-add'"); + expect(call).toContain("'--remove-label' 'old-drop'"); + expect(call).not.toContain("'--add-label' 'old-keep'"); + }); + + // ---- assignees: same diff shape ---- + + test('assignees patch: emits add/remove diff against current set', async () => { + on( + 'gh issue view', + JSON.stringify({ + number: 11, + url: 'https://github.com/org/repo/issues/11', + body: '', + labels: [], + assignees: [{ login: 'alice' }, { login: 'bob' }], + }), + ); + on('gh issue edit', 'https://github.com/org/repo/issues/11\n'); + + await workItemUpdateGithub({ + issue_ref: '#11', + patch: { assignees: ['alice', 'carol'] }, + }); + + const call = findCall('gh issue edit'); + expect(call).toContain("'--add-assignee' 'carol'"); + expect(call).toContain("'--remove-assignee' 'bob'"); + expect(call).not.toContain("'--add-assignee' 'alice'"); + }); + + // ---- milestone (GitHub only) ---- + + test('milestone patch: emits --milestone flag', async () => { + on('gh issue edit', 'https://github.com/org/repo/issues/3\n'); + + await workItemUpdateGithub({ + issue_ref: '#3', + patch: { milestone: 'v1.0' }, + }); + + const call = findCall('gh issue edit'); + expect(call).toContain("'--milestone' 'v1.0'"); + }); + + // ---- body_section: read-modify-write ---- + + test('body_section patch: pre-fetches body, splices section, sends full body', async () => { + const originalBody = [ + '## Summary', + 'short summary', + '', + '## Dependencies', + '- old dep', + '', + '## Acceptance Criteria', + '- [ ] AC', + ].join('\n'); + + on( + 'gh issue view', + JSON.stringify({ + number: 5, + url: 'https://github.com/org/repo/issues/5', + body: originalBody, + labels: [], + assignees: [], + }), + ); + on('gh issue edit', 'https://github.com/org/repo/issues/5\n'); + + const result = await workItemUpdateGithub({ + issue_ref: '#5', + patch: { body_section: { heading: 'Dependencies', content: '- new dep' } }, + }); + expectOk(result); + expect(result.data.updated_fields).toEqual(['body_section']); + expect(result.data.resolved_body).toContain('## Dependencies'); + expect(result.data.resolved_body).toContain('- new dep'); + expect(result.data.resolved_body).not.toContain('- old dep'); + // AC: section patching preserves other H2 sections unmodified + expect(result.data.resolved_body).toContain('## Summary'); + expect(result.data.resolved_body).toContain('short summary'); + expect(result.data.resolved_body).toContain('## Acceptance Criteria'); + expect(result.data.resolved_body).toContain('- [ ] AC'); + + const call = findCall('gh issue edit'); + expect(call).toContain('--body'); + }); + + test('body_section: missing section returns typed error, no edit invoked', async () => { + on( + 'gh issue view', + JSON.stringify({ + number: 5, + url: 'https://github.com/org/repo/issues/5', + body: '## Summary\nx\n', + labels: [], + assignees: [], + }), + ); + + const result = await workItemUpdateGithub({ + issue_ref: '#5', + patch: { body_section: { heading: 'Dependencies', content: '- x' } }, + }); + expectErr(result); + expect(result.code).toBe('section_splice_failed'); + expect(execCalls.some((c) => unquote(c).includes('gh issue edit'))).toBe(false); + }); + + // ---- dry_run: no side effect ---- + + test('dry_run:true returns proposed changes without invoking gh issue edit', async () => { + const result = await workItemUpdateGithub({ + issue_ref: '#42', + patch: { title: 'Preview' }, + dry_run: true, + }); + expectOk(result); + expect(result.data.dry_run).toBe(true); + expect(result.data.updated_fields).toEqual(['title']); + expect(execCalls.some((c) => unquote(c).includes('gh issue edit'))).toBe(false); + }); + + test('dry_run:true with body_section pre-fetches and returns resolved_body', async () => { + on( + 'gh issue view', + JSON.stringify({ + number: 9, + url: 'https://github.com/org/repo/issues/9', + body: '## A\nold\n', + labels: [], + assignees: [], + }), + ); + + const result = await workItemUpdateGithub({ + issue_ref: '#9', + patch: { body_section: { heading: 'A', content: 'new' } }, + dry_run: true, + }); + expectOk(result); + expect(result.data.dry_run).toBe(true); + expect(result.data.resolved_body).toContain('new'); + expect(execCalls.some((c) => unquote(c).includes('gh issue edit'))).toBe(false); + }); + + // ---- patch validation ---- + + test('rejects empty patch', async () => { + const result = await workItemUpdateGithub({ issue_ref: '#1', patch: {} }); + expectErr(result); + expect(result.code).toBe('empty_patch'); + }); + + test('rejects body + body_section together', async () => { + const result = await workItemUpdateGithub({ + issue_ref: '#1', + patch: { body: 'x', body_section: { heading: 'A', content: 'y' } }, + }); + expectErr(result); + expect(result.code).toBe('patch_conflict'); + }); + + // ---- error surface ---- + + test('returns AdapterResult.error on gh issue edit failure', async () => { + on('gh issue edit', () => { + const err = new Error('not authenticated') as ThrowableError; + err.stderr = 'gh: not authenticated'; + err.status = 1; + throw err; + }); + + const result = await workItemUpdateGithub({ issue_ref: '#1', patch: { title: 'x' } }); + expectErr(result); + expect(result.code).toBe('gh_issue_edit_failed'); + }); + + test('returns AdapterResult.error on gh issue view failure during pre-fetch', async () => { + on('gh issue view', () => { + const err = new Error('not found') as ThrowableError; + err.stderr = 'no such issue'; + err.status = 1; + throw err; + }); + + const result = await workItemUpdateGithub({ + issue_ref: '#1', + patch: { labels: ['x'] }, + }); + expectErr(result); + expect(result.code).toBe('gh_issue_view_failed'); + }); +}); diff --git a/lib/adapters/work-item-update-github.ts b/lib/adapters/work-item-update-github.ts new file mode 100644 index 0000000..9e6e9bf --- /dev/null +++ b/lib/adapters/work-item-update-github.ts @@ -0,0 +1,243 @@ +/** + * GitHub `work_item_update` adapter implementation (#287). + * + * Updates an existing GitHub issue via `gh issue edit`. Supports title-only, + * body-only, label, assignee, milestone, and section-level body patches. + * + * Argv composition: + * gh issue edit + * [--title ] + * [--body ] + * [--add-label