Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions handlers/work_item_update.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions lib/adapters/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -64,6 +65,7 @@ export const githubAdapter: PlatformAdapter = {
labelCreate: labelCreateGithub,
labelList: labelListGithub,
workItem: workItemGithub,
workItemUpdate: workItemUpdateGithub,
ibm: stubMethod,
epicSubIssues: stubMethod,
specGet: stubMethod,
Expand Down
2 changes: 2 additions & 0 deletions lib/adapters/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,6 +66,7 @@ export const gitlabAdapter: PlatformAdapter = {
labelCreate: labelCreateGitlab,
labelList: labelListGitlab,
workItem: workItemGitlab,
workItemUpdate: workItemUpdateGitlab,
ibm: stubMethod,
epicSubIssues: stubMethod,
specGet: stubMethod,
Expand Down
5 changes: 5 additions & 0 deletions lib/adapters/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
'prCreate',
'prDiff',
Expand All @@ -110,6 +114,7 @@ describe('PlatformAdapter contract', () => {
'labelList',
'resolveBranchSha',
'workItem',
'workItemUpdate',
'createBranch',
'findExistingPr',
'fetchCiTrustSignal',
Expand Down
61 changes: 61 additions & 0 deletions lib/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -846,6 +903,9 @@ export interface PlatformAdapter {
labelCreate(args: LabelCreateArgs): Promise<AdapterResult<NormalizedLabel>>;
labelList(args: LabelListArgs): Promise<AdapterResult<LabelListResponse>>;
workItem(args: WorkItemArgs): Promise<AdapterResult<WorkItemResponse>>;
workItemUpdate(
args: WorkItemUpdateArgs,
): Promise<AdapterResult<WorkItemUpdateResponse>>;
ibm(args: IbmArgs): Promise<AdapterResult<IbmResponse>>;
epicSubIssues(args: EpicSubIssuesArgs): Promise<AdapterResult<EpicSubIssuesResponse>>;

Expand Down Expand Up @@ -917,6 +977,7 @@ export const PLATFORM_ADAPTER_METHODS = [
'labelCreate',
'labelList',
'workItem',
'workItemUpdate',
'ibm',
'epicSubIssues',
'specGet',
Expand Down
Loading
Loading