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
42 changes: 41 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,18 @@ behaviors and smart defaults' in README.md for full resolution order.`,
.option('--assignee <email>')
.option('--title <t>')
.option('--description <text>')
.option('--body <md>')
.option('--body-file <path>')
.option('--task-type <name|id>')
.option('--kind <ref>', 'TaskType ref (e.g. tracker:taskTypes:Issue); power-user bypass of name lookup')
.option(
'--due <iso>',
'ISO 8601 e.g. 2026-07-01T14:00:00Z; pass an empty string ("") to clear an existing due date',
)
.option(
'--label <l...>',
'repeatable: --label bug --label auth (replaces existing labels); use --unset labels to clear all',
)
.addHelpText(
'after',
`
Expand All @@ -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 <ref> --label <name>' and
'huly issue label remove <ref> --label <name>'.

Side effects (classic projects): changing --assignee closes the previous
assignee's open todos (doneOn=now) and creates a new ProjectToDo for the
Expand Down Expand Up @@ -1322,6 +1340,7 @@ Examples:
.option('--title <t>')
.option('--description <text>')
.option('--body <md>')
.option('--body-file <path>')
.action(async (ref, opts, cmd) => {
try {
await updateIssueTemplate(ref, { ...opts, ...globalsFrom(cmd) })
Expand Down Expand Up @@ -2124,6 +2143,26 @@ exact email or full name — e.g. '--owner alice@example.com' or
.option('--priority <p>')
.option('--visibility <v>')
.option('--owner <email>')
.option('--attached-to <ref>', 'NOT YET SUPPORTED — would reparent the task; throws on use')
.option(
'--attached-to-class <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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Help text is misleading — it claims reparenting is supported, then immediately says it isn't exposed.

The first sentence ("Reparenting is supported via 'removeCollection' on the old parent + 'addCollection' on the new parent…") reads like a feature description, but the CLI just throws "reparenting an action via update is not yet supported" whenever these flags are passed. The WorkSlot preservation and cascade semantics are implementation details the user can't act on. Consider rewriting to lead with the rejection: e.g., "Reparenting is not supported. To move an action, delete it and recreate it under the new parent. (The new action will receive a new _id; downstream references will break.)"

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 <ref> --yes
huly action create --attached-to <ref> --attached-to-class <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) })
Expand Down Expand Up @@ -2539,6 +2578,7 @@ To fetch a calendar (the container, not an event inside it), use
.description('Update an event')
.option('--title <t>')
.option('--description <text>')
.option('--body <md>')
.option('--start <iso>')
.option('--end <iso>')
.option('--all-day')
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/resources/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ export async function updateEvent(
opts: {
title?: string
description?: string
body?: string
start?: string
end?: string
allDay?: boolean
Expand All @@ -720,7 +721,10 @@ export async function updateEvent(
if (!doc) throw new CliError(ExitCode.NotFound, `event ${ref} not found`)
const ops: Record<string, unknown> = {}
if (opts.title) ops.title = opts.title
if (opts.description !== undefined) ops.description = opts.description
// HULY-8: --body takes precedence over --description, mirroring createEvent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: ops.description = opts.body writes raw markup text directly to the event, which is exactly the bug pattern called out in HULY-20 (per the PR description, comment add/update does the same and renders literal <h1>/<p> tags). The issue update path in this PR correctly routes through updateMarkup (packages/cli/src/resources/issue.ts:1157) so the description field gets a MarkupBlobRef. Calendar events should follow the same pattern — route --body and --description through uploadMarkup/updateMarkup so rich content renders properly in the event UI.

// (`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
Expand All @@ -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}:`)
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/resources/issue-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export async function updateIssueTemplate(
title?: string
description?: string
body?: string
bodyFile?: string
json?: boolean
ci?: boolean
dryRun?: boolean
Expand All @@ -223,10 +224,22 @@ export async function updateIssueTemplate(
if (!doc) throw new CliError(ExitCode.NotFound, `issue-template ${ref} not found`)
const ops: Record<string, unknown> = {}
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Same HULY-20 bug pattern — ops.description = (await fs.readFile(opts.bodyFile, 'utf8')).trim() writes raw markup text directly. The pre-existing --body path on this same function had the same bug, so the new --body-file path is at least consistent. But neither is correct: the issue-template's description field expects a MarkupBlobRef, so both --body and --body-file on update should call uploadMarkup (or updateMarkup) and store the returned ref, mirroring how updateIssue handles --body.

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 : ''
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(
Expand Down
85 changes: 63 additions & 22 deletions packages/cli/src/resources/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,19 @@ function stripStatusCategoryPrefix(cat: string): string {
}

async function readBody(opts: { body?: string; bodyFile?: string }): Promise<string | undefined> {
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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1142,48 +1152,79 @@ export async function updateIssue(
workspace: opts.workspace,
})) as Ref<Doc>
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<Class<Doc>>,
issue._id as Ref<Doc>,
'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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: --body "" is silently a no-op on issue update — asymmetric with the issue-template update fix in this PR.

readBody (issue.ts:91-101) uses truthy checks (if (opts.body) return opts.body), so --body "" makes readBody return undefined. Then markupBody = body ?? opts.description resolves to opts.description (also undefined unless the user passed --description), markupRequested = false, and updateMarkup is never called.

Meanwhile, on huly issue-template update (issue-template.ts:235 after this PR's CodeRabbit-driven fix), else if (opts.body !== undefined) ops.description = opts.body correctly clears the description. The two update commands now have inconsistent --body "" behavior, even though both got --body/--body-file parity in the same PR.

Either mirror the fix by changing readBody's if (opts.body) to if (opts.body !== undefined) (one-line change, also fixes the same latent issue on createIssue), or document the asymmetry. The downstream updateMarkup already handles empty body by sending EMPTY_PROSEMIRROR_DOC, so the fix is safe.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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 <ref> 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 <iso> 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: --label "" (single empty string) passes through as opts.label = [''] and this branch fires, so ops.labels = [''] is sent to the server — silently creating an empty-string label on the issue. Add a guard that rejects empty/whitespace-only label values, e.g. const cleaned = opts.label?.filter((l) => l.trim().length > 0); if (cleaned && cleaned.length > 0) ops.labels = cleaned.

// 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)',
)
}

if (opts.dryRun) {
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,
),
)
return
}

if (markupRequested) {
await updateMarkup(
client,
CLASS.Issue as Ref<Class<Doc>>,
issue._id as Ref<Doc>,
'description',
markupBody,
'markup',
)
markupUpdated = true
}

const hasOps = Object.keys(ops).length > 0
if (hasOps) {
await withSpinner('Updating…', () =>
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/resources/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,8 @@ export interface UpdateActionOpts {
priority?: string
visibility?: string
owner?: string
attachedTo?: string
attachedToClass?: string
dryRun?: boolean
json?: boolean
ci?: boolean
Expand All @@ -560,6 +562,21 @@ export interface UpdateActionOpts {
}

export async function updateAction(ref: string, opts: UpdateActionOpts): Promise<void> {
// 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 <ref> --yes' then 'huly action create --attached-to <ref> --attached-to-class <class> ...'`,
)
}
const client = await connectCli({ url: opts.url, workspace: opts.workspace })
try {
const id = await resolveRef(ref, {
Expand Down
Loading