Skip to content

Commit 511dcb3

Browse files
arul28claude
andcommitted
Add Linear issue dropdown to lane creation
Surface a searchable Linear issue picker in the new-lane dialog so users can attach a Linear issue at lane creation time instead of pasting an identifier. Adds the supporting Linear browser, CLI plumbing, and doc updates for the workflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3343e80 commit 511dcb3

33 files changed

Lines changed: 1770 additions & 932 deletions

apps/ade-cli/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,15 @@ ade auth status
5555
ade doctor
5656
ade lanes list --text
5757
ade lanes create "fix-checkout-flow" --parent main
58+
ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}'
59+
ade --role cto linear quick-view --text
60+
ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50
5861
ade git commit --lane lane-id
5962
ade git push --lane lane-id
6063
ade git branches --lane lane-id --text
6164
ade git user-identity --lane lane-id --text
6265
ade prs create --lane lane-id --base main --title "Fix checkout flow"
66+
ade prs create --lane lane-id --base main --close-linear-issue-on-merge
6367
ade prs list-open --text
6468
ade prs path-to-merge --pr pr-id --model gpt-5.5 --max-rounds 3 --no-auto-merge
6569
ade prs path-to-merge --pr pr-id --model gpt-5.5 --conflict-strategy auto --force-finalize conditional

apps/ade-cli/src/adeRpcServer.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,13 @@ function createRuntime() {
750750
createComment: vi.fn(async () => ({ id: "comment-1" })),
751751
fetchWorkflowStates: vi.fn(async () => [{ id: "state-done", name: "Done" }]),
752752
updateIssueState: vi.fn(async () => {}),
753+
listProjects: vi.fn(async () => [{ id: "proj-1", name: "ADE", slug: "ade", teamName: "ADE", teamKey: "ADE" }]),
754+
listUsers: vi.fn(async () => [{ id: "user-1", name: "Arul", displayName: "Arul" }]),
755+
listWorkflowStates: vi.fn(async () => [{ id: "state-1", name: "Todo", type: "unstarted", teamId: "team-1", teamKey: "ADE" }]),
756+
searchIssues: vi.fn(async (query: any) => ({
757+
issues: [{ id: "issue-1", identifier: "ADE-123", title: "Test", _query: query }],
758+
pageInfo: { hasNextPage: false, endCursor: null },
759+
})),
753760
} as any,
754761
linearSyncService: {
755762
getDashboard: vi.fn(() => ({ enabled: true, running: false, ingressMode: "webhook-first", reconciliationIntervalSec: 60, lastPollAt: null, lastSuccessAt: null, lastError: null, queue: { queued: 1, blocked: 0, failed: 0 }, workflowRuns: { active: 1, waiting: 0 }, recentIssues: [] })),
@@ -1360,6 +1367,55 @@ describe("adeRpcServer", () => {
13601367
);
13611368
});
13621369

1370+
it("returns the Linear issue picker data for cto callers", async () => {
1371+
const { runtime } = createRuntime();
1372+
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
1373+
1374+
await initialize(handler, { callerId: "cto-1", role: "cto" });
1375+
const result = await callTool(handler, "getLinearIssuePickerData", {});
1376+
1377+
expect((runtime.linearIssueTracker as any).listProjects).toHaveBeenCalled();
1378+
expect((runtime.linearIssueTracker as any).listUsers).toHaveBeenCalled();
1379+
expect((runtime.linearIssueTracker as any).listWorkflowStates).toHaveBeenCalled();
1380+
expect(result.structuredContent).toEqual(
1381+
expect.objectContaining({
1382+
projects: expect.any(Array),
1383+
users: expect.any(Array),
1384+
states: expect.any(Array),
1385+
}),
1386+
);
1387+
});
1388+
1389+
it("forwards search filters when calling searchLinearIssues", async () => {
1390+
const { runtime } = createRuntime();
1391+
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
1392+
1393+
await initialize(handler, { callerId: "cto-1", role: "cto" });
1394+
const result = await callTool(handler, "searchLinearIssues", {
1395+
projectId: "proj-1",
1396+
stateTypes: ["started", "unstarted"],
1397+
query: "auth",
1398+
first: 25,
1399+
includeArchived: true,
1400+
});
1401+
1402+
expect((runtime.linearIssueTracker as any).searchIssues).toHaveBeenCalledWith(
1403+
expect.objectContaining({
1404+
projectId: "proj-1",
1405+
stateTypes: ["started", "unstarted"],
1406+
query: "auth",
1407+
first: 25,
1408+
includeArchived: true,
1409+
}),
1410+
);
1411+
expect(result.structuredContent).toEqual(
1412+
expect.objectContaining({
1413+
issues: expect.any(Array),
1414+
pageInfo: expect.objectContaining({ hasNextPage: false }),
1415+
}),
1416+
);
1417+
});
1418+
13631419
it("forwards employeeOverride and laneId when resuming a Linear sync queue item", async () => {
13641420
const { runtime } = createRuntime();
13651421
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });

