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
129 changes: 129 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3261,6 +3261,87 @@ describe("adeRpcServer", () => {
expect(fixture.runtime.conflictService.getLaneStatus).toHaveBeenCalledWith({ laneId: "lane-1" });
});

it("projects projectless Linear lane issues and link rows instead of dropping them", async () => {
const fixture = createRuntime();
const projectlessIssue = {
id: "issue-69",
identifier: "ADE-69",
title: "Projectless issue linked to a lane",
description: null,
url: "https://linear.app/ade-linear/issue/ADE-69/projectless",
projectId: "",
projectSlug: "",
projectName: null,
teamId: "team-ade",
teamKey: "ADE",
teamName: "ADE",
stateId: "state-backlog",
stateName: "Backlog",
stateType: "backlog",
priority: 2,
priorityLabel: "high",
labels: ["bug"],
assigneeId: null,
assigneeName: null,
creatorId: null,
creatorName: null,
dueDate: null,
estimate: null,
branchName: "ade-69-projectless",
createdAt: "2026-05-31T08:32:17.115Z",
updatedAt: "2026-05-31T08:32:17.115Z",
};
(fixture.runtime.laneService.list as any).mockResolvedValueOnce([
{
id: "lane-1",
name: "ADE-69 Projectless issue linked to a lane",
laneType: "worktree",
parentLaneId: null,
baseRef: "main",
branchRef: "ade-69-projectless",
worktreePath: "/tmp/project/.ade/worktrees/ade-69",
archivedAt: null,
stackDepth: 0,
linearIssue: projectlessIssue,
linearIssueLinks: [
{
id: "link-69",
laneId: "lane-1",
issue: projectlessIssue,
role: "primary",
source: "lane_create",
includeInPr: true,
closeOnMerge: false,
evidence: null,
createdAt: "2026-05-31T08:32:17.115Z",
updatedAt: "2026-05-31T08:32:17.115Z",
},
],
status: { dirty: false, ahead: 0, behind: 0 },
},
]);
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler);
const response = await callTool(handler, "get_lane_status", { laneId: "lane-1" });

expect(response?.isError).toBeUndefined();
expect(response.structuredContent.lane.linearIssue).toMatchObject({
id: "issue-69",
identifier: "ADE-69",
projectId: "",
projectSlug: "",
});
expect(response.structuredContent.lane.linearIssueLinks).toEqual([
expect.objectContaining({
id: "link-69",
role: "primary",
source: "lane_create",
issue: expect.objectContaining({ identifier: "ADE-69", projectId: "" }),
}),
]);
});

it("routes check_conflicts with a single laneId", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
Expand Down Expand Up @@ -3349,6 +3430,54 @@ describe("adeRpcServer", () => {
expect((fixture.runtime.laneService.create as any).mock.calls[0][0].linearIssue).not.toHaveProperty("secretToken");
});

it("accepts projectless Linear issue data when creating a linked lane", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { callerId: "orchestrator", role: "orchestrator" });
const response = await callTool(handler, "create_lane", {
name: "projectless-linear-lane",
linearIssue: {
id: "issue-projectless",
identifier: "ADE-69",
title: "Projectless Linear issue",
description: null,
url: null,
projectId: "",
projectSlug: "",
projectName: null,
teamId: "team-ade",
teamKey: "ADE",
teamName: "ADE",
stateId: "state-backlog",
stateName: "Backlog",
stateType: "backlog",
priority: 2,
priorityLabel: "high",
labels: [],
assigneeId: null,
assigneeName: null,
creatorId: null,
creatorName: null,
dueDate: null,
estimate: null,
createdAt: "2026-05-31T08:32:17.115Z",
updatedAt: "2026-05-31T08:32:17.115Z",
},
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.laneService.create).toHaveBeenCalledWith(
expect.objectContaining({
linearIssue: expect.objectContaining({
identifier: "ADE-69",
projectId: "",
projectSlug: "",
}),
}),
);
});

