From 47b509e79339d5882cfddfd32fca920217f02ce9 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:50:32 +0200 Subject: [PATCH 01/35] feat(projects)!: drop archive in favour of delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear's schema marks `projectArchive` as "Deprecated in favor of projectDelete", and documents `projectDelete` as "Deletes (trashes) a project. The project can be restored later with projectUnarchive". The two mutations are two verbs over one state — Linear collapsed archiving and trashing into a single put-away flag. Exposing both as `projects archive` and `projects delete` therefore sold a distinction that does not exist. A caller who archived and then looked for their project among the trashed ones, or vice versa, was reading a difference the API never made. It also left the CLI standing on a mutation Linear has signalled it may remove. Remove `projects archive`, `archiveProject()`, the `ArchiveProject` mutation and the `ArchivedProject` type. `projects delete` trashes and `projects unarchive` restores; that pair is the whole lifecycle, and `PROJECTS_META.context` now says so, because it is the one thing a caller cannot infer from the verb names alone. Keeping `archive` as an alias for `delete` was considered and rejected. An alias that silently does something other than what its name says is worse than its absence, and the deprecation gives no reason to believe the underlying mutation will outlive the CLI. BREAKING CHANGE: `linearis projects archive ` is removed. Use `linearis projects delete ` to trash a project and `linearis projects unarchive ` to restore it. Linear treats archived and trashed projects as one state, so the replacement is behaviourally identical. --- README.md | 2 +- graphql/mutations/projects.graphql | 16 +++++------ src/commands/projects.ts | 22 ++++---------- src/services/project-service.ts | 27 ++++++----------- tests/unit/commands/projects.test.ts | 25 ++-------------- tests/unit/services/project-service.test.ts | 32 --------------------- 6 files changed, 25 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 0f987fd2..02d607f8 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The table below is the honest picture of the whole surface — what works today, | Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management | | `issues` | ✅ | List, filter, full-text search, read, create, update, batch create/update, archive/unarchive, delete/restore, snooze; assign labels/assignee/delegate/state/priority/project/cycle/team (including moves between teams); subscribe/unsubscribe, share/unshare, reminders; find the issue for a git branch (`from-branch`); relations (list/add/remove); activity history | Deliberately excluded: the AI-assist and integration-suggestion queries (Figma file lookup, filter/repository suggestions, title-from-customer-request) — see the Integrations row — and `issuePriorityValues`, a static list already in the help text | | `initiatives` | 🟡 | List, read, create, update, archive/unarchive, delete; attach/detach projects; initiative-to-initiative relations; initiative updates (list, read, create, update, archive/unarchive); discussions | Initiative labels, lead-team reassignment, relation reordering | -| `projects` | 🟡 | List, read, create, update, archive/unarchive, delete; assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); discussions | Project updates (status posts), project-label CRUD, project relations, project status administration, Slack channel creation | +| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); discussions | Project updates (status posts), project-label CRUD, project relations, project status administration, Slack channel creation | | `documents` | 🟡 | List, read, create, update, delete | Content history, document full-text search, unarchive | | `milestones` | 🟡 | List, read, create, update (per project) | Delete, reordering/move between projects | | `attachments` | 🟡 | List on an issue, create from a URL, delete, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index b0ce91c0..84a7d3ff 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -30,15 +30,10 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } } -mutation ArchiveProject($id: String!) { - projectArchive(id: $id) { - success - entity { - ...ProjectDetailWithDefaultConnectionsFields - } - } -} - +# Restore a trashed project +# +# Linear collapses "archived" and "trashed" into a single state, so this +# restores whatever projectDelete put away. mutation UnarchiveProject($id: String!) { projectUnarchive(id: $id) { success @@ -48,6 +43,9 @@ mutation UnarchiveProject($id: String!) { } } +# Trash a project +# +# Reversible: UnarchiveProject restores it. mutation DeleteProject($id: String!) { projectDelete(id: $id) { success diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 23a53453..58472721 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -32,7 +32,6 @@ import { unresolveDiscussion, } from "../services/discussion-service.js"; import { - archiveProject, type CreateProjectInput, createProject, deleteProject, @@ -183,6 +182,9 @@ export const PROJECTS_META: DomainMeta = { "have a status (backlog, planned, started, paused, completed,", "canceled), priority (0-4), health (onTrack, atRisk, offTrack),", "and can be assigned labels, a lead, and members.", + "", + "projects have one put-away state, not two: `delete` trashes a project", + "and `unarchive` restores it. there is no `archive` verb.", ].join("\n"), arguments: { project: "project identifier (UUID or name)", @@ -837,23 +839,9 @@ export function setupProjectsCommands(program: Command): void { ), ); - projects - .command("archive ") - .description("archive a project") - .action( - commandAction<[string, unknown, Command]>( - async (project, _unused1, command) => { - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.gql, project); - const result = await archiveProject(ctx.gql, projectId); - outputSuccess(result); - }, - ), - ); - projects .command("unarchive ") - .description("unarchive a project") + .description("restore a project from the trash") .action( commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { @@ -869,7 +857,7 @@ export function setupProjectsCommands(program: Command): void { projects .command("delete ") - .description("delete a project") + .description("move a project to the trash (restore with unarchive)") .action( commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 4482d3c2..3fd73672 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -10,8 +10,6 @@ import { } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { - ArchiveProjectDocument, - type ArchiveProjectMutation, CreateProjectDocument, type CreateProjectMutation, DeleteProjectDocument, @@ -37,9 +35,6 @@ export type CreatedProject = NonNullable< export type UpdatedProject = NonNullable< UpdateProjectMutation["projectUpdate"]["project"] >; -export type ArchivedProject = NonNullable< - ArchiveProjectMutation["projectArchive"]["entity"] ->; export type UnarchivedProject = NonNullable< UnarchiveProjectMutation["projectUnarchive"]["entity"] >; @@ -205,19 +200,12 @@ export async function updateProject( ); } -export async function archiveProject( - client: GraphQLClient, - id: UUID, -): Promise { - const result = await client.request(ArchiveProjectDocument, { id }); - - return requireMutationEntity( - result.projectArchive, - "entity", - `Failed to archive project "${id}"`, - ); -} - +/** + * Restores a project from the trash. + * + * Linear has one put-away state for projects, so this is the inverse of + * {@link deleteProject} — there is no separate archived state to restore from. + */ export async function unarchiveProject( client: GraphQLClient, id: UUID, @@ -231,6 +219,9 @@ export async function unarchiveProject( ); } +/** + * Trashes a project. Reversible via {@link unarchiveProject}. + */ export async function deleteProject( client: GraphQLClient, id: UUID, diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 321ec669..7287c72e 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -35,7 +35,6 @@ vi.mock("../../../src/resolvers/user-resolver.js", () => ({ })); vi.mock("../../../src/services/project-service.js", () => ({ - archiveProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Archived" }), listProjects: vi.fn().mockResolvedValue({ nodes: [], pageInfo: {} }), getProject: vi.fn().mockResolvedValue({ id: "proj-1" }), getProjectLabelIds: vi.fn().mockResolvedValue([]), @@ -136,7 +135,6 @@ import { unresolveDiscussion, } from "../../../src/services/discussion-service.js"; import { - archiveProject, createProject, deleteProject, getProject, @@ -259,28 +257,11 @@ describe("projects lifecycle", () => { vi.spyOn(process, "exit").mockImplementation(() => undefined as never); }); - it("archive resolves project and outputs result", async () => { + it("does not register an archive command", () => { const program = createProgram(); - await program.parseAsync([ - "node", - "test", - "projects", - "archive", - "My Project", - ]); + const projects = program.commands.find((c) => c.name() === "projects"); - expect(resolveProjectId).toHaveBeenCalledWith( - expect.anything(), - "My Project", - ); - expect(archiveProject).toHaveBeenCalledWith( - expect.anything(), - "resolved-project-uuid", - ); - expect(outputSuccess).toHaveBeenCalledWith({ - id: "proj-1", - name: "Archived", - }); + expect(projects?.commands.map((c) => c.name())).not.toContain("archive"); }); it("unarchive resolves project and outputs result", async () => { diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 20a1595e..84ad4002 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -5,14 +5,12 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; import { - ArchiveProjectDocument, GetProjectDocument, GetProjectLabelIdsDocument, GetProjectWithReactionsDocument, UpdateProjectDocument, } from "../../../src/gql/graphql.js"; import { - archiveProject, createProject, deleteProject, getProject, @@ -597,36 +595,6 @@ describe("updateProject", () => { }); }); -describe("archiveProject", () => { - it("returns archived project on success", async () => { - const client = mockGqlClient({ - projectArchive: { - success: true, - entity: { id: "proj-1", name: "Archived Project" }, - }, - }); - - await expect(archiveProject(client, asUuid("proj-1"))).resolves.toEqual({ - id: "proj-1", - name: "Archived Project", - }); - - expect(client.request).toHaveBeenCalledWith(ArchiveProjectDocument, { - id: "proj-1", - }); - }); - - it("throws on failure", async () => { - const client = mockGqlClient({ - projectArchive: { success: false, entity: null }, - }); - - await expect(archiveProject(client, asUuid("proj-1"))).rejects.toThrow( - 'Failed to archive project "proj-1"', - ); - }); -}); - describe("unarchiveProject", () => { it("returns unarchived project on success", async () => { const client = mockGqlClient({ From 0357b2d4324b8ede69ccbbc8bc6d5c61d04e5c43 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:51:22 +0200 Subject: [PATCH 02/35] refactor(projects): split the command module by subgroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/commands/projects.ts` was a single 890-line file, and the work queued behind this commit adds three more subgroups to it (status updates, relations, status administration). Growing one file to ~2000 lines would make it the largest command module in the repo by a wide margin and would bury the domain registration under the CRUD. Adopt the layout `src/commands/initiatives/` already uses: `index.ts` owns `PROJECTS_META`, the domain command, and the `usage` subcommand; `entity.ts` owns everything registered on it. Each new subgroup lands as its own sibling file wired from `index.ts`, so a reader looking for "what does `projects` expose" reads one short file instead of scanning for `.command(` calls. Discussions stay in `entity.ts` rather than moving to a file of their own, matching `initiatives/entity.ts` — they are registered flat on the domain, not as a subgroup, and splitting them would diverge from the module this layout is copied from. Pure move plus import-path adjustment; no command, flag, or output changed. --- docs/architecture.md | 2 +- .../{projects.ts => projects/entity.ts} | 68 +++++-------------- src/commands/projects/index.ts | 42 ++++++++++++ src/main.ts | 5 +- tests/unit/commands/projects.test.ts | 2 +- 5 files changed, 66 insertions(+), 53 deletions(-) rename src/commands/{projects.ts => projects/entity.ts} (92%) create mode 100644 src/commands/projects/index.ts diff --git a/docs/architecture.md b/docs/architecture.md index d8c9477e..277a028b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,7 +127,7 @@ Shared utilities used across layers. - **src/commands/cycles.ts** - Cycle listing and reading - **src/commands/teams.ts** - Team listing - **src/commands/users.ts** - User listing -- **src/commands/projects.ts** - Project listing +- **src/commands/projects/** - Project commands (`index.ts` registers the domain, `entity.ts` holds CRUD and discussions) - **src/commands/labels.ts** - Label listing - **src/commands/comments.ts** - Comment creation - **src/commands/embeds.ts** - File operations diff --git a/src/commands/projects.ts b/src/commands/projects/entity.ts similarity index 92% rename from src/commands/projects.ts rename to src/commands/projects/entity.ts index 58472721..efb3d5d4 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects/entity.ts @@ -1,19 +1,22 @@ import type { Command } from "commander"; -import { createContext, getRootOpts } from "../common/context.js"; -import { type Priority, parseLabelMode } from "../common/domain-values.js"; -import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { invalidParameterError } from "../common/errors.js"; -import { asUuid } from "../common/identifier.js"; -import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; -import { buildPaginationOptions } from "../common/types.js"; -import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; +import { createContext, getRootOpts } from "../../common/context.js"; +import { type Priority, parseLabelMode } from "../../common/domain-values.js"; +import { resolveReactionEmojiInput } from "../../common/emoji.js"; +import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; +import { + commandAction, + outputSuccess, + parseLimit, +} from "../../common/output.js"; +import { buildPaginationOptions } from "../../common/types.js"; import { resolveProjectId, resolveProjectLabelIds, -} from "../resolvers/project-resolver.js"; -import { resolveProjectStatusId } from "../resolvers/project-status-resolver.js"; -import { resolveTeamId } from "../resolvers/team-resolver.js"; -import { resolveUserId } from "../resolvers/user-resolver.js"; +} from "../../resolvers/project-resolver.js"; +import { resolveProjectStatusId } from "../../resolvers/project-status-resolver.js"; +import { resolveTeamId } from "../../resolvers/team-resolver.js"; +import { resolveUserId } from "../../resolvers/user-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -30,7 +33,7 @@ import { resolveDiscussion, startProjectDiscussion, unresolveDiscussion, -} from "../services/discussion-service.js"; +} from "../../services/discussion-service.js"; import { type CreateProjectInput, createProject, @@ -41,7 +44,7 @@ import { type UpdateProjectInput, unarchiveProject, updateProject, -} from "../services/project-service.js"; +} from "../../services/project-service.js"; interface ListOptions { limit: string; @@ -173,30 +176,6 @@ interface UpdateOptions { clearLabels?: boolean; } -export const PROJECTS_META: DomainMeta = { - name: "projects", - summary: "groups of issues toward a goal", - context: [ - "a project collects related issues across teams. projects can have", - "milestones to track progress toward deadlines or phases. projects", - "have a status (backlog, planned, started, paused, completed,", - "canceled), priority (0-4), health (onTrack, atRisk, offTrack),", - "and can be assigned labels, a lead, and members.", - "", - "projects have one put-away state, not two: `delete` trashes a project", - "and `unarchive` restores it. there is no `archive` verb.", - ].join("\n"), - arguments: { - project: "project identifier (UUID or name)", - name: "string", - }, - seeAlso: [ - "milestones list --project", - "documents list --project", - "issues create --project", - ], -}; - function parsePriority(value: string): Priority { const priority = Number.parseInt(value, 10); if (Number.isNaN(priority) || priority < 0 || priority > 4) { @@ -251,11 +230,7 @@ function getUpdateTeamNames(options: UpdateOptions): string[] | undefined { return parseCommaSeparatedOption(options.teams ? "--teams" : "--team", teams); } -export function setupProjectsCommands(program: Command): void { - const projects = program - .command("projects") - .description("Project operations"); - +export function setupProjectEntityCommands(projects: Command): void { projects .command("list") .description("list projects") @@ -870,11 +845,4 @@ export function setupProjectsCommands(program: Command): void { }, ), ); - - projects - .command("usage") - .description("show detailed usage for projects") - .action(() => { - console.log(formatDomainUsage(projects, PROJECTS_META)); - }); } diff --git a/src/commands/projects/index.ts b/src/commands/projects/index.ts new file mode 100644 index 00000000..18c9f7f9 --- /dev/null +++ b/src/commands/projects/index.ts @@ -0,0 +1,42 @@ +import type { Command } from "commander"; +import { type DomainMeta, formatDomainUsage } from "../../common/usage.js"; +import { setupProjectEntityCommands } from "./entity.js"; + +export const PROJECTS_META: DomainMeta = { + name: "projects", + summary: "groups of issues toward a goal", + context: [ + "a project collects related issues across teams. projects can have", + "milestones to track progress toward deadlines or phases. projects", + "have a status (backlog, planned, started, paused, completed,", + "canceled), priority (0-4), health (onTrack, atRisk, offTrack),", + "and can be assigned labels, a lead, and members.", + "", + "projects have one put-away state, not two: `delete` trashes a project", + "and `unarchive` restores it. there is no `archive` verb.", + ].join("\n"), + arguments: { + project: "project identifier (UUID or name)", + name: "string", + }, + seeAlso: [ + "milestones list --project", + "documents list --project", + "issues create --project", + ], +}; + +export function setupProjectsCommands(program: Command): void { + const projects = program + .command("projects") + .description("Project operations"); + + setupProjectEntityCommands(projects); + + projects + .command("usage") + .description("show detailed usage for projects") + .action(() => { + console.log(formatDomainUsage(projects, PROJECTS_META)); + }); +} diff --git a/src/main.ts b/src/main.ts index 0455281a..a0c95671 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,10 @@ import { MILESTONES_META, setupMilestonesCommands, } from "./commands/milestones.js"; -import { PROJECTS_META, setupProjectsCommands } from "./commands/projects.js"; +import { + PROJECTS_META, + setupProjectsCommands, +} from "./commands/projects/index.js"; import { setupTeamsCommands, TEAMS_META } from "./commands/teams.js"; import { setupUsersCommands, USERS_META } from "./commands/users.js"; import { setupVersionCommands, VERSION_META } from "./commands/version.js"; diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 7287c72e..02c58661 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -110,7 +110,7 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ .mockResolvedValue({ id: "reaction-1", success: true }), })); -import { setupProjectsCommands } from "../../../src/commands/projects.js"; +import { setupProjectsCommands } from "../../../src/commands/projects/index.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveProjectId, From c66da2b7860438683fd90a39d3e8f91a9ce34709 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:55:49 +0200 Subject: [PATCH 03/35] feat(projects): post and manage project status updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project's `health` field is derived from its most recent status update, and the CLI wired the read but not the write. Callers could see that a project was `atRisk` and had no way to say so — the only route to changing health was the Linear web app. Wire the seven `projectUpdate*` root fields as `projects updates`, using the same subgroup shape as `initiatives updates` (list/read/create/ update/archive/unarchive) so an agent that has learned one knows the other without reading help text. `remind` maps `createProjectUpdateReminder`; its payload carries no entity, so the service echoes the project id rather than emitting a bare `{success: true}` that says nothing about what succeeded. `projectUpdateDelete` is deliberately left unwired — Linear deprecates it in favour of `projectUpdateArchive`, which is reversible. Two supporting changes: - `parseHealth()` moves from `initiative-update-service.ts` to `common/domain-values.ts`. Both domains need it and a service importing another service would break the layer rules. Linear declares the health enum twice with identical members, so the shared parser returns one `UpdateHealth` union that satisfies both codegen types. - `projects read` now selects `lastUpdate` and `healthUpdatedAt`. The read that reports `health` should say where that health came from, and it makes the newly-wired updates discoverable from a call callers already make rather than requiring them to know the subgroup exists. --- README.md | 2 +- graphql/mutations/project-updates.graphql | 52 ++++ graphql/queries/project-updates.graphql | 60 +++++ graphql/queries/projects.graphql | 9 + src/commands/initiatives/updates.ts | 2 +- src/commands/projects/index.ts | 6 + src/commands/projects/updates.ts | 215 +++++++++++++++ src/common/domain-values.ts | 27 ++ src/services/initiative-update-service.ts | 17 -- src/services/project-update-service.ts | 193 ++++++++++++++ tests/unit/commands/project-updates.test.ts | 219 ++++++++++++++++ tests/unit/common/domain-values.test.ts | 20 ++ .../services/project-update-service.test.ts | 247 ++++++++++++++++++ 13 files changed, 1050 insertions(+), 19 deletions(-) create mode 100644 graphql/mutations/project-updates.graphql create mode 100644 graphql/queries/project-updates.graphql create mode 100644 src/commands/projects/updates.ts create mode 100644 src/services/project-update-service.ts create mode 100644 tests/unit/commands/project-updates.test.ts create mode 100644 tests/unit/services/project-update-service.test.ts diff --git a/README.md b/README.md index 02d607f8..998010f3 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The table below is the honest picture of the whole surface — what works today, | Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management | | `issues` | ✅ | List, filter, full-text search, read, create, update, batch create/update, archive/unarchive, delete/restore, snooze; assign labels/assignee/delegate/state/priority/project/cycle/team (including moves between teams); subscribe/unsubscribe, share/unshare, reminders; find the issue for a git branch (`from-branch`); relations (list/add/remove); activity history | Deliberately excluded: the AI-assist and integration-suggestion queries (Figma file lookup, filter/repository suggestions, title-from-customer-request) — see the Integrations row — and `issuePriorityValues`, a static list already in the help text | | `initiatives` | 🟡 | List, read, create, update, archive/unarchive, delete; attach/detach projects; initiative-to-initiative relations; initiative updates (list, read, create, update, archive/unarchive); discussions | Initiative labels, lead-team reassignment, relation reordering | -| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); discussions | Project updates (status posts), project-label CRUD, project relations, project status administration, Slack channel creation | +| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); discussions | Project-label CRUD, project relations, project status administration, Slack channel creation | | `documents` | 🟡 | List, read, create, update, delete | Content history, document full-text search, unarchive | | `milestones` | 🟡 | List, read, create, update (per project) | Delete, reordering/move between projects | | `attachments` | 🟡 | List on an issue, create from a URL, delete, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | diff --git a/graphql/mutations/project-updates.graphql b/graphql/mutations/project-updates.graphql new file mode 100644 index 00000000..c0be183f --- /dev/null +++ b/graphql/mutations/project-updates.graphql @@ -0,0 +1,52 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear project status updates +# +# `projectUpdateDelete` is deliberately not wired: Linear deprecates it +# in favour of `projectUpdateArchive`, which is reversible. +# ------------------------------------------------------------ + +mutation CreateProjectUpdate($input: ProjectUpdateCreateInput!) { + projectUpdateCreate(input: $input) { + success + projectUpdate { + ...ProjectUpdateCoreFields + } + } +} + +mutation EditProjectUpdate($id: String!, $input: ProjectUpdateUpdateInput!) { + projectUpdateUpdate(id: $id, input: $input) { + success + projectUpdate { + ...ProjectUpdateCoreFields + } + } +} + +mutation ArchiveProjectUpdate($id: String!) { + projectUpdateArchive(id: $id) { + success + entity { + ...ProjectUpdateCoreFields + } + } +} + +mutation UnarchiveProjectUpdate($id: String!) { + projectUpdateUnarchive(id: $id) { + success + entity { + ...ProjectUpdateCoreFields + } + } +} + +# Nudge someone to post the next update +# +# The payload carries no entity — there is nothing to return but whether +# the notification was created. +mutation CreateProjectUpdateReminder($projectId: String!, $userId: String) { + createProjectUpdateReminder(projectId: $projectId, userId: $userId) { + success + } +} diff --git a/graphql/queries/project-updates.graphql b/graphql/queries/project-updates.graphql new file mode 100644 index 00000000..178e4ff7 --- /dev/null +++ b/graphql/queries/project-updates.graphql @@ -0,0 +1,60 @@ +# ------------------------------------------------------------ +# GraphQL queries for Linear project status updates +# +# A project update is a dated status post on a project: a markdown +# body plus a health signal. It is a different entity from the +# `projectUpdate` mutation, which edits the project itself. +# ------------------------------------------------------------ + +fragment ProjectUpdateCoreFields on ProjectUpdate { + id + body + health + isDiffHidden + isStale + url + createdAt + updatedAt + editedAt + archivedAt + project { + id + name + } + user { + id + name + } +} + +# List the status updates posted on one project +# +# `projectUpdates` is workspace-wide, so the project is applied as a +# filter rather than traversed from the project itself. +query ListProjectUpdates( + $projectId: ID! + $first: Int = 50 + $after: String + $includeArchived: Boolean = false +) { + projectUpdates( + first: $first + after: $after + includeArchived: $includeArchived + filter: { project: { id: { eq: $projectId } } } + ) { + nodes { + ...ProjectUpdateCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query GetProjectUpdate($id: String!) { + projectUpdate(id: $id) { + ...ProjectUpdateCoreFields + } +} diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index b8d9c310..774d313f 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -110,6 +110,15 @@ fragment ProjectDetailFields on Project { hasNextPage } } + # The project's `health` is derived from its latest status update, so the + # read that reports the health also reports where it came from. + healthUpdatedAt + lastUpdate { + id + health + body + createdAt + } } fragment ProjectDetailWithDefaultConnectionsFields on Project { diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 96d16504..76c81e18 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../../common/context.js"; +import { parseHealth } from "../../common/domain-values.js"; import { invalidParameterError } from "../../common/errors.js"; import { asUuid } from "../../common/identifier.js"; import { @@ -15,7 +16,6 @@ import { createInitiativeUpdate, getInitiativeUpdate, listInitiativeUpdates, - parseHealth, type UpdateInitiativeUpdateInput, unarchiveInitiativeUpdate, updateInitiativeUpdate, diff --git a/src/commands/projects/index.ts b/src/commands/projects/index.ts index 18c9f7f9..fb97e1a5 100644 --- a/src/commands/projects/index.ts +++ b/src/commands/projects/index.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { type DomainMeta, formatDomainUsage } from "../../common/usage.js"; import { setupProjectEntityCommands } from "./entity.js"; +import { setupProjectUpdateCommands } from "./updates.js"; export const PROJECTS_META: DomainMeta = { name: "projects", @@ -14,9 +15,13 @@ export const PROJECTS_META: DomainMeta = { "", "projects have one put-away state, not two: `delete` trashes a project", "and `unarchive` restores it. there is no `archive` verb.", + "", + "a project's health is derived from its most recent status update, so", + "changing health means posting one with `projects updates create`.", ].join("\n"), arguments: { project: "project identifier (UUID or name)", + update: "project status update identifier (UUID)", name: "string", }, seeAlso: [ @@ -32,6 +37,7 @@ export function setupProjectsCommands(program: Command): void { .description("Project operations"); setupProjectEntityCommands(projects); + setupProjectUpdateCommands(projects); projects .command("usage") diff --git a/src/commands/projects/updates.ts b/src/commands/projects/updates.ts new file mode 100644 index 00000000..305e3699 --- /dev/null +++ b/src/commands/projects/updates.ts @@ -0,0 +1,215 @@ +import type { Command } from "commander"; +import { createContext, getRootOpts } from "../../common/context.js"; +import { parseHealth } from "../../common/domain-values.js"; +import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; +import { + commandAction, + outputSuccess, + parseLimit, +} from "../../common/output.js"; +import { buildPaginationOptions } from "../../common/types.js"; +import { resolveProjectId } from "../../resolvers/project-resolver.js"; +import { resolveUserId } from "../../resolvers/user-resolver.js"; +import { + archiveProjectUpdate, + type CreateProjectUpdateInput, + createProjectUpdate, + type EditProjectUpdateInput, + editProjectUpdate, + getProjectUpdate, + listProjectUpdates, + remindProjectUpdate, + unarchiveProjectUpdate, +} from "../../services/project-update-service.js"; + +interface ProjectUpdatesListOptions { + project: string; + limit: string; + after?: string; + includeArchived?: boolean; +} + +interface ProjectUpdatesCreateOptions { + project: string; + body?: string; + health?: string; + hideDiff?: boolean; +} + +interface ProjectUpdatesUpdateOptions { + body?: string; + health?: string; +} + +interface ProjectUpdatesRemindOptions { + project: string; + user?: string; +} + +export function setupProjectUpdateCommands(projects: Command): void { + const updates = projects + .command("updates") + .description("project status update operations"); + + updates + .command("list") + .description("list project status updates") + .requiredOption("--project ", "project name or UUID") + .option("-l, --limit ", "max results", "50") + .option("--after ", "cursor for next page") + .option("--include-archived", "include archived updates") + .action( + commandAction<[ProjectUpdatesListOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, options.project); + + const result = await listProjectUpdates(ctx.gql, { + projectId, + ...buildPaginationOptions(parseLimit(options.limit), options.after), + includeArchived: options.includeArchived ?? false, + }); + + outputSuccess(result); + }, + ), + ); + + updates + .command("read ") + .description("get project status update details") + .action( + commandAction<[string, unknown, Command]>( + async (updateId, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await getProjectUpdate(ctx.gql, asUuid(updateId)); + outputSuccess(result); + }, + ), + ); + + updates + .command("create") + .description("post a project status update") + .requiredOption("--project ", "project name or UUID") + .option("--body ", "update body (markdown)") + .option("--health ", "onTrack, atRisk, offTrack") + .option("--hide-diff", "hide the diff against the previous update") + .action( + commandAction<[ProjectUpdatesCreateOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, options.project); + + const input: CreateProjectUpdateInput = { projectId }; + + if (options.body !== undefined) { + input.body = options.body; + } + + const health = parseHealth(options.health); + if (health) { + input.health = health; + } + + if (options.hideDiff) { + input.isDiffHidden = true; + } + + const result = await createProjectUpdate(ctx.gql, input); + outputSuccess(result); + }, + ), + ); + + updates + .command("update ") + .description("edit a project status update") + .option("--body ", "new body (markdown)") + .option("--health ", "onTrack, atRisk, offTrack") + .action( + commandAction<[string, ProjectUpdatesUpdateOptions, Command]>( + async (updateId, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: EditProjectUpdateInput = {}; + + if (options.body !== undefined) { + input.body = options.body; + } + + const health = parseHealth(options.health); + if (health) { + input.health = health; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } + + const result = await editProjectUpdate( + ctx.gql, + asUuid(updateId), + input, + ); + outputSuccess(result); + }, + ), + ); + + updates + .command("archive ") + .description("archive a project status update") + .action( + commandAction<[string, unknown, Command]>( + async (updateId, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await archiveProjectUpdate(ctx.gql, asUuid(updateId)); + outputSuccess(result); + }, + ), + ); + + updates + .command("unarchive ") + .description("unarchive a project status update") + .action( + commandAction<[string, unknown, Command]>( + async (updateId, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await unarchiveProjectUpdate( + ctx.gql, + asUuid(updateId), + ); + outputSuccess(result); + }, + ), + ); + + updates + .command("remind") + .description("notify someone that the project is due an update") + .requiredOption("--project ", "project name or UUID") + .option("--user ", "user to remind; omitted, Linear picks the target") + .action( + commandAction<[ProjectUpdatesRemindOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, options.project); + const userId = options.user + ? await resolveUserId(ctx.gql, options.user) + : undefined; + + const result = await remindProjectUpdate(ctx.gql, projectId, userId); + outputSuccess(result); + }, + ), + ); +} diff --git a/src/common/domain-values.ts b/src/common/domain-values.ts index e5e79e37..191459fe 100644 --- a/src/common/domain-values.ts +++ b/src/common/domain-values.ts @@ -30,3 +30,30 @@ export function parseSetMode( export function parseLabelMode(value: string | undefined): SetMode | undefined { return parseSetMode("--label-mode", value); } + +/** + * Health of a status update. + * + * Linear declares this twice — `InitiativeUpdateHealthType` and + * `ProjectUpdateHealthType` — with identical members, so one union serves + * both codegen enums. + */ +export type UpdateHealth = "onTrack" | "atRisk" | "offTrack"; + +/** + * Parses `--health` case-insensitively, because the API spelling is + * camelCase and nobody types `atRisk` on a shell prompt reliably. + */ +export function parseHealth(value?: string): UpdateHealth | undefined { + if (!value) return undefined; + + const normalized = value.trim().toLowerCase(); + if (normalized === "ontrack") return "onTrack"; + if (normalized === "atrisk") return "atRisk"; + if (normalized === "offtrack") return "offTrack"; + + throw invalidParameterError( + "--health", + 'must be one of: "onTrack", "atRisk", "offTrack"', + ); +} diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index fd2aa4f4..8c09c9fd 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -11,7 +11,6 @@ import { GetInitiativeUpdateDocument, type GetInitiativeUpdateQuery, type InitiativeUpdateCreateInput, - type InitiativeUpdateHealthType, type InitiativeUpdateUpdateInput, ListInitiativeUpdatesDocument, type ListInitiativeUpdatesQuery, @@ -57,22 +56,6 @@ export type UpdateInitiativeUpdateInput = Pick< "body" | "health" >; -export function parseHealth( - value?: string, -): InitiativeUpdateHealthType | undefined { - if (!value) return undefined; - - const normalized = value.trim().toLowerCase(); - if (normalized === "ontrack") return "onTrack"; - if (normalized === "atrisk") return "atRisk"; - if (normalized === "offtrack") return "offTrack"; - - throw invalidParameterError( - "--health", - 'must be one of: "onTrack", "atRisk", "offTrack"', - ); -} - export async function listInitiativeUpdates( client: GraphQLClient, options: InitiativeUpdateListOptions, diff --git a/src/services/project-update-service.ts b/src/services/project-update-service.ts new file mode 100644 index 00000000..4605a8e0 --- /dev/null +++ b/src/services/project-update-service.ts @@ -0,0 +1,193 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { invalidParameterError } from "../common/errors.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import type { PaginatedResult } from "../common/types.js"; +import { + ArchiveProjectUpdateDocument, + type ArchiveProjectUpdateMutation, + CreateProjectUpdateDocument, + type CreateProjectUpdateMutation, + CreateProjectUpdateReminderDocument, + EditProjectUpdateDocument, + type EditProjectUpdateMutation, + GetProjectUpdateDocument, + type GetProjectUpdateQuery, + ListProjectUpdatesDocument, + type ListProjectUpdatesQuery, + type ProjectUpdateCreateInput, + type ProjectUpdateUpdateInput, + UnarchiveProjectUpdateDocument, + type UnarchiveProjectUpdateMutation, +} from "../gql/graphql.js"; + +// Project update projection types +export type ProjectUpdateListItem = + ListProjectUpdatesQuery["projectUpdates"]["nodes"][0]; +export type ProjectUpdateDetail = NonNullable< + GetProjectUpdateQuery["projectUpdate"] +>; +export type CreatedProjectUpdate = NonNullable< + CreateProjectUpdateMutation["projectUpdateCreate"]["projectUpdate"] +>; +export type EditedProjectUpdate = NonNullable< + EditProjectUpdateMutation["projectUpdateUpdate"]["projectUpdate"] +>; +export type ArchivedProjectUpdate = NonNullable< + ArchiveProjectUpdateMutation["projectUpdateArchive"]["entity"] +>; +export type UnarchivedProjectUpdate = NonNullable< + UnarchiveProjectUpdateMutation["projectUpdateUnarchive"]["entity"] +>; +export type ProjectUpdateReminder = { + projectId: string; + success: true; +}; + +export interface ProjectUpdateListOptions { + projectId: UUID; + limit?: number; + after?: string; + includeArchived?: boolean; +} + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateProjectUpdateInput = BrandUuidFields< + Pick< + ProjectUpdateCreateInput, + "projectId" | "body" | "health" | "isDiffHidden" + >, + "projectId" +>; +export type EditProjectUpdateInput = Pick< + ProjectUpdateUpdateInput, + "body" | "health" +>; + +export async function listProjectUpdates( + client: GraphQLClient, + options: ProjectUpdateListOptions, +): Promise> { + const { projectId, limit = 50, after, includeArchived = false } = options; + + const result = await client.request(ListProjectUpdatesDocument, { + projectId, + first: limit, + after, + includeArchived, + }); + + return { + nodes: result.projectUpdates.nodes, + pageInfo: result.projectUpdates.pageInfo, + }; +} + +export async function getProjectUpdate( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(GetProjectUpdateDocument, { id }); + + if (!result.projectUpdate) { + throw new Error(`Project update with ID "${id}" not found`); + } + + return result.projectUpdate; +} + +export async function createProjectUpdate( + client: GraphQLClient, + input: CreateProjectUpdateInput, +): Promise { + const gqlInput: ProjectUpdateCreateInput = input; + const result = await client.request(CreateProjectUpdateDocument, { + input: gqlInput, + }); + + return requireMutationEntity( + result.projectUpdateCreate, + "projectUpdate", + "Failed to create project update", + ); +} + +export async function editProjectUpdate( + client: GraphQLClient, + id: UUID, + input: EditProjectUpdateInput, +): Promise { + const hasAtLeastOneField = Object.values(input).some( + (value) => value !== undefined, + ); + + if (!hasAtLeastOneField) { + throw invalidParameterError( + "update options", + "at least one update field must be provided", + ); + } + + const gqlInput: ProjectUpdateUpdateInput = input; + const result = await client.request(EditProjectUpdateDocument, { + id, + input: gqlInput, + }); + + return requireMutationEntity( + result.projectUpdateUpdate, + "projectUpdate", + `Failed to update project update "${id}"`, + ); +} + +export async function archiveProjectUpdate( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(ArchiveProjectUpdateDocument, { id }); + + return requireMutationEntity( + result.projectUpdateArchive, + "entity", + `Failed to archive project update "${id}"`, + ); +} + +export async function unarchiveProjectUpdate( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(UnarchiveProjectUpdateDocument, { id }); + + return requireMutationEntity( + result.projectUpdateUnarchive, + "entity", + `Failed to unarchive project update "${id}"`, + ); +} + +/** + * Asks Linear to notify someone that the project is due an update. + * + * The payload carries no entity, so the project is echoed back to keep the + * JSON self-describing rather than returning a bare `{ success: true }`. + */ +export async function remindProjectUpdate( + client: GraphQLClient, + projectId: UUID, + userId?: UUID, +): Promise { + const result = await client.request(CreateProjectUpdateReminderDocument, { + projectId, + userId, + }); + + if (!result.createProjectUpdateReminder.success) { + throw new Error( + `Failed to create an update reminder for project "${projectId}"`, + ); + } + + return { projectId, success: true }; +} diff --git a/tests/unit/commands/project-updates.test.ts b/tests/unit/commands/project-updates.test.ts new file mode 100644 index 00000000..9dd79f73 --- /dev/null +++ b/tests/unit/commands/project-updates.test.ts @@ -0,0 +1,219 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/context.js", () => ({ + createContext: vi.fn(() => ({ gql: { request: vi.fn() } })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), +})); + +vi.mock("../../../src/common/output.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, outputSuccess: vi.fn() }; +}); + +vi.mock("../../../src/resolvers/project-resolver.js", () => ({ + resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"), + resolveProjectLabelIds: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../../src/resolvers/user-resolver.js", () => ({ + resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), +})); + +vi.mock("../../../src/services/project-update-service.js", () => ({ + listProjectUpdates: vi.fn().mockResolvedValue({ nodes: [], pageInfo: {} }), + getProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + createProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-new" }), + editProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + archiveProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + unarchiveProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + remindProjectUpdate: vi + .fn() + .mockResolvedValue({ projectId: "proj-1", success: true }), +})); + +import { setupProjectUpdateCommands } from "../../../src/commands/projects/updates.js"; +import { outputSuccess } from "../../../src/common/output.js"; +import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + archiveProjectUpdate, + createProjectUpdate, + editProjectUpdate, + getProjectUpdate, + listProjectUpdates, + remindProjectUpdate, +} from "../../../src/services/project-update-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token "); + const projects = program.command("projects"); + setupProjectUpdateCommands(projects); + return program; +} + +describe("projects updates", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("list resolves the project and forwards pagination", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "list", + "--project", + "My Project", + "--limit", + "10", + "--include-archived", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(listProjectUpdates).toHaveBeenCalledWith(expect.anything(), { + projectId: "resolved-project-uuid", + limit: 10, + after: undefined, + includeArchived: true, + }); + }); + + it("read passes the update ID straight through", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "read", + "upd-1", + ]); + + expect(getProjectUpdate).toHaveBeenCalledWith(expect.anything(), "upd-1"); + expect(outputSuccess).toHaveBeenCalledWith({ id: "upd-1" }); + }); + + it("create maps --health and --hide-diff onto the input", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "create", + "--project", + "My Project", + "--body", + "Week 1", + "--health", + "atrisk", + "--hide-diff", + ]); + + expect(createProjectUpdate).toHaveBeenCalledWith(expect.anything(), { + projectId: "resolved-project-uuid", + body: "Week 1", + health: "atRisk", + isDiffHidden: true, + }); + }); + + it("create rejects an unknown health value", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "create", + "--project", + "My Project", + "--health", + "sideways", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("--health"), + ); + expect(createProjectUpdate).not.toHaveBeenCalled(); + }); + + it("update requires at least one field", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "update", + "upd-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("at least one option must be provided"), + ); + expect(editProjectUpdate).not.toHaveBeenCalled(); + }); + + it("archive passes the update ID straight through", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "archive", + "upd-1", + ]); + + expect(archiveProjectUpdate).toHaveBeenCalledWith( + expect.anything(), + "upd-1", + ); + }); + + it("remind resolves the target user when one is named", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "remind", + "--project", + "My Project", + "--user", + "alice", + ]); + + expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "alice"); + expect(remindProjectUpdate).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + "resolved-user-uuid", + ); + }); + + it("remind leaves the target unset when no user is named", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "remind", + "--project", + "My Project", + ]); + + expect(resolveUserId).not.toHaveBeenCalled(); + expect(remindProjectUpdate).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + undefined, + ); + }); +}); diff --git a/tests/unit/common/domain-values.test.ts b/tests/unit/common/domain-values.test.ts index 064b3eb1..b1fa58e8 100644 --- a/tests/unit/common/domain-values.test.ts +++ b/tests/unit/common/domain-values.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + parseHealth, parseLabelMode, parseSetMode, } from "../../../src/common/domain-values.js"; @@ -39,3 +40,22 @@ describe("parseSetMode", () => { expect(parseSetMode("--subscriber-mode", "add")).toBe("add"); }); }); + +describe("parseHealth", () => { + it("returns undefined for an absent or empty value", () => { + expect(parseHealth(undefined)).toBeUndefined(); + expect(parseHealth("")).toBeUndefined(); + }); + + it("accepts any casing and returns the API spelling", () => { + expect(parseHealth("ontrack")).toBe("onTrack"); + expect(parseHealth("atRisk")).toBe("atRisk"); + expect(parseHealth(" OFFTRACK ")).toBe("offTrack"); + }); + + it("throws for an unknown health value", () => { + expect(() => parseHealth("sideways")).toThrow( + 'Invalid --health: must be one of: "onTrack", "atRisk", "offTrack"', + ); + }); +}); diff --git a/tests/unit/services/project-update-service.test.ts b/tests/unit/services/project-update-service.test.ts new file mode 100644 index 00000000..11a7c0eb --- /dev/null +++ b/tests/unit/services/project-update-service.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + ArchiveProjectUpdateDocument, + CreateProjectUpdateDocument, + CreateProjectUpdateReminderDocument, + EditProjectUpdateDocument, + GetProjectUpdateDocument, + ListProjectUpdatesDocument, + UnarchiveProjectUpdateDocument, +} from "../../../src/gql/graphql.js"; +import { + archiveProjectUpdate, + createProjectUpdate, + editProjectUpdate, + getProjectUpdate, + listProjectUpdates, + remindProjectUpdate, + unarchiveProjectUpdate, +} from "../../../src/services/project-update-service.js"; + +function mockGqlClient(response: Record): { + client: GraphQLClient; + request: ReturnType; +} { + const request = vi.fn().mockResolvedValue(response); + return { + client: { request } as unknown as GraphQLClient, + request, + }; +} + +describe("listProjectUpdates", () => { + it("forwards the project filter and pagination", async () => { + const { client, request } = mockGqlClient({ + projectUpdates: { + nodes: [{ id: "upd-1", body: "Week 1" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await expect( + listProjectUpdates(client, { + projectId: asUuid("proj-1"), + limit: 5, + after: "cursor-1", + includeArchived: true, + }), + ).resolves.toEqual({ + nodes: [{ id: "upd-1", body: "Week 1" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + expect(request).toHaveBeenCalledWith(ListProjectUpdatesDocument, { + projectId: "proj-1", + first: 5, + after: "cursor-1", + includeArchived: true, + }); + }); + + it("defaults to 50 results and excludes archived updates", async () => { + const { client, request } = mockGqlClient({ + projectUpdates: { nodes: [], pageInfo: { hasNextPage: false } }, + }); + + await listProjectUpdates(client, { projectId: asUuid("proj-1") }); + + expect(request).toHaveBeenCalledWith(ListProjectUpdatesDocument, { + projectId: "proj-1", + first: 50, + after: undefined, + includeArchived: false, + }); + }); +}); + +describe("getProjectUpdate", () => { + it("returns the update when found", async () => { + const update = { id: "upd-1", body: "Week 1", health: "onTrack" }; + const { client, request } = mockGqlClient({ projectUpdate: update }); + + await expect(getProjectUpdate(client, asUuid("upd-1"))).resolves.toEqual( + update, + ); + expect(request).toHaveBeenCalledWith(GetProjectUpdateDocument, { + id: "upd-1", + }); + }); + + it("throws when the update is missing", async () => { + const { client } = mockGqlClient({ projectUpdate: null }); + + await expect(getProjectUpdate(client, asUuid("upd-1"))).rejects.toThrow( + 'Project update with ID "upd-1" not found', + ); + }); +}); + +describe("createProjectUpdate", () => { + it("returns the created update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateCreate: { + success: true, + projectUpdate: { id: "upd-1", body: "Week 1" }, + }, + }); + + await expect( + createProjectUpdate(client, { + projectId: asUuid("proj-1"), + body: "Week 1", + health: "atRisk", + isDiffHidden: true, + }), + ).resolves.toEqual({ id: "upd-1", body: "Week 1" }); + + expect(request).toHaveBeenCalledWith(CreateProjectUpdateDocument, { + input: { + projectId: "proj-1", + body: "Week 1", + health: "atRisk", + isDiffHidden: true, + }, + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + projectUpdateCreate: { success: false, projectUpdate: null }, + }); + + await expect( + createProjectUpdate(client, { projectId: asUuid("proj-1") }), + ).rejects.toThrow("Failed to create project update"); + }); +}); + +describe("editProjectUpdate", () => { + it("returns the edited update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateUpdate: { + success: true, + projectUpdate: { id: "upd-1", body: "Revised" }, + }, + }); + + await expect( + editProjectUpdate(client, asUuid("upd-1"), { body: "Revised" }), + ).resolves.toEqual({ id: "upd-1", body: "Revised" }); + + expect(request).toHaveBeenCalledWith(EditProjectUpdateDocument, { + id: "upd-1", + input: { body: "Revised" }, + }); + }); + + it("rejects an empty patch before calling the API", async () => { + const { client, request } = mockGqlClient({}); + + await expect( + editProjectUpdate(client, asUuid("upd-1"), {}), + ).rejects.toThrow("at least one update field must be provided"); + + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe("archiveProjectUpdate", () => { + it("returns the archived update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateArchive: { success: true, entity: { id: "upd-1" } }, + }); + + await expect( + archiveProjectUpdate(client, asUuid("upd-1")), + ).resolves.toEqual({ id: "upd-1" }); + + expect(request).toHaveBeenCalledWith(ArchiveProjectUpdateDocument, { + id: "upd-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + projectUpdateArchive: { success: false, entity: null }, + }); + + await expect(archiveProjectUpdate(client, asUuid("upd-1"))).rejects.toThrow( + 'Failed to archive project update "upd-1"', + ); + }); +}); + +describe("unarchiveProjectUpdate", () => { + it("returns the restored update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateUnarchive: { success: true, entity: { id: "upd-1" } }, + }); + + await expect( + unarchiveProjectUpdate(client, asUuid("upd-1")), + ).resolves.toEqual({ id: "upd-1" }); + + expect(request).toHaveBeenCalledWith(UnarchiveProjectUpdateDocument, { + id: "upd-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + projectUpdateUnarchive: { success: false, entity: null }, + }); + + await expect( + unarchiveProjectUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to unarchive project update "upd-1"'); + }); +}); + +describe("remindProjectUpdate", () => { + it("echoes the project because the payload carries no entity", async () => { + const { client, request } = mockGqlClient({ + createProjectUpdateReminder: { success: true }, + }); + + await expect( + remindProjectUpdate(client, asUuid("proj-1"), asUuid("user-1")), + ).resolves.toEqual({ projectId: "proj-1", success: true }); + + expect(request).toHaveBeenCalledWith(CreateProjectUpdateReminderDocument, { + projectId: "proj-1", + userId: "user-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + createProjectUpdateReminder: { success: false }, + }); + + await expect(remindProjectUpdate(client, asUuid("proj-1"))).rejects.toThrow( + 'Failed to create an update reminder for project "proj-1"', + ); + }); +}); From a6e1a1613b0beb76a94c807b7dfb7caa7ee19403 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:00:09 +0200 Subject: [PATCH 04/35] feat(projects): administer the workspace project status flow `projects create --status ` and `projects update --status ` already resolved status names, but nothing could list what those names are, let alone change them. The status flow was readable only as a side effect of a failed resolution. Wire the seven `projectStatus*` root fields as `projects statuses`. Statuses are workspace-scoped rather than per-project, but callers meet them through `projects --status`, so that is where they will look for them; the subgroup's help text says the scope out loud so nobody assumes per-team flows. Three shaping decisions: - `projects statuses read` folds `projectStatusProjectCount` into the payload instead of adding a `count` verb. The count is exactly what decides whether an archive will be refused, so a caller who reads a status and then hits that refusal had the answer in hand already. - `projectReassignStatus` is `[INTERNAL]` and reassignment on its own is not a task anyone sets out to do, so it becomes `archive --reassign-to ` rather than its own command. Linear refuses to archive a status that still holds projects; this is the one flag that makes the documented failure recoverable in a single step. Reassigning a status onto itself is rejected up front, since the API would accept it as a no-op and the archive would then fail anyway. - `position` is `Float!` on `ProjectStatusCreateInput`, but "where in the flow" is rarely what a caller has in mind. Omitting `--position` reads the current flow and appends past the highest position, so creating a status does not require first learning the numbering scheme. `resolveProjectStatusId()` gains an `includeArchived` option. `statuses unarchive ` names a status that is by definition not in the default set, so without it the command could only ever take a UUID. --- README.md | 2 +- graphql/mutations/project-statuses.graphql | 58 ++++ graphql/queries/project-statuses.graphql | 50 +++ graphql/queries/projects.graphql | 4 +- src/commands/projects/index.ts | 6 + src/commands/projects/statuses.ts | 277 +++++++++++++++ src/resolvers/project-status-resolver.ts | 8 +- src/services/project-status-service.ts | 210 ++++++++++++ tests/unit/commands/project-statuses.test.ts | 180 ++++++++++ .../resolvers/project-status-resolver.test.ts | 21 ++ .../services/project-status-service.test.ts | 314 ++++++++++++++++++ 11 files changed, 1126 insertions(+), 4 deletions(-) create mode 100644 graphql/mutations/project-statuses.graphql create mode 100644 graphql/queries/project-statuses.graphql create mode 100644 src/commands/projects/statuses.ts create mode 100644 src/services/project-status-service.ts create mode 100644 tests/unit/commands/project-statuses.test.ts create mode 100644 tests/unit/services/project-status-service.test.ts diff --git a/README.md b/README.md index 998010f3..9fbdb4dc 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The table below is the honest picture of the whole surface — what works today, | Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management | | `issues` | ✅ | List, filter, full-text search, read, create, update, batch create/update, archive/unarchive, delete/restore, snooze; assign labels/assignee/delegate/state/priority/project/cycle/team (including moves between teams); subscribe/unsubscribe, share/unshare, reminders; find the issue for a git branch (`from-branch`); relations (list/add/remove); activity history | Deliberately excluded: the AI-assist and integration-suggestion queries (Figma file lookup, filter/repository suggestions, title-from-customer-request) — see the Integrations row — and `issuePriorityValues`, a static list already in the help text | | `initiatives` | 🟡 | List, read, create, update, archive/unarchive, delete; attach/detach projects; initiative-to-initiative relations; initiative updates (list, read, create, update, archive/unarchive); discussions | Initiative labels, lead-team reassignment, relation reordering | -| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); discussions | Project-label CRUD, project relations, project status administration, Slack channel creation | +| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); administer the workspace project status flow (`projects statuses`); discussions | Project-label CRUD, project relations, Slack channel creation | | `documents` | 🟡 | List, read, create, update, delete | Content history, document full-text search, unarchive | | `milestones` | 🟡 | List, read, create, update (per project) | Delete, reordering/move between projects | | `attachments` | 🟡 | List on an issue, create from a URL, delete, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | diff --git a/graphql/mutations/project-statuses.graphql b/graphql/mutations/project-statuses.graphql new file mode 100644 index 00000000..70aa2a33 --- /dev/null +++ b/graphql/mutations/project-statuses.graphql @@ -0,0 +1,58 @@ +# ------------------------------------------------------------ +# GraphQL mutations for the workspace project status flow +# ------------------------------------------------------------ + +mutation CreateProjectStatus($input: ProjectStatusCreateInput!) { + projectStatusCreate(input: $input) { + success + status { + ...ProjectStatusCoreFields + } + } +} + +mutation UpdateProjectStatus($id: String!, $input: ProjectStatusUpdateInput!) { + projectStatusUpdate(id: $id, input: $input) { + success + status { + ...ProjectStatusCoreFields + } + } +} + +# Archive a status +# +# Linear refuses this while projects are still assigned to the status, or +# when it is the last status of its type. +mutation ArchiveProjectStatus($id: String!) { + projectStatusArchive(id: $id) { + success + entity { + ...ProjectStatusCoreFields + } + } +} + +mutation UnarchiveProjectStatus($id: String!) { + projectStatusUnarchive(id: $id) { + success + entity { + ...ProjectStatusCoreFields + } + } +} + +# Move every project off one status and onto another +# +# The payload carries no entity — only whether the reassignment ran. +mutation ReassignProjectStatus( + $originalProjectStatusId: String! + $newProjectStatusId: String! +) { + projectReassignStatus( + originalProjectStatusId: $originalProjectStatusId + newProjectStatusId: $newProjectStatusId + ) { + success + } +} diff --git a/graphql/queries/project-statuses.graphql b/graphql/queries/project-statuses.graphql new file mode 100644 index 00000000..6ddaa5b6 --- /dev/null +++ b/graphql/queries/project-statuses.graphql @@ -0,0 +1,50 @@ +# ------------------------------------------------------------ +# GraphQL queries for the workspace project status flow +# +# Project statuses are workspace-scoped, not per-team: every project in +# the workspace draws its status from this one ordered list. +# ------------------------------------------------------------ + +fragment ProjectStatusCoreFields on ProjectStatus { + id + name + description + color + type + position + indefinite + createdAt + updatedAt + archivedAt +} + +# List the workspace's project statuses +# +# The connection takes no name filter, which is why +# `resolveProjectStatusId()` matches names client-side. +query ListProjectStatuses($includeArchived: Boolean = false) { + projectStatuses(includeArchived: $includeArchived) { + nodes { + ...ProjectStatusCoreFields + } + } +} + +query GetProjectStatus($id: String!) { + projectStatus(id: $id) { + ...ProjectStatusCoreFields + } +} + +# How many projects currently sit in a status +# +# Folded into `projects statuses read` rather than exposed as its own +# verb: the count only means anything next to the status it describes, +# and it is what tells you whether an archive will be refused. +query GetProjectStatusProjectCount($id: String!) { + projectStatusProjectCount(id: $id) { + count + privateCount + archivedTeamCount + } +} diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index 774d313f..24502d45 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -228,8 +228,8 @@ query GetProjectWithReactions($id: String!, $first: Int, $after: String) { # Fetches project statuses for name-to-UUID resolution. # The API does not support filter args on this connection, # so all statuses are fetched and filtered client-side. -query GetProjectStatuses { - projectStatuses { +query GetProjectStatuses($includeArchived: Boolean = false) { + projectStatuses(includeArchived: $includeArchived) { nodes { id name diff --git a/src/commands/projects/index.ts b/src/commands/projects/index.ts index fb97e1a5..4c0cf12e 100644 --- a/src/commands/projects/index.ts +++ b/src/commands/projects/index.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { type DomainMeta, formatDomainUsage } from "../../common/usage.js"; import { setupProjectEntityCommands } from "./entity.js"; +import { setupProjectStatusCommands } from "./statuses.js"; import { setupProjectUpdateCommands } from "./updates.js"; export const PROJECTS_META: DomainMeta = { @@ -18,10 +19,14 @@ export const PROJECTS_META: DomainMeta = { "", "a project's health is derived from its most recent status update, so", "changing health means posting one with `projects updates create`.", + "", + "project statuses are workspace-scoped, not per-team: `projects", + "statuses` administers the one ordered flow every project draws from.", ].join("\n"), arguments: { project: "project identifier (UUID or name)", update: "project status update identifier (UUID)", + status: "project status identifier (UUID or name)", name: "string", }, seeAlso: [ @@ -38,6 +43,7 @@ export function setupProjectsCommands(program: Command): void { setupProjectEntityCommands(projects); setupProjectUpdateCommands(projects); + setupProjectStatusCommands(projects); projects .command("usage") diff --git a/src/commands/projects/statuses.ts b/src/commands/projects/statuses.ts new file mode 100644 index 00000000..4fcd6b4e --- /dev/null +++ b/src/commands/projects/statuses.ts @@ -0,0 +1,277 @@ +import type { Command } from "commander"; +import { createContext, getRootOpts } from "../../common/context.js"; +import { invalidParameterError } from "../../common/errors.js"; +import { commandAction, outputSuccess } from "../../common/output.js"; +import type { ProjectStatusType } from "../../gql/graphql.js"; +import { resolveProjectStatusId } from "../../resolvers/project-status-resolver.js"; +import { + archiveProjectStatus, + type CreateProjectStatusInput, + createProjectStatus, + getProjectStatus, + listProjectStatuses, + reassignProjectStatus, + type UpdateProjectStatusInput, + unarchiveProjectStatus, + updateProjectStatus, +} from "../../services/project-status-service.js"; + +const STATUS_TYPES = [ + "backlog", + "planned", + "started", + "paused", + "completed", + "canceled", +] as const satisfies readonly ProjectStatusType[]; + +interface StatusesListOptions { + includeArchived?: boolean; +} + +// `--type` and `--color` are requiredOption, so commander guarantees them. +interface StatusesCreateOptions { + type: string; + color: string; + description?: string; + position?: string; + indefinite?: boolean; +} + +interface StatusesUpdateOptions { + name?: string; + type?: string; + color?: string; + description?: string; + position?: string; + indefinite?: boolean; + notIndefinite?: boolean; +} + +interface StatusesArchiveOptions { + reassignTo?: string; +} + +function parseStatusType(value: string): ProjectStatusType { + const match = STATUS_TYPES.find((type) => type === value); + if (!match) { + throw invalidParameterError( + "--type", + `must be one of: ${STATUS_TYPES.join(", ")}`, + ); + } + return match; +} + +function parsePosition(value: string): number { + const position = Number.parseFloat(value); + if (!Number.isFinite(position)) { + throw invalidParameterError("--position", "must be a number"); + } + return position; +} + +export function setupProjectStatusCommands(projects: Command): void { + const statuses = projects + .command("statuses") + .description("workspace project status flow operations") + .addHelpText( + "after", + "\nProject statuses are workspace-scoped, not per-team: every project\n" + + "in the workspace draws its status from this one ordered list.", + ); + + statuses + .command("list") + .description("list the workspace project statuses") + .option("--include-archived", "include archived statuses") + .action( + commandAction<[StatusesListOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await listProjectStatuses( + ctx.gql, + options.includeArchived ?? false, + ); + outputSuccess(result); + }, + ), + ); + + statuses + .command("read ") + .description("get a project status with its project count") + .action( + commandAction<[string, unknown, Command]>( + async (status, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const statusId = await resolveProjectStatusId(ctx.gql, status, { + includeArchived: true, + }); + const result = await getProjectStatus(ctx.gql, statusId); + outputSuccess(result); + }, + ), + ); + + statuses + .command("create ") + .description("create a project status") + .requiredOption("--type ", STATUS_TYPES.join(" | ")) + .requiredOption("--color ", "status color as a hex string") + .option("--description ", "status description") + .option("--position ", "position in the flow; default is last") + .option("--indefinite", "projects may stay in this status indefinitely") + .action( + commandAction<[string, StatusesCreateOptions, Command]>( + async (name, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: CreateProjectStatusInput = { + name, + type: parseStatusType(options.type), + color: options.color, + }; + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.position !== undefined) { + input.position = parsePosition(options.position); + } + + if (options.indefinite) { + input.indefinite = true; + } + + const result = await createProjectStatus(ctx.gql, input); + outputSuccess(result); + }, + ), + ); + + statuses + .command("update ") + .description("update a project status") + .option("--name ", "new name") + .option("--type ", STATUS_TYPES.join(" | ")) + .option("--color ", "new color as a hex string") + .option("--description ", "new description") + .option("--position ", "new position in the flow") + .option("--indefinite", "projects may stay in this status indefinitely") + .option("--not-indefinite", "projects may not stay in this status forever") + .action( + commandAction<[string, StatusesUpdateOptions, Command]>( + async (status, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: UpdateProjectStatusInput = {}; + + if (options.name !== undefined) { + input.name = options.name; + } + + if (options.type !== undefined) { + input.type = parseStatusType(options.type); + } + + if (options.color !== undefined) { + input.color = options.color; + } + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.position !== undefined) { + input.position = parsePosition(options.position); + } + + if (options.indefinite && options.notIndefinite) { + throw invalidParameterError( + "--indefinite", + "cannot be combined with --not-indefinite", + ); + } + + if (options.indefinite) { + input.indefinite = true; + } else if (options.notIndefinite) { + input.indefinite = false; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } + + const statusId = await resolveProjectStatusId(ctx.gql, status, { + includeArchived: true, + }); + const result = await updateProjectStatus(ctx.gql, statusId, input); + outputSuccess(result); + }, + ), + ); + + statuses + .command("archive ") + .description("archive a project status") + .addHelpText( + "after", + "\nLinear refuses to archive a status that still has projects in it.\n" + + "--reassign-to moves them first, which is the only way to make that\n" + + "failure recoverable in one step.", + ) + .option( + "--reassign-to ", + "move projects onto this status before archiving", + ) + .action( + commandAction<[string, StatusesArchiveOptions, Command]>( + async (status, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const statusId = await resolveProjectStatusId(ctx.gql, status); + + if (options.reassignTo !== undefined) { + const newStatusId = await resolveProjectStatusId( + ctx.gql, + options.reassignTo, + ); + + if (newStatusId === statusId) { + throw invalidParameterError( + "--reassign-to", + "must name a different status than the one being archived", + ); + } + + await reassignProjectStatus(ctx.gql, statusId, newStatusId); + } + + const result = await archiveProjectStatus(ctx.gql, statusId); + outputSuccess(result); + }, + ), + ); + + statuses + .command("unarchive ") + .description("unarchive a project status") + .action( + commandAction<[string, unknown, Command]>( + async (status, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const statusId = await resolveProjectStatusId(ctx.gql, status, { + includeArchived: true, + }); + const result = await unarchiveProjectStatus(ctx.gql, statusId); + outputSuccess(result); + }, + ), + ); +} diff --git a/src/resolvers/project-status-resolver.ts b/src/resolvers/project-status-resolver.ts index a998280b..53406491 100644 --- a/src/resolvers/project-status-resolver.ts +++ b/src/resolvers/project-status-resolver.ts @@ -13,16 +13,22 @@ import { GetProjectStatusesDocument } from "../gql/graphql.js"; * * @param client - GraphQL client for querying project statuses * @param nameOrId - Status name or UUID + * @param options.includeArchived - Search archived statuses too. Needed by + * `projects statuses unarchive`, where the status being named is by + * definition not in the default set. * @returns Status UUID * @throws Error if status name not found */ export async function resolveProjectStatusId( client: GraphQLClient, nameOrId: string, + options: { includeArchived?: boolean } = {}, ): Promise { if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.request(GetProjectStatusesDocument); + const result = await client.request(GetProjectStatusesDocument, { + includeArchived: options.includeArchived ?? false, + }); const match = result.projectStatuses.nodes.find( (s) => s.name.toLowerCase() === nameOrId.toLowerCase(), ); diff --git a/src/services/project-status-service.ts b/src/services/project-status-service.ts new file mode 100644 index 00000000..7a08d4f6 --- /dev/null +++ b/src/services/project-status-service.ts @@ -0,0 +1,210 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { invalidParameterError } from "../common/errors.js"; +import type { UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import { + ArchiveProjectStatusDocument, + type ArchiveProjectStatusMutation, + CreateProjectStatusDocument, + type CreateProjectStatusMutation, + GetProjectStatusDocument, + GetProjectStatusProjectCountDocument, + type GetProjectStatusProjectCountQuery, + type GetProjectStatusQuery, + ListProjectStatusesDocument, + type ListProjectStatusesQuery, + type ProjectStatusCreateInput, + type ProjectStatusUpdateInput, + ReassignProjectStatusDocument, + UnarchiveProjectStatusDocument, + type UnarchiveProjectStatusMutation, + UpdateProjectStatusDocument, + type UpdateProjectStatusMutation, +} from "../gql/graphql.js"; + +// Project status projection types +export type ProjectStatusListItem = + ListProjectStatusesQuery["projectStatuses"]["nodes"][0]; +export type ProjectStatusDetail = NonNullable< + GetProjectStatusQuery["projectStatus"] +> & { + projectCount: GetProjectStatusProjectCountQuery["projectStatusProjectCount"]; +}; +export type CreatedProjectStatus = NonNullable< + CreateProjectStatusMutation["projectStatusCreate"]["status"] +>; +export type UpdatedProjectStatus = NonNullable< + UpdateProjectStatusMutation["projectStatusUpdate"]["status"] +>; +export type ArchivedProjectStatus = NonNullable< + ArchiveProjectStatusMutation["projectStatusArchive"]["entity"] +>; +export type UnarchivedProjectStatus = NonNullable< + UnarchiveProjectStatusMutation["projectStatusUnarchive"]["entity"] +>; + +// Service-owned input types. Project statuses carry no UUID references, +// so these are the codegen inputs unchanged apart from `position`. +export type CreateProjectStatusInput = Omit< + ProjectStatusCreateInput, + "id" | "position" +> & { + /** Omitted places the status last in the workspace flow. */ + position?: number; +}; +export type UpdateProjectStatusInput = ProjectStatusUpdateInput; + +export async function listProjectStatuses( + client: GraphQLClient, + includeArchived = false, +): Promise<{ nodes: ProjectStatusListItem[] }> { + const result = await client.request(ListProjectStatusesDocument, { + includeArchived, + }); + + return { nodes: result.projectStatuses.nodes }; +} + +/** + * Reads one status together with how many projects sit in it. + * + * The count is what decides whether {@link archiveProjectStatus} will be + * refused, so returning the status without it would leave callers making a + * second call they cannot know they need. + */ +export async function getProjectStatus( + client: GraphQLClient, + id: UUID, +): Promise { + const [statusResult, countResult] = await Promise.all([ + client.request(GetProjectStatusDocument, { id }), + client.request(GetProjectStatusProjectCountDocument, { id }), + ]); + + if (!statusResult.projectStatus) { + throw new Error(`Project status with ID "${id}" not found`); + } + + return { + ...statusResult.projectStatus, + projectCount: countResult.projectStatusProjectCount, + }; +} + +/** + * Appends a status to the end of the workspace flow. + * + * `position` is required by the API but rarely what a caller has in mind, so + * an omitted position reads the current flow and takes the next slot. + */ +async function nextProjectStatusPosition( + client: GraphQLClient, +): Promise { + const { nodes } = await listProjectStatuses(client); + const highest = nodes.reduce( + (max, status) => Math.max(max, status.position), + 0, + ); + + return highest + 1; +} + +export async function createProjectStatus( + client: GraphQLClient, + input: CreateProjectStatusInput, +): Promise { + const { position, ...rest } = input; + const gqlInput: ProjectStatusCreateInput = { + ...rest, + position: position ?? (await nextProjectStatusPosition(client)), + }; + + const result = await client.request(CreateProjectStatusDocument, { + input: gqlInput, + }); + + return requireMutationEntity( + result.projectStatusCreate, + "status", + `Failed to create project status "${input.name}"`, + ); +} + +export async function updateProjectStatus( + client: GraphQLClient, + id: UUID, + input: UpdateProjectStatusInput, +): Promise { + const hasAtLeastOneField = Object.values(input).some( + (value) => value !== undefined, + ); + + if (!hasAtLeastOneField) { + throw invalidParameterError( + "update options", + "at least one update field must be provided", + ); + } + + const result = await client.request(UpdateProjectStatusDocument, { + id, + input, + }); + + return requireMutationEntity( + result.projectStatusUpdate, + "status", + `Failed to update project status "${id}"`, + ); +} + +/** + * Moves every project off one status and onto another. + * + * Exposed only through `projects statuses archive --reassign-to`, because + * Linear marks the mutation `[INTERNAL]` and reassignment on its own is not + * a task anyone sets out to do. + */ +export async function reassignProjectStatus( + client: GraphQLClient, + originalProjectStatusId: UUID, + newProjectStatusId: UUID, +): Promise { + const result = await client.request(ReassignProjectStatusDocument, { + originalProjectStatusId, + newProjectStatusId, + }); + + if (!result.projectReassignStatus.success) { + throw new Error( + `Failed to reassign projects from status "${originalProjectStatusId}" ` + + `to "${newProjectStatusId}"`, + ); + } +} + +export async function archiveProjectStatus( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(ArchiveProjectStatusDocument, { id }); + + return requireMutationEntity( + result.projectStatusArchive, + "entity", + `Failed to archive project status "${id}"`, + ); +} + +export async function unarchiveProjectStatus( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(UnarchiveProjectStatusDocument, { id }); + + return requireMutationEntity( + result.projectStatusUnarchive, + "entity", + `Failed to unarchive project status "${id}"`, + ); +} diff --git a/tests/unit/commands/project-statuses.test.ts b/tests/unit/commands/project-statuses.test.ts new file mode 100644 index 00000000..5b6c1505 --- /dev/null +++ b/tests/unit/commands/project-statuses.test.ts @@ -0,0 +1,180 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/context.js", () => ({ + createContext: vi.fn(() => ({ gql: { request: vi.fn() } })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), +})); + +vi.mock("../../../src/common/output.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, outputSuccess: vi.fn() }; +}); + +vi.mock("../../../src/resolvers/project-status-resolver.js", () => ({ + resolveProjectStatusId: vi + .fn() + .mockImplementation(async (_client: unknown, nameOrId: string) => + nameOrId === "In Review" ? "status-uuid-2" : "status-uuid-1", + ), +})); + +vi.mock("../../../src/services/project-status-service.js", () => ({ + listProjectStatuses: vi.fn().mockResolvedValue({ nodes: [] }), + getProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), + createProjectStatus: vi.fn().mockResolvedValue({ id: "st-new" }), + updateProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), + reassignProjectStatus: vi.fn().mockResolvedValue(undefined), + archiveProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), + unarchiveProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), +})); + +import { setupProjectStatusCommands } from "../../../src/commands/projects/statuses.js"; +import { resolveProjectStatusId } from "../../../src/resolvers/project-status-resolver.js"; +import { + archiveProjectStatus, + createProjectStatus, + listProjectStatuses, + reassignProjectStatus, + unarchiveProjectStatus, + updateProjectStatus, +} from "../../../src/services/project-status-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token "); + setupProjectStatusCommands(program.command("projects")); + return program; +} + +async function run(...argv: string[]): Promise { + await createProgram().parseAsync(["node", "test", "projects", ...argv]); +} + +describe("projects statuses", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("list forwards --include-archived", async () => { + await run("statuses", "list", "--include-archived"); + + expect(listProjectStatuses).toHaveBeenCalledWith(expect.anything(), true); + }); + + it("read resolves archived statuses too", async () => { + await run("statuses", "read", "Done"); + + expect(resolveProjectStatusId).toHaveBeenCalledWith( + expect.anything(), + "Done", + { includeArchived: true }, + ); + }); + + it("create leaves position unset so the service appends", async () => { + await run( + "statuses", + "create", + "Blocked", + "--type", + "paused", + "--color", + "#B45309", + ); + + expect(createProjectStatus).toHaveBeenCalledWith(expect.anything(), { + name: "Blocked", + type: "paused", + color: "#B45309", + }); + }); + + it("create rejects a type outside the enum", async () => { + await run( + "statuses", + "create", + "Blocked", + "--type", + "stalled", + "--color", + "#B45309", + ); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --type"), + ); + expect(createProjectStatus).not.toHaveBeenCalled(); + }); + + it("update maps --not-indefinite to false", async () => { + await run("statuses", "update", "Done", "--not-indefinite"); + + expect(updateProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + { indefinite: false }, + ); + }); + + it("update refuses contradictory indefinite flags", async () => { + await run("statuses", "update", "Done", "--indefinite", "--not-indefinite"); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("cannot be combined with --not-indefinite"), + ); + expect(updateProjectStatus).not.toHaveBeenCalled(); + }); + + it("archive reassigns before archiving when --reassign-to is given", async () => { + await run("statuses", "archive", "Done", "--reassign-to", "In Review"); + + expect(reassignProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + "status-uuid-2", + ); + expect(archiveProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + ); + }); + + it("archive refuses to reassign a status onto itself", async () => { + await run("statuses", "archive", "Done", "--reassign-to", "Done"); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must name a different status"), + ); + expect(reassignProjectStatus).not.toHaveBeenCalled(); + expect(archiveProjectStatus).not.toHaveBeenCalled(); + }); + + it("archive skips reassignment when the flag is absent", async () => { + await run("statuses", "archive", "Done"); + + expect(reassignProjectStatus).not.toHaveBeenCalled(); + expect(archiveProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + ); + }); + + it("unarchive resolves archived statuses", async () => { + await run("statuses", "unarchive", "Done"); + + expect(resolveProjectStatusId).toHaveBeenCalledWith( + expect.anything(), + "Done", + { includeArchived: true }, + ); + expect(unarchiveProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + ); + }); +}); diff --git a/tests/unit/resolvers/project-status-resolver.test.ts b/tests/unit/resolvers/project-status-resolver.test.ts index 488580e3..9efca19d 100644 --- a/tests/unit/resolvers/project-status-resolver.test.ts +++ b/tests/unit/resolvers/project-status-resolver.test.ts @@ -33,6 +33,27 @@ describe("resolveProjectStatusId", () => { expect(result).toBe("status-uuid"); }); + it("excludes archived statuses unless asked", async () => { + const client = mockGqlClient([{ id: "status-uuid", name: "Started" }]); + await resolveProjectStatusId(client, "Started"); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + includeArchived: false, + }); + }); + + it("searches archived statuses when asked", async () => { + const client = mockGqlClient([{ id: "status-uuid", name: "Retired" }]); + const result = await resolveProjectStatusId(client, "Retired", { + includeArchived: true, + }); + + expect(result).toBe("status-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + includeArchived: true, + }); + }); + it("throws when status not found", async () => { const client = mockGqlClient([]); await expect(resolveProjectStatusId(client, "Nonexistent")).rejects.toThrow( diff --git a/tests/unit/services/project-status-service.test.ts b/tests/unit/services/project-status-service.test.ts new file mode 100644 index 00000000..9c670945 --- /dev/null +++ b/tests/unit/services/project-status-service.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + ArchiveProjectStatusDocument, + CreateProjectStatusDocument, + GetProjectStatusDocument, + GetProjectStatusProjectCountDocument, + ListProjectStatusesDocument, + ReassignProjectStatusDocument, + UnarchiveProjectStatusDocument, + UpdateProjectStatusDocument, +} from "../../../src/gql/graphql.js"; +import { + archiveProjectStatus, + createProjectStatus, + getProjectStatus, + listProjectStatuses, + reassignProjectStatus, + unarchiveProjectStatus, + updateProjectStatus, +} from "../../../src/services/project-status-service.js"; + +/** Routes each document to its own canned response. */ +function mockGqlClient(responses: Map): { + client: GraphQLClient; + request: ReturnType; +} { + const request = vi.fn(async (document: unknown) => { + if (!responses.has(document)) { + throw new Error("unexpected document"); + } + return responses.get(document); + }); + + return { client: { request } as unknown as GraphQLClient, request }; +} + +describe("listProjectStatuses", () => { + it("excludes archived statuses by default", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ListProjectStatusesDocument, { projectStatuses: { nodes: [] } }], + ]), + ); + + await expect(listProjectStatuses(client)).resolves.toEqual({ nodes: [] }); + expect(request).toHaveBeenCalledWith(ListProjectStatusesDocument, { + includeArchived: false, + }); + }); +}); + +describe("getProjectStatus", () => { + it("folds the project count into the status payload", async () => { + const { client } = mockGqlClient( + new Map([ + [GetProjectStatusDocument, { projectStatus: { id: "st-1" } }], + [ + GetProjectStatusProjectCountDocument, + { + projectStatusProjectCount: { + count: 3, + privateCount: 1, + archivedTeamCount: 0, + }, + }, + ], + ]), + ); + + await expect(getProjectStatus(client, asUuid("st-1"))).resolves.toEqual({ + id: "st-1", + projectCount: { count: 3, privateCount: 1, archivedTeamCount: 0 }, + }); + }); + + it("throws when the status is missing", async () => { + const { client } = mockGqlClient( + new Map([ + [GetProjectStatusDocument, { projectStatus: null }], + [ + GetProjectStatusProjectCountDocument, + { projectStatusProjectCount: { count: 0 } }, + ], + ]), + ); + + await expect(getProjectStatus(client, asUuid("st-1"))).rejects.toThrow( + 'Project status with ID "st-1" not found', + ); + }); +}); + +describe("createProjectStatus", () => { + it("appends past the highest existing position when none is given", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + ListProjectStatusesDocument, + { projectStatuses: { nodes: [{ position: 2 }, { position: 5 }] } }, + ], + [ + CreateProjectStatusDocument, + { projectStatusCreate: { success: true, status: { id: "st-new" } } }, + ], + ]), + ); + + await expect( + createProjectStatus(client, { + name: "Blocked", + type: "paused", + color: "#B45309", + }), + ).resolves.toEqual({ id: "st-new" }); + + expect(request).toHaveBeenCalledWith(CreateProjectStatusDocument, { + input: { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 6, + }, + }); + }); + + it("uses an explicit position without reading the flow", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + CreateProjectStatusDocument, + { projectStatusCreate: { success: true, status: { id: "st-new" } } }, + ], + ]), + ); + + await createProjectStatus(client, { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 1.5, + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith(CreateProjectStatusDocument, { + input: { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 1.5, + }, + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient( + new Map([ + [ + CreateProjectStatusDocument, + { projectStatusCreate: { success: false, status: null } }, + ], + ]), + ); + + await expect( + createProjectStatus(client, { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 1, + }), + ).rejects.toThrow('Failed to create project status "Blocked"'); + }); +}); + +describe("updateProjectStatus", () => { + it("forwards the patch", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + UpdateProjectStatusDocument, + { projectStatusUpdate: { success: true, status: { id: "st-1" } } }, + ], + ]), + ); + + await expect( + updateProjectStatus(client, asUuid("st-1"), { indefinite: false }), + ).resolves.toEqual({ id: "st-1" }); + + expect(request).toHaveBeenCalledWith(UpdateProjectStatusDocument, { + id: "st-1", + input: { indefinite: false }, + }); + }); + + it("rejects an empty patch before calling the API", async () => { + const { client, request } = mockGqlClient(new Map()); + + await expect( + updateProjectStatus(client, asUuid("st-1"), {}), + ).rejects.toThrow("at least one update field must be provided"); + + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe("reassignProjectStatus", () => { + it("resolves when the mutation succeeds", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + ReassignProjectStatusDocument, + { projectReassignStatus: { success: true } }, + ], + ]), + ); + + await expect( + reassignProjectStatus(client, asUuid("st-1"), asUuid("st-2")), + ).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledWith(ReassignProjectStatusDocument, { + originalProjectStatusId: "st-1", + newProjectStatusId: "st-2", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient( + new Map([ + [ + ReassignProjectStatusDocument, + { projectReassignStatus: { success: false } }, + ], + ]), + ); + + await expect( + reassignProjectStatus(client, asUuid("st-1"), asUuid("st-2")), + ).rejects.toThrow( + 'Failed to reassign projects from status "st-1" to "st-2"', + ); + }); +}); + +describe("archiveProjectStatus", () => { + it("returns the archived status", async () => { + const { client } = mockGqlClient( + new Map([ + [ + ArchiveProjectStatusDocument, + { projectStatusArchive: { success: true, entity: { id: "st-1" } } }, + ], + ]), + ); + + await expect(archiveProjectStatus(client, asUuid("st-1"))).resolves.toEqual( + { id: "st-1" }, + ); + }); + + it("throws when Linear refuses the archive", async () => { + const { client } = mockGqlClient( + new Map([ + [ + ArchiveProjectStatusDocument, + { projectStatusArchive: { success: false, entity: null } }, + ], + ]), + ); + + await expect(archiveProjectStatus(client, asUuid("st-1"))).rejects.toThrow( + 'Failed to archive project status "st-1"', + ); + }); +}); + +describe("unarchiveProjectStatus", () => { + it("returns the restored status", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + UnarchiveProjectStatusDocument, + { projectStatusUnarchive: { success: true, entity: { id: "st-1" } } }, + ], + ]), + ); + + await expect( + unarchiveProjectStatus(client, asUuid("st-1")), + ).resolves.toEqual({ id: "st-1" }); + + expect(request).toHaveBeenCalledWith(UnarchiveProjectStatusDocument, { + id: "st-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient( + new Map([ + [ + UnarchiveProjectStatusDocument, + { projectStatusUnarchive: { success: false, entity: null } }, + ], + ]), + ); + + await expect( + unarchiveProjectStatus(client, asUuid("st-1")), + ).rejects.toThrow('Failed to unarchive project status "st-1"'); + }); +}); From 4bf8dcc50e69b764f87bc8c1712c2a72f3dcdd76 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:05:44 +0200 Subject: [PATCH 05/35] feat(labels): full CRUD and retire/restore for project labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `labels` already selected its entity kind with `--type issue|project`, but only `list` honoured it. `read`, `update` and `delete` were issue-only, so a project label could be applied by name through `projects --labels` and then never edited or removed from the CLI. Thread `--type` through every verb. `read/update/delete` now route to the `projectLabel*` operations when asked, `create` gains `--type`, and `retire`/`restore` are added for both kinds. Retire is the reversible alternative to delete — the label stays on whatever already carries it but cannot be applied to anything new — which is what you actually want when killing off a taxonomy that history still references. Extending `labels` rather than adding `projects labels` closes the project-label gap and the `labels` gap in one place. A second home for the same nouns would mean two commands to learn and two places to look. Notes on the shape: - The service takes `type` as a trailing parameter defaulting to `"issue"`, so every existing call site keeps its meaning and the dispatch lives in one place instead of six `if`s in the command. - `--team`/`--scope` are rejected under `--type project` on every verb, reusing the guard `labels list` already had. Project labels have no team dimension, so ignoring the flags would answer a question the caller did not ask. - `--parent` resolves against the same label kind as the label being written. Groups and their children are always the same kind, so the other resolver could only produce a not-found or a parent the API would reject. - The label fragments now select `isGroup`, `retiredAt` and `parent`. Without `retiredAt` the retire/restore commands would report success with no visible difference in their own output. --- README.md | 4 +- graphql/mutations/labels.graphql | 70 +++++++- graphql/queries/labels.graphql | 20 +++ src/commands/labels.ts | 208 ++++++++++++++++++---- src/services/label-service.ts | 186 ++++++++++++++++--- tests/unit/commands/labels.test.ts | 173 ++++++++++++++++-- tests/unit/services/label-service.test.ts | 179 +++++++++++++++++++ 7 files changed, 767 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9fbdb4dc..3e93a011 100644 --- a/README.md +++ b/README.md @@ -157,13 +157,13 @@ The table below is the honest picture of the whole surface — what works today, | Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management | | `issues` | ✅ | List, filter, full-text search, read, create, update, batch create/update, archive/unarchive, delete/restore, snooze; assign labels/assignee/delegate/state/priority/project/cycle/team (including moves between teams); subscribe/unsubscribe, share/unshare, reminders; find the issue for a git branch (`from-branch`); relations (list/add/remove); activity history | Deliberately excluded: the AI-assist and integration-suggestion queries (Figma file lookup, filter/repository suggestions, title-from-customer-request) — see the Integrations row — and `issuePriorityValues`, a static list already in the help text | | `initiatives` | 🟡 | List, read, create, update, archive/unarchive, delete; attach/detach projects; initiative-to-initiative relations; initiative updates (list, read, create, update, archive/unarchive); discussions | Initiative labels, lead-team reassignment, relation reordering | -| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); administer the workspace project status flow (`projects statuses`); discussions | Project-label CRUD, project relations, Slack channel creation | +| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); administer the workspace project status flow (`projects statuses`); discussions | Project relations, Slack channel creation | | `documents` | 🟡 | List, read, create, update, delete | Content history, document full-text search, unarchive | | `milestones` | 🟡 | List, read, create, update (per project) | Delete, reordering/move between projects | | `attachments` | 🟡 | List on an issue, create from a URL, delete, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | | `files` | 🟡 | Upload a file, download via signed URL | Delete uploads, image-from-URL, CSV export reports | | `teams` | 🟡 | List, read, create, update; list/add/remove members | Delete, workflow-state administration, triage responsibility, git automation, SLA configuration | -| `labels` | 🟠 | Issue labels: list, read, create, update, delete; project labels: list (`--type project`) | Project-label create/update/delete, initiative labels, retire/restore | +| `labels` | 🟡 | Issue and project labels alike (`--type issue\|project`): list, read, create, update, delete, retire/restore; label groups (`--group`, `--parent`) | Initiative labels | | `cycles` | 🟠 | List cycles, read a cycle with its issues | Create, update, archive, shift all, start upcoming cycle | | `users` | 🟠 | List workspace members | Read a single user, update, role changes, suspend/unsuspend, user settings, session management | | Integrations | 🔴 | — | All 73 integration root fields (65 mutations, 8 queries): Slack, GitHub, GitLab, Jira, Figma, Sentry, PagerDuty, Intercom, Salesforce, and more | diff --git a/graphql/mutations/labels.graphql b/graphql/mutations/labels.graphql index 460afeed..44f7ee9b 100644 --- a/graphql/mutations/labels.graphql +++ b/graphql/mutations/labels.graphql @@ -1,5 +1,8 @@ # ------------------------------------------------------------ -# GraphQL mutations for Linear issue labels +# GraphQL mutations for Linear issue and project labels +# +# The two label kinds are separate types with parallel mutations; +# `labels --type issue|project` picks between them. # ------------------------------------------------------------ mutation CreateIssueLabel($input: IssueLabelCreateInput!) { @@ -26,3 +29,68 @@ mutation DeleteIssueLabel($id: String!) { entityId } } + +# Retire an issue label +# +# Retired labels stay on the issues that already carry them but cannot be +# applied to new ones — a softer alternative to delete. +mutation RetireIssueLabel($id: String!) { + issueLabelRetire(id: $id) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation RestoreIssueLabel($id: String!) { + issueLabelRestore(id: $id) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation CreateProjectLabel($input: ProjectLabelCreateInput!) { + projectLabelCreate(input: $input) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation UpdateProjectLabel($id: String!, $input: ProjectLabelUpdateInput!) { + projectLabelUpdate(id: $id, input: $input) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation DeleteProjectLabel($id: String!) { + projectLabelDelete(id: $id) { + success + entityId + } +} + +mutation RetireProjectLabel($id: String!) { + projectLabelRetire(id: $id) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation RestoreProjectLabel($id: String!) { + projectLabelRestore(id: $id) { + success + projectLabel { + ...ProjectLabelFields + } + } +} diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 6c66ceca..843b614e 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -20,6 +20,14 @@ fragment LabelFields on IssueLabel { name color description + isGroup + # Retired labels stay on the entities that already carry them but cannot + # be applied to new ones, so a null here is what "usable" means. + retiredAt + parent { + id + name + } } fragment ProjectLabelFields on ProjectLabel { @@ -27,6 +35,12 @@ fragment ProjectLabelFields on ProjectLabel { name color description + isGroup + retiredAt + parent { + id + name + } } query GetIssueLabel($id: String!) { @@ -67,6 +81,12 @@ query GetLabels( } } +query GetProjectLabel($id: String!) { + projectLabel(id: $id) { + ...ProjectLabelFields + } +} + query GetProjectLabels($first: Int = 50, $after: String) { projectLabels(first: $first, after: $after) { nodes { diff --git a/src/commands/labels.ts b/src/commands/labels.ts index a22e4bdf..c8127ee7 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -13,6 +13,7 @@ import { type LabelResolverScope, resolveLabelId, } from "../resolvers/label-resolver.js"; +import { resolveProjectLabelId } from "../resolvers/project-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { type CreateLabelInput, @@ -23,6 +24,8 @@ import { type LabelType, listLabels, listProjectLabels, + restoreLabel, + retireLabel, type UpdateLabelInput, updateLabel, } from "../services/label-service.js"; @@ -36,20 +39,26 @@ interface ListLabelsOptions extends CommandOptions { } interface LabelLookupOptions extends CommandOptions { + type?: string; team?: string; scope?: string; } interface CreateLabelOptions extends CommandOptions { + type?: string; team?: string; color?: string; description?: string; + parent?: string; + group?: boolean; } interface UpdateLabelOptions extends LabelLookupOptions { name?: string; color?: string; description?: string; + parent?: string; + group?: boolean; } function parseLabelType(value?: string): LabelType { @@ -83,14 +92,76 @@ function parseLabelColor(value?: string): string | undefined { return value; } -async function resolveIssueLabelLookup( +/** + * Project labels have no team dimension at all, so silently ignoring + * `--team`/`--scope` would answer a question the caller did not ask. + */ +function rejectTeamScopingForProjectLabels( + team: string | undefined, + scope: LabelScope | undefined, +): void { + if (team) { + throw invalidParameterError( + "--team", + "cannot be used with --type project because project labels are workspace-scoped", + ); + } + + if (scope) { + throw invalidParameterError( + "--scope", + "cannot be used with --type project because project labels are always workspace-scoped", + ); + } +} + +/** + * Resolves `--parent` against the same label kind as the label being written. + * + * A group and its children are always the same kind, so routing the parent + * through the other resolver could only ever produce a not-found or a + * cross-kind parent the API would reject. + */ +async function resolveLabelParentId( + client: ReturnType["gql"], + parent: string, + type: LabelType, +): Promise { + return type === "project" + ? resolveProjectLabelId(client, parent) + : resolveLabelId(client, parent); +} + +/** + * Resolves `