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
4 changes: 3 additions & 1 deletion skills/linear-cli/references/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Options:
--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)
--label <label> - Project label associated with the project. May be repeated.
-i, --interactive - Interactive mode (default if no flags provided)
-j, --json - Output created project as JSON
```
Expand All @@ -117,7 +118,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> - Project label associated with the project. May be repeated.
```

### delete
Expand Down
54 changes: 53 additions & 1 deletion src/commands/project/project-create.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Command } from "@cliffy/command"
import { Input, Select } from "@cliffy/prompt"
import { Checkbox, Input, Select } from "@cliffy/prompt"
import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import {
getAllTeams,
getProjectLabels,
getTeamIdByKey,
getTeamKey,
lookupUserId,
resolveProjectLabelIds,
} from "../../utils/linear.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import {
Expand Down Expand Up @@ -50,6 +52,8 @@ const AddProjectToInitiative = gql(`
}
`)

const CREATE_PROJECT_LABEL_OPTION = "__create_project_label__"

async function resolveInitiativeId(
// deno-lint-ignore no-explicit-any
client: any,
Expand Down Expand Up @@ -136,6 +140,11 @@ export const createCommand = new Command()
"--initiative <initiative:string>",
"Add to initiative immediately (ID, slug, or name)",
)
.option(
"--label <label:string>",
"Project label associated with the project. May be repeated.",
{ collect: true },
)
.option(
"-i, --interactive",
"Interactive mode (default if no flags provided)",
Expand All @@ -152,6 +161,7 @@ export const createCommand = new Command()
startDate: providedStartDate,
targetDate: providedTargetDate,
initiative: providedInitiative,
label: providedLabels,
interactive: interactiveFlag,
json: jsonOutput,
} = options
Expand All @@ -166,6 +176,8 @@ export const createCommand = new Command()
let status = providedStatus
let startDate = providedStartDate
let targetDate = providedTargetDate
let labels = providedLabels || []
let labelIds: string[] = []

// Determine if we should run in interactive mode
const noFlagsProvided = !name && teams.length === 0
Expand Down Expand Up @@ -265,6 +277,40 @@ export const createCommand = new Command()
})
if (!targetDate) targetDate = undefined
}

if (labels.length === 0) {
const projectLabels = await getProjectLabels()
const selectedLabelIds = await Checkbox.prompt({
message:
"Select project labels (use space to select, enter to confirm)",
search: projectLabels.length > 0,
searchLabel: "Search project labels",
options: [
...projectLabels.map((label) => ({
name: label.name,
value: label.id,
})),
{
name: "Create new project label",
value: CREATE_PROJECT_LABEL_OPTION,
},
],
})

labelIds = selectedLabelIds.filter((id) =>
id !== CREATE_PROJECT_LABEL_OPTION
)

if (selectedLabelIds.includes(CREATE_PROJECT_LABEL_OPTION)) {
const newLabels = await Input.prompt({
message:
"New project label names (comma-separated - press Enter to skip):",
})
labels = newLabels.split(",").map((label) => label.trim()).filter(
Boolean,
)
}
}
}

// Validate required fields
Expand Down Expand Up @@ -346,6 +392,11 @@ export const createCommand = new Command()
throw new ValidationError("Target date must be in YYYY-MM-DD format")
}

if (labels.length > 0) {
labelIds.push(...await resolveProjectLabelIds(labels))
}
labelIds = [...new Set(labelIds)]

const input = {
name,
teamIds,
Expand All @@ -354,6 +405,7 @@ export const createCommand = new Command()
...(statusId && { statusId }),
...(startDate && { startDate }),
...(targetDate && { targetDate }),
...(labelIds.length > 0 && { labelIds }),
}

const { Spinner } = await import("@std/cli/unstable-spinner")
Expand Down
19 changes: 17 additions & 2 deletions src/commands/project/project-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { Command } from "@cliffy/command"
import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import {
getProjectLabelIdsForProject,
getTeamIdByKey,
lookupUserId,
resolveProjectId,
resolveProjectLabelIds,
} from "../../utils/linear.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import {
Expand Down Expand Up @@ -70,6 +72,11 @@ export const updateCommand = new Command()
"Team key (can be repeated for multiple teams)",
{ collect: true },
)
.option(
"--label <label:string>",
"Project label associated with the project. May be repeated.",
{ collect: true },
)
.action(
async (
{
Expand All @@ -80,6 +87,7 @@ export const updateCommand = new Command()
startDate,
targetDate,
team: teams,
label: labels,
},
projectId,
) => {
Expand All @@ -90,13 +98,14 @@ export const updateCommand = new Command()
try {
if (
!name && description == null && !status && !lead &&
!startDate && !targetDate && (!teams || teams.length === 0)
!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, --status, --lead, --start-date, --target-date, or --team",
"Use --name, --description, --status, --lead, --start-date, --target-date, --team, or --label",
},
)
}
Expand Down Expand Up @@ -164,6 +173,12 @@ export const updateCommand = new Command()
input.teamIds = teamIds
}

