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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- `project update --content <markdown>` and `--content-file <path>` 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ linear project view # view project details
linear project view <projectId> --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 <projectId> --content-file overview.md # replace the project's overview body
```

### cycle commands
Expand Down
10 changes: 10 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions skills/linear-cli/references/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Options:
-d, --description <description> - Project description (max 255 characters, enforced by Linear's API)
-f, --description-file <path> - Read project description from file (still subject to the 255-character API
limit)
--content <markdown> - Project overview markdown
--content-file <path> - Read project overview markdown from a file
-s, --status <status> - Status (planned, started, paused, completed, canceled, backlog)
-l, --lead <lead> - Project lead (username, email, or @me)
--start-date <startDate> - Start date (YYYY-MM-DD)
Expand Down
20 changes: 18 additions & 2 deletions src/commands/project/project-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down Expand Up @@ -72,6 +73,11 @@ export const updateCommand = new Command()
"-f, --description-file <path:string>",
`Read project description from file (still subject to the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character API limit)`,
)
.option("--content <markdown:string>", "Project overview markdown")
.option(
"--content-file <path:string>",
"Read project overview markdown from a file",
)
.option(
"-s, --status <status:string>",
"Status (planned, started, paused, completed, canceled, backlog)",
Expand All @@ -95,6 +101,8 @@ export const updateCommand = new Command()
name,
description,
descriptionFile,
content,
contentFile,
status,
lead,
startDate,
Expand All @@ -109,16 +117,19 @@ 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)
) {
throw new ValidationError(
"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",
},
)
}
Expand All @@ -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")
Expand All @@ -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

Expand Down
38 changes: 38 additions & 0 deletions test/commands/project/__snapshots__/project-update.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Options:
-d, --description <description> - Project description (max 255 characters, enforced by Linear's API)
-f, --description-file <path> - Read project description from file (still subject to the 255-character API
limit)
--content <markdown> - Project overview markdown
--content-file <path> - Read project overview markdown from a file
-s, --status <status> - Status (planned, started, paused, completed, canceled, backlog)
-l, --lead <lead> - Project lead (username, email, or @me)
--start-date <startDate> - Start date (YYYY-MM-DD)
Expand Down Expand Up @@ -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:
""
`;
233 changes: 233 additions & 0 deletions test/commands/project/project-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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,
)
})
Loading