Skip to content

Commit 3f363b9

Browse files
arul28cursoragent
andauthored
Expose Linear GraphQL through ADE CLI (#451)
* Expose Linear GraphQL through ADE CLI Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * Align Linear CLI cards and agent guidance Co-authored-by: Arul Sharma <arul28@users.noreply.github.com> * ship: address Linear CLI review comments * ship: finish Linear review follow-ups * ship: dedupe Linear live-status updates --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Arul Sharma <arul28@users.noreply.github.com>
1 parent 986203e commit 3f363b9

21 files changed

Lines changed: 678 additions & 55 deletions

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ function createRuntime() {
667667
labels: [],
668668
assigneeName: null,
669669
})),
670+
runGraphQL: vi.fn(async (args: unknown) => ({ viewer: { id: "user-1" }, _args: args })),
670671
createComment: vi.fn(async () => ({ id: "comment-1" })),
671672
fetchWorkflowStates: vi.fn(async () => [{ id: "state-done", name: "Done" }]),
672673
updateIssueState: vi.fn(async () => {}),
@@ -2951,6 +2952,18 @@ describe("adeRpcServer", () => {
29512952
});
29522953
expect(setState?.isError).toBeUndefined();
29532954
expect(fixture.runtime.linearIssueTracker.updateIssueState).toHaveBeenCalledWith("ENG-431", "state-done");
2955+
2956+
const graphql = await callTool(handler, "run_ade_action", {
2957+
domain: "linear_issue_tracker",
2958+
action: "graphql",
2959+
args: { query: "query Viewer { viewer { id } }", variables: { first: 1 } },
2960+
});
2961+
expect(graphql?.isError).toBeUndefined();
2962+
expect(fixture.runtime.linearIssueTracker.runGraphQL).toHaveBeenCalledWith({
2963+
query: "query Viewer { viewer { id } }",
2964+
variables: { first: 1 },
2965+
});
2966+
expect(graphql.structuredContent.result).toMatchObject({ viewer: { id: "user-1" } });
29542967
});
29552968