if (labels && labels.length > 0) {
const currentLabelIds = await getProjectLabelIdsForProject(resolvedId)
const newLabelIds = await resolveProjectLabelIds(labels)
input.labelIds = [...new Set([...currentLabelIds, ...newLabelIds])]
}

const result = await client.request(UpdateProject, {
id: resolvedId,
input,
Expand Down
124 changes: 123 additions & 1 deletion src/utils/linear.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { gql } from "../__codegen__/gql.ts"
import type {
CreateProjectLabelMutation,
GetAllTeamsQuery,
GetAllTeamsQueryVariables as _GetAllTeamsQueryVariables,
GetIssueDetailsQuery,
GetIssueDetailsWithCommentsQuery,
GetIssuesForQueryQuery,
GetIssuesForStateQuery,
GetProjectLabelIdsForProjectQuery,
GetProjectLabelsQuery,
GetTeamMembersQuery,
IssueFilter,
IssueSortInput,
Expand All @@ -14,7 +17,7 @@ import type {
} from "../__codegen__/graphql.ts"
import { Select } from "@cliffy/prompt"
import { getOption } from "../config.ts"
import { NotFoundError, ValidationError } from "./errors.ts"
import { CliError, NotFoundError, ValidationError } from "./errors.ts"
import { getGraphQLClient } from "./graphql.ts"
import { normalizeIssueIdentifier } from "./issue-identifier.ts"
import { getCurrentIssueFromVcs } from "./vcs.ts"
Expand Down Expand Up @@ -1435,6 +1438,125 @@ export async function getIssueLabelOptionsByNameForTeam(
return Object.fromEntries(sortedResults.map((t) => [t.id, t.name]))
}

export type ProjectLabel = { id: string; name: string; color: string }

export async function getProjectLabels(): Promise<ProjectLabel[]> {
const client = getGraphQLClient()
const query = gql(/* GraphQL */ `
query GetProjectLabels($first: Int, $after: String) {
projectLabels(
first: $first
after: $after
filter: { isGroup: { eq: false } }
) {
nodes {
id
name
color
}
pageInfo {
hasNextPage
endCursor
}
}
}
`)

const allLabels: ProjectLabel[] = []
let hasNextPage = true
let after: string | null | undefined = undefined

while (hasNextPage) {
const result: GetProjectLabelsQuery = await client.request(query, {
first: 100,
after,
})
allLabels.push(...result.projectLabels.nodes)
hasNextPage = result.projectLabels.pageInfo.hasNextPage
after = result.projectLabels.pageInfo.endCursor
}

return allLabels.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
)
}

export async function resolveProjectLabelIds(
labelNames: string[],
): Promise<string[]> {
const client = getGraphQLClient()
const createMutation = gql(/* GraphQL */ `
mutation CreateProjectLabel($input: ProjectLabelCreateInput!) {
projectLabelCreate(input: $input) {
success
projectLabel {
id
name
color
}
}
}
`)

const labels = await getProjectLabels()
const labelIds: string[] = []
const seenNames = new Set<string>()

for (const rawName of labelNames) {
const name = rawName.trim()
if (!name) continue

const normalizedName = name.toLowerCase()
if (seenNames.has(normalizedName)) continue
seenNames.add(normalizedName)

const existingLabel = labels.find((label) =>
label.name.toLowerCase() === normalizedName
)
if (existingLabel) {
labelIds.push(existingLabel.id)
continue
}

const result: CreateProjectLabelMutation = await client.request(
createMutation,
{
input: { name, isGroup: false },
},
)
if (!result.projectLabelCreate.success) {
throw new CliError(`Failed to create project label: ${name}`)
}

const createdLabel = result.projectLabelCreate.projectLabel
labels.push(createdLabel)
labelIds.push(createdLabel.id)
}

return labelIds
}

export async function getProjectLabelIdsForProject(
projectId: string,
): Promise<string[]> {
const client = getGraphQLClient()
const query = gql(/* GraphQL */ `
query GetProjectLabelIdsForProject($id: String!) {
project(id: $id) {
labelIds
}
}
`)

const result: GetProjectLabelIdsForProjectQuery = await client.request(
query,
{
id: projectId,
},
)
return [...result.project.labelIds]
}

export async function getAllTeams(): Promise<
Array<{ id: string; key: string; name: string }>
> {
Expand Down
17 changes: 17 additions & 0 deletions test/commands/project/__snapshots__/project-create.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Options:
--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)
--label <label> - Project label associated with the project. May be repeated.
-i, --interactive - Interactive mode (default if no flags provided)
-j, --json - Output created project as JSON

Expand All @@ -43,3 +44,19 @@ stdout:
stderr:
""
`;

snapshot[`Project Create Command - With Labels 1`] = `
stdout:
'{
"success": true,
"project": {
"id": "550e8400-e29b-41d4-a716-446655440010",
"slugId": "project-with-labels",
"name": "Project With Labels",
"url": "https://linear.app/test/project/project-with-labels"
}
}
'
stderr:
""
`;
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 @@ -19,6 +19,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> - Project label associated with the project. May be repeated.

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

snapshot[`Project Update Command - Add Labels 1`] = `
stdout:
"✓ Updated project: Test Project
https://linear.app/test/project/proj-labels
"
stderr:
""
`;
Loading