diff --git a/docs/usage.md b/docs/usage.md index d98fefc6..fbe843cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 @@ -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 diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..f8b6a311 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -21,6 +21,7 @@ import { getWorkflowStateByNameOrType, getWorkflowStates, lookupUserId, + resolveIssueUser, searchTeamsByKeySubstring, selectOption, type WorkflowState, @@ -455,6 +456,10 @@ export const createCommand = new Command() "-a, --assignee ", "Assign the issue to 'self' or someone (by username or name)", ) + .option( + "--delegate ", + "Delegate the issue to an agent user (by username or name)", + ) .option( "--due-date ", "Due date of the issue", @@ -515,6 +520,7 @@ export const createCommand = new Command() { start, assignee, + delegate, dueDate, useDefaultTemplate, parent: parentIdentifier, @@ -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 @@ -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 = [] @@ -802,6 +854,7 @@ export const createCommand = new Command() const input = { title, assigneeId, + delegateId, dueDate, parentId, priority, diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 1487516f..ccbc88c2 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -11,7 +11,7 @@ import { getProjectIdByName, getTeamIdByKey, getWorkflowStateByNameOrType, - lookupUserId, + resolveIssueUser, } from "../../utils/linear.ts" import { CliError, @@ -28,6 +28,10 @@ export const updateCommand = new Command() "-a, --assignee ", "Assign the issue to 'self' or someone (by username or name)", ) + .option( + "--delegate ", + "Delegate the issue to an agent user (by username or name)", + ) .option( "--due-date ", "Due date of the issue", @@ -82,6 +86,7 @@ export const updateCommand = new Command() async ( { assignee, + delegate, dueDate, parent, priority, @@ -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 = [] @@ -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) diff --git a/src/utils/linear.ts b/src/utils/linear.ts index cd4d2c0c..28c861e0 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -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" @@ -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 { +): 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 */ ` @@ -726,6 +742,7 @@ export async function lookupUserId( email displayName name + app } } } @@ -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>> } + | { + kind: "wrong_type" + user: NonNullable>> + } + | { kind: "ambiguous" } + | { kind: "not_found" } + +function selectTypedUserMatch( + users: Array>>>, + 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 { + 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 { + const user = await lookupUser(input) + return user?.id } export async function getIssueLabelIdByNameForTeam( diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 729a7283..8d607642 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -14,6 +14,7 @@ Options: -h, --help - Show this help. --start - Start the issue after creation -a, --assignee - Assign the issue to 'self' or someone (by username or name) + --delegate - Delegate the issue to an agent user (by username or name) --due-date - Due date of the issue --parent - Parent issue (if any) as a team_number code -p, --priority - Priority of the issue (1-4, descending priority) @@ -45,6 +46,35 @@ stderr: "" `; +snapshot[`Issue Create Command - Delegate Happy Path 1`] = ` +stdout: +"Creating issue in ENG + +https://linear.app/test-team/issue/ENG-124/delegate-agent-work +" +stderr: +"" +`; + +snapshot[`Issue Create Command - Assignee Rejects App User 1`] = ` +stdout: +"" +stderr: +"✗ Failed to create issue: Cannot use --assignee with app user 'agent-name' + Use --delegate agent-name to delegate the issue to an agent user. +" +`; + +snapshot[`Issue Create Command - Assignee Rejects Ambiguous Mixed Matches 1`] = ` +stdout: +"Creating issue in ENG + +" +stderr: +"✗ Failed to create issue: No mock response configured for this query +" +`; + snapshot[`Issue Create Command - With Milestone 1`] = ` stdout: "Creating issue in ENG diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 74280470..74058161 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -13,6 +13,7 @@ Options: -h, --help - Show this help. -a, --assignee - Assign the issue to 'self' or someone (by username or name) + --delegate - Delegate the issue to an agent user (by username or name) --due-date - Due date of the issue --parent - Parent issue (if any) as a team_number code -p, --priority - Priority of the issue (1-4, descending priority) @@ -43,6 +44,36 @@ stderr: "" `; +snapshot[`Issue Update Command - Delegate Happy Path 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Assignee Rejects App User 1`] = ` +stdout: +"" +stderr: +"✗ Failed to update issue: Cannot use --assignee with app user 'agent-name' + Use --delegate agent-name to delegate the issue to an agent user. +" +`; + +snapshot[`Issue Update Command - Delegate Rejects Ambiguous Mixed Matches 1`] = ` +stdout: +"Updating issue ENG-123 + +" +stderr: +"✗ Failed to update issue: No mock response configured for this query +" +`; + snapshot[`Issue Update Command - With Milestone 1`] = ` stdout: "Updating issue ENG-123 diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 17811fb6..b80c82d9 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -60,6 +60,10 @@ await snapshotTest({ data: { viewer: { id: "user-self-123", + email: "me@example.com", + displayName: "me", + name: "Me", + app: false, }, }, }, @@ -94,6 +98,198 @@ await snapshotTest({ }, }) +await snapshotTest({ + name: "Issue Create Command - Delegate Happy Path", + meta: import.meta, + colors: false, + args: [ + "--title", + "Delegate agent work", + "--delegate", + "agent-name", + "--team", + "ENG", + "--no-interactive", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUsersForIssueResolution", + variables: { input: "agent-name" }, + response: { + data: { + users: { + nodes: [{ + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", + app: true, + }], + }, + }, + }, + }, + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-new-delegate", + identifier: "ENG-124", + url: + "https://linear.app/test-team/issue/ENG-124/delegate-agent-work", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Issue Create Command - Assignee Rejects App User", + meta: import.meta, + colors: false, + canFail: true, + args: [ + "--title", + "Delegate agent work", + "--assignee", + "agent-name", + "--team", + "ENG", + "--no-interactive", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUsersForIssueResolution", + variables: { input: "agent-name" }, + response: { + data: { + users: { + nodes: [{ + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", + app: true, + }], + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Issue Create Command - Assignee Rejects Ambiguous Mixed Matches", + meta: import.meta, + colors: false, + canFail: true, + args: [ + "--title", + "Delegate agent work", + "--assignee", + "agent-name", + "--team", + "ENG", + "--no-interactive", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUsersForIssueResolution", + variables: { input: "agent-name" }, + response: { + data: { + users: { + nodes: [ + { + id: "user-human-123", + email: "person@example.com", + displayName: "person-one", + name: "Agent Name Builder", + app: false, + }, + { + id: "user-agent-123", + email: "agent-one@oauthapp.linear.app", + displayName: "agent-one", + name: "Agent Name Runner", + app: true, + }, + ], + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse() + } finally { + await cleanup() + } + }, +}) + // Test creating an issue with milestone await snapshotTest({ name: "Issue Create Command - With Milestone", diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 275a18b4..f235ac77 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -58,6 +58,10 @@ await snapshotTest({ data: { viewer: { id: "user-self-123", + email: "me@example.com", + displayName: "me", + name: "Me", + app: false, }, }, }, @@ -90,6 +94,183 @@ await snapshotTest({ }, }) +await snapshotTest({ + name: "Issue Update Command - Delegate Happy Path", + meta: import.meta, + colors: false, + args: [ + "ENG-123", + "--delegate", + "agent-name", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUsersForIssueResolution", + variables: { input: "agent-name" }, + response: { + data: { + users: { + nodes: [{ + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", + app: true, + }], + }, + }, + }, + }, + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Issue Update Command - Assignee Rejects App User", + meta: import.meta, + colors: false, + canFail: true, + args: [ + "ENG-123", + "--assignee", + "agent-name", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUsersForIssueResolution", + variables: { input: "agent-name" }, + response: { + data: { + users: { + nodes: [{ + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", + app: true, + }], + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Issue Update Command - Delegate Rejects Ambiguous Mixed Matches", + meta: import.meta, + colors: false, + canFail: true, + args: [ + "ENG-123", + "--delegate", + "agent-name", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUsersForIssueResolution", + variables: { input: "agent-name" }, + response: { + data: { + users: { + nodes: [ + { + id: "user-human-123", + email: "person@example.com", + displayName: "person-one", + name: "Agent Name Builder", + app: false, + }, + { + id: "user-agent-123", + email: "agent-one@oauthapp.linear.app", + displayName: "agent-one", + name: "Agent Name Runner", + app: true, + }, + ], + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + // Test updating an issue with milestone await snapshotTest({ name: "Issue Update Command - With Milestone", diff --git a/test/utils/mock_linear_server.ts b/test/utils/mock_linear_server.ts index 025036bd..a056e453 100644 --- a/test/utils/mock_linear_server.ts +++ b/test/utils/mock_linear_server.ts @@ -20,7 +20,7 @@ interface MockResponse { export class MockLinearServer { private server?: Deno.HttpServer - private port = 3333 + private port = 0 private mockResponses: MockResponse[] constructor(responses: MockResponse[] = []) { @@ -28,7 +28,13 @@ export class MockLinearServer { } async start(): Promise { - this.server = Deno.serve({ port: this.port }, (request) => { + this.server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen: ({ port }) => { + this.port = port + }, + }, (request) => { // Handle CORS preflight if (request.method === "OPTIONS") { return new Response(null, {