Skip to content
Open
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: 23 additions & 4 deletions src/commands/project/project-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down Expand Up @@ -113,7 +117,14 @@ export const createCommand = new Command()
.name("create")
.description("Create a new Linear project")
.option("-n, --name <name:string>", "Project name (required)")
.option("-d, --description <description:string>", "Project description")
.option(
"-d, --description <description:string>",
`Project description (max ${PROJECT_DESCRIPTION_MAX_LENGTH} characters, enforced by Linear's API)`,
)
.option(
"-f, --description-file <path:string>",
`Read project description from file (still subject to the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character API limit)`,
)
.option(
"-t, --team <team:string>",
"Team key (required, can be repeated for multiple teams)",
Expand Down Expand Up @@ -146,6 +157,7 @@ export const createCommand = new Command()
const {
name: providedName,
description: providedDescription,
descriptionFile: providedDescriptionFile,
team: providedTeams,
lead: providedLead,
status: providedStatus,
Expand All @@ -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
Expand All @@ -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):",
})
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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 }),
Expand Down
51 changes: 51 additions & 0 deletions src/commands/project/project-description.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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 <slug>\`.`,
},
)
}

return value
}
27 changes: 22 additions & 5 deletions src/commands/project/project-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down Expand Up @@ -57,7 +61,14 @@ export const updateCommand = new Command()
.description("Update a Linear project")
.arguments("<projectId:string>")
.option("-n, --name <name:string>", "Project name")
.option("-d, --description <description:string>", "Project description")
.option(
"-d, --description <description:string>",
`Project description (max ${PROJECT_DESCRIPTION_MAX_LENGTH} characters, enforced by Linear's API)`,
)
.option(
"-f, --description-file <path:string>",
`Read project description from file (still subject to the ${PROJECT_DESCRIPTION_MAX_LENGTH}-character API limit)`,
)
.option(
"-s, --status <status:string>",
"Status (planned, started, paused, completed, canceled, backlog)",
Expand All @@ -75,6 +86,7 @@ export const updateCommand = new Command()
{
name,
description,
descriptionFile,
status,
lead,
startDate,
Expand All @@ -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")
}
Expand All @@ -116,7 +133,7 @@ export const updateCommand = new Command()
const input: Record<string, unknown> = {}

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

Expand Down
40 changes: 29 additions & 11 deletions test/commands/project/__snapshots__/project-create.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,41 @@ Description:

Options:

-h, --help - Show this help.
-n, --name <name> - Project name (required)
-d, --description <description> - Project description
-t, --team <team> - Team key (required, can be repeated for multiple teams)
-l, --lead <lead> - Project lead (username, email, or @me)
-s, --status <status> - Project status (planned, started, paused, completed, canceled, backlog)
--start-date <startDate> - Start date (YYYY-MM-DD)
--target-date <targetDate> - Target completion date (YYYY-MM-DD)
--initiative <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 <name> - Project name (required)
-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)
-t, --team <team> - Team key (required, can be repeated for multiple teams)
-l, --lead <lead> - Project lead (username, email, or @me)
-s, --status <status> - Project status (planned, started, paused, completed, canceled, backlog)
--start-date <startDate> - Start date (YYYY-MM-DD)
--target-date <targetDate> - Target completion date (YYYY-MM-DD)
--initiative <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:
'{
Expand Down
18 changes: 10 additions & 8 deletions test/commands/project/__snapshots__/project-update.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ Description:

Options:

-h, --help - Show this help.
-n, --name <name> - Project name
-d, --description <description> - Project description
-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)
--target-date <targetDate> - Target date (YYYY-MM-DD)
-t, --team <team> - Team key (can be repeated for multiple teams)
-h, --help - Show this help.
-n, --name <name> - Project name
-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)
-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)
--target-date <targetDate> - Target date (YYYY-MM-DD)
-t, --team <team> - Team key (can be repeated for multiple teams)

"
stderr:
Expand Down
66 changes: 66 additions & 0 deletions test/commands/project/project-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading