diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index 094300cf..b4e3c134 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 { + ...ProjectDetailFields + } + } +} diff --git a/src/commands/projects.ts b/src/commands/projects.ts index c0807876..57fae5f4 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,49 @@ 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, { + includeArchived: true, + }); + 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, { + includeArchived: true, + }); + 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..1759ba1e 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,6 +1,7 @@ import type { ArchiveInitiativeMutation, ArchiveInitiativeUpdateMutation, + ArchiveProjectMutation, AttachmentCreateMutation, CreateCommentMutation, CreateInitiativeMutation, @@ -40,6 +41,7 @@ import type { SearchIssuesQuery, UnarchiveInitiativeMutation, UnarchiveInitiativeUpdateMutation, + UnarchiveProjectMutation, UpdateCommentMutation, UpdateInitiativeMutation, UpdateInitiativeUpdateMutation, @@ -141,6 +143,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: string; + success: true; +}; // Milestone types export type MilestoneDetail = NonNullable< diff --git a/src/resolvers/project-resolver.ts b/src/resolvers/project-resolver.ts index df2f87d2..c88caa76 100644 --- a/src/resolvers/project-resolver.ts +++ b/src/resolvers/project-resolver.ts @@ -1,22 +1,37 @@ 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 { + 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, + first: 2, + includeArchived: options.includeArchived, }); if (result.nodes.length === 0) { 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/src/services/project-service.ts b/src/services/project-service.ts index c5a10280..38eb27c8 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) { + throw new Error(`Failed to delete project "${id}"`); + } + + return { + id: result.projectDelete.entity?.id ?? id, + success: true, + }; +} diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 112d22c5..d093a965 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,89 @@ 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", + { includeArchived: true }, + ); + 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", + { includeArchived: true }, + ); + 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/resolvers/project-resolver.test.ts b/tests/unit/resolvers/project-resolver.test.ts index 94cc550d..0c0785cc 100644 --- a/tests/unit/resolvers/project-resolver.test.ts +++ b/tests/unit/resolvers/project-resolver.test.ts @@ -40,12 +40,40 @@ 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: 2, + includeArchived: true, + }); + }); + it("throws when project not found", async () => { const client = mockSdkClient([]); await expect(resolveProjectId(client, "Nonexistent")).rejects.toThrow( '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", () => { diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 2184e326..8aa6a451 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -1,10 +1,14 @@ // tests/unit/services/project-service.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { ArchiveProjectDocument } from "../../../src/gql/graphql.js"; import { + archiveProject, createProject, + deleteProject, getProject, listProjects, + unarchiveProject, updateProject, } from "../../../src/services/project-service.js"; @@ -287,3 +291,101 @@ 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(ArchiveProjectDocument, { + 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("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 }, + }); + + await expect(deleteProject(client, "proj-1")).rejects.toThrow( + 'Failed to delete project "proj-1"', + ); + }); +});