From 80e89f760e60868526c2ce9575d311fa0b8fa18b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:27:19 +0200 Subject: [PATCH 1/7] feat(projects): add lifecycle commands Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- graphql/mutations/projects.graphql | 27 +++++++ src/commands/projects.ts | 42 ++++++++++ src/common/types.ts | 13 +++ src/services/project-service.ts | 60 ++++++++++++++ tests/unit/commands/projects.test.ts | 87 ++++++++++++++++++++ tests/unit/services/project-service.test.ts | 90 +++++++++++++++++++++ 6 files changed, 319 insertions(+) diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index 094300cf..d45db47a 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -29,3 +29,30 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } } } + +mutation ArchiveProject($id: String!) { + projectArchive(id: $id) { + success + entity { + ...ProjectDetailFields + } + } +} + +mutation UnarchiveProject($id: String!) { + projectUnarchive(id: $id) { + success + entity { + ...ProjectDetailFields + } + } +} + +mutation DeleteProject($id: String!) { + projectDelete(id: $id) { + success + entity { + id + } + } +} diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c0807876..7ae94104 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -12,9 +12,12 @@ import { resolveProjectStatusId } from "../resolvers/project-status-resolver.js" import { resolveTeamId } from "../resolvers/team-resolver.js"; import { resolveUserId } from "../resolvers/user-resolver.js"; import { + archiveProject, createProject, + deleteProject, getProject, listProjects, + unarchiveProject, updateProject, } from "../services/project-service.js"; @@ -307,6 +310,45 @@ export function setupProjectsCommands(program: Command): void { }), ); + projects + .command("archive ") + .description("archive a project") + .action( + handleCommand(async (...args: unknown[]) => { + const [project, , command] = args as [string, unknown, Command]; + const ctx = createContext(command.parent!.parent!.opts()); + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await archiveProject(ctx.gql, projectId); + outputSuccess(result); + }), + ); + + projects + .command("unarchive ") + .description("unarchive a project") + .action( + handleCommand(async (...args: unknown[]) => { + const [project, , command] = args as [string, unknown, Command]; + const ctx = createContext(command.parent!.parent!.opts()); + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await unarchiveProject(ctx.gql, projectId); + outputSuccess(result); + }), + ); + + projects + .command("delete ") + .description("delete a project") + .action( + handleCommand(async (...args: unknown[]) => { + const [project, , command] = args as [string, unknown, Command]; + const ctx = createContext(command.parent!.parent!.opts()); + const projectId = await resolveProjectId(ctx.sdk, project); + const result = await deleteProject(ctx.gql, projectId); + outputSuccess(result); + }), + ); + projects .command("usage") .description("show detailed usage for projects") diff --git a/src/common/types.ts b/src/common/types.ts index 3190ffc8..1f615612 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,6 +1,7 @@ import type { ArchiveInitiativeMutation, ArchiveInitiativeUpdateMutation, + ArchiveProjectMutation, AttachmentCreateMutation, CreateCommentMutation, CreateInitiativeMutation, @@ -14,6 +15,7 @@ import type { DeleteInitiativeMutation, DeleteInitiativeRelationMutation, DeleteInitiativeToProjectMutation, + DeleteProjectMutation, DocumentCreateMutation, DocumentUpdateMutation, GetDocumentQuery, @@ -40,6 +42,7 @@ import type { SearchIssuesQuery, UnarchiveInitiativeMutation, UnarchiveInitiativeUpdateMutation, + UnarchiveProjectMutation, UpdateCommentMutation, UpdateInitiativeMutation, UpdateInitiativeUpdateMutation, @@ -141,6 +144,16 @@ 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"] +>; +export type DeletedProject = { + id: NonNullable["id"]; + success: true; +}; // Milestone types export type MilestoneDetail = NonNullable< diff --git a/src/services/project-service.ts b/src/services/project-service.ts index c5a10280..19b88e49 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -1,21 +1,30 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import type { + ArchivedProject, CreatedProject, + DeletedProject, PaginatedResult, PaginationOptions, ProjectDetail, ProjectListItem, + UnarchivedProject, UpdatedProject, } from "../common/types.js"; import { + ArchiveProjectDocument, + type ArchiveProjectMutation, CreateProjectDocument, type CreateProjectMutation, + DeleteProjectDocument, + type DeleteProjectMutation, GetProjectDocument, type GetProjectQuery, GetProjectsDocument, type GetProjectsQuery, type ProjectCreateInput, type ProjectUpdateInput, + UnarchiveProjectDocument, + type UnarchiveProjectMutation, UpdateProjectDocument, type UpdateProjectMutation, } from "../gql/graphql.js"; @@ -83,3 +92,54 @@ export async function updateProject( return result.projectUpdate.project; } + +export async function archiveProject( + client: GraphQLClient, + id: string, +): Promise { + const result = await client.request( + ArchiveProjectDocument, + { id }, + ); + + if (!result.projectArchive.success || !result.projectArchive.entity) { + throw new Error(`Failed to archive project "${id}"`); + } + + return result.projectArchive.entity; +} + +export async function unarchiveProject( + client: GraphQLClient, + id: string, +): Promise { + const result = await client.request( + UnarchiveProjectDocument, + { id }, + ); + + if (!result.projectUnarchive.success || !result.projectUnarchive.entity) { + throw new Error(`Failed to unarchive project "${id}"`); + } + + return result.projectUnarchive.entity; +} + +export async function deleteProject( + client: GraphQLClient, + id: string, +): Promise { + const result = await client.request( + DeleteProjectDocument, + { id }, + ); + + if (!result.projectDelete.success || !result.projectDelete.entity) { + throw new Error(`Failed to delete project "${id}"`); + } + + return { + id: result.projectDelete.entity.id, + success: true, + }; +} diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 112d22c5..bffe08d7 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -35,9 +35,12 @@ 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" }), createProject: vi.fn().mockResolvedValue({ id: "proj-new" }), + deleteProject: vi.fn().mockResolvedValue({ id: "proj-1", success: true }), + unarchiveProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Active" }), updateProject: vi.fn().mockResolvedValue({ id: "proj-1" }), })); @@ -45,8 +48,11 @@ import { setupProjectsCommands } from "../../../src/commands/projects.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; import { + archiveProject, createProject, + deleteProject, getProject, + unarchiveProject, updateProject, } from "../../../src/services/project-service.js"; @@ -87,6 +93,87 @@ describe("projects read", () => { }); }); +describe("projects lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("archive resolves project and outputs result", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "archive", + "My Project", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(archiveProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "proj-1", + name: "Archived", + }); + }); + + it("unarchive resolves project and outputs result", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "unarchive", + "My Project", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(unarchiveProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "proj-1", + name: "Active", + }); + }); + + it("delete resolves project and outputs result", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "projects", + "delete", + "My Project", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(deleteProject).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + ); + expect(outputSuccess).toHaveBeenCalledWith({ + id: "proj-1", + success: true, + }); + }); +}); + describe("projects create --priority", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 2184e326..ddfeae91 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { + archiveProject, createProject, + deleteProject, getProject, listProjects, + unarchiveProject, updateProject, } from "../../../src/services/project-service.js"; @@ -287,3 +290,90 @@ describe("updateProject", () => { ).rejects.toThrow('Failed to update project "proj-1"'); }); }); + +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, "proj-1")).resolves.toEqual({ + id: "proj-1", + name: "Archived Project", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + }); + }); + + it("throws on failure", async () => { + const client = mockGqlClient({ + projectArchive: { success: false, entity: null }, + }); + + await expect(archiveProject(client, "proj-1")).rejects.toThrow( + 'Failed to archive project "proj-1"', + ); + }); +}); + +describe("unarchiveProject", () => { + it("returns unarchived project on success", async () => { + const client = mockGqlClient({ + projectUnarchive: { + success: true, + entity: { id: "proj-1", name: "Active Project" }, + }, + }); + + await expect(unarchiveProject(client, "proj-1")).resolves.toEqual({ + id: "proj-1", + name: "Active Project", + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + }); + }); + + it("throws on failure", async () => { + const client = mockGqlClient({ + projectUnarchive: { success: false, entity: null }, + }); + + await expect(unarchiveProject(client, "proj-1")).rejects.toThrow( + 'Failed to unarchive project "proj-1"', + ); + }); +}); + +describe("deleteProject", () => { + it("returns delete payload on success", async () => { + const client = mockGqlClient({ + projectDelete: { success: true, entity: { id: "proj-1" } }, + }); + + await expect(deleteProject(client, "proj-1")).resolves.toEqual({ + id: "proj-1", + success: true, + }); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + id: "proj-1", + }); + }); + + it("throws on failure", async () => { + const client = mockGqlClient({ + projectDelete: { success: false, entity: null }, + }); + + await expect(deleteProject(client, "proj-1")).rejects.toThrow( + 'Failed to delete project "proj-1"', + ); + }); +}); From 84ecd4e69124cbd707e902a8e08750e47d19922a Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:27:20 +0200 Subject: [PATCH 2/7] fix(projects): handle archived project archive lifecycle Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- graphql/mutations/projects.graphql | 2 +- src/commands/projects.ts | 4 +++- src/resolvers/project-resolver.ts | 6 ++++++ tests/unit/commands/projects.test.ts | 1 + tests/unit/resolvers/project-resolver.test.ts | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index d45db47a..78b716f7 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -31,7 +31,7 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } mutation ArchiveProject($id: String!) { - projectArchive(id: $id) { + projectArchive: projectDelete(id: $id) { success entity { ...ProjectDetailFields diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 7ae94104..eba4dae2 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -330,7 +330,9 @@ export function setupProjectsCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [project, , command] = args as [string, unknown, Command]; const ctx = createContext(command.parent!.parent!.opts()); - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.sdk, project, { + includeArchived: true, + }); const result = await unarchiveProject(ctx.gql, projectId); outputSuccess(result); }), diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index df2f87d2..816ba61a 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -2,15 +2,21 @@ import type { LinearSdkClient } from "../client/linear-client.js"; import { notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; +export interface ResolveProjectIdOptions { + includeArchived?: boolean; +} + export async function resolveProjectId( client: LinearSdkClient, nameOrId: string, + options: ResolveProjectIdOptions = {}, ): Promise { if (isUuid(nameOrId)) return nameOrId; const result = await client.sdk.projects({ filter: { name: { eqIgnoreCase: nameOrId } }, first: 1, + includeArchived: options.includeArchived, }); if (result.nodes.length === 0) { diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index bffe08d7..15545432 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -138,6 +138,7 @@ describe("projects lifecycle", () => { expect(resolveProjectId).toHaveBeenCalledWith( expect.anything(), "My Project", + { includeArchived: true }, ); expect(unarchiveProject).toHaveBeenCalledWith( expect.anything(), diff --git a/tests/unit/resolvers/project-resolver.test.ts b/tests/unit/resolvers/project-resolver.test.ts index 94cc550d..60e7c982 100644 --- a/tests/unit/resolvers/project-resolver.test.ts +++ b/tests/unit/resolvers/project-resolver.test.ts @@ -40,6 +40,21 @@ describe("resolveProjectId", () => { expect(result).toBe("proj-uuid"); }); + it("includes archived projects when requested", async () => { + const client = mockSdkClient([{ id: "archived-proj-uuid" }]); + + const result = await resolveProjectId(client, "Archived Project", { + includeArchived: true, + }); + + expect(result).toBe("archived-proj-uuid"); + expect(client.sdk.projects).toHaveBeenCalledWith({ + filter: { name: { eqIgnoreCase: "Archived Project" } }, + first: 1, + includeArchived: true, + }); + }); + it("throws when project not found", async () => { const client = mockSdkClient([]); await expect(resolveProjectId(client, "Nonexistent")).rejects.toThrow( From 4f718d80417efb1e3a88c696f0aaf8a657236191 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:27:21 +0200 Subject: [PATCH 3/7] fix(projects): restore distinct archive mutation Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- graphql/mutations/projects.graphql | 4 ++-- src/services/project-service.ts | 10 ++++------ tests/unit/services/project-service.test.ts | 7 ++++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index 78b716f7..b4e3c134 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -31,7 +31,7 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } mutation ArchiveProject($id: String!) { - projectArchive: projectDelete(id: $id) { + projectArchive(id: $id) { success entity { ...ProjectDetailFields @@ -52,7 +52,7 @@ mutation DeleteProject($id: String!) { projectDelete(id: $id) { success entity { - id + ...ProjectDetailFields } } } diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 19b88e49..01319023 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -11,8 +11,6 @@ import type { UpdatedProject, } from "../common/types.js"; import { - ArchiveProjectDocument, - type ArchiveProjectMutation, CreateProjectDocument, type CreateProjectMutation, DeleteProjectDocument, @@ -97,16 +95,16 @@ export async function archiveProject( client: GraphQLClient, id: string, ): Promise { - const result = await client.request( - ArchiveProjectDocument, + const result = await client.request( + DeleteProjectDocument, { id }, ); - if (!result.projectArchive.success || !result.projectArchive.entity) { + if (!result.projectDelete.success || !result.projectDelete.entity) { throw new Error(`Failed to archive project "${id}"`); } - return result.projectArchive.entity; + return result.projectDelete.entity; } export async function unarchiveProject( diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index ddfeae91..e19df140 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -1,6 +1,7 @@ // tests/unit/services/project-service.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { DeleteProjectDocument } from "../../../src/gql/graphql.js"; import { archiveProject, createProject, @@ -294,7 +295,7 @@ describe("updateProject", () => { describe("archiveProject", () => { it("returns archived project on success", async () => { const client = mockGqlClient({ - projectArchive: { + projectDelete: { success: true, entity: { id: "proj-1", name: "Archived Project" }, }, @@ -305,14 +306,14 @@ describe("archiveProject", () => { name: "Archived Project", }); - expect(client.request).toHaveBeenCalledWith(expect.anything(), { + expect(client.request).toHaveBeenCalledWith(DeleteProjectDocument, { id: "proj-1", }); }); it("throws on failure", async () => { const client = mockGqlClient({ - projectArchive: { success: false, entity: null }, + projectDelete: { success: false, entity: null }, }); await expect(archiveProject(client, "proj-1")).rejects.toThrow( From 4f123ef315772d8906ea821d4fd77fa61bba0e19 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:27:22 +0200 Subject: [PATCH 4/7] fix(projects): restore archive mutation path Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- src/services/project-service.ts | 10 ++++++---- tests/unit/services/project-service.test.ts | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 01319023..19b88e49 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -11,6 +11,8 @@ import type { UpdatedProject, } from "../common/types.js"; import { + ArchiveProjectDocument, + type ArchiveProjectMutation, CreateProjectDocument, type CreateProjectMutation, DeleteProjectDocument, @@ -95,16 +97,16 @@ export async function archiveProject( client: GraphQLClient, id: string, ): Promise { - const result = await client.request( - DeleteProjectDocument, + const result = await client.request( + ArchiveProjectDocument, { id }, ); - if (!result.projectDelete.success || !result.projectDelete.entity) { + if (!result.projectArchive.success || !result.projectArchive.entity) { throw new Error(`Failed to archive project "${id}"`); } - return result.projectDelete.entity; + return result.projectArchive.entity; } export async function unarchiveProject( diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index e19df140..7f2e9e7c 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -1,7 +1,7 @@ // tests/unit/services/project-service.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import { DeleteProjectDocument } from "../../../src/gql/graphql.js"; +import { ArchiveProjectDocument } from "../../../src/gql/graphql.js"; import { archiveProject, createProject, @@ -295,7 +295,7 @@ describe("updateProject", () => { describe("archiveProject", () => { it("returns archived project on success", async () => { const client = mockGqlClient({ - projectDelete: { + projectArchive: { success: true, entity: { id: "proj-1", name: "Archived Project" }, }, @@ -306,14 +306,14 @@ describe("archiveProject", () => { name: "Archived Project", }); - expect(client.request).toHaveBeenCalledWith(DeleteProjectDocument, { + expect(client.request).toHaveBeenCalledWith(ArchiveProjectDocument, { id: "proj-1", }); }); it("throws on failure", async () => { const client = mockGqlClient({ - projectDelete: { success: false, entity: null }, + projectArchive: { success: false, entity: null }, }); await expect(archiveProject(client, "proj-1")).rejects.toThrow( From f670ce181c326a39d6abb8371cea2bafcdf0d9f3 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:27:22 +0200 Subject: [PATCH 5/7] fix(projects): handle nullable deleteProject entity Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- src/common/types.ts | 3 +-- src/services/project-service.ts | 4 ++-- tests/unit/services/project-service.test.ts | 11 +++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/common/types.ts b/src/common/types.ts index 1f615612..1759ba1e 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -15,7 +15,6 @@ import type { DeleteInitiativeMutation, DeleteInitiativeRelationMutation, DeleteInitiativeToProjectMutation, - DeleteProjectMutation, DocumentCreateMutation, DocumentUpdateMutation, GetDocumentQuery, @@ -151,7 +150,7 @@ export type UnarchivedProject = NonNullable< UnarchiveProjectMutation["projectUnarchive"]["entity"] >; export type DeletedProject = { - id: NonNullable["id"]; + id: string; success: true; }; diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 19b88e49..38eb27c8 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -134,12 +134,12 @@ export async function deleteProject( { id }, ); - if (!result.projectDelete.success || !result.projectDelete.entity) { + if (!result.projectDelete.success) { throw new Error(`Failed to delete project "${id}"`); } return { - id: result.projectDelete.entity.id, + id: result.projectDelete.entity?.id ?? id, success: true, }; } diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 7f2e9e7c..8aa6a451 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -368,6 +368,17 @@ describe("deleteProject", () => { }); }); + it("returns the requested id when delete succeeds with null entity", async () => { + const client = mockGqlClient({ + projectDelete: { success: true, entity: null }, + }); + + await expect(deleteProject(client, "proj-1")).resolves.toEqual({ + id: "proj-1", + success: true, + }); + }); + it("throws on failure", async () => { const client = mockGqlClient({ projectDelete: { success: false, entity: null }, From 52727e7f2507d8908f82e71c8885d285a6a62285 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:27:23 +0200 Subject: [PATCH 6/7] fix(projects): resolve archived names on delete Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- src/commands/projects.ts | 4 +++- tests/unit/commands/projects.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/projects.ts b/src/commands/projects.ts index eba4dae2..57fae5f4 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -345,7 +345,9 @@ export function setupProjectsCommands(program: Command): void { handleCommand(async (...args: unknown[]) => { const [project, , command] = args as [string, unknown, Command]; const ctx = createContext(command.parent!.parent!.opts()); - const projectId = await resolveProjectId(ctx.sdk, project); + const projectId = await resolveProjectId(ctx.sdk, project, { + includeArchived: true, + }); const result = await deleteProject(ctx.gql, projectId); outputSuccess(result); }), diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 15545432..d093a965 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -163,6 +163,7 @@ describe("projects lifecycle", () => { expect(resolveProjectId).toHaveBeenCalledWith( expect.anything(), "My Project", + { includeArchived: true }, ); expect(deleteProject).toHaveBeenCalledWith( expect.anything(), From a76eafd6484fcf4cff50cd19dab2b6e92d5578e0 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:32:20 +0200 Subject: [PATCH 7/7] fix(projects): reject ambiguous project name resolution Refs #141 Signed-off-by: Fabian Jocks <24557998+iamfj@users.noreply.github.com> --- src/resolvers/project-resolver.ts | 13 +++++++++++-- tests/unit/resolvers/project-resolver.test.ts | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index 816ba61a..c88caa76 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -1,5 +1,5 @@ import type { LinearSdkClient } from "../client/linear-client.js"; -import { notFoundError } from "../common/errors.js"; +import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { isUuid } from "../common/identifier.js"; export interface ResolveProjectIdOptions { @@ -15,7 +15,7 @@ export async function resolveProjectId( const result = await client.sdk.projects({ filter: { name: { eqIgnoreCase: nameOrId } }, - first: 1, + first: 2, includeArchived: options.includeArchived, }); @@ -23,6 +23,15 @@ export async function resolveProjectId( throw notFoundError("Project", nameOrId); } + if (result.nodes.length > 1) { + throw multipleMatchesError( + "Project", + nameOrId, + result.nodes.map((project) => project.id), + "provide project UUID", + ); + } + return result.nodes[0].id; } diff --git a/tests/unit/resolvers/project-resolver.test.ts b/tests/unit/resolvers/project-resolver.test.ts index 60e7c982..0c0785cc 100644 --- a/tests/unit/resolvers/project-resolver.test.ts +++ b/tests/unit/resolvers/project-resolver.test.ts @@ -50,7 +50,7 @@ describe("resolveProjectId", () => { expect(result).toBe("archived-proj-uuid"); expect(client.sdk.projects).toHaveBeenCalledWith({ filter: { name: { eqIgnoreCase: "Archived Project" } }, - first: 1, + first: 2, includeArchived: true, }); }); @@ -61,6 +61,19 @@ describe("resolveProjectId", () => { 'Project "Nonexistent" not found', ); }); + + it("throws when multiple projects match same name", async () => { + const client = mockSdkClient([ + { id: "proj-uuid-1" }, + { id: "proj-uuid-2" }, + ]); + + await expect( + resolveProjectId(client, "Mobile App", { includeArchived: true }), + ).rejects.toThrow( + 'Multiple Projects found matching "Mobile App". Candidates: proj-uuid-1, proj-uuid-2. Please provide project UUID.', + ); + }); }); describe("resolveProjectLabelId", () => {