apps/ade-cli/src/adeRpcServer.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,6 +1737,31 @@ const CTO_LINEAR_SYNC_TOOL_SPECS: ToolSpec[] = [
17371737
description: "Read a compact Linear workspace, project, and issue quick view through the connected Linear SDK account.",
17381738
inputSchema: { type: "object", additionalProperties: false, properties: {} }
17391739
},
1740+
{
1741+
name: "getLinearIssuePickerData",
1742+
description: "Read the projects, users, and workflow states needed to populate the Linear issue picker for lane creation.",
1743+
inputSchema: { type: "object", additionalProperties: false, properties: {} }
1744+
},
1745+
{
1746+
name: "searchLinearIssues",
1747+
description: "Search Linear issues for the lane Linear-issue picker, filtered by project, team, state, assignee, priority, or text query.",
1748+
inputSchema: {
1749+
type: "object",
1750+
additionalProperties: false,
1751+
properties: {
1752+
projectId: { anyOf: [{ type: "string" }, { type: "null" }] },
1753+
projectSlug: { anyOf: [{ type: "string" }, { type: "null" }] },
1754+
teamKey: { anyOf: [{ type: "string" }, { type: "null" }] },
1755+
stateTypes: { type: "array", items: { type: "string" } },
1756+
assigneeId: { anyOf: [{ type: "string" }, { type: "null" }] },
1757+
priority: { anyOf: [{ type: "number" }, { type: "null" }] },
1758+
query: { anyOf: [{ type: "string" }, { type: "null" }] },
1759+
first: { type: "number", minimum: 1, maximum: 200 },
1760+
after: { anyOf: [{ type: "string" }, { type: "null" }] },
1761+
includeArchived: { type: "boolean" }
1762+
}
1763+
}
1764+
},
17401765
{
17411766
name: "getLinearSyncDashboard",
17421767
description: "Read the ADE Linear sync dashboard.",
@@ -1961,6 +1986,8 @@ const READ_ONLY_TOOLS = new Set([
19611986
"getChatStatus",
19621987
"readChatTranscript",
19631988
"getLinearQuickView",
1989+
"getLinearIssuePickerData",
1990+
"searchLinearIssues",
19641991
"listLinearWorkflows",
19651992
"getLinearRunStatus",
19661993
"getLinearSyncDashboard",
@@ -4352,6 +4379,35 @@ async function runTool(args: {
43524379
}
43534380
}
43544381

4382+
if (name === "getLinearIssuePickerData") {
4383+
const tracker = requireLinearIssueTracker(runtime);
4384+
const [projects, users, states] = await Promise.all([
4385+
tracker.listProjects().catch(() => []),
4386+
tracker.listUsers().catch(() => []),
4387+
tracker.listWorkflowStates().catch(() => []),
4388+
]);
4389+
return { projects, users, states };
4390+
}
4391+
4392+
if (name === "searchLinearIssues") {
4393+
const tracker = requireLinearIssueTracker(runtime);
4394+
const stateTypes = Array.isArray(toolArgs.stateTypes)
4395+
? assertStringArray(toolArgs.stateTypes, "stateTypes")
4396+
: [];
4397+
return await tracker.searchIssues({
4398+
projectId: assertOptionalStringOrNull(toolArgs.projectId ?? null, "projectId"),
4399+
projectSlug: assertOptionalStringOrNull(toolArgs.projectSlug ?? null, "projectSlug"),
4400+
teamKey: assertOptionalStringOrNull(toolArgs.teamKey ?? null, "teamKey"),
4401+
stateTypes,
4402+
assigneeId: assertOptionalStringOrNull(toolArgs.assigneeId ?? null, "assigneeId"),
4403+
priority: assertOptionalNumberOrNull(toolArgs.priority ?? null, "priority"),
4404+
query: assertOptionalStringOrNull(toolArgs.query ?? null, "query"),
4405+
first: typeof toolArgs.first === "number" ? toolArgs.first : 50,
4406+
after: assertOptionalStringOrNull(toolArgs.after ?? null, "after"),
4407+
includeArchived: asBoolean(toolArgs.includeArchived, false),
4408+
});
4409+
}
4410+
43554411
if (name === "getLinearSyncDashboard") {
43564412
return requireLinearSyncService(runtime).getDashboard();
43574413
}

apps/ade-cli/src/cli.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,48 @@ describe("ADE CLI", () => {
877877
});
878878
});
879879

880+
it("maps Linear picker data to the typed RPC tool", () => {
881+
const plan = buildCliPlan(["linear", "picker-data", "--text"]);
882+
883+
expect(plan.kind).toBe("execute");
884+
if (plan.kind !== "execute") return;
885+
expect(plan.label).toBe("Linear picker data");
886+
expect(plan.steps[0]?.params).toEqual({
887+
name: "getLinearIssuePickerData",
888+
arguments: {},
889+
});
890+
});
891+
892+
it("maps Linear search-issues filters to the typed RPC tool", () => {
893+
const plan = buildCliPlan([
894+
"linear",
895+
"search-issues",
896+
"--project-id",
897+
"proj-1",
898+
"--state-type",
899+
"started,unstarted",
900+
"--query",
901+
"auth",
902+
"--first",
903+
"25",
904+
"--include-archived",
905+
]);
906+
907+
expect(plan.kind).toBe("execute");
908+
if (plan.kind !== "execute") return;
909+
expect(plan.label).toBe("Linear search issues");
910+
expect(plan.steps[0]?.params).toEqual({
911+
name: "searchLinearIssues",
912+
arguments: {
913+
projectId: "proj-1",
914+
stateTypes: ["started", "unstarted"],
915+
query: "auth",
916+
first: 25,
917+
includeArchived: true,
918+
},
919+
});
920+
});
921+
880922
it("shows focused ios-sim help for subcommand help flags", () => {
881923
const renderHelp = buildCliPlan(["ios-sim", "preview-render", "--help"]);
882924
expect(renderHelp.kind).toBe("help");

apps/ade-cli/src/cli.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
681681
$ ade lanes show <lane> --text Inspect one lane status
682682
$ ade lanes create --name <name> Create a lane from the current project context
683683
$ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue
684+
$ ade lanes create --branch-name <branch> Override the auto-generated branch name
684685
$ ade lanes child --lane <parent> --name <name> Create a child lane under a parent
685686
$ ade lanes import --branch <branch> Register an existing branch/worktree
686687
$ ade lanes archive <lane> Archive a lane in ADE
@@ -1006,6 +1007,9 @@ const HELP_BY_COMMAND: Record<string, string> = {
10061007
Linear workflows
10071008
10081009
$ ade --role cto linear quick-view --text Show connected workspace, projects, and issues
1010+
$ ade --role cto linear picker-data --text Read projects/users/states for the issue picker
1011+
$ ade --role cto linear search-issues --query "auth" --state-type started,unstarted --first 50
1012+
Search issues for the lane Linear-issue picker
10091013
$ ade linear workflows --text List configured workflows
10101014
$ ade linear sync dashboard --text Show sync dashboard
10111015
$ ade linear sync run Trigger a sync run
@@ -3186,6 +3190,29 @@ function buildLinearPlan(args: string[]): CliPlan {
31863190
if (sub === "quick-view" || sub === "quick" || sub === "overview") {
31873191
return { kind: "execute", label: "Linear quick view", formatter: "linear-quick-view", steps: [actionCallStep("result", "getLinearQuickView", collectGenericObjectArgs(args))] };
31883192
}
3193+
if (sub === "picker-data" || sub === "picker") {
3194+
return { kind: "execute", label: "Linear picker data", steps: [actionCallStep("result", "getLinearIssuePickerData", collectGenericObjectArgs(args))] };
3195+
}
3196+
if (sub === "search-issues" || sub === "search") {
3197+
const stateTypesValue = readValue(args, ["--state-type", "--state-types", "--state"]);
3198+
const stateTypes = stateTypesValue
3199+
? stateTypesValue.split(",").map((entry) => entry.trim()).filter(Boolean)
3200+
: [];
3201+
const input: JsonObject = {};
3202+
maybePut(input, "projectId", readValue(args, ["--project-id"]));
3203+
maybePut(input, "projectSlug", readValue(args, ["--project-slug", "--project"]));
3204+
maybePut(input, "teamKey", readValue(args, ["--team-key", "--team"]));
3205+
if (stateTypes.length) input.stateTypes = stateTypes;
3206+
maybePut(input, "assigneeId", readValue(args, ["--assignee", "--assignee-id"]));
3207+
const priority = readNumberOption(args, ["--priority"]);
3208+
if (priority !== undefined) input.priority = priority;
3209+
maybePut(input, "query", readValue(args, ["--query", "-q"]));
3210+
const first = readNumberOption(args, ["--first", "--limit"]);
3211+
if (first !== undefined) input.first = first;
3212+
maybePut(input, "after", readValue(args, ["--after", "--cursor"]));
3213+
if (readFlag(args, ["--include-archived"])) input.includeArchived = true;
3214+
return { kind: "execute", label: "Linear search issues", steps: [actionCallStep("result", "searchLinearIssues", collectGenericObjectArgs(args, input))] };
3215+
}
31893216
if (sub === "workflows") return { kind: "execute", label: "Linear workflows", steps: [actionCallStep("result", "listLinearWorkflows", collectGenericObjectArgs(args))] };
31903217
if (sub === "run") {
31913218
const mode = firstPositional(args) ?? "status";

apps/desktop/src/main/services/cto/issueTracker.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export type IssueTracker = {
6666
connected: boolean;
6767
viewerId: string | null;
6868
viewerName: string | null;
69+
organizationId?: string | null;
70+
organizationName?: string | null;
71+
organizationUrlKey?: string | null;
72+
organizationLogoUrl?: string | null;
6973
message: string | null;
7074
}>;
7175
};

apps/desktop/src/main/services/cto/linearAuth.test.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -371,22 +371,37 @@ describe("linearOAuthService", () => {
371371
expect(session.error).toContain("User declined");
372372
});
373373

374-
it("handles OAuth callback with state mismatch", async () => {
374+
it("rejects stale OAuth callbacks without failing the active session", async () => {
375375
const credentials = createCredentialsMock();
376+
const mockFetch = vi.fn(async () => ({
377+
ok: true,
378+
status: 200,
379+
json: async () => ({ access_token: "linear-access-token-123" }),
380+
})) as any;
376381
const service = createLinearOAuthService({
377382
credentials: credentials as any,
378383
logger: createLogger(),
384+
fetchImpl: mockFetch,
379385
});
380386
activeServices.push(service);
381387

382-
const { sessionId, redirectUri } = await service.startSession();
388+
const { sessionId, authUrl, redirectUri } = await service.startSession();
389+
const stateParam = new URL(authUrl).searchParams.get("state")!;
390+
391+
const staleCallbackUrl = `${redirectUri}?code=stale-code&state=wrong-state`;
392+
const staleResponse = await httpGet(staleCallbackUrl);
393+
394+
expect(staleResponse.statusCode).toBe(400);
395+
expect(service.getSession(sessionId).status).toBe("pending");
396+
expect(credentials.setOAuthToken).not.toHaveBeenCalled();
383397

384-
const callbackUrl = `${redirectUri}?code=test-code&state=wrong-state`;
398+
const callbackUrl = `${redirectUri}?code=test-code&state=${stateParam}`;
385399
await httpGet(callbackUrl);
386400

387-
await waitForSessionStatus(service, sessionId, "failed");
388-
const session = service.getSession(sessionId);
389-
expect(session.error).toContain("state did not match");
401+
await waitForSessionStatus(service, sessionId, "completed");
402+
expect(credentials.setOAuthToken).toHaveBeenCalledWith(expect.objectContaining({
403+
accessToken: "linear-access-token-123",
404+
}));
390405
});
391406

392407
it("handles OAuth callback without authorization code", async () => {
@@ -623,6 +638,44 @@ describe("linearClient", () => {
623638
expect(fetchImpl).toHaveBeenCalledTimes(2);
624639
});
625640

641+
it("loads connection identity with the authorized Linear workspace", async () => {
642+
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
643+
expect(init?.headers).toMatchObject({ authorization: "Bearer test-token" });
644+
return new Response(
645+
JSON.stringify({
646+
data: {
647+
viewer: { id: "viewer-1", displayName: "Alex" },
648+
organization: {
649+
id: "org-1",
650+
name: "Acme Workspace",
651+
urlKey: "acme",
652+
logoUrl: "https://linear.app/acme/logo.png",
653+
},
654+
},
655+
}),
656+
{ status: 200, headers: { "content-type": "application/json" } }
657+
);
658+
});
659+
660+
const client = createLinearClient({
661+
credentials: {
662+
getTokenOrThrow: () => "Bearer test-token",
663+
getStatus: () => ({ authMode: "oauth" }),
664+
} as any,
665+
fetchImpl: fetchImpl as any,
666+
logger: null,
667+
});
668+
669+
await expect(client.getConnectionIdentity()).resolves.toEqual({
670+
viewerId: "viewer-1",
671+
viewerName: "Alex",
672+
organizationId: "org-1",
673+
organizationName: "Acme Workspace",
674+
organizationUrlKey: "acme",
675+
organizationLogoUrl: "https://linear.app/acme/logo.png",
676+
});
677+
});
678+
626679
it("lists projects with their owning team names", async () => {
627680
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
628681
const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string };

0 commit comments

Comments
 (0)