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
3 changes: 2 additions & 1 deletion skills/linear-cli/references/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ Options:
-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)
-t, --team <team> - Team key (can be repeated for multiple teams)
--label <label> - Replace the project's labels. May be repeated to set multiple labels.
```

### view
Expand Down
22 changes: 2 additions & 20 deletions src/commands/project/project-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getGraphQLClient } from "../../utils/graphql.ts"
import type { GraphQLClient } from "graphql-request"
import {
getAllTeams,
getProjectLabelIdByName,
getTeamIdByKey,
getTeamKey,
lookupUserId,
Expand Down Expand Up @@ -56,17 +57,6 @@ const AddProjectToInitiative = gql(`
}
`)

const GetProjectLabelIdByName = gql(`
query GetProjectLabelIdByNameForCreate($name: String!) {
projectLabels(filter: { name: { eqIgnoreCase: $name } }) {
nodes {
id
name
}
}
}
`)

const PRIORITY_MAPPING: Record<string, number> = {
"none": 0,
"urgent": 1,
Expand Down Expand Up @@ -168,14 +158,6 @@ export async function resolveProjectContent(
}
}

async function lookupProjectLabelId(
client: GraphQLClient,
label: string,
): Promise<string | undefined> {
const result = await client.request(GetProjectLabelIdByName, { name: label })
return result.projectLabels?.nodes[0]?.id
}

export const createCommand = new Command()
.name("create")
.description("Create a new Linear project")
Expand Down Expand Up @@ -459,7 +441,7 @@ export const createCommand = new Command()

const labelIds: string[] = []
for (const label of labels) {
const labelId = await lookupProjectLabelId(client, label)
const labelId = await getProjectLabelIdByName(label)
if (!labelId) {
throw new NotFoundError("Project label", label)
}
Expand Down
44 changes: 41 additions & 3 deletions src/commands/project/project-update.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Command } from "@cliffy/command"
import { gql } from "../../__codegen__/gql.ts"
import type { ProjectUpdateInput } from "../../__codegen__/graphql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import {
getProjectLabelIdByName,
getTeamIdByKey,
lookupUserId,
resolveProjectId,
Expand Down Expand Up @@ -81,6 +83,11 @@ export const updateCommand = new Command()
"Team key (can be repeated for multiple teams)",
{ collect: true },
)
.option(
"--label <label:string>",
"Replace the project's labels. May be repeated to set multiple labels.",
{ collect: true },
)
.action(
async (
{
Expand All @@ -92,6 +99,7 @@ export const updateCommand = new Command()
startDate,
targetDate,
team: teams,
label: labels,
},
projectId,
) => {
Expand All @@ -102,17 +110,28 @@ export const updateCommand = new Command()
try {
if (
!name && description == null && descriptionFile == null && !status &&
!lead && !startDate && !targetDate && (!teams || teams.length === 0)
!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, or --team",
"Use --name, --description, --description-file, --status, --lead, --start-date, --target-date, --team, or --label",
},
)
}

if (labels) {
for (const label of labels) {
if (label.trim() === "") {
throw new ValidationError("Project label cannot be empty", {
suggestion: 'Provide a label name, e.g. --label "My Label".',
})
}
}
}

const resolvedDescription = await resolveProjectDescription(
description,
descriptionFile,
Expand All @@ -130,7 +149,7 @@ export const updateCommand = new Command()
const client = getGraphQLClient()
const resolvedId = await resolveProjectId(projectId)

const input: Record<string, unknown> = {}
const input: ProjectUpdateInput = {}

if (name) input.name = name
if (resolvedDescription != null) input.description = resolvedDescription
Expand Down Expand Up @@ -181,6 +200,25 @@ export const updateCommand = new Command()
input.teamIds = teamIds
}

if (labels && labels.length > 0) {
// Replace the project's labels with exactly the resolved set,
// matching `project update --team` and `issue update --label`.
const labelIds: string[] = []
const seen = new Set<string>()
for (const label of labels) {
const labelId = await getProjectLabelIdByName(label)
if (!labelId) {
spinner?.stop()
throw new NotFoundError("Project label", label)
}
if (!seen.has(labelId)) {
seen.add(labelId)
labelIds.push(labelId)
}
}
input.labelIds = labelIds
}

const result = await client.request(UpdateProject, {
id: resolvedId,
input,
Expand Down
20 changes: 20 additions & 0 deletions src/utils/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,26 @@ export async function getIssueLabelIdByNameForTeam(
return data.issueLabels?.nodes[0]?.id
}

export async function getProjectLabelIdByName(
name: string,
): Promise<string | undefined> {
const client = getGraphQLClient()
const query = gql(/* GraphQL */ `
query GetProjectLabelIdByName($name: String!) {
projectLabels(
filter: { name: { eqIgnoreCase: $name }, isGroup: { eq: false } }
) {
nodes {
id
name
}
}
}
`)
const data = await client.request(query, { name })
return data.projectLabels?.nodes[0]?.id
}

export async function getIssueLabelOptionsByNameForTeam(
name: string,
teamKey: string,
Expand Down
10 changes: 10 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,7 @@ Options:
--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)
--label <label> - Replace the project's labels. May be repeated to set multiple labels.

"
stderr:
Expand Down Expand Up @@ -53,3 +54,12 @@ https://linear.app/test/project/proj-status
stderr:
""
`;

snapshot[`Project Update Command - Replace Labels 1`] = `
stdout:
"✓ Updated project: Test Project
https://linear.app/test/project/proj-labels
"
stderr:
""
`;
6 changes: 3 additions & 3 deletions test/commands/project/project-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ await cliffySnapshotTest({
},
},
{
queryName: "GetProjectLabelIdByNameForCreate",
queryName: "GetProjectLabelIdByName",
variables: { name: "Frontend" },
response: {
data: {
Expand All @@ -223,7 +223,7 @@ await cliffySnapshotTest({
},
},
{
queryName: "GetProjectLabelIdByNameForCreate",
queryName: "GetProjectLabelIdByName",
variables: { name: "Backend" },
response: {
data: {
Expand Down Expand Up @@ -462,7 +462,7 @@ Deno.test("Project Create Command - rejects an unknown project label", async ()
response: { data: { teams: { nodes: [{ id: "team-eng-123" }] } } },
},
{
queryName: "GetProjectLabelIdByNameForCreate",
queryName: "GetProjectLabelIdByName",
variables: { name: "Nonexistent" },
response: { data: { projectLabels: { nodes: [] } } },
},
Expand Down
Loading
Loading