diff --git a/src/commands/project/project-create.ts b/src/commands/project/project-create.ts index f181d394..82262695 100644 --- a/src/commands/project/project-create.ts +++ b/src/commands/project/project-create.ts @@ -140,6 +140,7 @@ export const createCommand = new Command() "-i, --interactive", "Interactive mode (default if no flags provided)", ) + .option("-j, --json", "Output created project as JSON") .action( async (options) => { const { @@ -152,6 +153,7 @@ export const createCommand = new Command() targetDate: providedTargetDate, initiative: providedInitiative, interactive: interactiveFlag, + json: jsonOutput, } = options const client = getGraphQLClient() @@ -374,13 +376,7 @@ export const createCommand = new Command() throw new CliError("Failed to create project: no project returned") } - console.log(`✓ Created project: ${project.name}`) - console.log(` Slug: ${project.slugId}`) - if (project.url) { - console.log(` URL: ${project.url}`) - } - - // Add to initiative if specified + // Add to initiative if specified (before JSON output so warnings go to stderr) if (initiative) { const initiativeId = await resolveInitiativeId(client, initiative) if (!initiativeId) { @@ -395,9 +391,11 @@ export const createCommand = new Command() }, }) - if (linkResult.initiativeToProjectCreate.success) { + if (linkResult.initiativeToProjectCreate.success && !jsonOutput) { console.log(`✓ Added to initiative: ${initiative}`) - } else { + } else if ( + !linkResult.initiativeToProjectCreate.success + ) { console.error(`\nWarning: Failed to add project to initiative`) } } catch (error) { @@ -408,6 +406,27 @@ export const createCommand = new Command() } } } + + if (jsonOutput) { + console.log( + JSON.stringify( + { + id: project.id, + slugId: project.slugId, + name: project.name, + url: project.url, + }, + null, + 2, + ), + ) + } else { + console.log(`✓ Created project: ${project.name}`) + console.log(` Slug: ${project.slugId}`) + if (project.url) { + console.log(` URL: ${project.url}`) + } + } } catch (error) { spinner?.stop() handleError(error, "Failed to create project") diff --git a/src/commands/project/project-delete.ts b/src/commands/project/project-delete.ts new file mode 100644 index 00000000..a49fade1 --- /dev/null +++ b/src/commands/project/project-delete.ts @@ -0,0 +1,69 @@ +import { Command } from "@cliffy/command" +import { Confirm } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { resolveProjectId } from "../../utils/linear.ts" +import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { CliError, handleError, ValidationError } from "../../utils/errors.ts" + +const DeleteProject = gql(` + mutation DeleteProject($id: String!) { + projectDelete(id: $id) { + success + entity { + id + name + } + } + } +`) + +export const deleteCommand = new Command() + .name("delete") + .description("Delete (trash) a Linear project") + .arguments("") + .option("-f, --force", "Skip confirmation prompt") + .action(async ({ force }, projectId) => { + if (!force) { + if (!Deno.stdin.isTerminal()) { + throw new ValidationError("Interactive confirmation required", { + suggestion: "Use --force to skip confirmation.", + }) + } + const confirmed = await Confirm.prompt({ + message: `Are you sure you want to delete project ${projectId}?`, + default: false, + }) + + if (!confirmed) { + console.log("Deletion canceled") + return + } + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = shouldShowSpinner() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const client = getGraphQLClient() + const resolvedId = await resolveProjectId(projectId) + + const result = await client.request(DeleteProject, { + id: resolvedId, + }) + spinner?.stop() + + if (!result.projectDelete.success) { + throw new CliError("Failed to delete project") + } + + const entity = result.projectDelete.entity + const displayName = entity?.name ?? projectId + console.log(`✓ Deleted project: ${displayName}`) + } catch (error) { + spinner?.stop() + handleError(error, "Failed to delete project") + } + }) diff --git a/src/commands/project/project-list.ts b/src/commands/project/project-list.ts index fef26f94..fc51dc3e 100644 --- a/src/commands/project/project-list.ts +++ b/src/commands/project/project-list.ts @@ -66,7 +66,8 @@ export const listCommand = new Command() .option("--status ", "Filter by status name") .option("-w, --web", "Open in web browser") .option("-a, --app", "Open in Linear.app") - .action(async ({ team, allTeams, status, web, app }) => { + .option("-j, --json", "Output as JSON") + .action(async ({ team, allTeams, status, web, app, json }) => { if (web || app) { let workspace = getOption("workspace") if (!workspace) { @@ -96,7 +97,7 @@ export const listCommand = new Command() return } const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = shouldShowSpinner() && !json const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -149,7 +150,11 @@ export const listCommand = new Command() let projects: Project[] = allProjects if (projects.length === 0) { - console.log("No projects found.") + if (json) { + console.log("[]") + } else { + console.log("No projects found.") + } return } @@ -178,6 +183,38 @@ export const listCommand = new Command() return a.name.localeCompare(b.name) }) + // JSON output + if (json) { + const jsonOutput = projects.map((project) => ({ + id: project.id, + slugId: project.slugId, + name: project.name, + description: project.description, + status: { + id: project.status.id, + name: project.status.name, + type: project.status.type, + }, + lead: project.lead + ? { + name: project.lead.name, + displayName: project.lead.displayName, + initials: project.lead.initials, + } + : null, + teams: project.teams.nodes.map((t) => t.key), + priority: project.priority, + health: project.health, + startDate: project.startDate, + targetDate: project.targetDate, + url: project.url, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })) + console.log(JSON.stringify(jsonOutput, null, 2)) + return + } + // Helper function to get the most relevant date to display const getDisplayDate = ( project: GetProjectsQuery["projects"]["nodes"][0], diff --git a/src/commands/project/project-update.ts b/src/commands/project/project-update.ts new file mode 100644 index 00000000..50a5088a --- /dev/null +++ b/src/commands/project/project-update.ts @@ -0,0 +1,189 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + getTeamIdByKey, + lookupUserId, + resolveProjectId, +} from "../../utils/linear.ts" +import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { + CliError, + handleError, + NotFoundError, + ValidationError, +} from "../../utils/errors.ts" + +const UpdateProject = gql(` + mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { + projectUpdate(id: $id, input: $input) { + success + project { + id + slugId + name + description + url + updatedAt + } + } + } +`) + +const GetProjectStatuses = gql(` + query GetProjectStatuses { + projectStatuses { + nodes { + id + name + type + } + } + } +`) + +const STATUS_TYPE_MAPPING: Record = { + "planned": "planned", + "in progress": "started", + "started": "started", + "paused": "paused", + "completed": "completed", + "canceled": "canceled", + "backlog": "backlog", +} + +export const updateCommand = new Command() + .name("update") + .description("Update a Linear project") + .arguments("") + .option("-n, --name ", "Project name") + .option("-d, --description ", "Project description") + .option( + "-s, --status ", + "Status (planned, started, paused, completed, canceled, backlog)", + ) + .option("-l, --lead ", "Project lead (username, email, or @me)") + .option("--start-date ", "Start date (YYYY-MM-DD)") + .option("--target-date ", "Target date (YYYY-MM-DD)") + .option( + "-t, --team ", + "Team key (can be repeated for multiple teams)", + { collect: true }, + ) + .action( + async ( + { + name, + description, + status, + lead, + startDate, + targetDate, + team: teams, + }, + projectId, + ) => { + if ( + !name && description == null && !status && !lead && + !startDate && !targetDate && (!teams || teams.length === 0) + ) { + throw new ValidationError( + "At least one update option must be provided", + { + suggestion: + "Use --name, --description, --status, --lead, --start-date, --target-date, or --team", + }, + ) + } + + if (startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) { + throw new ValidationError("Start date must be in YYYY-MM-DD format") + } + + if (targetDate && !/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { + throw new ValidationError("Target date must be in YYYY-MM-DD format") + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = shouldShowSpinner() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const client = getGraphQLClient() + const resolvedId = await resolveProjectId(projectId) + + const input: Record = {} + + if (name) input.name = name + if (description != null) input.description = description + if (startDate) input.startDate = startDate + if (targetDate) input.targetDate = targetDate + + if (status) { + const statusLower = status.toLowerCase() + const apiStatusType = STATUS_TYPE_MAPPING[statusLower] + if (!apiStatusType) { + spinner?.stop() + throw new ValidationError(`Invalid status: ${status}`, { + suggestion: + "Valid values: planned, started, paused, completed, canceled, backlog", + }) + } + const statusResult = await client.request(GetProjectStatuses) + const projectStatuses = statusResult.projectStatuses?.nodes || [] + const matchingStatus = projectStatuses.find( + (s: { type: string }) => s.type === apiStatusType, + ) + if (!matchingStatus) { + spinner?.stop() + throw new NotFoundError("Project status", apiStatusType) + } + input.statusId = matchingStatus.id + } + + if (lead) { + const leadId = await lookupUserId(lead) + if (!leadId) { + spinner?.stop() + throw new NotFoundError("Lead", lead) + } + input.leadId = leadId + } + + if (teams && teams.length > 0) { + const teamIds: string[] = [] + for (const teamKey of teams) { + const teamId = await getTeamIdByKey(teamKey.toUpperCase()) + if (!teamId) { + spinner?.stop() + throw new NotFoundError("Team", teamKey) + } + teamIds.push(teamId) + } + input.teamIds = teamIds + } + + const result = await client.request(UpdateProject, { + id: resolvedId, + input, + }) + spinner?.stop() + + if (!result.projectUpdate.success) { + throw new CliError("Failed to update project") + } + + const project = result.projectUpdate.project + if (project) { + console.log(`✓ Updated project: ${project.name}`) + if (project.url) { + console.log(project.url) + } + } + } catch (error) { + spinner?.stop() + handleError(error, "Failed to update project") + } + }, + ) diff --git a/src/commands/project/project.ts b/src/commands/project/project.ts index deabab47..670b8e11 100644 --- a/src/commands/project/project.ts +++ b/src/commands/project/project.ts @@ -2,6 +2,8 @@ import { Command } from "@cliffy/command" import { listCommand } from "./project-list.ts" import { viewCommand } from "./project-view.ts" import { createCommand } from "./project-create.ts" +import { updateCommand } from "./project-update.ts" +import { deleteCommand } from "./project-delete.ts" export const projectCommand = new Command() .description("Manage Linear projects") @@ -11,3 +13,5 @@ export const projectCommand = new Command() .command("list", listCommand) .command("view", viewCommand) .command("create", createCommand) + .command("update", updateCommand) + .command("delete", deleteCommand) diff --git a/test/commands/project/__snapshots__/project-create.test.ts.snap b/test/commands/project/__snapshots__/project-create.test.ts.snap new file mode 100644 index 00000000..b84f5ac6 --- /dev/null +++ b/test/commands/project/__snapshots__/project-create.test.ts.snap @@ -0,0 +1,42 @@ +export const snapshot = {}; + +snapshot[`Project Create Command - Help Text 1`] = ` +stdout: +" +\\x1b[1mUsage:\\x1b[22m \\x1b[95mcreate\\x1b[39m + +\\x1b[1mDescription:\\x1b[22m + + Create a new Linear project + +\\x1b[1mOptions:\\x1b[22m + + \\x1b[94m-h\\x1b[39m, \\x1b[94m--help\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Show this help. + \\x1b[94m-n\\x1b[39m, \\x1b[94m--name\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mname\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project name (required) + \\x1b[94m-d\\x1b[39m, \\x1b[94m--description\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mdescription\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project description + \\x1b[94m-t\\x1b[39m, \\x1b[94m--team\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mteam\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Team key (required, can be repeated for multiple teams) + \\x1b[94m-l\\x1b[39m, \\x1b[94m--lead\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mlead\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project lead (username, email, or @me) + \\x1b[94m-s\\x1b[39m, \\x1b[94m--status\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mstatus\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project status (planned, started, paused, completed, canceled, backlog) + \\x1b[94m--start-date\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mstartDate\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Start date (YYYY-MM-DD) + \\x1b[94m--target-date\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mtargetDate\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Target completion date (YYYY-MM-DD) + \\x1b[94m--initiative\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95minitiative\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Add to initiative immediately (ID, slug, or name) + \\x1b[94m-i\\x1b[39m, \\x1b[94m--interactive\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Interactive mode (default if no flags provided) + \\x1b[94m-j\\x1b[39m, \\x1b[94m--json\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Output created project as JSON + +" +stderr: +"" +`; + +snapshot[`Project Create Command - With JSON Output 1`] = ` +stdout: +'{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "slugId": "json-test-project", + "name": "JSON Test Project", + "url": "https://linear.app/test/project/json-test-project" +} +' +stderr: +"" +`; diff --git a/test/commands/project/__snapshots__/project-delete.test.ts.snap b/test/commands/project/__snapshots__/project-delete.test.ts.snap new file mode 100644 index 00000000..55bcde79 --- /dev/null +++ b/test/commands/project/__snapshots__/project-delete.test.ts.snap @@ -0,0 +1,28 @@ +export const snapshot = {}; + +snapshot[`Project Delete Command - Help Text 1`] = ` +stdout: +" +\\x1b[1mUsage:\\x1b[22m \\x1b[95mdelete \\x1b[33m<\\x1b[95m\\x1b[95mprojectId\\x1b[95m\\x1b[33m>\\x1b[95m\\x1b[39m + +\\x1b[1mDescription:\\x1b[22m + + Delete (trash) a Linear project + +\\x1b[1mOptions:\\x1b[22m + + \\x1b[94m-h\\x1b[39m, \\x1b[94m--help\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Show this help. + \\x1b[94m-f\\x1b[39m, \\x1b[94m--force\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Skip confirmation prompt + +" +stderr: +"" +`; + +snapshot[`Project Delete Command - With Force Flag 1`] = ` +stdout: +"✓ Deleted project: Deleted Project +" +stderr: +"" +`; diff --git a/test/commands/project/__snapshots__/project-list.test.ts.snap b/test/commands/project/__snapshots__/project-list.test.ts.snap index 7fa5b8b4..5d7bc305 100644 --- a/test/commands/project/__snapshots__/project-list.test.ts.snap +++ b/test/commands/project/__snapshots__/project-list.test.ts.snap @@ -3,20 +3,21 @@ export const snapshot = {}; snapshot[`Project List Command - Help Text 1`] = ` stdout: " -Usage: list +\\x1b[1mUsage:\\x1b[22m \\x1b[95mlist\\x1b[39m -Description: +\\x1b[1mDescription:\\x1b[22m List projects -Options: +\\x1b[1mOptions:\\x1b[22m - -h, --help - Show this help. - --team - Filter by team key - --all-teams - Show projects from all teams - --status - Filter by status name - -w, --web - Open in web browser - -a, --app - Open in Linear.app + \\x1b[94m-h\\x1b[39m, \\x1b[94m--help\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Show this help. + \\x1b[94m--team\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mteam\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Filter by team key + \\x1b[94m--all-teams\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Show projects from all teams + \\x1b[94m--status\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mstatus\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Filter by status name + \\x1b[94m-w\\x1b[39m, \\x1b[94m--web\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Open in web browser + \\x1b[94m-a\\x1b[39m, \\x1b[94m--app\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Open in Linear.app + \\x1b[94m-j\\x1b[39m, \\x1b[94m--json\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Output as JSON " stderr: @@ -30,3 +31,46 @@ stdout: stderr: "" `; + +snapshot[`Project List Command - No Projects Found JSON 1`] = ` +stdout: +"[] +" +stderr: +"" +`; + +snapshot[`Project List Command - With JSON Output 1`] = ` +stdout: +'[ + { + "id": "project-json-1", + "slugId": "json-proj", + "name": "JSON Test Project", + "description": "A project for JSON output", + "status": { + "id": "status-1", + "name": "In Progress", + "type": "started" + }, + "lead": { + "name": "test.user", + "displayName": "Test User", + "initials": "TU" + }, + "teams": [ + "ENG" + ], + "priority": 2, + "health": "onTrack", + "startDate": "2024-01-15", + "targetDate": "2024-03-30", + "url": "https://linear.app/test/project/json-proj", + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-20T15:30:00Z" + } +] +' +stderr: +"" +`; diff --git a/test/commands/project/__snapshots__/project-update.test.ts.snap b/test/commands/project/__snapshots__/project-update.test.ts.snap new file mode 100644 index 00000000..d2ec7840 --- /dev/null +++ b/test/commands/project/__snapshots__/project-update.test.ts.snap @@ -0,0 +1,53 @@ +export const snapshot = {}; + +snapshot[`Project Update Command - Help Text 1`] = ` +stdout: +" +\\x1b[1mUsage:\\x1b[22m \\x1b[95mupdate \\x1b[33m<\\x1b[95m\\x1b[95mprojectId\\x1b[95m\\x1b[33m>\\x1b[95m\\x1b[39m + +\\x1b[1mDescription:\\x1b[22m + + Update a Linear project + +\\x1b[1mOptions:\\x1b[22m + + \\x1b[94m-h\\x1b[39m, \\x1b[94m--help\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Show this help. + \\x1b[94m-n\\x1b[39m, \\x1b[94m--name\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mname\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project name + \\x1b[94m-d\\x1b[39m, \\x1b[94m--description\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mdescription\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project description + \\x1b[94m-s\\x1b[39m, \\x1b[94m--status\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mstatus\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Status (planned, started, paused, completed, canceled, backlog) + \\x1b[94m-l\\x1b[39m, \\x1b[94m--lead\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mlead\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Project lead (username, email, or @me) + \\x1b[94m--start-date\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mstartDate\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Start date (YYYY-MM-DD) + \\x1b[94m--target-date\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mtargetDate\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Target date (YYYY-MM-DD) + \\x1b[94m-t\\x1b[39m, \\x1b[94m--team\\x1b[39m \\x1b[33m<\\x1b[39m\\x1b[95mteam\\x1b[39m\\x1b[33m>\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Team key (can be repeated for multiple teams) + +" +stderr: +"" +`; + +snapshot[`Project Update Command - Update Name 1`] = ` +stdout: +"✓ Updated project: Updated Project Name +https://linear.app/test/project/updated-proj +" +stderr: +"" +`; + +snapshot[`Project Update Command - Update Description 1`] = ` +stdout: +"✓ Updated project: Test Project +https://linear.app/test/project/proj-desc +" +stderr: +"" +`; + +snapshot[`Project Update Command - Update Status 1`] = ` +stdout: +"✓ Updated project: Test Project +https://linear.app/test/project/proj-status +" +stderr: +"" +`; diff --git a/test/commands/project/__snapshots__/project-view.test.ts.snap b/test/commands/project/__snapshots__/project-view.test.ts.snap index d04956fe..33a2af11 100644 --- a/test/commands/project/__snapshots__/project-view.test.ts.snap +++ b/test/commands/project/__snapshots__/project-view.test.ts.snap @@ -3,17 +3,17 @@ export const snapshot = {}; snapshot[`Project View Command - Help Text 1`] = ` stdout: " -Usage: view +\\x1b[1mUsage:\\x1b[22m \\x1b[95mview \\x1b[33m<\\x1b[95m\\x1b[95mprojectId\\x1b[95m\\x1b[33m>\\x1b[95m\\x1b[39m -Description: +\\x1b[1mDescription:\\x1b[22m View project details -Options: +\\x1b[1mOptions:\\x1b[22m - -h, --help - Show this help. - -w, --web - Open in web browser - -a, --app - Open in Linear.app + \\x1b[94m-h\\x1b[39m, \\x1b[94m--help\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Show this help. + \\x1b[94m-w\\x1b[39m, \\x1b[94m--web\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Open in web browser + \\x1b[94m-a\\x1b[39m, \\x1b[94m--app\\x1b[39m \\x1b[31m\\x1b[1m-\\x1b[22m\\x1b[39m Open in Linear.app " stderr: diff --git a/test/commands/project/project-create.test.ts b/test/commands/project/project-create.test.ts new file mode 100644 index 00000000..73b1d895 --- /dev/null +++ b/test/commands/project/project-create.test.ts @@ -0,0 +1,74 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { createCommand } from "../../../src/commands/project/project-create.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +// Test help output +await cliffySnapshotTest({ + name: "Project Create Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await createCommand.parse() + }, +}) + +// Test project create with --json output +await cliffySnapshotTest({ + name: "Project Create Command - With JSON Output", + meta: import.meta, + colors: false, + args: [ + "--name", + "JSON Test Project", + "--team", + "ENG", + "--json", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-123" }], + }, + }, + }, + }, + { + queryName: "CreateProject", + response: { + data: { + projectCreate: { + success: true, + project: { + id: "550e8400-e29b-41d4-a716-446655440000", + slugId: "json-test-project", + name: "JSON Test Project", + url: "https://linear.app/test/project/json-test-project", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await createCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/project/project-delete.test.ts b/test/commands/project/project-delete.test.ts new file mode 100644 index 00000000..0a2f8043 --- /dev/null +++ b/test/commands/project/project-delete.test.ts @@ -0,0 +1,58 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { deleteCommand } from "../../../src/commands/project/project-delete.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +// Test help output +await cliffySnapshotTest({ + name: "Project Delete Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await deleteCommand.parse() + }, +}) + +// Test successful project deletion with --force flag +await cliffySnapshotTest({ + name: "Project Delete Command - With Force Flag", + meta: import.meta, + colors: false, + args: [ + "550e8400-e29b-41d4-a716-446655440000", + "--force", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "DeleteProject", + response: { + data: { + projectDelete: { + success: true, + entity: { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Deleted Project", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await deleteCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/project/project-list.test.ts b/test/commands/project/project-list.test.ts index f21db581..41fabb65 100644 --- a/test/commands/project/project-list.test.ts +++ b/test/commands/project/project-list.test.ts @@ -200,6 +200,119 @@ await cliffySnapshotTest({ }, }) +// Test with empty projects list and --json +await cliffySnapshotTest({ + name: "Project List Command - No Projects Found JSON", + meta: import.meta, + colors: false, + args: ["--all-teams", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjects", + variables: { filter: undefined, first: 100, after: undefined }, + response: { + data: { + projects: { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test with projects and --json +await cliffySnapshotTest({ + name: "Project List Command - With JSON Output", + meta: import.meta, + colors: false, + args: ["--all-teams", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjects", + variables: { filter: undefined, first: 100, after: undefined }, + response: { + data: { + projects: { + nodes: [ + { + id: "project-json-1", + name: "JSON Test Project", + description: "A project for JSON output", + slugId: "json-proj", + icon: null, + color: "#3b82f6", + status: { + id: "status-1", + name: "In Progress", + color: "#f59e0b", + type: "started", + }, + lead: { + name: "test.user", + displayName: "Test User", + initials: "TU", + }, + priority: 2, + health: "onTrack", + startDate: "2024-01-15", + targetDate: "2024-03-30", + startedAt: "2024-01-16T09:00:00Z", + completedAt: null, + canceledAt: null, + createdAt: "2024-01-10T10:00:00Z", + updatedAt: "2024-01-20T15:30:00Z", + url: "https://linear.app/test/project/json-proj", + teams: { + nodes: [{ key: "ENG" }], + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + // Test pagination - multiple pages await snapshotTest({ name: "Project List Command - Pagination (Multiple Pages)", diff --git a/test/commands/project/project-update.test.ts b/test/commands/project/project-update.test.ts new file mode 100644 index 00000000..d23a74de --- /dev/null +++ b/test/commands/project/project-update.test.ts @@ -0,0 +1,173 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { updateCommand } from "../../../src/commands/project/project-update.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +// Test help output +await cliffySnapshotTest({ + name: "Project Update Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await updateCommand.parse() + }, +}) + +// Test project update - name only +await cliffySnapshotTest({ + name: "Project Update Command - Update Name", + meta: import.meta, + colors: false, + args: [ + "550e8400-e29b-41d4-a716-446655440000", + "--name", + "Updated Project Name", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "UpdateProject", + response: { + data: { + projectUpdate: { + success: true, + project: { + id: "550e8400-e29b-41d4-a716-446655440000", + slugId: "updated-proj", + name: "Updated Project Name", + description: null, + url: "https://linear.app/test/project/updated-proj", + updatedAt: "2024-01-20T15:30:00Z", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test project update - description +await cliffySnapshotTest({ + name: "Project Update Command - Update Description", + meta: import.meta, + colors: false, + args: [ + "550e8400-e29b-41d4-a716-446655440001", + "--description", + "New project description", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "UpdateProject", + response: { + data: { + projectUpdate: { + success: true, + project: { + id: "550e8400-e29b-41d4-a716-446655440001", + slugId: "proj-desc", + name: "Test Project", + description: "New project description", + url: "https://linear.app/test/project/proj-desc", + updatedAt: "2024-01-20T15:30:00Z", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// Test project update - status (requires GetProjectStatuses) +await cliffySnapshotTest({ + name: "Project Update Command - Update Status", + meta: import.meta, + colors: false, + args: [ + "550e8400-e29b-41d4-a716-446655440002", + "--status", + "completed", + ], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjectStatuses", + response: { + data: { + projectStatuses: { + nodes: [ + { + id: "status-completed-id", + name: "Completed", + type: "completed", + }, + ], + }, + }, + }, + }, + { + queryName: "UpdateProject", + response: { + data: { + projectUpdate: { + success: true, + project: { + id: "550e8400-e29b-41d4-a716-446655440002", + slugId: "proj-status", + name: "Test Project", + description: null, + url: "https://linear.app/test/project/proj-status", + updatedAt: "2024-01-20T15:30:00Z", + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await updateCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})