it("routes simulate_integration as a read-only dry-merge", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
Expand Down
51 changes: 49 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type ComputerUseBackendStyle,
type ComputerUseArtifactOwner,
type LaneLinearIssue,
type LaneLinearIssueLink,
type MergeMethod,
type AppNavigationRequest,
} from "../../desktop/src/shared/types";
Expand Down Expand Up @@ -1934,8 +1935,8 @@ function parseLaneLinearIssue(value: unknown, field = "linearIssue"): LaneLinear
title: assertNonEmptyString(issue.title, `${field}.title`),
description: assertOptionalStringOrNull(issue.description, `${field}.description`),
url: assertOptionalStringOrNull(issue.url, `${field}.url`),
projectId: assertNonEmptyString(issue.projectId, `${field}.projectId`),
projectSlug: assertNonEmptyString(issue.projectSlug, `${field}.projectSlug`),
projectId: asTrimmedString(issue.projectId),
projectSlug: asTrimmedString(issue.projectSlug),
projectName: assertOptionalStringOrNull(issue.projectName, `${field}.projectName`),
teamId: assertNonEmptyString(issue.teamId, `${field}.teamId`),
teamKey: assertNonEmptyString(issue.teamKey, `${field}.teamKey`),
Expand Down Expand Up @@ -1967,6 +1968,46 @@ function projectLaneLinearIssue(value: unknown): LaneLinearIssue | null {
}
}

function projectLaneLinearIssueLink(value: unknown): LaneLinearIssueLink | null {
const link = safeObject(value);
if (Object.keys(link).length === 0) return null;
const issue = projectLaneLinearIssue(link.issue);
if (!issue) return null;
const role = asOptionalTrimmedString(link.role);
const source = asOptionalTrimmedString(link.source);
const laneId = asOptionalTrimmedString(link.laneId);
const evidenceRecord = safeObject(link.evidence);
const evidence = Object.keys(evidenceRecord).length
? {
chatSessionId: asOptionalTrimmedString(evidenceRecord.chatSessionId),
commitSha: asOptionalTrimmedString(evidenceRecord.commitSha),
prId: asOptionalTrimmedString(evidenceRecord.prId),
}
: null;
return {
id: asOptionalTrimmedString(link.id) ?? "",
laneId: laneId ?? "",
issue,
role: role === "primary" || role === "worked" || role === "referenced" || role === "inferred"
? role
: "worked",
source: source === "lane_create"
|| source === "lane_link"
|| source === "chat_attach"
|| source === "linear_open_issue"
|| source === "commit"
|| source === "pr_body"
|| source === "manual"
? source
: "manual",
includeInPr: link.includeInPr !== false,
closeOnMerge: link.closeOnMerge === true,
evidence,
createdAt: asOptionalTrimmedString(link.createdAt) ?? "",
updatedAt: asOptionalTrimmedString(link.updatedAt) ?? "",
};
}

function assertNonEmptyString(value: unknown, field: string): string {
const text = asTrimmedString(value);
if (!text.length) {
Expand Down Expand Up @@ -2786,6 +2827,11 @@ function resolveSpawnContextFile(args: {
}

function mapLaneSummary(lane: Record<string, unknown>): Record<string, unknown> {
const linearIssueLinks = Array.isArray(lane.linearIssueLinks)
? lane.linearIssueLinks
.map(projectLaneLinearIssueLink)
.filter((link): link is LaneLinearIssueLink => Boolean(link))
: [];
return {
id: lane.id,
name: lane.name,
Expand All @@ -2797,6 +2843,7 @@ function mapLaneSummary(lane: Record<string, unknown>): Record<string, unknown>
archivedAt: lane.archivedAt,
stackDepth: lane.stackDepth,
linearIssue: projectLaneLinearIssue(lane.linearIssue),
linearIssueLinks,
status: lane.status
};
}
Expand Down
Loading
Loading