From daa68a5ada31f08d510316a2883058119ca465cc Mon Sep 17 00:00:00 2001 From: nSimonFR-ai <265587706+nSimonFR-ai@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:18:45 +0200 Subject: [PATCH 1/3] fix(issues): link Linear issues from projects without a git host A thread in a project with no repository identity (or no Linear team binding) could never link or read a Linear issue: IssueService resolved every issue reference against the project's own sources, which come from its git remote or a per-project Linear binding, and failed with "Issues cannot be browsed for this project's host yet." Adapters may now resolve a repository named outright. Linear maps a team key to the connected account that can see it (environment account first), cached for ten minutes. IssueService falls back to it only after the project's own sources miss, so GitHub/GitLab routing is unchanged. The MCP issue tools now spell out the Linear contract: repository is the team key (CO for CO-130), number the trailing digits, provider linear. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/issue/IssueProvider.ts | 11 ++++ .../server/src/issue/IssueProviderRegistry.ts | 41 +++++++++++++ apps/server/src/issue/IssueService.test.ts | 47 +++++++++++++++ apps/server/src/issue/IssueService.ts | 58 +++++++++++-------- .../src/issue/LinearIssueProvider.test.ts | 44 ++++++++++++++ apps/server/src/issue/LinearIssueProvider.ts | 51 +++++++++++++++- apps/server/src/mcp/toolkits/issues/tools.ts | 20 +++++-- docs/user/source-control.md | 4 +- 8 files changed, 246 insertions(+), 30 deletions(-) diff --git a/apps/server/src/issue/IssueProvider.ts b/apps/server/src/issue/IssueProvider.ts index 9b619ab78276..660de6d4c92c 100644 --- a/apps/server/src/issue/IssueProvider.ts +++ b/apps/server/src/issue/IssueProvider.ts @@ -220,6 +220,17 @@ export interface IssueAdapter { project: OrchestrationProjectShell, ) => Effect.Effect; + /** + * A source for a repository a caller names outright, for trackers whose items do not belong to + * the project's checkout or binding: a Linear issue can be linked from any project. Null when + * this adapter cannot read that repository with any connected account. May spend an API call, + * so it is only asked once a reference matched none of the project's own sources. + */ + readonly resolveReference?: ( + project: OrchestrationProjectShell, + repository: string, + ) => Effect.Effect; + /** The signed-in account, which is what involvement filtering compares against. */ readonly getViewer: ( input: { diff --git a/apps/server/src/issue/IssueProviderRegistry.ts b/apps/server/src/issue/IssueProviderRegistry.ts index 4cbca2c04a80..5470d2e6521c 100644 --- a/apps/server/src/issue/IssueProviderRegistry.ts +++ b/apps/server/src/issue/IssueProviderRegistry.ts @@ -270,6 +270,17 @@ export class IssueProviderRegistry extends Context.Service< projects: ReadonlyArray, filter: IssueProjectFilter, ) => Effect.Effect; + /** Providers whose repositories can be named outright, independent of any project. */ + readonly referenceProviders: ReadonlySet; + /** The first adapter that can read a repository the caller named outright, if any. */ + readonly resolveReference: ( + project: OrchestrationProjectShell, + reference: { + readonly repository: string; + readonly provider?: IssueProviderKind | undefined; + readonly host?: string | undefined; + }, + ) => Effect.Effect; } >()("t3/issue/IssueProviderRegistry") {} @@ -293,6 +304,36 @@ export const fromProviders = Effect.fn("IssueProviderRegistry.fromProviders")(fu : Effect.succeed(tracker); }, resolveProjects: yield* projectResolver(byKind), + referenceProviders: new Set( + providers.flatMap((provider) => + provider.resolveReference === undefined ? [] : [provider.kind], + ), + ), + resolveReference: Effect.fn("IssueProviderRegistry.resolveReference")(function* ( + project: OrchestrationProjectShell, + reference: { + readonly repository: string; + readonly provider?: IssueProviderKind | undefined; + readonly host?: string | undefined; + }, + ) { + for (const adapter of byKind.values()) { + if (adapter.resolveReference === undefined) continue; + if (reference.provider !== undefined && adapter.kind !== reference.provider) continue; + const source = yield* adapter.resolveReference(project, reference.repository.trim()); + if (source === null) continue; + const host = source.host.trim().toLowerCase(); + if (reference.host !== undefined && host !== reference.host.toLowerCase()) continue; + return { + project, + adapter, + repository: source.repository, + host, + ...(source.credentialId === undefined ? {} : { credentialId: source.credentialId }), + } satisfies IssueProjectSource; + } + return null; + }), }; }); diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 2cb2ca1134b9..e2410c1ca2a2 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -959,6 +959,53 @@ it.effect("routes each repository on one project through its matching adapter", }), ); +it.effect("reads a Linear issue from a project with no git host or Linear binding", () => + Effect.gen(function* () { + const asked: Array = []; + const linear = fakeProvider("linear", { + resolveReference: (_project, repository) => + Effect.succeed( + repository === "CO" + ? { host: "linear.app", repository: "CO", credentialId: "user-1" } + : null, + ), + getIssue: ({ repository, number, credentialId }) => { + asked.push(`${repository}-${number}:${credentialId}`); + return Effect.succeed(issueDetail(number, { title: "Linear issue" })); + }, + }); + const hostless = [project({ id: "p1", title: "TRUSK", workspaceRoot: "/trusk" })]; + const service = yield* makeService({ + projects: hostless, + providers: [fakeProvider("github"), linear], + }); + + const detail = yield* service.detail({ + projectId: "p1" as ProjectId, + repository: "CO", + number: 130, + provider: "linear", + }); + assert.strictEqual(detail.title, "Linear issue"); + assert.strictEqual(detail.provider, "linear"); + assert.deepStrictEqual(asked, ["CO-130:user-1"]); + + const unknownTeam = yield* service + .detail({ projectId: "p1" as ProjectId, repository: "OPS", number: 1, provider: "linear" }) + .pipe(Effect.flip); + assert.strictEqual(unknownTeam._tag, "IssueOperationError"); + assert.include(unknownTeam.message, "No connected linear account can read OPS"); + const github = yield* service.detail({ ...REFERENCE, provider: "github" }).pipe(Effect.flip); + assert.strictEqual(github._tag, "IssueUnavailableError"); + + // A project on a Git host reads an unbound Linear team the same way, and its own repository + // still goes to its own host. + const onGitHub = yield* makeService({ projects: ONE_PROJECT, providers: [linear] }); + yield* onGitHub.detail({ projectId: "p1" as ProjectId, repository: "CO", number: 7 }); + assert.deepStrictEqual(asked, ["CO-130:user-1", "CO-7:user-1"]); + }), +); + it.effect("keeps a project available when one of its issue sources is unreadable", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 6dc63fd0c45f..1c6763946fd8 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -421,6 +421,8 @@ export const make = Effect.gen(function* () { /** * The project a request names, with the repository it claims checked against the project's own * remote: that field travels through the client, so it is never handed to a provider verbatim. + * A reference none of the project's sources match may still name a tracker that is not tied to + * the checkout, such as a Linear team, which its adapter accepts only for a connected account. */ const requireProject = ( ref: Pick, @@ -428,31 +430,41 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const resolved = yield* ResolvedSource; if (resolved !== null) return resolved; - return yield* listWorkspaceProjects({ projectId: ref.projectId }).pipe( - Effect.flatMap( - ({ supported }): Effect.Effect => { - if (supported.length === 0) { - return Effect.fail(new IssueUnavailableError({ reason: "provider-unsupported" })); - } - const repository = ref.repository.trim().toLowerCase(); - const match = supported.find( - (project) => - project.repository.toLowerCase() === repository && - (ref.provider === undefined || project.adapter.kind === ref.provider) && - (ref.host === undefined || project.host.toLowerCase() === ref.host.toLowerCase()), - ); - if (match === undefined) { - return Effect.fail( - new IssueOperationError({ - operation: "resolveRepository", - detail: "The issue does not belong to the selected project.", - }), - ); - } - return Effect.succeed(match); - }, + const { supported } = yield* listWorkspaceProjects({ projectId: ref.projectId }); + const repository = ref.repository.trim().toLowerCase(); + const match = supported.find( + (project) => + project.repository.toLowerCase() === repository && + (ref.provider === undefined || project.adapter.kind === ref.provider) && + (ref.host === undefined || project.host.toLowerCase() === ref.host.toLowerCase()), + ); + if (match !== undefined) return match; + const shells = yield* projects.listShells({ projectIds: [ref.projectId] }).pipe( + Effect.mapError( + (error) => + new IssueOperationError({ + operation: "resolveRepository", + detail: "The project could not be read.", + cause: error, + }), ), ); + const shell = shells.find((project) => project.id === ref.projectId); + const referenced = shell === undefined ? null : yield* registry.resolveReference(shell, ref); + if (referenced !== null) return referenced; + if (ref.provider !== undefined && registry.referenceProviders.has(ref.provider)) { + return yield* new IssueOperationError({ + operation: "resolveRepository", + detail: `No connected ${ref.provider} account can read ${ref.repository}.`, + }); + } + if (supported.length === 0) { + return yield* new IssueUnavailableError({ reason: "provider-unsupported" }); + } + return yield* new IssueOperationError({ + operation: "resolveRepository", + detail: "The issue does not belong to the selected project.", + }); }); /** diff --git a/apps/server/src/issue/LinearIssueProvider.test.ts b/apps/server/src/issue/LinearIssueProvider.test.ts index 8c28e805cfae..c942d04c4b79 100644 --- a/apps/server/src/issue/LinearIssueProvider.test.ts +++ b/apps/server/src/issue/LinearIssueProvider.test.ts @@ -419,3 +419,47 @@ it.effect("carries a Linear issue's ancestors and nested sub-issues on its detai ), ), ); + +it.effect("resolves a named team to the connected account that can read it", () => + Effect.gen(function* () { + let connectionReads = 0; + const account = (credentialId: string, keys: ReadonlyArray) => ({ + credentialId, + status: "authenticated" as const, + accountName: credentialId, + accountEmail: null, + projects: keys.map((key) => ({ id: key, key, name: key })), + }); + const adapter = yield* make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(LinearApi.LinearApi)({ + connection: Effect.sync(() => { + connectionReads += 1; + return { + status: "authenticated" as const, + hasStoredToken: true, + accountName: "first", + accountEmail: null, + projects: [], + accounts: [account("first", ["ENG"]), account("second", ["CO"])], + }; + }), + }), + ServerSettings.layerTest({ issueTracking: { connections: { linear: {} } } } as never), + ), + ), + ); + + assert.deepStrictEqual(yield* adapter.resolveReference!(PROJECT, "co"), { + host: "linear.app", + repository: "CO", + credentialId: "second", + }); + yield* adapter.resolveReference!(PROJECT, "CO"); + assert.strictEqual(connectionReads, 1); + assert.isNull(yield* adapter.resolveReference!(PROJECT, "OPS")); + assert.isNull(yield* adapter.resolveReference!(PROJECT, "acme/web")); + assert.strictEqual(connectionReads, 2); + }), +); diff --git a/apps/server/src/issue/LinearIssueProvider.ts b/apps/server/src/issue/LinearIssueProvider.ts index 157eacf1d256..489b43510fc9 100644 --- a/apps/server/src/issue/LinearIssueProvider.ts +++ b/apps/server/src/issue/LinearIssueProvider.ts @@ -1,3 +1,5 @@ +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import type { IssueCapabilities, @@ -13,7 +15,17 @@ import type { import * as ServerSettings from "../serverSettings.ts"; import * as LinearConnection from "./LinearConnection.ts"; import * as LinearApi from "./LinearApi.ts"; -import { IssueProviderError, type IssueAdapter, type ProviderIssue } from "./IssueProvider.ts"; +import { + IssueProviderError, + type IssueAdapter, + type IssueAdapterSource, + type ProviderIssue, +} from "./IssueProvider.ts"; + +/** A Linear team key, the letters before the dash in an identifier such as `ENG-123`. */ +const TEAM_KEY = /^[A-Za-z][A-Za-z0-9_]*$/; +/** How long the account that can read a team is believed without asking Linear again. */ +const TEAM_ACCOUNT_CACHE_TTL = Duration.minutes(10); const CAPABILITIES: IssueCapabilities = { sorts: ["updated"], @@ -195,6 +207,42 @@ export const make = Effect.gen(function* () { ); const identifier = (repository: string, number: number) => `${repository}-${number}`; + const teamAccounts = new Map< + string, + { readonly at: number; readonly source: IssueAdapterSource } + >(); + /** + * The connected account that can read a team, preferring the environment account like an + * unbound request does. Only answers are held: a team that is missing now may be granted later. + */ + const resolveTeam = (repository: string): Effect.Effect => + Effect.gen(function* () { + if (!TEAM_KEY.test(repository)) return null; + const key = repository.toUpperCase(); + const now = yield* Clock.currentTimeMillis; + const held = teamAccounts.get(key); + if (held !== undefined && now - held.at <= Duration.toMillis(TEAM_ACCOUNT_CACHE_TTL)) { + return held.source; + } + const connection = yield* api.connection; + const readsTeam = (projects: ReadonlyArray<{ readonly key: string }>) => + projects.find((team) => team.key.toUpperCase() === key)?.key; + const environment = connection.environmentAccount; + const environmentTeam = + environment?.status === "authenticated" ? readsTeam(environment.projects) : undefined; + let source: IssueAdapterSource | null = + environmentTeam === undefined ? null : { host: "linear.app", repository: environmentTeam }; + for (const account of connection.accounts) { + if (source !== null) break; + const team = account.status === "authenticated" ? readsTeam(account.projects) : undefined; + if (team !== undefined) { + source = { host: "linear.app", repository: team, credentialId: account.credentialId }; + } + } + if (source !== null) teamAccounts.set(key, { at: now, source }); + return source; + }).pipe(Effect.orElseSucceed(() => null)); + return { kind: "linear", tracker: yield* LinearConnection.make, @@ -215,6 +263,7 @@ export const make = Effect.gen(function* () { }), Effect.orElseSucceed(() => null), ), + resolveReference: (_project, repository) => resolveTeam(repository), getViewer: ({ credentialId }) => api.getViewer(credentialId === undefined ? {} : { credentialId }).pipe( Effect.map((viewer) => viewer.id), diff --git a/apps/server/src/mcp/toolkits/issues/tools.ts b/apps/server/src/mcp/toolkits/issues/tools.ts index d4f7c1d774ce..dae86a6f7983 100644 --- a/apps/server/src/mcp/toolkits/issues/tools.ts +++ b/apps/server/src/mcp/toolkits/issues/tools.ts @@ -25,9 +25,19 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; export const IssueTargetInput = Schema.Struct({ - repository: TrimmedNonEmptyString, - number: PositiveInt, - provider: Schema.optional(IssueProviderKind), + repository: TrimmedNonEmptyString.annotate({ + description: + "Where the issue lives: owner/name (or the host's full project path) for a Git host, or the team key for Linear, the letters before the dash in an identifier such as CO in CO-130.", + }), + number: PositiveInt.annotate({ + description: "Issue number; for Linear, the digits after the dash, such as 130 in CO-130.", + }), + provider: Schema.optional( + IssueProviderKind.annotate({ + description: + "Issue tracker. Defaults to the project's own. Pass linear for a Linear issue: it can be read and linked from any project while a connected Linear account can see its team.", + }), + ), }); export class IssueThreadNotFoundError extends Schema.TaggedError()( @@ -61,7 +71,7 @@ const dependencies = [ const ReadIssueTool = Tool.make("read_issue", { description: - "Read an issue body and a bounded page of comments as Markdown. Linked issues use their saved project and host; unlinked references use this thread's project. Pass repository, number, and provider when needed. Pass url to choose between linked issues with the same repository and number on different hosts. If nextCommentsCursor is present, pass it as commentsCursor to read the next page without reloading the issue body. commentsTruncated=true with a null cursor means this host cannot return the rest through T3.", + "Read an issue body and a bounded page of comments as Markdown. Linked issues use their saved project and host; unlinked references use this thread's project. Pass repository, number, and provider when needed; a Linear issue CO-130 is repository CO, number 130, provider linear. Pass url to choose between linked issues with the same repository and number on different hosts; it never selects an unlinked issue. If nextCommentsCursor is present, pass it as commentsCursor to read the next page without reloading the issue body. commentsTruncated=true with a null cursor means this host cannot return the rest through T3.", parameters: Schema.Struct({ ...IssueTargetInput.fields, url: Schema.optional(TrimmedNonEmptyString), @@ -83,7 +93,7 @@ const ReadIssueTool = Tool.make("read_issue", { const LinkIssueTool = Tool.make("link_issue", { description: - "Link an issue in this thread's project to this thread. Use after taking work on an issue. The link appears with the thread and its pull requests. Linking the same issue again succeeds with alreadyLinked=true.", + "Link an issue to this thread. Git-host issues must belong to this thread's project; a Linear issue only needs a connected Linear account that can see its team, so pass its team key as repository (CO for CO-130), its number, and provider linear. Use after taking work on an issue. The link appears with the thread and its pull requests. Linking the same issue again succeeds with alreadyLinked=true.", parameters: IssueTargetInput, success: Schema.Struct({ issue: ThreadIssueLink, alreadyLinked: Schema.Boolean }), failure: IssueToolError, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index ffbf7dee1d2a..3b79a462fdbc 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -202,7 +202,9 @@ does not show its diff, so marks are made and read on web and desktop. Open **Settings → Integrations → Issue Tracking**, then select **Add account** under **Linear accounts**. Enter a Linear API key, then choose an account and team for each project. You can add -several accounts. Keys stay on the connected server. +several accounts. Keys stay on the connected server. Choosing a team puts its issues in the +project's issue list; a thread can link any Linear issue a connected account can see, even in a +project with no team chosen or no Git remote. Linear supports browsing, search, comments, reactions, and agent handoffs. Create issues and change their title, description, state, labels, or assignees in Linear. Disconnecting a saved account removes From 2aa9567e515fdb0b8e54cc0f776d4fb2fadfa165 Mon Sep 17 00:00:00 2001 From: nSimonFR-ai <265587706+nSimonFR-ai@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:04:41 +0200 Subject: [PATCH 2/3] fix(web): keep Linear account names readable beside a long team list The account name, email and team list shared one row and all shrank, so a workspace with many teams squeezed the name and email down to a letter. The teams now take their own truncating line, with the full list in a tooltip. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/LinearIntegrationSettings.tsx | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/settings/LinearIntegrationSettings.tsx b/apps/web/src/components/settings/LinearIntegrationSettings.tsx index 111665e2025a..1450af5597ea 100644 --- a/apps/web/src/components/settings/LinearIntegrationSettings.tsx +++ b/apps/web/src/components/settings/LinearIntegrationSettings.tsx @@ -24,6 +24,7 @@ import { import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SettingsRow, SettingsSection } from "./settingsLayout"; @@ -160,21 +161,36 @@ export function LinearIntegrationSettings() { key={account.credentialId} className="flex items-center gap-3 rounded-lg border border-border/60 px-3 py-2" > -
- {account.accountName} - - {account.status !== "authenticated" ? ( - Needs attention +
+
+ {account.accountName} + + {account.status !== "authenticated" ? ( + Needs attention + ) : null} +
+ {account.projects.length > 0 ? ( + + + {account.projects + .map((team) => `${team.name} (${team.key})`) + .join(", ")} + + } + /> + + {account.projects.map((team) => `${team.name} (${team.key})`).join(", ")} + + ) : null} - - {account.projects.map((team) => `${team.name} (${team.key})`).join(", ")} -