29562969
it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => {

apps/ade-cli/src/bootstrap.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ import type { createLinearIssueTracker } from "../../desktop/src/main/services/c
6565
import type { createLinearIngressService } from "../../desktop/src/main/services/cto/linearIngressService";
6666
import type { createLinearRoutingService } from "../../desktop/src/main/services/cto/linearRoutingService";
6767
import type { createLinearSyncService } from "../../desktop/src/main/services/cto/linearSyncService";
68+
import {
69+
publishLinearChatSessionCard,
70+
publishLinearLaneCard,
71+
} from "../../desktop/src/main/services/cto/linearLaneCardService";
6872
import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService";
6973
import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore";
7074
import type { createSyncService } from "./services/sync/syncService";
@@ -430,6 +434,45 @@ export async function createAdeRuntime(args: {
430434
let conflictServiceRef: ReturnType<typeof createConflictService> | null = null;
431435
let rebaseSuggestionServiceRef: ReturnType<typeof createRebaseSuggestionService> | null = null;
432436
let autoRebaseServiceRef: ReturnType<typeof createAutoRebaseService> | null = null;
437+
let linearIssueTrackerRef: ReturnType<typeof createLinearIssueTracker> | null = null;
438+
let githubServiceRef: ReturnType<typeof createGithubService> | null = null;
439+
const linearChatCardPublishKeys = new Set<string>();
440+
const publishLinearChatLink = ({
441+
laneId,
442+
sessionId,
443+
sessionTitle,
444+
issue,
445+
linkedAt,
446+
}: {
447+
laneId: string;
448+
sessionId: string;
449+
sessionTitle?: string | null;
450+
issue: Parameters<typeof publishLinearChatSessionCard>[0]["issue"];
451+
linkedAt: string;
452+
}) => {
453+
const tracker = linearIssueTrackerRef;
454+
if (!tracker) return;
455+
const key = `${issue.id}:${sessionId}`;
456+
if (linearChatCardPublishKeys.has(key)) return;
457+
linearChatCardPublishKeys.add(key);
458+
void publishLinearChatSessionCard({
459+
issueTracker: tracker,
460+
issue,
461+
laneId,
462+
sessionId,
463+
sessionTitle,
464+
linkedAt,
465+
}).catch((error) => {
466+
linearChatCardPublishKeys.delete(key);
467+
logger.warn("linear.chat_session_card_publish_failed", {
468+
laneId,
469+
sessionId,
470+
issueId: issue.id,
471+
issueIdentifier: issue.identifier,
472+
error: error instanceof Error ? error.message : String(error),
473+
});
474+
});
475+
};
433476

434477
const laneService = createLaneService({
435478
db,
@@ -462,6 +505,32 @@ export async function createAdeRuntime(args: {
462505
});
463506
}
464507
},
508+
onLinearIssueLinked: ({ lane, issue, linkedAt }) => {
509+
const tracker = linearIssueTrackerRef;
510+
if (!tracker) return;
511+
void githubServiceRef?.getRepoOrThrow()
512+
.catch(() => null)
513+
.then((repo) => publishLinearLaneCard({
514+
issueTracker: tracker,
515+
lane,
516+
issue,
517+
projectRoot,
518+
linkedAt,
519+
repoOwner: repo?.owner ?? null,
520+
repoName: repo?.name ?? null,
521+
postInitialComment: true,
522+
log: (event, fields) => logger.warn(event, fields),
523+
}))
524+
.catch((error) => {
525+
logger.warn("linear.lane_card_publish_failed", {
526+
laneId: lane.id,
527+
issueId: issue.id,
528+
issueIdentifier: issue.identifier,
529+
error: error instanceof Error ? error.message : String(error),
530+
});
531+
});
532+
},
533+
onLinearIssueSessionLinked: publishLinearChatLink,
465534
logger,
466535
});
467536
await laneService.ensurePrimaryLane();
@@ -856,6 +925,8 @@ export async function createAdeRuntime(args: {
856925
onLinearWorkflowEvent: (event) =>
857926
pushEvent("runtime", { type: "linear_workflow_event", event }),
858927
});
928+
linearIssueTrackerRef = headlessLinearServices.linearIssueTracker;
929+
githubServiceRef = headlessLinearServices.githubService as ReturnType<typeof createGithubService>;
859930
const linearOAuthService = createLinearOAuthService({
860931
credentials: headlessLinearServices.linearCredentialService as never,
861932
logger,
@@ -904,6 +975,7 @@ export async function createAdeRuntime(args: {
904975
logger,
905976
appVersion: "ade-cli",
906977
getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv,
978+
onLinearIssueChatLinked: publishLinearChatLink,
907979
onEvent: (event) => {
908980
pushEvent("runtime", event as unknown as Record<string, unknown>);
909981
},

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

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2253,7 +2253,7 @@ describe("ADE CLI", () => {
22532253
});
22542254
});
22552255

2256-
it("chains create_lane -> chat createSession -> kickoff for create-from-linear --start-chat", () => {
2256+
it("chains create_lane -> chat createSession -> attach Linear issue -> kickoff for create-from-linear --start-chat", () => {
22572257
const plan = buildCliPlan([
22582258
"lanes",
22592259
"create-from-linear",
@@ -2267,7 +2267,7 @@ describe("ADE CLI", () => {
22672267
]);
22682268
expect(plan.kind).toBe("execute");
22692269
if (plan.kind !== "execute") return;
2270-
expect(plan.steps).toHaveLength(3);
2270+
expect(plan.steps).toHaveLength(4);
22712271
expect(plan.steps[0]?.key).toBe("lane");
22722272

22732273
// Step 2 derives laneId from the create_lane result.
@@ -2285,8 +2285,26 @@ describe("ADE CLI", () => {
22852285
},
22862286
});
22872287

2288-
// Step 3 derives sessionId from the createSession result and sends a kickoff.
2289-
const sendStep = plan.steps[2]!;
2288+
// Step 3 attaches the issue to the chat so the runtime posts chat/lane cards.
2289+
const attachStep = plan.steps[2]!;
2290+
const attachParams = (attachStep.params as (v: Record<string, unknown>) => Record<string, unknown>)({
2291+
chat: { domain: "chat", action: "createSession", result: { id: "session-new" } },
2292+
});
2293+
expect(attachParams).toMatchObject({
2294+
arguments: {
2295+
domain: "lane",
2296+
action: "attachLinearIssueToSession",
2297+
args: {
2298+
chatSessionId: "session-new",
2299+
issues: [{ id: "issue-1", identifier: "ENG-431", title: "Fix OAuth", url: "https://linear.app/x/ENG-431" }],
2300+
role: "worked",
2301+
source: "chat_attach",
2302+
},
2303+
},
2304+
});
2305+
2306+
// Step 4 derives sessionId from the createSession result and sends a kickoff.
2307+
const sendStep = plan.steps[3]!;
22902308
const sendParams = (sendStep.params as (v: Record<string, unknown>) => Record<string, unknown>)({
22912309
chat: { domain: "chat", action: "createSession", result: { id: "session-new" } },
22922310
});
@@ -2408,6 +2426,57 @@ describe("ADE CLI", () => {
24082426
}
24092427
});
24102428

2429+
it("routes linear graphql through the runtime-owned Linear connection", () => {
2430+
const plan = buildCliPlan([
2431+
"linear",
2432+
"graphql",
2433+
"--query",
2434+
"query Viewer { viewer { id name } }",
2435+
"--operation-name",
2436+
"Viewer",
2437+
"--variables-json",
2438+
"{\"includeArchived\":false}",
2439+
]);
2440+
expect(plan.kind).toBe("execute");
2441+
if (plan.kind !== "execute") return;
2442+
expect(plan.steps[0]?.params).toEqual({
2443+
name: "run_ade_action",
2444+
arguments: {
2445+
domain: "linear_issue_tracker",
2446+
action: "graphql",
2447+
args: {
2448+
query: "query Viewer { viewer { id name } }",
2449+
operationName: "Viewer",
2450+
variables: { includeArchived: false },
2451+
},
2452+
},
2453+
});
2454+
});
2455+
2456+
it("revalidates linear graphql payloads after generic argument overrides", () => {
2457+
expect(() =>
2458+
buildCliPlan([
2459+
"linear",
2460+
"graphql",
2461+
"--query",
2462+
"query Viewer { viewer { id name } }",
2463+
"--arg-json",
2464+
"variables=[]",
2465+
]),
2466+
).toThrow(/--variables-json must be a JSON object/);
2467+
2468+
expect(() =>
2469+
buildCliPlan([
2470+
"linear",
2471+
"graphql",
2472+
"--query",
2473+
"query Viewer { viewer { id name } }",
2474+
"--input-json",
2475+
"{\"query\":123}",
2476+
]),
2477+
).toThrow(/GraphQL query is required/);
2478+
});
2479+
24112480
it("attaches an issue to the current session via linear attach --this-session", () => {
24122481
const prev = process.env.ADE_CHAT_SESSION_ID;
24132482
process.env.ADE_CHAT_SESSION_ID = "current-session";

apps/ade-cli/src/cli.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER}
392392
$ ade chat list | create | send | interrupt Work with ADE agent chats
393393
$ ade agent spawn --lane <id> --prompt <text> Launch an agent session in ADE
394394
$ ade cto state | chats Operate CTO state and Work chats
395-
$ ade linear workflows | run | sync Operate Linear routing and sync workflows
395+
$ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows
396396
$ ade automations list | create | run | runs Manage automation rules
397397
$ ade coordinator <tool> Call coordinator runtime tools
398398
$ ade tests list | run | stop | runs | logs Run configured test suites
@@ -1415,6 +1415,10 @@ const HELP_BY_COMMAND: Record<string, string> = {
14151415
$ ade linear set-state ENG-431 <state-id> Move an issue to a workflow state
14161416
$ ade linear assign ENG-431 <user-id|none> Assign or clear an issue assignee
14171417
$ ade linear label ENG-431 "needs-review" Add a label to an issue
1418+
$ ade linear graphql --query 'query { viewer { id name } }'
1419+
Run Linear GraphQL through the project connection
1420+
$ ade linear graphql --query-file query.graphql --variables-file vars.json
1421+
Use files for larger GraphQL operations
14181422
$ ade linear detach --this-session [--issue-id ENG-431]
14191423
Detach one issue (or all) from this session
14201424
@@ -1910,6 +1914,18 @@ function readJsonFileOption(
19101914
return parseJson(text, label);
19111915
}
19121916

1917+
function readTextFileOption(args: string[], names: string[], label: string): string | null {
1918+
const filePath = readValue(args, names);
1919+
if (filePath == null) return null;
1920+
const resolvedPath = path.resolve(filePath);
1921+
try {
1922+
return fs.readFileSync(resolvedPath, "utf8");
1923+
} catch (error) {
1924+
const message = error instanceof Error ? error.message : String(error);
1925+
throw new CliUsageError(`Could not read ${label} file '${filePath}': ${message}`);
1926+
}
1927+
}
1928+
19131929
function readJsonPayloadOption(
19141930
args: string[],
19151931
jsonNames: string[],
@@ -2056,6 +2072,60 @@ function readIssueIdFlag(args: string[]): string | null {
20562072
return readValue(args, ["--issue-id", "--linear-issue-id", "--issue"]);
20572073
}
20582074

2075+
function normalizeLinearGraphQLInput(input: JsonObject): JsonObject {
2076+
const query = asString(input.query);
2077+
if (!query) {
2078+
throw new CliUsageError("GraphQL query is required.");
2079+
}
2080+
2081+
const variables = input.variables;
2082+
if (variables != null && !isRecord(variables)) {
2083+
throw new CliUsageError("--variables-json must be a JSON object.");
2084+
}
2085+
2086+
const maxRetries = input.maxRetries;
2087+
if (maxRetries != null && (typeof maxRetries !== "number" || !Number.isFinite(maxRetries))) {
2088+
throw new CliUsageError("--max-retries must be a number.");
2089+
}
2090+
2091+
const normalized: JsonObject = { ...input, query };
2092+
if (variables == null) {
2093+
delete normalized.variables;
2094+
}
2095+
const operationName = asString(input.operationName);
2096+
if (operationName) {
2097+
normalized.operationName = operationName;
2098+
} else {
2099+
delete normalized.operationName;
2100+
}
2101+
return normalized;
2102+
}
2103+
2104+
function readLinearGraphQLArgs(args: string[]): JsonObject {
2105+
const inlineQuery = readValue(args, ["--query", "--graphql", "--gql"]);
2106+
const fileQuery = readTextFileOption(args, ["--query-file", "--graphql-file", "--gql-file"], "--query-file");
2107+
if (inlineQuery != null && fileQuery != null) {
2108+
throw new CliUsageError("Use either --query or --query-file, not both.");
2109+
}
2110+
const positionalQuery = inlineQuery == null && fileQuery == null ? firstPositional(args) : null;
2111+
const query = requireValue(inlineQuery ?? fileQuery ?? positionalQuery, "GraphQL query");
2112+
const variables = readJsonPayloadOption(
2113+
args,
2114+
["--variables-json", "--vars-json"],
2115+
["--variables-file", "--vars-file"],
2116+
"--variables-json",
2117+
);
2118+
if (variables !== undefined && !isRecord(variables)) {
2119+
throw new CliUsageError("--variables-json must be a JSON object.");
2120+
}
2121+
const input: JsonObject = { query };
2122+
if (variables !== undefined) input.variables = variables;
2123+
maybePut(input, "operationName", readValue(args, ["--operation-name", "--operation"]));
2124+
const maxRetries = readNumberOption(args, ["--max-retries"]);
2125+
if (maxRetries !== undefined) input.maxRetries = maxRetries;
2126+
return normalizeLinearGraphQLInput(collectGenericObjectArgs(args, input));
2127+
}
2128+
20592129
/**
20602130
* Resolve a Linear write-bridge command's issue id when the command takes no
20612131
* positional value (e.g. `ade linear issue [<id>]`). Precedence: --issue-id flag
@@ -3078,6 +3148,25 @@ function buildCreateLaneFromLinearPlan(args: string[], issue: JsonObject): CliPl
30783148
},
30793149
unwrapToolResult: true,
30803150
});
3151+
steps.push({
3152+
key: "attach",
3153+
method: "ade/actions/call",
3154+
params: (values) => {
3155+
const sessionId = sessionIdFromCreateChatValue(values.chat);
3156+
if (!sessionId) {
3157+
throw new CliUsageError("create-from-linear launched a chat but could not resolve its session id to attach the issue.");
3158+
}
3159+
return {
3160+
name: "run_ade_action",
3161+
arguments: {
3162+
domain: LINEAR_ATTACH_ACTIONS.domain,
3163+
action: LINEAR_ATTACH_ACTIONS.attachSession,
3164+
args: { chatSessionId: sessionId, issues: [issue], role: "worked", source: "chat_attach" },
3165+
},
3166+
};
3167+
},
3168+
unwrapToolResult: true,
3169+
});
30813170
steps.push({
30823171
key: "result",
30833172
method: "ade/actions/call",
@@ -8776,6 +8865,13 @@ function buildLinearPlan(args: string[]): CliPlan {
87768865
steps: [actionArgsListStep("result", "linear_issue_tracker", "fetchIssueById", [issueId])],
87778866
};
87788867
}
8868+
if (sub === "graphql" || sub === "gql") {
8869+
return {
8870+
kind: "execute",
8871+
label: "Linear GraphQL",
8872+
steps: [actionStep("result", "linear_issue_tracker", "graphql", readLinearGraphQLArgs(args))],
8873+
};
8874+
}
87798875
if (sub === "quick-view" || sub === "quick" || sub === "overview") {
87808876
return {
87818877
kind: "execute",

0 commit comments

Comments
 (0)