Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {}),
Expand Down Expand Up @@ -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 () => {
Expand Down
72 changes: 72 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -430,6 +434,45 @@ export async function createAdeRuntime(args: {
let conflictServiceRef: ReturnType<typeof createConflictService> | null = null;
let rebaseSuggestionServiceRef: ReturnType<typeof createRebaseSuggestionService> | null = null;
let autoRebaseServiceRef: ReturnType<typeof createAutoRebaseService> | null = null;
let linearIssueTrackerRef: ReturnType<typeof createLinearIssueTracker> | null = null;
let githubServiceRef: ReturnType<typeof createGithubService> | null = null;
const linearChatCardPublishKeys = new Set<string>();
const publishLinearChatLink = ({
laneId,
sessionId,
sessionTitle,
issue,
linkedAt,
}: {
laneId: string;
sessionId: string;
sessionTitle?: string | null;
issue: Parameters<typeof publishLinearChatSessionCard>[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,
Expand Down Expand Up @@ -462,6 +505,32 @@ 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: publishLinearChatLink,
logger,
});
await laneService.ensurePrimaryLane();
Expand Down Expand Up @@ -856,6 +925,8 @@ export async function createAdeRuntime(args: {
onLinearWorkflowEvent: (event) =>
pushEvent("runtime", { type: "linear_workflow_event", event }),
});
linearIssueTrackerRef = headlessLinearServices.linearIssueTracker;
githubServiceRef = headlessLinearServices.githubService as ReturnType<typeof createGithubService>;
const linearOAuthService = createLinearOAuthService({
credentials: headlessLinearServices.linearCredentialService as never,
logger,
Expand Down Expand Up @@ -904,6 +975,7 @@ export async function createAdeRuntime(args: {
logger,
appVersion: "ade-cli",
getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv,
onLinearIssueChatLinked: publishLinearChatLink,
onEvent: (event) => {
pushEvent("runtime", event as unknown as Record<string, unknown>);
},
Expand Down
77 changes: 73 additions & 4 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.
Expand All @@ -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<string, unknown>) => Record<string, unknown>)({
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<string, unknown>) => Record<string, unknown>)({
chat: { domain: "chat", action: "createSession", result: { id: "session-new" } },
});
Expand Down Expand Up @@ -2408,6 +2426,57 @@ 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("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";
Expand Down
98 changes: 97 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --prompt <text> 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 <tool> Call coordinator runtime tools
$ ade tests list | run | stop | runs | logs Run configured test suites
Expand Down Expand Up @@ -1415,6 +1415,10 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade linear set-state ENG-431 <state-id> Move an issue to a workflow state
$ ade linear assign ENG-431 <user-id|none> 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 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

Expand Down Expand Up @@ -1910,6 +1914,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[],
Expand Down Expand Up @@ -2056,6 +2072,60 @@ 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");
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 normalizeLinearGraphQLInput(collectGenericObjectArgs(args, input));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolve a Linear write-bridge command's issue id when the command takes no
* positional value (e.g. `ade linear issue [<id>]`). Precedence: --issue-id flag
Expand Down Expand Up @@ -3078,6 +3148,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",
Expand Down Expand Up @@ -8776,6 +8865,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",
Expand Down
Loading
Loading