From 35dc34884d014171dfbb5f565f933165c90549c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 May 2026 02:32:54 +0000 Subject: [PATCH 1/5] Expose Linear GraphQL through ADE CLI Co-authored-by: Arul Sharma --- apps/ade-cli/src/adeRpcServer.test.ts | 13 +++++ apps/ade-cli/src/cli.test.ts | 27 +++++++++++ apps/ade-cli/src/cli.ts | 48 ++++++++++++++++++- .../src/tuiClient/__tests__/commands.test.ts | 15 ++++++ apps/ade-cli/src/tuiClient/linearCommands.ts | 33 ++++++++++++- .../main/services/adeActions/registry.test.ts | 10 ++++ .../src/main/services/adeActions/registry.ts | 36 ++++++++++++++ .../src/main/services/cto/issueTracker.ts | 6 +++ .../src/main/services/cto/linearClient.ts | 22 ++++++++- .../main/services/cto/linearIssueTracker.ts | 4 ++ 10 files changed, 211 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 496c8da8d..99f1a0012 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -667,6 +667,7 @@ function createRuntime() { labels: [], assigneeName: null, })), + runGraphQL: vi.fn(async (args: unknown) => ({ viewer: { id: "user-1" }, _args: args })), createComment: vi.fn(async () => ({ id: "comment-1" })), fetchWorkflowStates: vi.fn(async () => [{ id: "state-done", name: "Done" }]), updateIssueState: vi.fn(async () => {}), @@ -2951,6 +2952,18 @@ describe("adeRpcServer", () => { }); expect(setState?.isError).toBeUndefined(); expect(fixture.runtime.linearIssueTracker.updateIssueState).toHaveBeenCalledWith("ENG-431", "state-done"); + + const graphql = await callTool(handler, "run_ade_action", { + domain: "linear_issue_tracker", + action: "graphql", + args: { query: "query Viewer { viewer { id } }", variables: { first: 1 } }, + }); + expect(graphql?.isError).toBeUndefined(); + expect(fixture.runtime.linearIssueTracker.runGraphQL).toHaveBeenCalledWith({ + query: "query Viewer { viewer { id } }", + variables: { first: 1 }, + }); + expect(graphql.structuredContent.result).toMatchObject({ viewer: { id: "user-1" } }); }); it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 077a1736c..f937c7c24 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2408,6 +2408,33 @@ describe("ADE CLI", () => { } }); + it("routes linear graphql through the runtime-owned Linear connection", () => { + const plan = buildCliPlan([ + "linear", + "graphql", + "--query", + "query Viewer { viewer { id name } }", + "--operation-name", + "Viewer", + "--variables-json", + "{\"includeArchived\":false}", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "linear_issue_tracker", + action: "graphql", + args: { + query: "query Viewer { viewer { id name } }", + operationName: "Viewer", + variables: { includeArchived: false }, + }, + }, + }); + }); + it("attaches an issue to the current session via linear attach --this-session", () => { const prev = process.env.ADE_CHAT_SESSION_ID; process.env.ADE_CHAT_SESSION_ID = "current-session"; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index cabb6c1de..70641113d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -392,7 +392,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade chat list | create | send | interrupt Work with ADE agent chats $ ade agent spawn --lane --prompt Launch an agent session in ADE $ ade cto state | chats Operate CTO state and Work chats - $ ade linear workflows | run | sync Operate Linear routing and sync workflows + $ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows $ ade automations list | create | run | runs Manage automation rules $ ade coordinator Call coordinator runtime tools $ ade tests list | run | stop | runs | logs Run configured test suites @@ -1415,6 +1415,8 @@ const HELP_BY_COMMAND: Record = { $ ade linear set-state ENG-431 Move an issue to a workflow state $ ade linear assign ENG-431 Assign or clear an issue assignee $ ade linear label ENG-431 "needs-review" Add a label to an issue + $ ade linear graphql --query 'query { viewer { id name } }' + Run Linear GraphQL through the project connection $ ade linear detach --this-session [--issue-id ENG-431] Detach one issue (or all) from this session @@ -1910,6 +1912,18 @@ function readJsonFileOption( return parseJson(text, label); } +function readTextFileOption(args: string[], names: string[], label: string): string | null { + const filePath = readValue(args, names); + if (filePath == null) return null; + const resolvedPath = path.resolve(filePath); + try { + return fs.readFileSync(resolvedPath, "utf8"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CliUsageError(`Could not read ${label} file '${filePath}': ${message}`); + } +} + function readJsonPayloadOption( args: string[], jsonNames: string[], @@ -2056,6 +2070,31 @@ function readIssueIdFlag(args: string[]): string | null { return readValue(args, ["--issue-id", "--linear-issue-id", "--issue"]); } +function readLinearGraphQLArgs(args: string[]): JsonObject { + const inlineQuery = readValue(args, ["--query", "--graphql", "--gql"]); + const fileQuery = readTextFileOption(args, ["--query-file", "--graphql-file", "--gql-file"], "--query-file"); + if (inlineQuery != null && fileQuery != null) { + throw new CliUsageError("Use either --query or --query-file, not both."); + } + const positionalQuery = inlineQuery == null && fileQuery == null ? firstPositional(args) : null; + const query = requireValue(inlineQuery ?? fileQuery ?? positionalQuery, "GraphQL query"); + const variables = readJsonPayloadOption( + args, + ["--variables-json", "--vars-json"], + ["--variables-file", "--vars-file"], + "--variables-json", + ); + if (variables !== undefined && !isRecord(variables)) { + throw new CliUsageError("--variables-json must be a JSON object."); + } + const input: JsonObject = { query }; + if (variables !== undefined) input.variables = variables; + maybePut(input, "operationName", readValue(args, ["--operation-name", "--operation"])); + const maxRetries = readNumberOption(args, ["--max-retries"]); + if (maxRetries !== undefined) input.maxRetries = maxRetries; + return collectGenericObjectArgs(args, input); +} + /** * Resolve a Linear write-bridge command's issue id when the command takes no * positional value (e.g. `ade linear issue []`). Precedence: --issue-id flag @@ -8776,6 +8815,13 @@ function buildLinearPlan(args: string[]): CliPlan { steps: [actionArgsListStep("result", "linear_issue_tracker", "fetchIssueById", [issueId])], }; } + if (sub === "graphql" || sub === "gql") { + return { + kind: "execute", + label: "Linear GraphQL", + steps: [actionStep("result", "linear_issue_tracker", "graphql", readLinearGraphQLArgs(args))], + }; + } if (sub === "quick-view" || sub === "quick" || sub === "overview") { return { kind: "execute", diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 1c8e1a1fd..3ceef3f56 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -445,6 +445,21 @@ describe("linear command routing", () => { }); }); + it("routes /linear graphql through the linear_issue_tracker action domain", () => { + expect( + buildLinearToolRequest("graphql --query 'query Viewer { viewer { id } }' --variables-json '{\"first\":10}'"), + ).toEqual({ + kind: "action", + title: "Linear GraphQL", + domain: "linear_issue_tracker", + action: "graphql", + args: { + query: "query Viewer { viewer { id } }", + variables: { first: 10 }, + }, + }); + }); + it("routes sync dashboard and queue resolution", () => { expect(buildLinearToolRequest("sync dashboard")).toEqual({ kind: "tool", diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts index a67a4f901..976591f09 100644 --- a/apps/ade-cli/src/tuiClient/linearCommands.ts +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -161,6 +161,20 @@ function attachmentFlags(options: Record): Record): Record | undefined { + const value = optionString(options, "variablesJson", "varsJson"); + if (!value) return undefined; + try { + const parsed = JSON.parse(value) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return undefined; + } + return undefined; +} + export function buildLinearToolRequest(input: string): LinearToolRequest { const parsed = parseLinearArgs(input); const [group, modeArg, ...rest] = parsed.positionals; @@ -169,7 +183,7 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { if (!group) { return usage( "Linear", - "Usage: /linear ...", + "Usage: /linear ...", ); } @@ -273,6 +287,23 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { return actionList("Linear issue", "linear_issue_tracker", "fetchIssueById", [issueId]); } + if (group === "graphql" || group === "gql") { + const query = + optionString(options, "query", "graphql", "gql") ?? + [modeArg, ...rest].filter(Boolean).join(" ").trim(); + if (!query) { + return usage( + "Linear GraphQL", + "Usage: /linear graphql --query 'query { viewer { id name } }' [--variables-json '{\"id\":\"...\"}']", + ); + } + return action("Linear GraphQL", "linear_issue_tracker", "graphql", { + query, + variables: parseGraphQLVariables(options), + operationName: optionString(options, "operationName", "operation") ?? undefined, + }); + } + if (group === "create-from" || group === "create-from-linear" || group === "create-lane-from") { // Create a lane from an issue. Auto-launching an agent from the TUI is out of // scope here (the desktop launch modal owns that); the CLI offers --start-chat. diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 5efcdfe40..1c319378e 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -362,6 +362,7 @@ describe("runtime Linear issue tracker actions", () => { listUsers: vi.fn(async () => users), listLabels: vi.fn(async () => labels), listWorkflowStates: vi.fn(async () => states), + runGraphQL: vi.fn(async (args: unknown) => ({ data: args })), }; const runtime = { linearCredentialService: { @@ -379,15 +380,24 @@ describe("runtime Linear issue tracker actions", () => { getWorkflowCatalog: () => Promise; getIssuePickerData: () => Promise; listIssues: (args?: Record) => Promise; + graphql: (args?: Record) => Promise; } & Record; expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getStatus"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("listIssues"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("graphql"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getWorkflowCatalog"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getIssuePickerData"); await expect(service.getStatus()).resolves.toMatchObject({ connected: true, tokenStored: true }); await expect(service.listIssues({ project: "desktop,cli", state: ["open"], limit: 2 })).resolves.toEqual(issues.slice(0, 2)); expect(fetchCandidateIssues).toHaveBeenCalledWith({ projectSlugs: ["desktop", "cli"], stateTypes: ["open"] }); + await expect(service.graphql({ query: "query Viewer { viewer { id } }", variables: { first: 1 } })).resolves.toEqual({ + data: { query: "query Viewer { viewer { id } }", variables: { first: 1 } }, + }); + expect(tracker.runGraphQL).toHaveBeenCalledWith({ + query: "query Viewer { viewer { id } }", + variables: { first: 1 }, + }); await expect(service.getWorkflowCatalog()).resolves.toEqual({ users, labels, states }); await expect(service.getIssuePickerData()).resolves.toEqual({ projects, users, states }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index e0d1088bf..a16c2f24a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -597,6 +597,7 @@ export const ADE_ACTION_ALLOWLIST: Partial; + operationName?: string | null; + maxRetries?: number; +} { + const actionArgs = asActionRecord(args); + const query = requireNonEmptyString(actionArgs.query, "query"); + const variables = actionArgs.variables; + if (variables != null && !isRecord(variables)) { + throw new Error("Expected 'variables' to be a JSON object when provided."); + } + const operationName = + typeof actionArgs.operationName === "string" && actionArgs.operationName.trim().length + ? actionArgs.operationName.trim() + : null; + const maxRetries = + typeof actionArgs.maxRetries === "number" && Number.isFinite(actionArgs.maxRetries) + ? Math.max(0, Math.min(10, Math.floor(actionArgs.maxRetries))) + : undefined; + return { + query, + ...(variables ? { variables } : {}), + ...(operationName ? { operationName } : {}), + ...(maxRetries !== undefined ? { maxRetries } : {}), + }; +} + function asStringArray(value: unknown): string[] { if (Array.isArray(value)) { return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) diff --git a/apps/desktop/src/main/services/cto/issueTracker.ts b/apps/desktop/src/main/services/cto/issueTracker.ts index eb683d7be..79936c5c6 100644 --- a/apps/desktop/src/main/services/cto/issueTracker.ts +++ b/apps/desktop/src/main/services/cto/issueTracker.ts @@ -70,6 +70,12 @@ export type IssueTrackerWorkflowState = { }; export type IssueTracker = { + runGraphQL(args: { + query: string; + variables?: Record; + operationName?: string | null; + maxRetries?: number; + }): Promise; listProjects(): Promise; getQuickView(connection: CtoLinearQuickView["connection"]): Promise; listUsers(): Promise; diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index 1bbc4a4df..4a7809d01 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -207,6 +207,7 @@ export function createLinearClient(args: LinearClientArgs) { const request = async >(params: { query: string; variables?: Record; + operationName?: string | null; maxRetries?: number; }): Promise => { // Proactively refresh an OAuth token that is at/near expiry before sending. @@ -230,7 +231,11 @@ export function createLinearClient(args: LinearClientArgs) { "content-type": "application/json", authorization: token, }, - body: JSON.stringify({ query: params.query, variables: params.variables ?? {} }), + body: JSON.stringify({ + query: params.query, + variables: params.variables ?? {}, + ...(params.operationName ? { operationName: params.operationName } : {}), + }), }); const payload = await res.json().catch(() => ({})) as { @@ -290,6 +295,20 @@ export function createLinearClient(args: LinearClientArgs) { }; }; + const runGraphQL = async (params: { + query: string; + variables?: Record; + operationName?: string | null; + maxRetries?: number; + }): Promise => { + return request({ + query: params.query, + variables: params.variables, + operationName: params.operationName, + maxRetries: params.maxRetries, + }); + }; + const getConnectionIdentity = async (): Promise<{ viewerId: string | null; viewerName: string | null; @@ -1429,6 +1448,7 @@ export function createLinearClient(args: LinearClientArgs) { return { request, + runGraphQL, getViewer, getConnectionIdentity, listProjects, diff --git a/apps/desktop/src/main/services/cto/linearIssueTracker.ts b/apps/desktop/src/main/services/cto/linearIssueTracker.ts index c37532640..ea3dfcab6 100644 --- a/apps/desktop/src/main/services/cto/linearIssueTracker.ts +++ b/apps/desktop/src/main/services/cto/linearIssueTracker.ts @@ -4,6 +4,10 @@ import { getErrorMessage } from "../shared/utils"; export function createLinearIssueTracker(args: { client: LinearClient }): IssueTracker { return { + runGraphQL(params) { + return args.client.runGraphQL(params); + }, + listProjects() { return args.client.listProjects(); }, From cbb020a0a11253bfdc3dcda0193c066a0442b946 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 31 May 2026 03:00:32 +0000 Subject: [PATCH 2/5] Align Linear CLI cards and agent guidance Co-authored-by: Arul Sharma --- apps/ade-cli/src/bootstrap.ts | 82 ++++++++++++++ apps/ade-cli/src/cli.test.ts | 26 ++++- apps/ade-cli/src/cli.ts | 21 ++++ .../agent-skills/ade-deeplinks/SKILL.md | 18 ++++ .../agent-skills/ade-linear/SKILL.md | 42 +++++++- .../scripts/validate-mac-artifacts.mjs | 1 + .../scripts/validate-win-artifacts.mjs | 1 + apps/desktop/src/main/main.ts | 87 ++++++++------- .../services/chat/agentChatService.test.ts | 3 + .../main/services/chat/agentChatService.ts | 1 + .../main/services/lanes/laneService.test.ts | 26 ++++- .../src/main/services/lanes/laneService.ts | 101 +++++++++++++++++- apps/desktop/src/shared/adeCliGuidance.ts | 1 + 13 files changed, 359 insertions(+), 51 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index cb1baf57a..fd25fc108 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -65,6 +65,10 @@ import type { createLinearIssueTracker } from "../../desktop/src/main/services/c import type { createLinearIngressService } from "../../desktop/src/main/services/cto/linearIngressService"; import type { createLinearRoutingService } from "../../desktop/src/main/services/cto/linearRoutingService"; import type { createLinearSyncService } from "../../desktop/src/main/services/cto/linearSyncService"; +import { + publishLinearChatSessionCard, + publishLinearLaneCard, +} from "../../desktop/src/main/services/cto/linearLaneCardService"; import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService"; import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore"; import type { createSyncService } from "./services/sync/syncService"; @@ -430,6 +434,9 @@ export async function createAdeRuntime(args: { let conflictServiceRef: ReturnType | null = null; let rebaseSuggestionServiceRef: ReturnType | null = null; let autoRebaseServiceRef: ReturnType | null = null; + let linearIssueTrackerRef: ReturnType | null = null; + let githubServiceRef: ReturnType | null = null; + const linearChatCardPublishKeys = new Set(); const laneService = createLaneService({ db, @@ -462,6 +469,55 @@ export async function createAdeRuntime(args: { }); } }, + onLinearIssueLinked: ({ lane, issue, linkedAt }) => { + const tracker = linearIssueTrackerRef; + if (!tracker) return; + void githubServiceRef?.getRepoOrThrow() + .catch(() => null) + .then((repo) => publishLinearLaneCard({ + issueTracker: tracker, + lane, + issue, + projectRoot, + linkedAt, + repoOwner: repo?.owner ?? null, + repoName: repo?.name ?? null, + postInitialComment: true, + log: (event, fields) => logger.warn(event, fields), + })) + .catch((error) => { + logger.warn("linear.lane_card_publish_failed", { + laneId: lane.id, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, + onLinearIssueSessionLinked: ({ laneId, sessionId, sessionTitle, issue, linkedAt }) => { + const tracker = linearIssueTrackerRef; + if (!tracker) return; + const key = `${issue.id}:${sessionId}`; + if (linearChatCardPublishKeys.has(key)) return; + linearChatCardPublishKeys.add(key); + void publishLinearChatSessionCard({ + issueTracker: tracker, + issue, + laneId, + sessionId, + sessionTitle, + linkedAt, + }).catch((error) => { + linearChatCardPublishKeys.delete(key); + logger.warn("linear.chat_session_card_publish_failed", { + laneId, + sessionId, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, logger, }); await laneService.ensurePrimaryLane(); @@ -856,6 +912,8 @@ export async function createAdeRuntime(args: { onLinearWorkflowEvent: (event) => pushEvent("runtime", { type: "linear_workflow_event", event }), }); + linearIssueTrackerRef = headlessLinearServices.linearIssueTracker; + githubServiceRef = headlessLinearServices.githubService as ReturnType; const linearOAuthService = createLinearOAuthService({ credentials: headlessLinearServices.linearCredentialService as never, logger, @@ -904,6 +962,30 @@ export async function createAdeRuntime(args: { logger, appVersion: "ade-cli", getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, + onLinearIssueChatLinked: ({ laneId, sessionId, sessionTitle, issue, linkedAt }) => { + const tracker = linearIssueTrackerRef; + if (!tracker) return; + const key = `${issue.id}:${sessionId}`; + if (linearChatCardPublishKeys.has(key)) return; + linearChatCardPublishKeys.add(key); + void publishLinearChatSessionCard({ + issueTracker: tracker, + issue, + laneId, + sessionId, + sessionTitle, + linkedAt, + }).catch((error) => { + linearChatCardPublishKeys.delete(key); + logger.warn("linear.chat_session_card_publish_failed", { + laneId, + sessionId, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, onEvent: (event) => { pushEvent("runtime", event as unknown as Record); }, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f937c7c24..ef5896e7a 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2253,7 +2253,7 @@ describe("ADE CLI", () => { }); }); - it("chains create_lane -> chat createSession -> kickoff for create-from-linear --start-chat", () => { + it("chains create_lane -> chat createSession -> attach Linear issue -> kickoff for create-from-linear --start-chat", () => { const plan = buildCliPlan([ "lanes", "create-from-linear", @@ -2267,7 +2267,7 @@ describe("ADE CLI", () => { ]); expect(plan.kind).toBe("execute"); if (plan.kind !== "execute") return; - expect(plan.steps).toHaveLength(3); + expect(plan.steps).toHaveLength(4); expect(plan.steps[0]?.key).toBe("lane"); // Step 2 derives laneId from the create_lane result. @@ -2285,8 +2285,26 @@ describe("ADE CLI", () => { }, }); - // Step 3 derives sessionId from the createSession result and sends a kickoff. - const sendStep = plan.steps[2]!; + // Step 3 attaches the issue to the chat so the runtime posts chat/lane cards. + const attachStep = plan.steps[2]!; + const attachParams = (attachStep.params as (v: Record) => Record)({ + chat: { domain: "chat", action: "createSession", result: { id: "session-new" } }, + }); + expect(attachParams).toMatchObject({ + arguments: { + domain: "lane", + action: "attachLinearIssueToSession", + args: { + chatSessionId: "session-new", + issues: [{ id: "issue-1", identifier: "ENG-431", title: "Fix OAuth", url: "https://linear.app/x/ENG-431" }], + role: "worked", + source: "chat_attach", + }, + }, + }); + + // Step 4 derives sessionId from the createSession result and sends a kickoff. + const sendStep = plan.steps[3]!; const sendParams = (sendStep.params as (v: Record) => Record)({ chat: { domain: "chat", action: "createSession", result: { id: "session-new" } }, }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 70641113d..030f78140 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1417,6 +1417,8 @@ const HELP_BY_COMMAND: Record = { $ ade linear label ENG-431 "needs-review" Add a label to an issue $ ade linear graphql --query 'query { viewer { id name } }' Run Linear GraphQL through the project connection + $ ade linear graphql --query-file query.graphql --variables-file vars.json + Use files for larger GraphQL operations $ ade linear detach --this-session [--issue-id ENG-431] Detach one issue (or all) from this session @@ -3117,6 +3119,25 @@ function buildCreateLaneFromLinearPlan(args: string[], issue: JsonObject): CliPl }, unwrapToolResult: true, }); + steps.push({ + key: "attach", + method: "ade/actions/call", + params: (values) => { + const sessionId = sessionIdFromCreateChatValue(values.chat); + if (!sessionId) { + throw new CliUsageError("create-from-linear launched a chat but could not resolve its session id to attach the issue."); + } + return { + name: "run_ade_action", + arguments: { + domain: LINEAR_ATTACH_ACTIONS.domain, + action: LINEAR_ATTACH_ACTIONS.attachSession, + args: { chatSessionId: sessionId, issues: [issue], role: "worked", source: "chat_attach" }, + }, + }; + }, + unwrapToolResult: true, + }); steps.push({ key: "result", method: "ade/actions/call", diff --git a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md index cd231b825..4e60fa241 100644 --- a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md @@ -125,6 +125,24 @@ cross-machine link to any Linear issue linked to the lane (Linear attachment + one-time comment). Agents do not need to call `ade link` for those flows — they fire on PR creation / Linear-link events. +Linear card/comment matrix: + +| Linear-related action | Who adds the ADE link | +| --- | --- | +| `ade lanes create --linear-issue-json` / `ade lanes create-from-linear` | ADE posts the lane attachment/comment | +| `ade lanes link-linear-issue` / `ade chat attach-linear-issue` / `ade linear attach --this-session` | ADE posts lane/chat attachments when a lane/session is known | +| `ade chat create --from-linear-issue` or `ade lanes create-from-linear --start-chat` | ADE posts the chat attachment after the issue is attached | +| `ade prs create` on a linked lane | ADE posts the PR attachment/footer | +| `ade linear comment`, `set-state`, `assign`, `label`, or `graphql` | The agent should include the relevant ADE link in any Linear comment it writes | + +If you create new Linear state directly (for example with `ade linear graphql`), +mint the Linear-pane link yourself and comment it back to the issue: + +```bash +ade link linear-issue ADE-123 --no-clipboard +ade linear comment ADE-123 "Created via ADE. Open in ADE: " +``` + Agents should still include a user-facing ADE PR link when handing off a newly created or adopted PR. Use the GitHub PR URL for the browser link and the `adeUrl` printed by `ade prs create`. If the PR came from another path, mint diff --git a/apps/desktop/resources/agent-skills/ade-linear/SKILL.md b/apps/desktop/resources/agent-skills/ade-linear/SKILL.md index c284f7e21..81c5582ce 100644 --- a/apps/desktop/resources/agent-skills/ade-linear/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-linear/SKILL.md @@ -65,6 +65,8 @@ ade linear issues --text # list issues attached to this ade linear issue --text # read your attached issue (full detail) ade linear issue ENG-431 --text # read a specific issue ade linear issue-comments --issue-id ENG-431 --text # read an issue's comment thread +ade linear graphql --query 'query { viewer { id name } }' # advanced Linear API via ADE credentials +ade linear graphql --query-file query.graphql --variables-file vars.json ``` Use `--text` for human-readable output; omit it for JSON when you want to parse. @@ -93,6 +95,40 @@ Notes: - `comment`, `set-state`, `assign`, and `label` all accept the value via a flag too (`--message/-m`, `--state-id`, `--assignee`, `--label`) if a positional is ambiguous. +- Use `ade linear graphql` for Linear operations not covered by the typed + commands. It still routes through ADE's saved Linear OAuth/API key; do not ask + for or print token material. + +## ADE deeplinks in Linear comments + +ADE posts deterministic Linear attachments/cards for these flows when the +runtime owns the Linear connection: + +| Flow | ADE handles it | +| --- | --- | +| Create a lane from a Linear issue | Lane attachment + one-time ADE branch comment | +| Attach a Linear issue to an existing lane | Lane attachment + one-time ADE branch comment | +| Create or attach a chat/CLI session with a Linear issue | ADE chat attachment | +| Open/create a PR from a linked lane | ADE PR attachment/footer | + +For direct issue actions (`comment`, `set-state`, `assign`, `label`, `graphql`) +include the relevant ADE link in any user-facing Linear comment you write, +especially when the action creates new Linear state or hands work to a human. +Use the **ade-deeplinks** skill to mint links: + +```bash +ade link linear-issue ENG-431 --no-clipboard +ade link branch --no-clipboard +ade link session "$ADE_CHAT_SESSION_ID" --lane --no-clipboard +ade link pr --no-clipboard +``` + +Example when creating a new Linear issue via GraphQL: + +```bash +ade linear graphql --query-file create-issue.graphql --variables-file vars.json +ade linear comment NEW-123 "Created via ADE. Open in ADE: $(ade link linear-issue NEW-123 --no-clipboard)" +``` ## Attaching / detaching this session @@ -107,8 +143,10 @@ ade linear detach --this-session # detach every issue from 1. When you start real work on the issue, move it to **In Progress** (`ade linear set-state `), so watchers see it's being worked. 2. As you make progress, **comment** what you did and link the PR - (`ade linear comment "..."`). That comment is how reviewers and the issue's - watchers see status — report what you actually did, not what you intend to do. + (`ade linear comment "..."`). Include ADE branch/session/PR links when a card + was not already posted automatically. That comment is how reviewers and the + issue's watchers see status — report what you actually did, not what you + intend to do. 3. When you finish, set the **appropriate final state** (e.g. Done / In Review). Defer the exact final-state policy to the user's workflow — if you're unsure whether to mark Done vs. In Review, comment your result and ask rather than diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index 842b44ff3..ac6317b43 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -22,6 +22,7 @@ const bundledAgentSkills = [ "ade-browser", "ade-pr-workflows", "ade-lanes-git", + "ade-linear", "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 8f0d31b31..e86cf61f6 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -24,6 +24,7 @@ const bundledAgentSkills = [ "ade-browser", "ade-pr-workflows", "ade-lanes-git", + "ade-linear", "ade-proof-artifacts", "ade-macos-vm", "ade-deeplinks", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 192932f03..3616f1476 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -86,6 +86,7 @@ import type { OpenProjectBinding, AppNavigationRequest, LaneDeleteProgress, + LaneLinearIssue, LaneSummary, PortLease, PrEventPayload, @@ -1761,6 +1762,51 @@ app.whenReady().then(async () => { null; let linearIssueTrackerRef: LinearIssueTracker | null = null; let linearLiveStatusServiceRef: LinearLiveStatusService | null = null; + const linearChatCardPublishKeys = new Set(); + const publishLinearChatLink = ({ laneId, sessionId, sessionTitle, issue, linkedAt }: { + laneId: string; + sessionId: string; + sessionTitle?: string | null; + issue: LaneLinearIssue; + linkedAt: string; + }) => { + const tracker = linearIssueTrackerRef; + if (!tracker) return; + const key = `${issue.id}:${sessionId}`; + if (linearChatCardPublishKeys.has(key)) return; + linearChatCardPublishKeys.add(key); + void publishLinearChatSessionCard({ + issueTracker: tracker, + issue, + laneId, + sessionId, + sessionTitle, + linkedAt, + }).catch((error) => { + linearChatCardPublishKeys.delete(key); + logger.warn("linear.chat_session_card_publish_failed", { + laneId, + sessionId, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + }); + // Agent launched against a Linear issue → reflect status into Linear + // (no-op unless the live round-trip flag is set). + void linearLiveStatusServiceRef?.onAgentLaunched({ + issue, + branchName: issue.branchName, + laneName: sessionTitle ?? null, + }).catch((error) => { + logger.warn("linear.live_status_launch_failed", { + laneId, + sessionId, + issueId: issue.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; const lastHeadByLaneId = new Map(); @@ -1897,6 +1943,7 @@ app.whenReady().then(async () => { }); }); }, + onLinearIssueSessionLinked: publishLinearChatLink, teardownDeps: laneTeardownDeps, logger, }); @@ -2380,7 +2427,6 @@ app.whenReady().then(async () => { null; let orchestrationServiceRef: ReturnType | null = null; - const linearChatCardPublishKeys = new Set(); const queueLandingService = createQueueLandingService({ db, logger, @@ -2758,44 +2804,7 @@ app.whenReady().then(async () => { logger, appVersion: app.getVersion(), getAdeCliAgentEnv: adeCliService.agentEnv, - onLinearIssueChatLinked: ({ laneId, sessionId, sessionTitle, issue, linkedAt }) => { - const tracker = linearIssueTrackerRef; - if (!tracker) return; - const key = `${issue.id}:${sessionId}`; - if (linearChatCardPublishKeys.has(key)) return; - linearChatCardPublishKeys.add(key); - void publishLinearChatSessionCard({ - issueTracker: tracker, - issue, - laneId, - sessionId, - sessionTitle, - linkedAt, - }).catch((error) => { - linearChatCardPublishKeys.delete(key); - logger.warn("linear.chat_session_card_publish_failed", { - laneId, - sessionId, - issueId: issue.id, - issueIdentifier: issue.identifier, - error: error instanceof Error ? error.message : String(error), - }); - }); - // Agent launched against a Linear issue → reflect status into Linear - // (no-op unless the live round-trip flag is set). - void linearLiveStatusServiceRef?.onAgentLaunched({ - issue, - branchName: issue.branchName, - laneName: sessionTitle, - }).catch((error) => { - logger.warn("linear.live_status_launch_failed", { - laneId, - sessionId, - issueId: issue.id, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, + onLinearIssueChatLinked: publishLinearChatLink, onEvent: (event) => { emitProjectEvent(projectRoot, IPC.agentChatEvent, event); }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 1c6d1361b..b1c8f1204 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1718,6 +1718,7 @@ describe("buildLinearSessionDirective", () => { expect(directive).toContain("ade linear"); expect(directive).toContain("Prefer `ade linear`"); expect(directive).toContain("ade-linear"); + expect(directive).toContain("ade-deeplinks"); }); }); @@ -2071,6 +2072,7 @@ describe("createAgentChatService", () => { expect(appended).toContain("ADE-123"); expect(appended).toContain("ade linear"); expect(appended).toContain("Prefer `ade linear`"); + expect(appended).toContain("ade-deeplinks"); }); it("keeps ADE tooling guidance out of Claude SDK user turns", async () => { @@ -2415,6 +2417,7 @@ describe("createAgentChatService", () => { expect(opts?.systemPrompt?.append).toBeTruthy(); expect(opts?.systemPrompt?.append).toContain("## Project slash commands and skills"); expect(opts?.systemPrompt?.append).toContain("/ade-cli-control-plane"); + expect(opts?.systemPrompt?.append).toContain("/ade-linear"); expect(opts?.systemPrompt?.append).not.toContain("Commands (file-backed prompts):"); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 69bec08ee..f1b91a91a 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -3593,6 +3593,7 @@ export function buildLinearSessionDirective( "## Linear-tracked work", `This work is tracked by Linear issue(s) ${idList}. You have ADE's Linear connection — read and update them with the \`ade linear\` CLI (issue, comment, set-state, assign, label; run \`ade linear --help\` or see the bundled **ade-linear** skill for exact syntax).`, "Prefer `ade linear` over any Linear MCP or direct Linear API — those are not authenticated in this environment.", + "When you comment on Linear directly, include relevant ADE deeplinks unless ADE already posted a lane/chat/PR card; see the bundled **ade-deeplinks** skill.", ].join("\n"); } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 42d5134ff..a92451823 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -203,12 +203,14 @@ describe("laneService createFromUnstaged", () => { const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); await seedProjectAndStack(db, { projectId: "proj-linear-projectless", repoRoot }); + const onLinearIssueLinked = vi.fn(); const service = createLaneService({ db, projectRoot: repoRoot, projectId: "proj-linear-projectless", defaultBaseRef: "main", worktreesDir: path.join(repoRoot, "worktrees"), + onLinearIssueLinked, }); const issue = { @@ -253,6 +255,10 @@ describe("laneService createFromUnstaged", () => { }), }), ])); + expect(onLinearIssueLinked).toHaveBeenCalledWith(expect.objectContaining({ + lane: expect.objectContaining({ id: "lane-child" }), + issue: expect.objectContaining({ identifier: "ADE-45" }), + })); }); it("moves unstaged and untracked changes into a new child lane", async () => { @@ -4091,14 +4097,14 @@ describe("laneService - branchSwitch", () => { }); describe("laneService session-scoped Linear issue links", () => { - function seedClaudeSession(db: any, args: { sessionId: string; laneId: string }) { + function seedClaudeSession(db: any, args: { sessionId: string; laneId: string; title?: string | null }) { const now = "2026-05-20T10:00:00.000Z"; db.run( ` insert into claude_sessions(session_id, lane_id, chat_session_id, title, tags_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?) `, - [args.sessionId, args.laneId, null, null, null, now, now], + [args.sessionId, args.laneId, null, args.title ?? null, null, now, now], ); } @@ -4177,13 +4183,17 @@ describe("laneService session-scoped Linear issue links", () => { const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { await seedProjectAndStack(db, { projectId: "proj-session-laned", repoRoot }); - seedClaudeSession(db, { sessionId: "chat-on-child", laneId: "lane-child" }); + seedClaudeSession(db, { sessionId: "chat-on-child", laneId: "lane-child", title: "Fix flaky sync run" }); + const onLinearIssueLinked = vi.fn(); + const onLinearIssueSessionLinked = vi.fn(); const service = createLaneService({ db, projectRoot: repoRoot, projectId: "proj-session-laned", defaultBaseRef: "main", worktreesDir: path.join(repoRoot, "worktrees"), + onLinearIssueLinked, + onLinearIssueSessionLinked, }); const links = service.attachLinearIssueToSession({ @@ -4206,6 +4216,16 @@ describe("laneService session-scoped Linear issue links", () => { const laneSessionLinks = service.listLinearIssuesForLaneSessions({ laneId: "lane-child" }); expect(laneSessionLinks).toHaveLength(1); expect(laneSessionLinks[0]?.sessionId).toBe("chat-on-child"); + expect(onLinearIssueLinked).toHaveBeenCalledWith(expect.objectContaining({ + lane: expect.objectContaining({ id: "lane-child" }), + issue: expect.objectContaining({ identifier: "ABC-42" }), + })); + expect(onLinearIssueSessionLinked).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-child", + sessionId: "chat-on-child", + sessionTitle: "Fix flaky sync run", + issue: expect.objectContaining({ identifier: "ABC-42" }), + })); } finally { db.close(); fs.rmSync(repoRoot, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 0c842f420..b20b2c13c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -913,6 +913,7 @@ export function createLaneService({ onDeleteEvent, onPlacementChanged, onLinearIssueLinked, + onLinearIssueSessionLinked, teardownDeps, macosVmHooks, logger: injectedLogger @@ -928,6 +929,13 @@ export function createLaneService({ onDeleteEvent?: (event: LaneDeleteEvent) => void; onPlacementChanged?: (event: LanePlacementChangedEvent) => void | Promise; onLinearIssueLinked?: (args: { lane: LaneSummary; issue: LaneLinearIssue; linkedAt: string }) => void | Promise; + onLinearIssueSessionLinked?: (args: { + laneId: string; + sessionId: string; + sessionTitle: string | null; + issue: LaneLinearIssue; + linkedAt: string; + }) => void | Promise; teardownDeps?: LaneDeleteTeardownDeps; macosVmHooks?: LaneMacosVmHooks | null; logger?: Logger; @@ -972,6 +980,32 @@ export function createLaneService({ } }; + const notifyLinearIssueSessionLinked = (link: SessionLinearIssueLink): void => { + if (!onLinearIssueSessionLinked || !link.laneId) return; + const logFailure = (error: unknown): void => { + logger.warn("laneService.linear_issue_session_link_notify_failed", { + laneId: link.laneId, + sessionId: link.sessionId, + issueId: link.issue.id, + error: error instanceof Error ? error.message : String(error), + }); + }; + try { + const result = onLinearIssueSessionLinked({ + laneId: link.laneId, + sessionId: link.sessionId, + sessionTitle: resolveSessionTitle(link.sessionId), + issue: link.issue, + linkedAt: link.createdAt, + }); + if (result && typeof (result as Promise).catch === "function") { + void (result as Promise).catch(logFailure); + } + } catch (error) { + logFailure(error); + } + }; + const linkExistingDependencyInstalls = (worktreePath: string): void => { if (!fs.existsSync(worktreePath)) return; @@ -1176,6 +1210,48 @@ export function createLaneService({ return null; }; + const resolveSessionTitle = (sessionId: string): string | null => { + const id = sessionId.trim(); + if (!id) return null; + try { + const chat = db.get<{ title: string | null }>( + "select title from claude_sessions where session_id = ? limit 1", + [id], + ); + if (chat?.title?.trim()) return chat.title.trim(); + } catch { + // Fall through to terminal session lookup. + } + try { + const terminal = db.get<{ title: string | null }>( + "select title from terminal_sessions where id = ? limit 1", + [id], + ); + if (terminal?.title?.trim()) return terminal.title.trim(); + } catch { + // No title is fine; card builders fall back to the session id. + } + return null; + }; + + const laneSummaryForLinearNotification = (row: LaneRow): LaneSummary => + toLaneSummary({ + row, + status: { + dirty: false, + ahead: 0, + behind: 0, + remoteBehind: -1, + rebaseInProgress: false, + }, + parentStatus: null, + childCount: 0, + stackDepth: 0, + activeBranchProfile: ensureBranchProfileForRow(row), + linearIssue: getLaneLinearIssue(row.id), + linearIssueLinks: getLaneLinearIssueLinks(row.id), + }); + const getSessionLinearIssueLinks = (sessionId: string): SessionLinearIssueLink[] => { const id = sessionId.trim(); if (!id) return []; @@ -2689,7 +2765,13 @@ export function createLaneService({ evidence: args.evidence ?? null, })); } - if (links.length) invalidateLaneListCache(); + if (links.length) { + invalidateLaneListCache(); + const summary = laneSummaryForLinearNotification(row); + for (const link of links) { + notifyLinearIssueLinked(summary, link.issue); + } + } return links; }, @@ -2722,6 +2804,7 @@ export function createLaneService({ const closeOnMerge = args.closeOnMerge ?? false; const evidence = args.evidence ?? { chatSessionId }; const links: SessionLinearIssueLink[] = []; + const mirroredLaneLinks: LaneLinearIssueLink[] = []; const seen = new Set(); let mirrored = false; for (const issue of args.issues) { @@ -2748,7 +2831,7 @@ export function createLaneService({ try { const primary = getLaneLinearIssue(laneId); if (!primary || (primary.id !== normalized.id && primary.identifier !== normalized.identifier)) { - upsertLaneLinearIssueLink({ + const laneLink = upsertLaneLinearIssueLink({ laneId, issue: normalized, role: role === "primary" ? "worked" : role, @@ -2757,6 +2840,7 @@ export function createLaneService({ closeOnMerge, evidence: { chatSessionId }, }); + mirroredLaneLinks.push(laneLink); mirrored = true; } } catch (error) { @@ -2769,7 +2853,18 @@ export function createLaneService({ } } } - if (mirrored) invalidateLaneListCache(); + if (mirrored) { + invalidateLaneListCache(); + if (laneRow) { + const summary = laneSummaryForLinearNotification(laneRow); + for (const link of mirroredLaneLinks) { + notifyLinearIssueLinked(summary, link.issue); + } + } + } + for (const link of links) { + notifyLinearIssueSessionLinked(link); + } return links; }, diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 8e2796a1a..553af2dbb 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -7,6 +7,7 @@ export const adeBundledAgentSkills = [ "ade-browser", "ade-pr-workflows", "ade-lanes-git", + "ade-linear", "ade-orchestrator", "ade-proof-artifacts", "ade-macos-vm", From 464d3c28f8c44805c7317c4b9847ea956cd494a8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 01:48:48 -0400 Subject: [PATCH 3/5] ship: address Linear CLI review comments --- apps/ade-cli/src/cli.test.ts | 24 ++++++++++ apps/ade-cli/src/cli.ts | 31 +++++++++++- .../src/tuiClient/__tests__/commands.test.ts | 17 +++++++ apps/ade-cli/src/tuiClient/linearCommands.ts | 23 ++++++--- apps/desktop/src/main/main.ts | 33 ++++++------- .../src/main/services/lanes/laneService.ts | 48 +++++++++---------- 6 files changed, 129 insertions(+), 47 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index ef5896e7a..02163f963 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2453,6 +2453,30 @@ describe("ADE CLI", () => { }); }); + it("revalidates linear graphql payloads after generic argument overrides", () => { + expect(() => + buildCliPlan([ + "linear", + "graphql", + "--query", + "query Viewer { viewer { id name } }", + "--arg-json", + "variables=[]", + ]), + ).toThrow(/--variables-json must be a JSON object/); + + expect(() => + buildCliPlan([ + "linear", + "graphql", + "--query", + "query Viewer { viewer { id name } }", + "--input-json", + "{\"query\":123}", + ]), + ).toThrow(/GraphQL query is required/); + }); + it("attaches an issue to the current session via linear attach --this-session", () => { const prev = process.env.ADE_CHAT_SESSION_ID; process.env.ADE_CHAT_SESSION_ID = "current-session"; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 030f78140..85aa589b1 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2072,6 +2072,35 @@ function readIssueIdFlag(args: string[]): string | null { return readValue(args, ["--issue-id", "--linear-issue-id", "--issue"]); } +function normalizeLinearGraphQLInput(input: JsonObject): JsonObject { + const query = asString(input.query); + if (!query) { + throw new CliUsageError("GraphQL query is required."); + } + + const variables = input.variables; + if (variables != null && !isRecord(variables)) { + throw new CliUsageError("--variables-json must be a JSON object."); + } + + const maxRetries = input.maxRetries; + if (maxRetries != null && (typeof maxRetries !== "number" || !Number.isFinite(maxRetries))) { + throw new CliUsageError("--max-retries must be a number."); + } + + const normalized: JsonObject = { ...input, query }; + if (variables == null) { + delete normalized.variables; + } + const operationName = asString(input.operationName); + if (operationName) { + normalized.operationName = operationName; + } else { + delete normalized.operationName; + } + return normalized; +} + function readLinearGraphQLArgs(args: string[]): JsonObject { const inlineQuery = readValue(args, ["--query", "--graphql", "--gql"]); const fileQuery = readTextFileOption(args, ["--query-file", "--graphql-file", "--gql-file"], "--query-file"); @@ -2094,7 +2123,7 @@ function readLinearGraphQLArgs(args: string[]): JsonObject { maybePut(input, "operationName", readValue(args, ["--operation-name", "--operation"])); const maxRetries = readNumberOption(args, ["--max-retries"]); if (maxRetries !== undefined) input.maxRetries = maxRetries; - return collectGenericObjectArgs(args, input); + return normalizeLinearGraphQLInput(collectGenericObjectArgs(args, input)); } /** diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 3ceef3f56..57eb6a5c0 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -460,6 +460,23 @@ describe("linear command routing", () => { }); }); + it("rejects malformed /linear graphql variables", () => { + expect( + buildLinearToolRequest("graphql --query 'query Viewer { viewer { id } }' --variables-json nope"), + ).toMatchObject({ + kind: "usage", + title: "Linear GraphQL", + body: expect.stringContaining("--variables-json must be valid JSON"), + }); + expect( + buildLinearToolRequest("graphql --query 'query Viewer { viewer { id } }' --variables-json '[1]'"), + ).toMatchObject({ + kind: "usage", + title: "Linear GraphQL", + body: expect.stringContaining("--variables-json must be a JSON object"), + }); + }); + it("routes sync dashboard and queue resolution", () => { expect(buildLinearToolRequest("sync dashboard")).toEqual({ kind: "tool", diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts index 976591f09..29169a406 100644 --- a/apps/ade-cli/src/tuiClient/linearCommands.ts +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -161,18 +161,22 @@ function attachmentFlags(options: Record): Record): Record | undefined { +type GraphQLVariablesParseResult = + | { ok: true; variables?: Record } + | { ok: false; message: string }; + +function parseGraphQLVariables(options: Record): GraphQLVariablesParseResult { const value = optionString(options, "variablesJson", "varsJson"); - if (!value) return undefined; + if (!value) return { ok: true }; try { const parsed = JSON.parse(value) as unknown; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; + return { ok: true, variables: parsed as Record }; } + return { ok: false, message: "--variables-json must be a JSON object." }; } catch { - return undefined; + return { ok: false, message: "--variables-json must be valid JSON." }; } - return undefined; } export function buildLinearToolRequest(input: string): LinearToolRequest { @@ -297,9 +301,16 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { "Usage: /linear graphql --query 'query { viewer { id name } }' [--variables-json '{\"id\":\"...\"}']", ); } + const variables = parseGraphQLVariables(options); + if (!variables.ok) { + return usage( + "Linear GraphQL", + `${variables.message}\nUsage: /linear graphql --query 'query { viewer { id name } }' [--variables-json '{\"id\":\"...\"}']`, + ); + } return action("Linear GraphQL", "linear_issue_tracker", "graphql", { query, - variables: parseGraphQLVariables(options), + variables: variables.variables, operationName: optionString(options, "operationName", "operation") ?? undefined, }); } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3616f1476..d64b8b01e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1773,25 +1773,26 @@ app.whenReady().then(async () => { const tracker = linearIssueTrackerRef; if (!tracker) return; const key = `${issue.id}:${sessionId}`; - if (linearChatCardPublishKeys.has(key)) return; - linearChatCardPublishKeys.add(key); - void publishLinearChatSessionCard({ - issueTracker: tracker, - issue, - laneId, - sessionId, - sessionTitle, - linkedAt, - }).catch((error) => { - linearChatCardPublishKeys.delete(key); - logger.warn("linear.chat_session_card_publish_failed", { + if (!linearChatCardPublishKeys.has(key)) { + linearChatCardPublishKeys.add(key); + void publishLinearChatSessionCard({ + issueTracker: tracker, + issue, laneId, sessionId, - issueId: issue.id, - issueIdentifier: issue.identifier, - error: error instanceof Error ? error.message : String(error), + sessionTitle, + linkedAt, + }).catch((error) => { + linearChatCardPublishKeys.delete(key); + logger.warn("linear.chat_session_card_publish_failed", { + laneId, + sessionId, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); }); - }); + } // Agent launched against a Linear issue → reflect status into Linear // (no-op unless the live round-trip flag is set). void linearLiveStatusServiceRef?.onAgentLaunched({ diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b20b2c13c..b938285f9 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -961,6 +961,30 @@ export function createLaneService({ } }; + const resolveSessionTitle = (sessionId: string): string | null => { + const id = sessionId.trim(); + if (!id) return null; + try { + const chat = db.get<{ title: string | null }>( + "select title from claude_sessions where session_id = ? limit 1", + [id], + ); + if (chat?.title?.trim()) return chat.title.trim(); + } catch { + // Fall through to terminal session lookup. + } + try { + const terminal = db.get<{ title: string | null }>( + "select title from terminal_sessions where id = ? limit 1", + [id], + ); + if (terminal?.title?.trim()) return terminal.title.trim(); + } catch { + // No title is fine; card builders fall back to the session id. + } + return null; + }; + const notifyLinearIssueLinked = (lane: LaneSummary, issue: LaneLinearIssue): void => { if (!onLinearIssueLinked) return; const logFailure = (error: unknown): void => { @@ -1210,30 +1234,6 @@ export function createLaneService({ return null; }; - const resolveSessionTitle = (sessionId: string): string | null => { - const id = sessionId.trim(); - if (!id) return null; - try { - const chat = db.get<{ title: string | null }>( - "select title from claude_sessions where session_id = ? limit 1", - [id], - ); - if (chat?.title?.trim()) return chat.title.trim(); - } catch { - // Fall through to terminal session lookup. - } - try { - const terminal = db.get<{ title: string | null }>( - "select title from terminal_sessions where id = ? limit 1", - [id], - ); - if (terminal?.title?.trim()) return terminal.title.trim(); - } catch { - // No title is fine; card builders fall back to the session id. - } - return null; - }; - const laneSummaryForLinearNotification = (row: LaneRow): LaneSummary => toLaneSummary({ row, From 1c0acbcfd99e6db4933a03c5feb971f77b27b983 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 02:04:25 -0400 Subject: [PATCH 4/5] ship: finish Linear review follow-ups --- apps/ade-cli/src/bootstrap.ts | 86 ++++++++----------- .../src/tuiClient/__tests__/commands.test.ts | 8 ++ apps/ade-cli/src/tuiClient/linearCommands.ts | 2 +- .../main/services/adeActions/registry.test.ts | 9 ++ .../main/services/lanes/laneService.test.ts | 11 +++ .../src/main/services/lanes/laneService.ts | 8 +- 6 files changed, 72 insertions(+), 52 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index fd25fc108..e10c457d8 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -437,6 +437,42 @@ export async function createAdeRuntime(args: { let linearIssueTrackerRef: ReturnType | null = null; let githubServiceRef: ReturnType | null = null; const linearChatCardPublishKeys = new Set(); + const publishLinearChatLink = ({ + laneId, + sessionId, + sessionTitle, + issue, + linkedAt, + }: { + laneId: string; + sessionId: string; + sessionTitle?: string | null; + issue: Parameters[0]["issue"]; + linkedAt: string; + }) => { + const tracker = linearIssueTrackerRef; + if (!tracker) return; + const key = `${issue.id}:${sessionId}`; + if (linearChatCardPublishKeys.has(key)) return; + linearChatCardPublishKeys.add(key); + void publishLinearChatSessionCard({ + issueTracker: tracker, + issue, + laneId, + sessionId, + sessionTitle, + linkedAt, + }).catch((error) => { + linearChatCardPublishKeys.delete(key); + logger.warn("linear.chat_session_card_publish_failed", { + laneId, + sessionId, + issueId: issue.id, + issueIdentifier: issue.identifier, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; const laneService = createLaneService({ db, @@ -494,30 +530,7 @@ export async function createAdeRuntime(args: { }); }); }, - onLinearIssueSessionLinked: ({ laneId, sessionId, sessionTitle, issue, linkedAt }) => { - const tracker = linearIssueTrackerRef; - if (!tracker) return; - const key = `${issue.id}:${sessionId}`; - if (linearChatCardPublishKeys.has(key)) return; - linearChatCardPublishKeys.add(key); - void publishLinearChatSessionCard({ - issueTracker: tracker, - issue, - laneId, - sessionId, - sessionTitle, - linkedAt, - }).catch((error) => { - linearChatCardPublishKeys.delete(key); - logger.warn("linear.chat_session_card_publish_failed", { - laneId, - sessionId, - issueId: issue.id, - issueIdentifier: issue.identifier, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, + onLinearIssueSessionLinked: publishLinearChatLink, logger, }); await laneService.ensurePrimaryLane(); @@ -962,30 +975,7 @@ export async function createAdeRuntime(args: { logger, appVersion: "ade-cli", getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, - onLinearIssueChatLinked: ({ laneId, sessionId, sessionTitle, issue, linkedAt }) => { - const tracker = linearIssueTrackerRef; - if (!tracker) return; - const key = `${issue.id}:${sessionId}`; - if (linearChatCardPublishKeys.has(key)) return; - linearChatCardPublishKeys.add(key); - void publishLinearChatSessionCard({ - issueTracker: tracker, - issue, - laneId, - sessionId, - sessionTitle, - linkedAt, - }).catch((error) => { - linearChatCardPublishKeys.delete(key); - logger.warn("linear.chat_session_card_publish_failed", { - laneId, - sessionId, - issueId: issue.id, - issueIdentifier: issue.identifier, - error: error instanceof Error ? error.message : String(error), - }); - }); - }, + onLinearIssueChatLinked: publishLinearChatLink, onEvent: (event) => { pushEvent("runtime", event as unknown as Record); }, diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 57eb6a5c0..35a932d50 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -477,6 +477,14 @@ describe("linear command routing", () => { }); }); + it("keeps fallback usage in sync with /linear graphql", () => { + expect(buildLinearToolRequest("unknown")).toMatchObject({ + kind: "usage", + title: "Linear", + body: expect.stringContaining("graphql"), + }); + }); + it("routes sync dashboard and queue resolution", () => { expect(buildLinearToolRequest("sync dashboard")).toEqual({ kind: "tool", diff --git a/apps/ade-cli/src/tuiClient/linearCommands.ts b/apps/ade-cli/src/tuiClient/linearCommands.ts index 29169a406..951202734 100644 --- a/apps/ade-cli/src/tuiClient/linearCommands.ts +++ b/apps/ade-cli/src/tuiClient/linearCommands.ts @@ -438,6 +438,6 @@ export function buildLinearToolRequest(input: string): LinearToolRequest { return usage( "Linear", - "Usage: /linear ...", + "Usage: /linear ...", ); } diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 1c319378e..2397d8f17 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -381,11 +381,13 @@ describe("runtime Linear issue tracker actions", () => { getIssuePickerData: () => Promise; listIssues: (args?: Record) => Promise; graphql: (args?: Record) => Promise; + runGraphQL: (args?: Record) => Promise; } & Record; expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getStatus"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("listIssues"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("graphql"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("runGraphQL"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getWorkflowCatalog"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getIssuePickerData"); await expect(service.getStatus()).resolves.toMatchObject({ connected: true, tokenStored: true }); @@ -398,6 +400,13 @@ describe("runtime Linear issue tracker actions", () => { query: "query Viewer { viewer { id } }", variables: { first: 1 }, }); + await expect(service.runGraphQL({ query: "query Viewer { viewer { id } }", variables: { first: 2 } })).resolves.toEqual({ + data: { query: "query Viewer { viewer { id } }", variables: { first: 2 } }, + }); + expect(tracker.runGraphQL).toHaveBeenLastCalledWith({ + query: "query Viewer { viewer { id } }", + variables: { first: 2 }, + }); await expect(service.getWorkflowCatalog()).resolves.toEqual({ users, labels, states }); await expect(service.getIssuePickerData()).resolves.toEqual({ projects, users, states }); }); diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index a92451823..8a8343833 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -4226,6 +4226,17 @@ describe("laneService session-scoped Linear issue links", () => { sessionTitle: "Fix flaky sync run", issue: expect.objectContaining({ identifier: "ABC-42" }), })); + + onLinearIssueLinked.mockClear(); + const mirroredAgain = service.linkLinearIssues({ + laneId: "lane-child", + issues: [makeLinearIssue()], + role: "worked", + source: "chat_attach", + evidence: { chatSessionId: "chat-on-child" }, + }); + expect(mirroredAgain).toHaveLength(1); + expect(onLinearIssueLinked).not.toHaveBeenCalled(); } finally { db.close(); fs.rmSync(repoRoot, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b938285f9..da9ec543c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -2767,9 +2767,11 @@ export function createLaneService({ } if (links.length) { invalidateLaneListCache(); - const summary = laneSummaryForLinearNotification(row); - for (const link of links) { - notifyLinearIssueLinked(summary, link.issue); + if ((args.source ?? "manual") !== "chat_attach") { + const summary = laneSummaryForLinearNotification(row); + for (const link of links) { + notifyLinearIssueLinked(summary, link.issue); + } } } return links; From acc9ebe4087ea0956280e8db5e7ecf3357b8c635 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 31 May 2026 02:17:49 -0400 Subject: [PATCH 5/5] ship: dedupe Linear live-status updates --- apps/desktop/src/main/main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d64b8b01e..0962fa8d4 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1763,6 +1763,7 @@ app.whenReady().then(async () => { let linearIssueTrackerRef: LinearIssueTracker | null = null; let linearLiveStatusServiceRef: LinearLiveStatusService | null = null; const linearChatCardPublishKeys = new Set(); + const linearLiveStatusLaunchKeys = new Set(); const publishLinearChatLink = ({ laneId, sessionId, sessionTitle, issue, linkedAt }: { laneId: string; sessionId: string; @@ -1795,11 +1796,15 @@ app.whenReady().then(async () => { } // Agent launched against a Linear issue → reflect status into Linear // (no-op unless the live round-trip flag is set). - void linearLiveStatusServiceRef?.onAgentLaunched({ + const liveStatusService = linearLiveStatusServiceRef; + if (!liveStatusService || linearLiveStatusLaunchKeys.has(key)) return; + linearLiveStatusLaunchKeys.add(key); + void liveStatusService.onAgentLaunched({ issue, branchName: issue.branchName, laneName: sessionTitle ?? null, }).catch((error) => { + linearLiveStatusLaunchKeys.delete(key); logger.warn("linear.live_status_launch_failed", { laneId, sessionId,