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
6 changes: 6 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ linear issue create --title "Fix bug" --description "Description here"
# Create and assign to yourself
linear issue create --assignee self

# Create and delegate to an agent user
linear issue create --delegate agent-name

# Create with priority (1-4, where 1 is highest)
linear issue create --priority 1

Expand Down Expand Up @@ -157,6 +160,9 @@ update a specific issue:

```bash
linear issue update TEAM-123

# Delegate an existing issue to an agent user
linear issue update TEAM-123 --delegate agent-name
```

#### other issue commands
Expand Down
59 changes: 56 additions & 3 deletions src/commands/issue/issue-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getWorkflowStateByNameOrType,
getWorkflowStates,
lookupUserId,
resolveIssueUser,
searchTeamsByKeySubstring,
selectOption,
type WorkflowState,
Expand Down Expand Up @@ -455,6 +456,10 @@ export const createCommand = new Command()
"-a, --assignee <assignee:string>",
"Assign the issue to 'self' or someone (by username or name)",
)
.option(
"--delegate <delegate:string>",
"Delegate the issue to an agent user (by username or name)",
)
.option(
"--due-date <dueDate:string>",
"Due date of the issue",
Expand Down Expand Up @@ -515,6 +520,7 @@ export const createCommand = new Command()
{
start,
assignee,
delegate,
dueDate,
useDefaultTemplate,
parent: parentIdentifier,
Expand Down Expand Up @@ -559,7 +565,7 @@ export const createCommand = new Command()
}

// If no flags are provided (or only parent is provided), use interactive mode
const noFlagsProvided = !title && !assignee && !dueDate &&
const noFlagsProvided = !title && !assignee && !delegate && !dueDate &&
priority === undefined && estimate === undefined && !finalDescription &&
(!labels || labels.length === 0) &&
!team && !project && !state && !milestone && !cycle && !start
Expand Down Expand Up @@ -710,10 +716,56 @@ export const createCommand = new Command()
let assigneeId = undefined

if (assignee) {
assigneeId = await lookupUserId(assignee)
if (assigneeId == null) {
const resolvedAssignee = await resolveIssueUser(assignee, false)
if (resolvedAssignee.kind === "not_found") {
throw new NotFoundError("User", assignee)
}
if (resolvedAssignee.kind === "ambiguous") {
throw new ValidationError(
`Ambiguous user lookup for '${assignee}'`,
{
suggestion:
"Use a more specific username, display name, or email address.",
},
)
}
if (resolvedAssignee.kind === "wrong_type") {
throw new ValidationError(
`Cannot use --assignee with app user '${assignee}'`,
{
suggestion:
`Use --delegate ${assignee} to delegate the issue to an agent user.`,
},
)
}
assigneeId = resolvedAssignee.user.id
}

let delegateId: string | undefined
if (delegate) {
const resolvedDelegate = await resolveIssueUser(delegate, true)
if (resolvedDelegate.kind === "not_found") {
throw new NotFoundError("User", delegate)
}
if (resolvedDelegate.kind === "ambiguous") {
throw new ValidationError(
`Ambiguous user lookup for '${delegate}'`,
{
suggestion:
"Use a more specific username, display name, or email address.",
},
)
}
if (resolvedDelegate.kind === "wrong_type") {
throw new ValidationError(
`Cannot use --delegate with human user '${delegate}'`,
{
suggestion:
`Use --assignee ${delegate} to assign the issue to a human user.`,
},
)
}
delegateId = resolvedDelegate.user.id
}

const labelIds = []
Expand Down Expand Up @@ -802,6 +854,7 @@ export const createCommand = new Command()
const input = {
title,
assigneeId,
delegateId,
dueDate,
parentId,
priority,
Expand Down
58 changes: 55 additions & 3 deletions src/commands/issue/issue-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
getProjectIdByName,
getTeamIdByKey,
getWorkflowStateByNameOrType,
lookupUserId,
resolveIssueUser,
} from "../../utils/linear.ts"
import {
CliError,
Expand All @@ -28,6 +28,10 @@ export const updateCommand = new Command()
"-a, --assignee <assignee:string>",
"Assign the issue to 'self' or someone (by username or name)",
)
.option(
"--delegate <delegate:string>",
"Delegate the issue to an agent user (by username or name)",
)
.option(
"--due-date <dueDate:string>",
"Due date of the issue",
Expand Down Expand Up @@ -82,6 +86,7 @@ export const updateCommand = new Command()
async (
{
assignee,
delegate,
dueDate,
parent,
priority,
Expand Down Expand Up @@ -175,10 +180,56 @@ export const updateCommand = new Command()

let assigneeId: string | undefined
if (assignee !== undefined) {
assigneeId = await lookupUserId(assignee)
if (!assigneeId) {
const resolvedAssignee = await resolveIssueUser(assignee, false)
if (resolvedAssignee.kind === "not_found") {
throw new NotFoundError("User", assignee)
}
if (resolvedAssignee.kind === "ambiguous") {
throw new ValidationError(
`Ambiguous user lookup for '${assignee}'`,
{
suggestion:
"Use a more specific username, display name, or email address.",
},
)
}
if (resolvedAssignee.kind === "wrong_type") {
throw new ValidationError(
`Cannot use --assignee with app user '${assignee}'`,
{
suggestion:
`Use --delegate ${assignee} to delegate the issue to an agent user.`,
},
)
}
assigneeId = resolvedAssignee.user.id
}

let delegateId: string | undefined
if (delegate !== undefined) {
const resolvedDelegate = await resolveIssueUser(delegate, true)
if (resolvedDelegate.kind === "not_found") {
throw new NotFoundError("User", delegate)
}
if (resolvedDelegate.kind === "ambiguous") {
throw new ValidationError(
`Ambiguous user lookup for '${delegate}'`,
{
suggestion:
"Use a more specific username, display name, or email address.",
},
)
}
if (resolvedDelegate.kind === "wrong_type") {
throw new ValidationError(
`Cannot use --delegate with human user '${delegate}'`,
{
suggestion:
`Use --assignee ${delegate} to assign the issue to a human user.`,
},
)
}
delegateId = resolvedDelegate.user.id
}

const labelIds = []
Expand Down Expand Up @@ -229,6 +280,7 @@ export const updateCommand = new Command()

if (title !== undefined) input.title = title
if (assigneeId !== undefined) input.assigneeId = assigneeId
if (delegateId !== undefined) input.delegateId = delegateId
if (dueDate !== undefined) input.dueDate = dueDate
if (parent !== undefined) {
const parentIdentifier = await getIssueIdentifier(parent)
Expand Down
115 changes: 109 additions & 6 deletions src/utils/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type {
GetTeamMembersQuery,
IssueFilter,
IssueSortInput,
LookupUsersForIssueResolutionQuery,
LookupUsersForIssueResolutionQueryVariables,
} from "../__codegen__/graphql.ts"
import { LookupUsersForIssueResolutionDocument } from "../__codegen__/graphql.ts"
import { Select } from "@cliffy/prompt"
import { getOption } from "../config.ts"
import { getGraphQLClient } from "./graphql.ts"
Expand Down Expand Up @@ -691,23 +694,36 @@ export async function searchTeamsByKeySubstring(
)
}

export async function lookupUserId(
export async function lookupUser(
/**
* email, username, display name, 'self', or '@me' for viewer
*/
input: "self" | "@me" | string,
): Promise<string | undefined> {
): Promise<
| {
id: string
email?: string | null
displayName?: string | null
name: string
app: boolean
}
| undefined
> {
if (input === "@me" || input === "self") {
const client = getGraphQLClient()
const query = gql(/* GraphQL */ `
query GetViewerId {
viewer {
id
email
displayName
name
app
}
}
`)
const data = await client.request(query, {})
return data.viewer.id
return data.viewer
} else {
const client = getGraphQLClient()
const query = gql(/* GraphQL */ `
Expand All @@ -726,6 +742,7 @@ export async function lookupUserId(
email
displayName
name
app
}
}
}
Expand All @@ -738,18 +755,104 @@ export async function lookupUserId(

for (const user of data.users.nodes) {
if (user.email?.toLowerCase() === input.toLowerCase()) {
return user.id
return user
}
}

for (const user of data.users.nodes) {
if (user.displayName?.toLowerCase() === input.toLowerCase()) {
return user.id
return user
}
}

return data.users.nodes[0]?.id
return data.users.nodes[0]
}
}

type LookupUserResult =
| { kind: "match"; user: NonNullable<Awaited<ReturnType<typeof lookupUser>>> }
| {
kind: "wrong_type"
user: NonNullable<Awaited<ReturnType<typeof lookupUser>>>
}
| { kind: "ambiguous" }
| { kind: "not_found" }

function selectTypedUserMatch(
users: Array<NonNullable<Awaited<ReturnType<typeof lookupUser>>>>,
expectedApp: boolean,
): LookupUserResult | null {
if (users.length === 0) {
return null
}

const expectedType = users.filter((user) => user.app === expectedApp)
if (expectedType.length === 1) {
return { kind: "match", user: expectedType[0] }
}
if (expectedType.length > 1) {
return { kind: "ambiguous" }
}

const oppositeType = users.filter((user) => user.app !== expectedApp)
if (oppositeType.length === 1) {
return { kind: "wrong_type", user: oppositeType[0] }
}

return { kind: "ambiguous" }
}

export async function resolveIssueUser(
input: "self" | "@me" | string,
expectedApp: boolean,
): Promise<LookupUserResult> {
const exactSelf = input === "@me" || input === "self"
? await lookupUser(input)
: undefined

if (exactSelf) {
return exactSelf.app === expectedApp
? { kind: "match", user: exactSelf }
: { kind: "wrong_type", user: exactSelf }
}

const client = getGraphQLClient()
const data: LookupUsersForIssueResolutionQuery = await client.request(
LookupUsersForIssueResolutionDocument,
{ input } satisfies LookupUsersForIssueResolutionQueryVariables,
)
const users = data.users?.nodes ?? []

if (users.length === 0) {
return { kind: "not_found" }
}

const normalizedInput = input.toLowerCase()
const exactEmail = users.filter((user) =>
user.email?.toLowerCase() === normalizedInput
)
const exactDisplayName = users.filter((user) =>
user.displayName?.toLowerCase() === normalizedInput
)
const exactName = users.filter((user) =>
user.name.toLowerCase() === normalizedInput
)

return selectTypedUserMatch(exactEmail, expectedApp) ??
selectTypedUserMatch(exactDisplayName, expectedApp) ??
selectTypedUserMatch(exactName, expectedApp) ??
selectTypedUserMatch(users, expectedApp) ??
{ kind: "not_found" }
}

export async function lookupUserId(
/**
* email, username, display name, 'self', or '@me' for viewer
*/
input: "self" | "@me" | string,
): Promise<string | undefined> {
const user = await lookupUser(input)
return user?.id
}

export async function getIssueLabelIdByNameForTeam(
Expand Down
Loading
Loading