From 9c050bfcbbae55bca97e15cc85a0715f39b941ca Mon Sep 17 00:00:00 2001 From: Shane Lindsay Date: Wed, 11 Mar 2026 11:10:12 +0000 Subject: [PATCH 1/4] feat(issue): add agent delegation support --- README.md | 2 + docs/usage.md | 6 + skills/linear-cli/references/issue.md | 2 + src/commands/issue/issue-create.ts | 41 +++++- src/commands/issue/issue-update.ts | 40 +++++- src/utils/linear.ts | 36 ++++- .../__snapshots__/issue-create.test.ts.snap | 20 +++ .../__snapshots__/issue-update.test.ts.snap | 21 +++ test/commands/issue/issue-create.test.ts | 132 ++++++++++++++++++ test/commands/issue/issue-update.test.ts | 121 ++++++++++++++++ test/utils/mock_linear_server.ts | 10 +- 11 files changed, 417 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dbda35c1..e0279281 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,10 @@ linear issue list -a # open issue list in Linear.app linear issue start # create/switch to issue branch and mark as started linear issue create # create a new issue (interactive prompts) linear issue create -t "title" -d "description" # create with flags +linear issue create --delegate rowan --team ENG --title "Investigate sync drift" # delegate to an agent user linear issue create --project "My Project" --milestone "Phase 1" # create with milestone linear issue update # update an issue (interactive prompts) +linear issue update ENG-123 --delegate rowan # delegate an existing issue to an agent user linear issue update ENG-123 --milestone "Phase 2" # set milestone on existing issue linear issue delete # delete an issue linear issue comment list # list comments on current issue diff --git a/docs/usage.md b/docs/usage.md index d98fefc6..ffc30a02 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 rowan + # 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 rowan ``` #### other issue commands diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 12de728d..a3c8cdb2 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -259,6 +259,7 @@ Options: -w, --workspace - Target workspace (uses credentials) --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) @@ -292,6 +293,7 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) -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) diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d9f37b29..69d831ed 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -20,6 +20,7 @@ import { getTeamKey, getWorkflowStateByNameOrType, getWorkflowStates, + lookupUser, lookupUserId, searchTeamsByKeySubstring, selectOption, @@ -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,38 @@ export const createCommand = new Command() let assigneeId = undefined if (assignee) { - assigneeId = await lookupUserId(assignee) - if (assigneeId == null) { + const resolvedAssignee = await lookupUser(assignee) + if (resolvedAssignee == null) { throw new NotFoundError("User", assignee) } + if (resolvedAssignee.app) { + throw new ValidationError( + `Cannot use --assignee with app user '${assignee}'`, + { + suggestion: + `Use --delegate ${assignee} to delegate the issue to an agent user.`, + }, + ) + } + assigneeId = resolvedAssignee.id + } + + let delegateId: string | undefined + if (delegate) { + const resolvedDelegate = await lookupUser(delegate) + if (resolvedDelegate == null) { + throw new NotFoundError("User", delegate) + } + if (!resolvedDelegate.app) { + throw new ValidationError( + `Cannot use --delegate with human user '${delegate}'`, + { + suggestion: + `Use --assignee ${delegate} to assign the issue to a human user.`, + }, + ) + } + delegateId = resolvedDelegate.id } const labelIds = [] @@ -802,6 +836,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..6cba8375 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -11,7 +11,7 @@ import { getProjectIdByName, getTeamIdByKey, getWorkflowStateByNameOrType, - lookupUserId, + lookupUser, } 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,38 @@ export const updateCommand = new Command() let assigneeId: string | undefined if (assignee !== undefined) { - assigneeId = await lookupUserId(assignee) - if (!assigneeId) { + const resolvedAssignee = await lookupUser(assignee) + if (!resolvedAssignee) { throw new NotFoundError("User", assignee) } + if (resolvedAssignee.app) { + throw new ValidationError( + `Cannot use --assignee with app user '${assignee}'`, + { + suggestion: + `Use --delegate ${assignee} to delegate the issue to an agent user.`, + }, + ) + } + assigneeId = resolvedAssignee.id + } + + let delegateId: string | undefined + if (delegate !== undefined) { + const resolvedDelegate = await lookupUser(delegate) + if (!resolvedDelegate) { + throw new NotFoundError("User", delegate) + } + if (!resolvedDelegate.app) { + throw new ValidationError( + `Cannot use --delegate with human user '${delegate}'`, + { + suggestion: + `Use --assignee ${delegate} to assign the issue to a human user.`, + }, + ) + } + delegateId = resolvedDelegate.id } const labelIds = [] @@ -229,6 +262,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..fd238fb7 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -691,23 +691,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 +739,7 @@ export async function lookupUserId( email displayName name + app } } } @@ -738,20 +752,30 @@ 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] } } +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( name: string, teamKey: string, diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 729a7283..6bc769e2 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,25 @@ 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 'rowan' + Use --delegate rowan to delegate the issue to an agent user. +" +`; + 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..8d0f48f8 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,26 @@ 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 'rowan' + Use --delegate rowan to delegate the issue to an agent user. +" +`; + 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..93f455bc 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,134 @@ await snapshotTest({ }, }) +await snapshotTest({ + name: "Issue Create Command - Delegate Happy Path", + meta: import.meta, + colors: false, + args: [ + "--title", + "Delegate agent work", + "--delegate", + "rowan", + "--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: "LookupUser", + variables: { input: "rowan" }, + response: { + data: { + users: { + nodes: [{ + id: "user-rowan-123", + email: "rowan@oauthapp.linear.app", + displayName: "rowan", + name: "Rowan", + 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", + "rowan", + "--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: "LookupUser", + variables: { input: "rowan" }, + response: { + data: { + users: { + nodes: [{ + id: "user-rowan-123", + email: "rowan@oauthapp.linear.app", + displayName: "rowan", + name: "Rowan", + 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..2d6479b1 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,123 @@ await snapshotTest({ }, }) +await snapshotTest({ + name: "Issue Update Command - Delegate Happy Path", + meta: import.meta, + colors: false, + args: [ + "ENG-123", + "--delegate", + "rowan", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUser", + variables: { input: "rowan" }, + response: { + data: { + users: { + nodes: [{ + id: "user-rowan-123", + email: "rowan@oauthapp.linear.app", + displayName: "rowan", + name: "Rowan", + 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", + "rowan", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "LookupUser", + variables: { input: "rowan" }, + response: { + data: { + users: { + nodes: [{ + id: "user-rowan-123", + email: "rowan@oauthapp.linear.app", + displayName: "rowan", + name: "Rowan", + 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, { From 341648f103e47f9e56c136f9caee1026ff2f1f4d Mon Sep 17 00:00:00 2001 From: Shane Lindsay Date: Wed, 11 Mar 2026 11:18:29 +0000 Subject: [PATCH 2/4] chore(docs): trim non-essential delegate docs --- README.md | 2 -- skills/linear-cli/references/issue.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/README.md b/README.md index e0279281..dbda35c1 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,8 @@ linear issue list -a # open issue list in Linear.app linear issue start # create/switch to issue branch and mark as started linear issue create # create a new issue (interactive prompts) linear issue create -t "title" -d "description" # create with flags -linear issue create --delegate rowan --team ENG --title "Investigate sync drift" # delegate to an agent user linear issue create --project "My Project" --milestone "Phase 1" # create with milestone linear issue update # update an issue (interactive prompts) -linear issue update ENG-123 --delegate rowan # delegate an existing issue to an agent user linear issue update ENG-123 --milestone "Phase 2" # set milestone on existing issue linear issue delete # delete an issue linear issue comment list # list comments on current issue diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index a3c8cdb2..12de728d 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -259,7 +259,6 @@ Options: -w, --workspace - Target workspace (uses credentials) --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) @@ -293,7 +292,6 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) -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) From 11fb81026557824870cb2b721c11bfa1c4b9ae2f Mon Sep 17 00:00:00 2001 From: Shane Lindsay Date: Wed, 11 Mar 2026 11:26:14 +0000 Subject: [PATCH 3/4] chore(tests): use generic agent fixtures --- docs/usage.md | 4 ++-- .../__snapshots__/issue-create.test.ts.snap | 4 ++-- .../__snapshots__/issue-update.test.ts.snap | 4 ++-- test/commands/issue/issue-create.test.ts | 24 +++++++++---------- test/commands/issue/issue-update.test.ts | 24 +++++++++---------- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ffc30a02..fbe843cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -130,7 +130,7 @@ linear issue create --title "Fix bug" --description "Description here" linear issue create --assignee self # Create and delegate to an agent user -linear issue create --delegate rowan +linear issue create --delegate agent-name # Create with priority (1-4, where 1 is highest) linear issue create --priority 1 @@ -162,7 +162,7 @@ update a specific issue: linear issue update TEAM-123 # Delegate an existing issue to an agent user -linear issue update TEAM-123 --delegate rowan +linear issue update TEAM-123 --delegate agent-name ``` #### other issue commands diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 6bc769e2..010b2909 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -60,8 +60,8 @@ snapshot[`Issue Create Command - Assignee Rejects App User 1`] = ` stdout: "" stderr: -"✗ Failed to create issue: Cannot use --assignee with app user 'rowan' - Use --delegate rowan to delegate the issue to an agent user. +"✗ Failed to create issue: Cannot use --assignee with app user 'agent-name' + Use --delegate agent-name to delegate the issue to an agent user. " `; diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 8d0f48f8..30640cb5 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -59,8 +59,8 @@ snapshot[`Issue Update Command - Assignee Rejects App User 1`] = ` stdout: "" stderr: -"✗ Failed to update issue: Cannot use --assignee with app user 'rowan' - Use --delegate rowan to delegate the issue to an agent user. +"✗ Failed to update issue: Cannot use --assignee with app user 'agent-name' + Use --delegate agent-name to delegate the issue to an agent user. " `; diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 93f455bc..87dbff8c 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -106,7 +106,7 @@ await snapshotTest({ "--title", "Delegate agent work", "--delegate", - "rowan", + "agent-name", "--team", "ENG", "--no-interactive", @@ -127,15 +127,15 @@ await snapshotTest({ }, { queryName: "LookupUser", - variables: { input: "rowan" }, + variables: { input: "agent-name" }, response: { data: { users: { nodes: [{ - id: "user-rowan-123", - email: "rowan@oauthapp.linear.app", - displayName: "rowan", - name: "Rowan", + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", app: true, }], }, @@ -180,7 +180,7 @@ await snapshotTest({ "--title", "Delegate agent work", "--assignee", - "rowan", + "agent-name", "--team", "ENG", "--no-interactive", @@ -201,15 +201,15 @@ await snapshotTest({ }, { queryName: "LookupUser", - variables: { input: "rowan" }, + variables: { input: "agent-name" }, response: { data: { users: { nodes: [{ - id: "user-rowan-123", - email: "rowan@oauthapp.linear.app", - displayName: "rowan", - name: "Rowan", + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", app: true, }], }, diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 2d6479b1..682a9289 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -101,7 +101,7 @@ await snapshotTest({ args: [ "ENG-123", "--delegate", - "rowan", + "agent-name", ], denoArgs: commonDenoArgs, async fn() { @@ -119,15 +119,15 @@ await snapshotTest({ }, { queryName: "LookupUser", - variables: { input: "rowan" }, + variables: { input: "agent-name" }, response: { data: { users: { nodes: [{ - id: "user-rowan-123", - email: "rowan@oauthapp.linear.app", - displayName: "rowan", - name: "Rowan", + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", app: true, }], }, @@ -168,7 +168,7 @@ await snapshotTest({ args: [ "ENG-123", "--assignee", - "rowan", + "agent-name", ], denoArgs: commonDenoArgs, async fn() { @@ -186,15 +186,15 @@ await snapshotTest({ }, { queryName: "LookupUser", - variables: { input: "rowan" }, + variables: { input: "agent-name" }, response: { data: { users: { nodes: [{ - id: "user-rowan-123", - email: "rowan@oauthapp.linear.app", - displayName: "rowan", - name: "Rowan", + id: "user-agent-123", + email: "agent-name@oauthapp.linear.app", + displayName: "agent-name", + name: "Agent Name", app: true, }], }, From f746a7c2f15644ff22e0dd6b0a28f8014a29cd84 Mon Sep 17 00:00:00 2001 From: Shane Lindsay Date: Wed, 11 Mar 2026 11:40:33 +0000 Subject: [PATCH 4/4] fix(issue): disambiguate agent user resolution --- src/commands/issue/issue-create.ts | 36 ++++++--- src/commands/issue/issue-update.ts | 36 ++++++--- src/utils/linear.ts | 79 +++++++++++++++++++ .../__snapshots__/issue-create.test.ts.snap | 10 +++ .../__snapshots__/issue-update.test.ts.snap | 10 +++ test/commands/issue/issue-create.test.ts | 68 +++++++++++++++- test/commands/issue/issue-update.test.ts | 64 ++++++++++++++- 7 files changed, 281 insertions(+), 22 deletions(-) diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index 69d831ed..f8b6a311 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -20,8 +20,8 @@ import { getTeamKey, getWorkflowStateByNameOrType, getWorkflowStates, - lookupUser, lookupUserId, + resolveIssueUser, searchTeamsByKeySubstring, selectOption, type WorkflowState, @@ -716,11 +716,20 @@ export const createCommand = new Command() let assigneeId = undefined if (assignee) { - const resolvedAssignee = await lookupUser(assignee) - if (resolvedAssignee == null) { + const resolvedAssignee = await resolveIssueUser(assignee, false) + if (resolvedAssignee.kind === "not_found") { throw new NotFoundError("User", assignee) } - if (resolvedAssignee.app) { + 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}'`, { @@ -729,16 +738,25 @@ export const createCommand = new Command() }, ) } - assigneeId = resolvedAssignee.id + assigneeId = resolvedAssignee.user.id } let delegateId: string | undefined if (delegate) { - const resolvedDelegate = await lookupUser(delegate) - if (resolvedDelegate == null) { + const resolvedDelegate = await resolveIssueUser(delegate, true) + if (resolvedDelegate.kind === "not_found") { throw new NotFoundError("User", delegate) } - if (!resolvedDelegate.app) { + 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}'`, { @@ -747,7 +765,7 @@ export const createCommand = new Command() }, ) } - delegateId = resolvedDelegate.id + delegateId = resolvedDelegate.user.id } const labelIds = [] diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 6cba8375..ccbc88c2 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -11,7 +11,7 @@ import { getProjectIdByName, getTeamIdByKey, getWorkflowStateByNameOrType, - lookupUser, + resolveIssueUser, } from "../../utils/linear.ts" import { CliError, @@ -180,11 +180,20 @@ export const updateCommand = new Command() let assigneeId: string | undefined if (assignee !== undefined) { - const resolvedAssignee = await lookupUser(assignee) - if (!resolvedAssignee) { + const resolvedAssignee = await resolveIssueUser(assignee, false) + if (resolvedAssignee.kind === "not_found") { throw new NotFoundError("User", assignee) } - if (resolvedAssignee.app) { + 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}'`, { @@ -193,16 +202,25 @@ export const updateCommand = new Command() }, ) } - assigneeId = resolvedAssignee.id + assigneeId = resolvedAssignee.user.id } let delegateId: string | undefined if (delegate !== undefined) { - const resolvedDelegate = await lookupUser(delegate) - if (!resolvedDelegate) { + const resolvedDelegate = await resolveIssueUser(delegate, true) + if (resolvedDelegate.kind === "not_found") { throw new NotFoundError("User", delegate) } - if (!resolvedDelegate.app) { + 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}'`, { @@ -211,7 +229,7 @@ export const updateCommand = new Command() }, ) } - delegateId = resolvedDelegate.id + delegateId = resolvedDelegate.user.id } const labelIds = [] diff --git a/src/utils/linear.ts b/src/utils/linear.ts index fd238fb7..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" @@ -766,6 +769,82 @@ export async function lookupUser( } } +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 diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 010b2909..8d607642 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -65,6 +65,16 @@ stderr: " `; +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 30640cb5..74058161 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -64,6 +64,16 @@ stderr: " `; +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 87dbff8c..b80c82d9 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -126,7 +126,7 @@ await snapshotTest({ }, }, { - queryName: "LookupUser", + queryName: "LookupUsersForIssueResolution", variables: { input: "agent-name" }, response: { data: { @@ -200,7 +200,7 @@ await snapshotTest({ }, }, { - queryName: "LookupUser", + queryName: "LookupUsersForIssueResolution", variables: { input: "agent-name" }, response: { data: { @@ -226,6 +226,70 @@ await snapshotTest({ }, }) +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 682a9289..f235ac77 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -118,7 +118,7 @@ await snapshotTest({ }, }, { - queryName: "LookupUser", + queryName: "LookupUsersForIssueResolution", variables: { input: "agent-name" }, response: { data: { @@ -185,7 +185,7 @@ await snapshotTest({ }, }, { - queryName: "LookupUser", + queryName: "LookupUsersForIssueResolution", variables: { input: "agent-name" }, response: { data: { @@ -211,6 +211,66 @@ await snapshotTest({ }, }) +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",