From 91231dc4209f49ce750a90efcc55e3b4478bdfac Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Mon, 10 Aug 2026 17:28:26 -0600 Subject: [PATCH] fix: align create/update flag surface across issue, issue-template, action, calendar (HULY-8) `huly issue update` was missing several flags that `huly issue create` accepts (`--label`, `--kind`, `--body`, `--body-file`, `--due`). The same pattern appeared in three other write-asymmetric surfaces: - `huly issue-template update` was missing `--body-file` - `huly action update` was missing `--attached-to` / `--attached-to-class` - `huly calendar update` was missing `--body` Each fix mirrors the corresponding `create` handler: * `--label` on issue update: replaces the labels array, matching create * `--kind` on issue update: power-user TaskType ref, skipping name lookup * `--body` / `--body-file` on issue update: uploadMarkup path, body takes precedence over --description (mirrors createIssue descriptionSource) * `--due` on issue update: empty string clears, ISO string sets * `--body-file` on issue-template update: file-read path matching create * `--attached-to` / `--attached-to-class` on action update: wired but rejected with a clear 'delete + recreate' workaround, since true reparenting needs removeCollection + addCollection + WorkSlot migration (out of scope; a dedicated 'action move' subcommand is the right fix) * `--body` on calendar update: body wins over --description, matching createEvent Also tidies the 'nothing to update' error hints and help text to enumerate all accepted flags, removing the stale `--kind` reference in the issue update hint that advertised a flag the CLI never registered. Verified on http://localhost:7180 with credentials test@aaravlabs.com/test against the `huly_v7_test` compose stack. --- packages/cli/src/cli.ts | 42 +++++++++- packages/cli/src/resources/calendar.ts | 8 +- packages/cli/src/resources/issue-template.ts | 17 +++- packages/cli/src/resources/issue.ts | 85 +++++++++++++++----- packages/cli/src/resources/todo.ts | 17 ++++ 5 files changed, 142 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7af6412..9a71b7e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -963,7 +963,18 @@ behaviors and smart defaults' in README.md for full resolution order.`, .option('--assignee ') .option('--title ') .option('--description ') + .option('--body ') + .option('--body-file ') .option('--task-type ') + .option('--kind ', 'TaskType ref (e.g. tracker:taskTypes:Issue); power-user bypass of name lookup') + .option( + '--due ', + 'ISO 8601 e.g. 2026-07-01T14:00:00Z; pass an empty string ("") to clear an existing due date', + ) + .option( + '--label ', + 'repeatable: --label bug --label auth (replaces existing labels); use --unset labels to clear all', + ) .addHelpText( 'after', ` @@ -972,8 +983,15 @@ Examples: $ huly issue update TSK-1 --description "Updated text" $ huly issue update TSK-1 --set priority=High --set assignee=bob@example.com $ huly issue update TSK-1 --set description=null # clear field + $ huly issue update TSK-1 --label bug --label p1 # set labels (replaces) + $ huly issue update TSK-1 --due 2026-09-01T00:00:00Z + $ huly issue update TSK-1 --body-file ./updated-spec.md -Pass any combination of --status/--priority/--assignee/--title/--description/--set. +Pass any combination of --status/--priority/--assignee/--title/--description/--body/--body-file/--label/--due/--task-type/--kind/--set. + +For adding/removing labels without replacing the existing list, use the +subcommands: 'huly issue label add --label ' and +'huly issue label remove --label '. Side effects (classic projects): changing --assignee closes the previous assignee's open todos (doneOn=now) and creates a new ProjectToDo for the @@ -1322,6 +1340,7 @@ Examples: .option('--title ') .option('--description ') .option('--body ') + .option('--body-file ') .action(async (ref, opts, cmd) => { try { await updateIssueTemplate(ref, { ...opts, ...globalsFrom(cmd) }) @@ -2124,6 +2143,26 @@ exact email or full name — e.g. '--owner alice@example.com' or .option('--priority

