From 66dc0ebd8744dc79bfb067507b6ae80d88518176 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 3 Sep 2026 16:37:44 -0700 Subject: [PATCH] Add --content and --content-file to project update A project's long-form overview body could be set at creation via project create --content / --content-file, but never changed afterwards: project update only exposed --description, which is Linear's separate 255-character summary field. Updating the body meant hand-writing a projectUpdate mutation through linear api and reading the markdown from a file yourself. project update now takes the same two flags as create, spelled and worded identically, and resolves them through create's existing helper so the mutual-exclusion and file-read behavior cannot drift between the two commands. Content and description are independent API fields and may be set together. The no-options guard uses null checks so an empty content file still counts as an explicit value to forward; Linear currently keeps the existing body when sent an empty string, so this is not a way to clear it, and cliffy rejects --content "" outright. No short aliases: -f already means --description-file on this command and create has none for content either. Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub --- CHANGELOG.md | 1 + README.md | 1 + docs/usage.md | 10 + skills/linear-cli/references/project.md | 2 + src/commands/project/project-update.ts | 20 +- .../__snapshots__/project-update.test.ts.snap | 38 +++ test/commands/project/project-update.test.ts | 233 ++++++++++++++++++ 7 files changed, 303 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e59eca..820c2b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `project update --content ` and `--content-file ` replace a project's long-form overview body, matching the flags `project create` already had. Previously the only way to change the body after creation was a hand-written `projectUpdate` mutation through `linear api` - `--json` (`-j`) on `team list`, `cycle list`, `cycle view`, `milestone list`, `milestone view`, and `project view`, the last read commands without machine-readable output. List commands emit the same `{ nodes, pageInfo }` connection shape as the other list commands, after the same filtering and ordering as the table; view commands emit the GraphQL object as fetched, including every issue rather than the ten-item preview, and `milestone view --all --json` includes every page. (A 2.0.0 entry claimed `cycle list --json`; that change never actually landed.) ([#276](https://github.com/schpet/linear-cli/issues/276); thanks @lakardion) ### Fixed diff --git a/README.md b/README.md index d785e9bd..1bb974cf 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ linear project view # view project details linear project view --json # project details as JSON linear project create --name "API v2" --team ENG --content-file overview.md linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com +linear project update --content-file overview.md # replace the project's overview body ``` ### cycle commands diff --git a/docs/usage.md b/docs/usage.md index fcc59751..cb481cb2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -268,6 +268,16 @@ linear project create --name "API v2" --team ENG --content-file overview.md linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com --icon rocket --color "#5E6AD2" ``` +#### update a project + +```bash +# --description is the short summary; --content is the long-form overview body +linear project update PROJECT-ID --description "Short summary" --content "## Overview" + +# Replace the overview body from a markdown file +linear project update PROJECT-ID --content-file overview.md +``` + #### list projects ```bash diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index 1dc7a1e5..ab4e5257 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -133,6 +133,8 @@ Options: -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) + --content - Project overview markdown + --content-file - Read project overview markdown from a file -s, --status - Status (planned, started, paused, completed, canceled, backlog) -l, --lead - Project lead (username, email, or @me) --start-date - Start date (YYYY-MM-DD) diff --git a/src/commands/project/project-update.ts b/src/commands/project/project-update.ts index 0a750238..28595177 100644 --- a/src/commands/project/project-update.ts +++ b/src/commands/project/project-update.ts @@ -19,6 +19,7 @@ import { PROJECT_DESCRIPTION_MAX_LENGTH, resolveProjectDescription, } from "./project-description.ts" +import { resolveProjectContent } from "./project-create.ts" import { withMarkdownHint } from "../../utils/markdown-help.ts" const UpdateProject = gql(` @@ -72,6 +73,11 @@ export const updateCommand = new Command() "-f, --description-file ", `Read project description from file (still subject to the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character API limit)`, ) + .option("--content ", "Project overview markdown") + .option( + "--content-file ", + "Read project overview markdown from a file", + ) .option( "-s, --status ", "Status (planned, started, paused, completed, canceled, backlog)", @@ -95,6 +101,8 @@ export const updateCommand = new Command() name, description, descriptionFile, + content, + contentFile, status, lead, startDate, @@ -109,8 +117,11 @@ export const updateCommand = new Command() const spinner = showSpinner ? new Spinner() : null try { + // Null checks, not truthiness: an empty --content-file is still an + // explicit value to forward, so it must count as an update. if ( - !name && description == null && descriptionFile == null && !status && + !name && description == null && descriptionFile == null && + content == null && contentFile == null && !status && !lead && !startDate && !targetDate && (!teams || teams.length === 0) && (!labels || labels.length === 0) ) { @@ -118,7 +129,7 @@ export const updateCommand = new Command() "At least one update option must be provided", { suggestion: - "Use --name, --description, --description-file, --status, --lead, --start-date, --target-date, --team, or --label", + "Use --name, --description, --description-file, --content, --content-file, --status, --lead, --start-date, --target-date, --team, or --label", }, ) } @@ -137,6 +148,10 @@ export const updateCommand = new Command() description, descriptionFile, ) + const resolvedContent = await resolveProjectContent( + content, + contentFile, + ) if (startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) { throw new ValidationError("Start date must be in YYYY-MM-DD format") @@ -154,6 +169,7 @@ export const updateCommand = new Command() if (name) input.name = name if (resolvedDescription != null) input.description = resolvedDescription + if (resolvedContent != null) input.content = resolvedContent if (startDate) input.startDate = startDate if (targetDate) input.targetDate = targetDate diff --git a/test/commands/project/__snapshots__/project-update.test.ts.snap b/test/commands/project/__snapshots__/project-update.test.ts.snap index 20b27df9..bb5d57af 100644 --- a/test/commands/project/__snapshots__/project-update.test.ts.snap +++ b/test/commands/project/__snapshots__/project-update.test.ts.snap @@ -21,6 +21,8 @@ Options: -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) + --content - Project overview markdown + --content-file - Read project overview markdown from a file -s, --status - Status (planned, started, paused, completed, canceled, backlog) -l, --lead - Project lead (username, email, or @me) --start-date - Start date (YYYY-MM-DD) @@ -68,3 +70,39 @@ https://linear.app/test/project/proj-labels stderr: "" `; + +snapshot[`Project Update Command - Update Content 1`] = ` +stdout: +"✓ Updated project: Test Project +https://linear.app/test/project/proj-content +" +stderr: +"" +`; + +snapshot[`Project Update Command - Update Content File 1`] = ` +stdout: +"✓ Updated project: Test Project +https://linear.app/test/project/proj-content +" +stderr: +"" +`; + +snapshot[`Project Update Command - Update Description And Content 1`] = ` +stdout: +"✓ Updated project: Test Project +https://linear.app/test/project/proj-content +" +stderr: +"" +`; + +snapshot[`Project Update Command - Empty Content File Sends Empty String 1`] = ` +stdout: +"✓ Updated project: Test Project +https://linear.app/test/project/proj-content +" +stderr: +"" +`; diff --git a/test/commands/project/project-update.test.ts b/test/commands/project/project-update.test.ts index 6dfe7049..4401c7a6 100644 --- a/test/commands/project/project-update.test.ts +++ b/test/commands/project/project-update.test.ts @@ -502,4 +502,237 @@ Deno.test("Project Update Command - requires at least one option", async () => { true, ) assertEquals(errorLogs.some((l) => l.includes("--label")), true) + assertEquals( + errorLogs.some((l) => + l.includes("--content") && l.includes("--content-file") + ), + true, + ) +}) + +// --- content (the long-form overview body) --------------------------------- + +const CONTENT_PROJECT_ID = "550e8400-e29b-41d4-a716-446655440020" + +function updatedProjectResponse(description = "") { + return { + data: { + projectUpdate: { + success: true, + project: { + id: CONTENT_PROJECT_ID, + slugId: "proj-content", + name: "Test Project", + description, + url: "https://linear.app/test/project/proj-content", + updatedAt: "2024-01-20T15:30:00Z", + }, + }, + }, + } +} + +async function runUpdateWithServer(server: MockLinearServer, args?: string[]) { + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await updateCommand.parse(args) + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } +} + +// The pinned variables prove --content reaches ProjectUpdateInput.content +// verbatim (a mock without variables would accept any input). +await cliffySnapshotTest({ + name: "Project Update Command - Update Content", + meta: import.meta, + colors: false, + args: [CONTENT_PROJECT_ID, "--content", "## Overview\nShip the project."], + denoArgs: commonDenoArgs, + async fn() { + await runUpdateWithServer( + new MockLinearServer([ + { + queryName: "UpdateProject", + variables: { + id: CONTENT_PROJECT_ID, + input: { content: "## Overview\nShip the project." }, + }, + response: updatedProjectResponse(), + }, + ]), + ) + }, +}) + +// The placeholder path is swapped for a real temp file at runtime (the +// snapshot runner re-executes this file with the declared args). +await cliffySnapshotTest({ + name: "Project Update Command - Update Content File", + meta: import.meta, + colors: false, + args: [ + CONTENT_PROJECT_ID, + "--content-file", + "placeholder-replaced-in-test.md", + ], + denoArgs: commonDenoArgs, + async fn() { + const overviewPath = await Deno.makeTempFile({ + prefix: "linear-project-overview-", + suffix: ".md", + }) + const body = "# Project Overview\n\nLoaded from a file.\n" + const server = new MockLinearServer([ + { + queryName: "UpdateProject", + variables: { id: CONTENT_PROJECT_ID, input: { content: body } }, + response: updatedProjectResponse(), + }, + ]) + + const placeholderIndex = Deno.args.indexOf( + "placeholder-replaced-in-test.md", + ) + if (placeholderIndex === -1) { + throw new Error("Expected content file placeholder argument") + } + try { + await Deno.writeTextFile(overviewPath, body) + Deno.args[placeholderIndex] = overviewPath + await runUpdateWithServer(server) + } finally { + Deno.args[placeholderIndex] = "placeholder-replaced-in-test.md" + await Deno.remove(overviewPath) + } + }, +}) + +// Summary and body are independent API fields and may be set together. +await cliffySnapshotTest({ + name: "Project Update Command - Update Description And Content", + meta: import.meta, + colors: false, + args: [ + CONTENT_PROJECT_ID, + "--description", + "Short summary", + "--content", + "# Full overview", + ], + denoArgs: commonDenoArgs, + async fn() { + await runUpdateWithServer( + new MockLinearServer([ + { + queryName: "UpdateProject", + variables: { + id: CONTENT_PROJECT_ID, + input: { description: "Short summary", content: "# Full overview" }, + }, + response: updatedProjectResponse("Short summary"), + }, + ]), + ) + }, +}) + +// An empty content file must pass the no-options guard and be sent as +// `content: ""`, not dropped by a truthiness check: the CLI forwards what the +// user gave it and lets the API decide. (Linear currently keeps the existing +// body when sent an empty string, and cliffy rejects `--content ""` as a +// missing value, so a file is the only way to send one at all.) +await cliffySnapshotTest({ + name: "Project Update Command - Empty Content File Sends Empty String", + meta: import.meta, + colors: false, + args: [CONTENT_PROJECT_ID, "--content-file", "placeholder-empty-in-test.md"], + denoArgs: commonDenoArgs, + async fn() { + const emptyPath = await Deno.makeTempFile({ + prefix: "linear-project-overview-empty-", + suffix: ".md", + }) + const server = new MockLinearServer([ + { + queryName: "UpdateProject", + variables: { id: CONTENT_PROJECT_ID, input: { content: "" } }, + response: updatedProjectResponse(), + }, + ]) + + const placeholderIndex = Deno.args.indexOf("placeholder-empty-in-test.md") + if (placeholderIndex === -1) { + throw new Error("Expected content file placeholder argument") + } + try { + Deno.args[placeholderIndex] = emptyPath + await runUpdateWithServer(server) + } finally { + Deno.args[placeholderIndex] = "placeholder-empty-in-test.md" + await Deno.remove(emptyPath) + } + }, +}) + +async function expectUpdateToFail(args: string[]): Promise { + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...a: unknown[]) => { + errorLogs.push(a.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + let exited = false + try { + await updateCommand.parse(args) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + } + assertEquals(exited, true) + return errorLogs +} + +// No server is configured, so these only pass if the command errors before +// making any request. +Deno.test("Project Update Command - rejects --content with --content-file", async () => { + const errorLogs = await expectUpdateToFail([ + CONTENT_PROJECT_ID, + "--description", + "Short summary", + "--content", + "Inline overview", + "--content-file", + "overview.md", + ]) + assertEquals( + errorLogs.some((l) => + l.includes("Cannot specify both --content and --content-file") + ), + true, + ) +}) + +Deno.test("Project Update Command - errors on a missing content file", async () => { + const errorLogs = await expectUpdateToFail([ + CONTENT_PROJECT_ID, + "--content-file", + "/nonexistent/linear-project-overview.md", + ]) + assertEquals( + errorLogs.some((l) => + l.includes( + "Failed to read content file: /nonexistent/linear-project-overview.md", + ) + ), + true, + ) })