From 0fd0d335d0860a1ca128ae247bd135c331ffbe13 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 19 Aug 2026 12:42:47 -0600 Subject: [PATCH 1/2] fix(issue): removeIssueLabel rejects labels attached to the issue (#48) `huly issue label remove` was looking up the label in the workspace-level `tags:class:TagElement` catalog first and throwing `label not found` when no catalog entry existed, even when the label was attached to the issue (e.g. labels imported via another client or seeded outside the catalog). Query `tags:class:TagReference` directly by `attachedTo = issue._id` + `title = `, which mirrors how labels are actually stored on the issue. Added 4 unit tests covering the original repro, orphan-label removal, and the not-attached / not-found error paths. --- CHANGELOG.md | 8 ++ packages/cli/src/resources/issue.test.ts | 94 ++++++++++++++++++++++++ packages/cli/src/resources/issue.ts | 14 ++-- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f74512e..02019b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **`huly issue create` human output printed the raw UUID** instead of `TSK-1`. Now re-fetches the issue to display the assigned identifier, and falls back to the UUID if the local server hasn't assigned one. (#1.10) +- **`huly issue label remove` rejected labels that WERE attached to the + issue** with `label not found`. The implementation first looked up + the label in the workspace-level `tags:class:TagElement` catalog and + bailed out if it could not be resolved there — but a label can be + attached to an issue even when no matching catalog entry exists. Now + looks up `tags:class:TagReference` directly by + `attachedTo = issue._id` + `title = `, mirroring how the labels are + actually stored. (#48) ### Changed diff --git a/packages/cli/src/resources/issue.test.ts b/packages/cli/src/resources/issue.test.ts index 6d5d719..1b53b74 100644 --- a/packages/cli/src/resources/issue.test.ts +++ b/packages/cli/src/resources/issue.test.ts @@ -7,6 +7,7 @@ import { seedDefaultPriorities, validateRelationType, previewDelete, + removeIssueLabel, } from './issue.js' import { CliError, ExitCode } from '../output/errors.js' import { fakePlatformClient } from '../__tests__/fakePlatformClient.js' @@ -379,3 +380,96 @@ describe('previewDelete', () => { expect(parsed[0].relations).toBe(1) }) }) + +describe('removeIssueLabel', () => { + beforeEach(() => { + mockClient.current = fakePlatformClient() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('removes a label that is attached to the issue, even if the workspace TagElement catalog is empty (issue #48)', async () => { + mockClient.current!.state.docs.push({ + _id: 'HULY-4', + _class: 'tracker:class:Issue', + space: 'p-1', + title: 'demo', + } as never) + mockClient.current!.state.docs.push({ + _id: 'tag-ref-1', + _class: 'tags:class:TagReference', + space: 'p-1', + attachedTo: 'HULY-4', + attachedToClass: 'tracker:class:Issue', + collection: 'labels', + tag: 'tag-el-1', + title: 'publication', + color: 0, + } as never) + mockClient.current!.state.docs.push({ + _id: 'tag-ref-2', + _class: 'tags:class:TagReference', + space: 'p-1', + attachedTo: 'HULY-4', + attachedToClass: 'tracker:class:Issue', + collection: 'labels', + tag: 'tag-el-2', + title: 'security', + color: 0, + } as never) + await removeIssueLabel('HULY-4', 'publication', { json: true }) + expect(mockClient.current!.state.collectionRemoves).toEqual([ + expect.objectContaining({ + id: 'tag-ref-1', + collection: 'labels', + parent: 'HULY-4', + }), + ]) + const remaining = mockClient.current!.state.docs.filter( + (d) => d._class === 'tags:class:TagReference' && (d as Record).attachedTo === 'HULY-4', + ) + expect(remaining.map((d) => (d as Record).title)).toEqual(['security']) + }) + + it('still removes the label even when no matching TagElement exists in the workspace catalog', async () => { + mockClient.current!.state.docs.push({ + _id: 'HULY-5', + _class: 'tracker:class:Issue', + space: 'p-1', + } as never) + mockClient.current!.state.docs.push({ + _id: 'tag-ref-9', + _class: 'tags:class:TagReference', + space: 'p-1', + attachedTo: 'HULY-5', + attachedToClass: 'tracker:class:Issue', + collection: 'labels', + tag: 'ghost-tag-id', + title: 'orphan-label', + color: 0, + } as never) + await removeIssueLabel('HULY-5', 'orphan-label', { json: true }) + expect(mockClient.current!.state.collectionRemoves).toHaveLength(1) + expect(mockClient.current!.state.collectionRemoves[0].id).toBe('tag-ref-9') + }) + + it('throws NotFound with a clear message when the label is not attached to the issue', async () => { + mockClient.current!.state.docs.push({ + _id: 'HULY-6', + _class: 'tracker:class:Issue', + space: 'p-1', + } as never) + await expect(removeIssueLabel('HULY-6', 'ghost', { json: true })).rejects.toMatchObject({ + code: ExitCode.NotFound, + message: /label ghost not on issue HULY-6/, + }) + expect(mockClient.current!.state.collectionRemoves).toHaveLength(0) + }) + + it('throws NotFound when the issue itself does not exist', async () => { + await expect(removeIssueLabel('HULY-999', 'whatever', { json: true })).rejects.toMatchObject({ + code: ExitCode.NotFound, + message: /issue HULY-999 not found/, + }) + }) +}) diff --git a/packages/cli/src/resources/issue.ts b/packages/cli/src/resources/issue.ts index b0ab4f1..f829fa4 100644 --- a/packages/cli/src/resources/issue.ts +++ b/packages/cli/src/resources/issue.ts @@ -1373,19 +1373,15 @@ export async function removeIssueLabel( }) const issue = await client.findOne(CLASS.Issue as Ref>, { _id: issueId as Ref }) if (!issue) throw new CliError(ExitCode.NotFound, `issue ${ref} not found`) - const tagClass = 'tags:class:TagElement' as Ref> - const tag = (await client.findAll(tagClass, { title: labelName }))[0] as - | (Doc & { _id: Ref }) - | undefined - if (!tag) throw new CliError(ExitCode.NotFound, `label ${labelName} not found`) - const refs = (await client.findAll('tags:class:TagReference' as Ref>, { + const tagRefClass = 'tags:class:TagReference' as Ref> + const refs = (await client.findAll(tagRefClass, { attachedTo: issue._id, - tag: tag._id, - })) as Doc[] + title: labelName, + })) as Array }> if (refs.length === 0) throw new CliError(ExitCode.NotFound, `label ${labelName} not on issue ${ref}`) for (const r of refs) { await client.removeCollection( - 'tags:class:TagReference' as Ref>, + tagRefClass, issue.space as Ref, r._id, issue._id, From bb4182a2af900dbed30351c29bf1f4b90e0e9711 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Wed, 19 Aug 2026 13:36:24 -0600 Subject: [PATCH 2/2] fix(issue): scope removeIssueLabel query to the labels collection Address PR #52 review feedback. The previous fix queried `tags:class:TagReference` by `attachedTo` + `title` alone, which could match references in other collections (`'components'`, etc.) attached to the same issue if they shared a title. The subsequent `removeCollection` call targeting `'labels'` would then fail or remove the wrong reference, aborting the loop partway through. Add `collection: 'labels'` to the `findAll` filter so orphan-label removal stays scoped to the label collection. Add a regression test that verifies a non-label collection reference with the same title is left untouched. --- packages/cli/src/resources/issue.test.ts | 36 ++++++++++++++++++++++++ packages/cli/src/resources/issue.ts | 1 + 2 files changed, 37 insertions(+) diff --git a/packages/cli/src/resources/issue.test.ts b/packages/cli/src/resources/issue.test.ts index 1b53b74..03d1660 100644 --- a/packages/cli/src/resources/issue.test.ts +++ b/packages/cli/src/resources/issue.test.ts @@ -472,4 +472,40 @@ describe('removeIssueLabel', () => { message: /issue HULY-999 not found/, }) }) + + it('only removes references from the labels collection, not other collections sharing the same title', async () => { + mockClient.current!.state.docs.push({ + _id: 'HULY-7', + _class: 'tracker:class:Issue', + space: 'p-1', + } as never) + mockClient.current!.state.docs.push({ + _id: 'tag-ref-labels', + _class: 'tags:class:TagReference', + space: 'p-1', + attachedTo: 'HULY-7', + attachedToClass: 'tracker:class:Issue', + collection: 'labels', + tag: 'tag-1', + title: 'shared-title', + color: 0, + } as never) + mockClient.current!.state.docs.push({ + _id: 'tag-ref-components', + _class: 'tags:class:TagReference', + space: 'p-1', + attachedTo: 'HULY-7', + attachedToClass: 'tracker:class:Issue', + collection: 'components', + tag: 'tag-2', + title: 'shared-title', + color: 0, + } as never) + await removeIssueLabel('HULY-7', 'shared-title', { json: true }) + expect(mockClient.current!.state.collectionRemoves).toEqual([ + expect.objectContaining({ id: 'tag-ref-labels', collection: 'labels' }), + ]) + const componentsRef = mockClient.current!.state.docs.find((d) => d._id === 'tag-ref-components') + expect(componentsRef).toBeDefined() + }) }) diff --git a/packages/cli/src/resources/issue.ts b/packages/cli/src/resources/issue.ts index f829fa4..84f1e3d 100644 --- a/packages/cli/src/resources/issue.ts +++ b/packages/cli/src/resources/issue.ts @@ -1376,6 +1376,7 @@ export async function removeIssueLabel( const tagRefClass = 'tags:class:TagReference' as Ref> const refs = (await client.findAll(tagRefClass, { attachedTo: issue._id, + collection: 'labels', title: labelName, })) as Array }> if (refs.length === 0) throw new CliError(ExitCode.NotFound, `label ${labelName} not on issue ${ref}`)