') .option('--visibility ') .option('--owner ') + .option('--attached-to ', 'NOT YET SUPPORTED — would reparent the task; throws on use') + .option( + '--attached-to-class ', + 'class id; use tracker:class:Issue to attach to an issue. Required with --attached-to. NOT YET SUPPORTED.', + ) + .addHelpText( + 'after', + ` +Note on --attached-to / --attached-to-class: + Passing these to 'action update' is rejected. Reparenting would need a + 'removeCollection' on the old parent + 'addCollection' on the new parent, + and WorkSlots attached to the task would need to be migrated too. Until + that gets a dedicated 'action move' subcommand, reparent by deleting the + task and recreating it under the new parent: + huly action delete --yes + huly action create --attached-to --attached-to-class ... + + Caveat: delete + recreate issues a NEW _id. Any references to the old task + id (e.g. in issues, comments, time entries) will need to be updated manually.`, + ) .action(async (ref, opts, cmd) => { try { await updateAction(ref, { ...opts, ...globalsFrom(cmd) }) @@ -2539,6 +2578,7 @@ To fetch a calendar (the container, not an event inside it), use .description('Update an event') .option('--title ') .option('--description ') + .option('--body ') .option('--start ') .option('--end ') .option('--all-day') diff --git a/packages/cli/src/resources/calendar.ts b/packages/cli/src/resources/calendar.ts index 138d277..db76f18 100644 --- a/packages/cli/src/resources/calendar.ts +++ b/packages/cli/src/resources/calendar.ts @@ -698,6 +698,7 @@ export async function updateEvent( opts: { title?: string description?: string + body?: string start?: string end?: string allDay?: boolean @@ -720,7 +721,10 @@ export async function updateEvent( if (!doc) throw new CliError(ExitCode.NotFound, `event ${ref} not found`) const ops: Record = {} if (opts.title) ops.title = opts.title - if (opts.description !== undefined) ops.description = opts.description + // HULY-8: --body takes precedence over --description, mirroring createEvent + // (`description: opts.description ?? opts.body ?? ''`, line 629). + if (opts.body !== undefined) ops.description = opts.body + else if (opts.description !== undefined) ops.description = opts.description if (opts.start) { const sd = parseDate(opts.start, '--start') ops.startDate = sd @@ -737,7 +741,7 @@ export async function updateEvent( throw new CliError( ExitCode.Validation, 'nothing to update', - 'pass --title/--description/--start/--end/--all-day/--location/--attendee', + 'pass --title/--description/--body/--start/--end/--all-day/--location/--attendee', ) if (opts.dryRun) { console.log(`would update event ${id}:`) diff --git a/packages/cli/src/resources/issue-template.ts b/packages/cli/src/resources/issue-template.ts index 1056eba..5bbd3b5 100644 --- a/packages/cli/src/resources/issue-template.ts +++ b/packages/cli/src/resources/issue-template.ts @@ -204,6 +204,7 @@ export async function updateIssueTemplate( title?: string description?: string body?: string + bodyFile?: string json?: boolean ci?: boolean dryRun?: boolean @@ -223,10 +224,22 @@ export async function updateIssueTemplate( if (!doc) throw new CliError(ExitCode.NotFound, `issue-template ${ref} not found`) const ops: Record = {} if (opts.title) ops.title = opts.title - if (opts.body) ops.description = opts.body + // HULY-8: --body-file mirrors the file-read path used by createIssueTemplate + // (lines 165-167). --body still wins if both are passed (matches create). + // Test `!== undefined` rather than truthiness so an explicit `--body ""` + // is honored as "clear the description" instead of silently falling + // through to opts.description. + if (opts.bodyFile) { + const fs = await import('node:fs/promises') + ops.description = (await fs.readFile(opts.bodyFile, 'utf8')).trim() + } else if (opts.body !== undefined) ops.description = opts.body else if (opts.description !== undefined) ops.description = opts.description ? opts.description : '' if (Object.keys(ops).length === 0) - throw new CliError(ExitCode.Validation, 'nothing to update', 'pass --title, --description, or --body') + throw new CliError( + ExitCode.Validation, + 'nothing to update', + 'pass --title, --description, --body, or --body-file', + ) if (opts.dryRun) { console.log(`would update issue-template ${id}:`) console.log( diff --git a/packages/cli/src/resources/issue.ts b/packages/cli/src/resources/issue.ts index 6fc6c18..b0ab4f1 100644 --- a/packages/cli/src/resources/issue.ts +++ b/packages/cli/src/resources/issue.ts @@ -89,14 +89,19 @@ function stripStatusCategoryPrefix(cat: string): string { } async function readBody(opts: { body?: string; bodyFile?: string }): Promise { - if (opts.body && opts.bodyFile) { + if (opts.body !== undefined && opts.bodyFile !== undefined) { throw new CliError(ExitCode.Validation, 'ambiguous body input', 'pass only one of --body or --body-file') } - if (opts.bodyFile) { + if (opts.bodyFile !== undefined) { const fs = await import('node:fs/promises') return (await fs.readFile(opts.bodyFile, 'utf8')).trim() } - if (opts.body) return opts.body + // HULY-8 (post-review): test `!== undefined` rather than truthiness so an + // explicit `--body ""` is preserved as a clear intent on issue update + // instead of silently falling through to opts.description / being treated + // as "no body provided". Matches the symmetric check added to + // issue-template.ts updateIssueTemplate. + if (opts.body !== undefined) return opts.body return undefined } @@ -1080,7 +1085,12 @@ export async function updateIssue( assignee?: string title?: string description?: string + body?: string + bodyFile?: string taskType?: string + kind?: string + due?: string + label?: string[] dryRun?: boolean minimal?: boolean workspace?: string @@ -1142,33 +1152,52 @@ export async function updateIssue( workspace: opts.workspace, })) as Ref if (opts.title) ops.title = opts.title - if (opts.description !== undefined) { - // Update only the ydoc (issue #3). The ydoc is the source of truth - // for collaborative reads; uploading a new JSON blob on every update - // leaves orphaned blobs in MinIO and risks partial-write failures - // (issue #12). Empty string is a deliberate clear and is forwarded - // to updateMarkup (which treats undefined as no-op). - await updateMarkup( - client, - CLASS.Issue as Ref>, - issue._id as Ref, - 'description', - opts.description, - 'markup', - ) - markupUpdated = true - } + // HULY-8: --body / --body-file take precedence over --description, + // mirroring createIssue's `descriptionSource = body ?? opts.description` + // (line 786). Resolve the markup source here but defer the upload until + // after the dry-run guard below — otherwise `--body ... --dry-run` would + // persist. The --kind / --due resolve calls are intentionally kept where + // they are (matches the existing resolveStatus / resolvePriority pattern). + const body = await readBody(opts) + const markupBody = body ?? opts.description + const markupRequested = markupBody !== undefined if (opts.taskType) ops.kind = await resolveTaskType(client, opts.taskType) + else if (opts.kind) { + // HULY-8: --kind lets power users select any TaskType. Same + // validation as --task-type but skips the by-name lookup. Mirrors + // the --kind branch in createIssue. + ops.kind = await resolveKindByRef(client, opts.kind) + } + if (opts.due !== undefined) { + // HULY-8: --due on update, mirroring createIssue (line 779). + // Empty string clears the due date; ISO string sets it; undefined + // (flag absent) leaves the existing due date untouched. + ops.dueDate = opts.due === '' ? null : parseDate(opts.due, '--due') + } + // HULY-8: --label replaces the labels array, matching issue create. + // Use 'issue label add' / 'issue label remove' for additive/subtractive + // semantics — those go through the TagReference collection, not this + // direct field set. Filter empty/whitespace entries so `--label ""` and + // accidental `--label " "` don't silently create bogus tags on the issue. + if (opts.label !== undefined) { + const cleaned = opts.label.map((l) => l.trim()).filter((l) => l.length > 0) + if (cleaned.length > 0) ops.labels = cleaned + else if (opts.label.length > 0) + // All entries were blank — user clearly meant "no labels". Surface + // that intent rather than silently no-op'ing (which would mislead the + // "nothing to update" guard below). + ops.labels = [] + } // --minimal means "I know what I'm doing with --set/--unset, don't // second-guess me." It only suppresses the empty-ops guard below, // so a minimal but explicit --set still sends through. Was previously // a dead flag; now actually does something safe. - if (Object.keys(ops).length === 0 && !markupUpdated && !opts.minimal) { + if (Object.keys(ops).length === 0 && !markupRequested && !opts.minimal) { throw new CliError( ExitCode.Validation, 'nothing to update', - 'pass --set/--unset, --status, --priority, --assignee, --title, --description, --task-type, or --kind (with --status-category to pick a workflow stage)', + 'pass --set/--unset, --status, --priority, --assignee, --title, --description, --body, --body-file, --task-type, --kind, --due, or --label (with --status-category to pick a workflow stage)', ) } @@ -1176,7 +1205,7 @@ export async function updateIssue( console.log(`would update issue ${issue.identifier} (${issue._id}):`) console.log( JSON.stringify( - { _class: CLASS.Issue, objectId: issue._id, space: issue.space, ops, markupUpdated }, + { _class: CLASS.Issue, objectId: issue._id, space: issue.space, ops, markupRequested }, null, 2, ), @@ -1184,6 +1213,18 @@ export async function updateIssue( return } + if (markupRequested) { + await updateMarkup( + client, + CLASS.Issue as Ref>, + issue._id as Ref, + 'description', + markupBody, + 'markup', + ) + markupUpdated = true + } + const hasOps = Object.keys(ops).length > 0 if (hasOps) { await withSpinner('Updating…', () => diff --git a/packages/cli/src/resources/todo.ts b/packages/cli/src/resources/todo.ts index db3b0f1..993a709 100644 --- a/packages/cli/src/resources/todo.ts +++ b/packages/cli/src/resources/todo.ts @@ -552,6 +552,8 @@ export interface UpdateActionOpts { priority?: string visibility?: string owner?: string + attachedTo?: string + attachedToClass?: string dryRun?: boolean json?: boolean ci?: boolean @@ -560,6 +562,21 @@ export interface UpdateActionOpts { } export async function updateAction(ref: string, opts: UpdateActionOpts): Promise { + // HULY-8: --attached-to / --attached-to-class on update are wired through + // the CLI for parity with 'action create' but reparenting requires a + // removeCollection + addCollection round-trip (WorkSlot refs preserved, + // but cascade semantics on the new parent fire on the server side). To + // avoid shipping a half-implemented move, reject early — BEFORE connectCli + // — so workspace-setup failures don't hide the clear "delete + recreate" + // guidance from the user. + if (opts.attachedTo !== undefined || opts.attachedToClass !== undefined) { + throw new CliError( + ExitCode.Validation, + 'reparenting an action via update is not yet supported', + 'delete the action and recreate it under the new parent: ' + + `'huly action delete --yes' then 'huly action create --attached-to --attached-to-class ...'`, + ) + } const client = await connectCli({ url: opts.url, workspace: opts.workspace }) try { const id = await resolveRef(ref, {