Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions graphql/mutations/projects.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
46 changes: 46 additions & 0 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -307,6 +310,49 @@ export function setupProjectsCommands(program: Command): void {
}),
);

projects
.command("archive <project>")
.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 <project>")
.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 <project>")
.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")
Expand Down
12 changes: 12 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ArchiveInitiativeMutation,
ArchiveInitiativeUpdateMutation,
ArchiveProjectMutation,
AttachmentCreateMutation,
CreateCommentMutation,
CreateInitiativeMutation,
Expand Down Expand Up @@ -40,6 +41,7 @@ import type {
SearchIssuesQuery,
UnarchiveInitiativeMutation,
UnarchiveInitiativeUpdateMutation,
UnarchiveProjectMutation,
UpdateCommentMutation,
UpdateInitiativeMutation,
UpdateInitiativeUpdateMutation,
Expand Down Expand Up @@ -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<
Expand Down
19 changes: 17 additions & 2 deletions src/resolvers/project-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}

Expand Down
60 changes: 60 additions & 0 deletions src/services/project-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -83,3 +92,54 @@ export async function updateProject(

return result.projectUpdate.project;
}

export async function archiveProject(
client: GraphQLClient,
id: string,
): Promise<ArchivedProject> {
const result = await client.request<ArchiveProjectMutation>(
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<UnarchivedProject> {
const result = await client.request<UnarchiveProjectMutation>(
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<DeletedProject> {
const result = await client.request<DeleteProjectMutation>(
DeleteProjectDocument,
{ id },
);

if (!result.projectDelete.success) {
throw new Error(`Failed to delete project "${id}"`);
}

return {
id: result.projectDelete.entity?.id ?? id,
success: true,
};
}
89 changes: 89 additions & 0 deletions tests/unit/commands/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,24 @@ 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" }),
}));

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";

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading