diff --git a/src/commands/project/project-create.ts b/src/commands/project/project-create.ts index 5d326f23..c4cdb97c 100644 --- a/src/commands/project/project-create.ts +++ b/src/commands/project/project-create.ts @@ -15,6 +15,10 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { + PROJECT_DESCRIPTION_MAX_LENGTH, + resolveProjectDescription, +} from "./project-description.ts" const CreateProject = gql(` mutation CreateProject($input: ProjectCreateInput!) { @@ -113,7 +117,14 @@ export const createCommand = new Command() .name("create") .description("Create a new Linear project") .option("-n, --name ", "Project name (required)") - .option("-d, --description ", "Project description") + .option( + "-d, --description ", + `Project description (max ${PROJECT_DESCRIPTION_MAX_LENGTH} characters, enforced by Linear's API)`, + ) + .option( + "-f, --description-file ", + `Read project description from file (still subject to the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character API limit)`, + ) .option( "-t, --team ", "Team key (required, can be repeated for multiple teams)", @@ -146,6 +157,7 @@ export const createCommand = new Command() const { name: providedName, description: providedDescription, + descriptionFile: providedDescriptionFile, team: providedTeams, lead: providedLead, status: providedStatus, @@ -161,6 +173,7 @@ export const createCommand = new Command() let name = providedName let description = providedDescription + const descriptionFile = providedDescriptionFile let teams = providedTeams || [] let lead = providedLead let status = providedStatus @@ -183,8 +196,8 @@ export const createCommand = new Command() }) } - // Description (optional) - if (!description) { + // Description (optional) — skip the prompt when --description-file was passed. + if (!description && descriptionFile == null) { description = await Input.prompt({ message: "Description (optional):", }) @@ -267,6 +280,11 @@ export const createCommand = new Command() } } + const resolvedDescription = await resolveProjectDescription( + description, + descriptionFile, + ) + // Validate required fields if (!name) { throw new ValidationError("Project name is required", { @@ -349,7 +367,8 @@ export const createCommand = new Command() const input = { name, teamIds, - ...(description && { description }), + ...(resolvedDescription != null && + { description: resolvedDescription }), ...(leadId && { leadId }), ...(statusId && { statusId }), ...(startDate && { startDate }), diff --git a/src/commands/project/project-description.ts b/src/commands/project/project-description.ts new file mode 100644 index 00000000..9c5e0f54 --- /dev/null +++ b/src/commands/project/project-description.ts @@ -0,0 +1,51 @@ +import { CliError, NotFoundError, ValidationError } from "../../utils/errors.ts" + +// Linear's API rejects project descriptions longer than this. The web UI +// accepts longer descriptions through a different endpoint, but the +// projectCreate / projectUpdate mutations exposed here are bound to this cap. +export const PROJECT_DESCRIPTION_MAX_LENGTH = 255 + +export async function resolveProjectDescription( + description: string | undefined, + descriptionFile: string | undefined, +): Promise { + if (description != null && descriptionFile != null) { + throw new ValidationError( + "Cannot use --description and --description-file together", + { + suggestion: "Pass only one of --description or --description-file.", + }, + ) + } + + let value: string | undefined + if (description != null) { + value = description + } else if (descriptionFile != null) { + try { + value = await Deno.readTextFile(descriptionFile) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new NotFoundError("File", descriptionFile) + } + throw new CliError( + `Failed to read description file: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ) + } + } + + if (value != null && value.length > PROJECT_DESCRIPTION_MAX_LENGTH) { + throw new ValidationError( + `Project description is ${value.length} characters, exceeds the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character limit enforced by Linear's API`, + { + suggestion: + `Shorten the description to ${PROJECT_DESCRIPTION_MAX_LENGTH} characters or fewer, or move the long content into an attached document via \`linear document create --project \`.`, + }, + ) + } + + return value +} diff --git a/src/commands/project/project-update.ts b/src/commands/project/project-update.ts index 52b33a60..697a9ab4 100644 --- a/src/commands/project/project-update.ts +++ b/src/commands/project/project-update.ts @@ -13,6 +13,10 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { + PROJECT_DESCRIPTION_MAX_LENGTH, + resolveProjectDescription, +} from "./project-description.ts" const UpdateProject = gql(` mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { @@ -57,7 +61,14 @@ export const updateCommand = new Command() .description("Update a Linear project") .arguments("") .option("-n, --name ", "Project name") - .option("-d, --description ", "Project description") + .option( + "-d, --description ", + `Project description (max ${PROJECT_DESCRIPTION_MAX_LENGTH} characters, enforced by Linear's API)`, + ) + .option( + "-f, --description-file ", + `Read project description from file (still subject to the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character API limit)`, + ) .option( "-s, --status ", "Status (planned, started, paused, completed, canceled, backlog)", @@ -75,6 +86,7 @@ export const updateCommand = new Command() { name, description, + descriptionFile, status, lead, startDate, @@ -89,18 +101,23 @@ export const updateCommand = new Command() try { if ( - !name && description == null && !status && !lead && - !startDate && !targetDate && (!teams || teams.length === 0) + !name && description == null && descriptionFile == 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", + "Use --name, --description, --description-file, --status, --lead, --start-date, --target-date, or --team", }, ) } + const resolvedDescription = await resolveProjectDescription( + description, + descriptionFile, + ) + if (startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) { throw new ValidationError("Start date must be in YYYY-MM-DD format") } @@ -116,7 +133,7 @@ export const updateCommand = new Command() const input: Record = {} if (name) input.name = name - if (description != null) input.description = description + if (resolvedDescription != null) input.description = resolvedDescription if (startDate) input.startDate = startDate if (targetDate) input.targetDate = targetDate diff --git a/test/commands/project/__snapshots__/project-create.test.ts.snap b/test/commands/project/__snapshots__/project-create.test.ts.snap index 59b0c524..b34c6740 100644 --- a/test/commands/project/__snapshots__/project-create.test.ts.snap +++ b/test/commands/project/__snapshots__/project-create.test.ts.snap @@ -11,23 +11,41 @@ Description: Options: - -h, --help - Show this help. - -n, --name - Project name (required) - -d, --description - Project description - -t, --team - Team key (required, can be repeated for multiple teams) - -l, --lead - Project lead (username, email, or @me) - -s, --status - Project status (planned, started, paused, completed, canceled, backlog) - --start-date - Start date (YYYY-MM-DD) - --target-date - Target completion date (YYYY-MM-DD) - --initiative - Add to initiative immediately (ID, slug, or name) - -i, --interactive - Interactive mode (default if no flags provided) - -j, --json - Output created project as JSON + -h, --help - Show this help. + -n, --name - Project name (required) + -d, --description - Project description (max 255 characters, enforced by Linear's API) + -f, --description-file - Read project description from file (still subject to the 255-character API + limit) + -t, --team - Team key (required, can be repeated for multiple teams) + -l, --lead - Project lead (username, email, or @me) + -s, --status - Project status (planned, started, paused, completed, canceled, backlog) + --start-date - Start date (YYYY-MM-DD) + --target-date - Target completion date (YYYY-MM-DD) + --initiative - Add to initiative immediately (ID, slug, or name) + -i, --interactive - Interactive mode (default if no flags provided) + -j, --json - Output created project as JSON " stderr: "" `; +snapshot[`Project Create Command - Description From File 1`] = ` +stdout: +'{ + "success": true, + "project": { + "id": "550e8400-e29b-41d4-a716-446655440010", + "slugId": "file-desc-project", + "name": "File Desc Project", + "url": "https://linear.app/test/project/file-desc-project" + } +} +' +stderr: +"" +`; + snapshot[`Project Create Command - With JSON Output 1`] = ` stdout: '{ diff --git a/test/commands/project/__snapshots__/project-update.test.ts.snap b/test/commands/project/__snapshots__/project-update.test.ts.snap index 291fa90b..409243f7 100644 --- a/test/commands/project/__snapshots__/project-update.test.ts.snap +++ b/test/commands/project/__snapshots__/project-update.test.ts.snap @@ -11,14 +11,16 @@ Description: Options: - -h, --help - Show this help. - -n, --name - Project name - -d, --description - Project description - -s, --status - Status (planned, started, paused, completed, canceled, backlog) - -l, --lead - Project lead (username, email, or @me) - --start-date - Start date (YYYY-MM-DD) - --target-date - Target date (YYYY-MM-DD) - -t, --team - Team key (can be repeated for multiple teams) + -h, --help - Show this help. + -n, --name - Project name + -d, --description - Project description (max 255 characters, enforced by Linear's API) + -f, --description-file - Read project description from file (still subject to the 255-character API + limit) + -s, --status - Status (planned, started, paused, completed, canceled, backlog) + -l, --lead - Project lead (username, email, or @me) + --start-date - Start date (YYYY-MM-DD) + --target-date - Target date (YYYY-MM-DD) + -t, --team - Team key (can be repeated for multiple teams) " stderr: diff --git a/test/commands/project/project-create.test.ts b/test/commands/project/project-create.test.ts index 73b1d895..a0f29e8c 100644 --- a/test/commands/project/project-create.test.ts +++ b/test/commands/project/project-create.test.ts @@ -3,6 +3,12 @@ import { createCommand } from "../../../src/commands/project/project-create.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" +const descriptionFilePath = await Deno.makeTempFile({ suffix: ".md" }) +await Deno.writeTextFile( + descriptionFilePath, + "Short description loaded from a file.", +) + // Test help output await cliffySnapshotTest({ name: "Project Create Command - Help Text", @@ -15,6 +21,66 @@ await cliffySnapshotTest({ }, }) +// Test project create reading description from --description-file +await cliffySnapshotTest({ + name: "Project Create Command - Description From File", + meta: import.meta, + colors: false, + args: [ + "--name", + "File Desc Project", + "--team", + "ENG", + "--description-file", + descriptionFilePath, + "--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-446655440010", + slugId: "file-desc-project", + name: "File Desc Project", + url: "https://linear.app/test/project/file-desc-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") + } + }, +}) + // Test project create with --json output await cliffySnapshotTest({ name: "Project Create Command - With JSON Output", diff --git a/test/commands/project/project-description.test.ts b/test/commands/project/project-description.test.ts new file mode 100644 index 00000000..1d03ac16 --- /dev/null +++ b/test/commands/project/project-description.test.ts @@ -0,0 +1,75 @@ +import { assertEquals, assertRejects } from "@std/assert" +import { + PROJECT_DESCRIPTION_MAX_LENGTH, + resolveProjectDescription, +} from "../../../src/commands/project/project-description.ts" +import { NotFoundError, ValidationError } from "../../../src/utils/errors.ts" + +Deno.test("resolveProjectDescription - returns undefined when neither flag set", async () => { + const result = await resolveProjectDescription(undefined, undefined) + assertEquals(result, undefined) +}) + +Deno.test("resolveProjectDescription - returns inline description", async () => { + const result = await resolveProjectDescription("hello", undefined) + assertEquals(result, "hello") +}) + +Deno.test("resolveProjectDescription - reads file content", async () => { + const tmp = await Deno.makeTempFile({ suffix: ".md" }) + try { + await Deno.writeTextFile(tmp, "from-file") + const result = await resolveProjectDescription(undefined, tmp) + assertEquals(result, "from-file") + } finally { + await Deno.remove(tmp) + } +}) + +Deno.test("resolveProjectDescription - rejects passing both flags", async () => { + await assertRejects( + () => resolveProjectDescription("inline", "/tmp/some.md"), + ValidationError, + "Cannot use --description and --description-file together", + ) +}) + +Deno.test("resolveProjectDescription - rejects inline description over the cap", async () => { + const tooLong = "x".repeat(PROJECT_DESCRIPTION_MAX_LENGTH + 1) + await assertRejects( + () => resolveProjectDescription(tooLong, undefined), + ValidationError, + `Project description is ${tooLong.length} characters`, + ) +}) + +Deno.test("resolveProjectDescription - rejects file content over the cap", async () => { + const tmp = await Deno.makeTempFile({ suffix: ".md" }) + try { + await Deno.writeTextFile( + tmp, + "y".repeat(PROJECT_DESCRIPTION_MAX_LENGTH + 50), + ) + await assertRejects( + () => resolveProjectDescription(undefined, tmp), + ValidationError, + "exceeds the 255-character limit", + ) + } finally { + await Deno.remove(tmp) + } +}) + +Deno.test("resolveProjectDescription - accepts description exactly at the cap", async () => { + const exact = "z".repeat(PROJECT_DESCRIPTION_MAX_LENGTH) + const result = await resolveProjectDescription(exact, undefined) + assertEquals(result, exact) +}) + +Deno.test("resolveProjectDescription - throws NotFoundError for missing file", async () => { + await assertRejects( + () => resolveProjectDescription(undefined, "/tmp/does-not-exist-xyz.md"), + NotFoundError, + "File not found", + ) +})