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/apps/web/src/components/issue/IssueTree.tsx b/apps/web/src/components/issue/IssueTree.tsx index 07651b585d2e..2e5a3c751f5b 100644 --- a/apps/web/src/components/issue/IssueTree.tsx +++ b/apps/web/src/components/issue/IssueTree.tsx @@ -1,5 +1,7 @@ import type { IssueLinkedPullRequest, IssueRelative, IssueRelativeNode } from "@t3tools/contracts"; +import { Fragment, type ReactNode } from "react"; + import { cn } from "~/lib/utils"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; import { IssueStateGlyph } from "./issuePresentation"; @@ -171,12 +173,17 @@ export function IssueTreePullRequestRows({ links, depth, onOpen, + renderPullRequest, }: { links: ReadonlyArray | undefined; depth: number; onOpen: (link: IssueLinkedPullRequest) => void; + /** A fuller row for a pull request the caller already knows, such as one linked to the thread. */ + renderPullRequest?: ((link: IssueLinkedPullRequest, depth: number) => ReactNode) | undefined; }) { return (links ?? []).map((link) => { + const custom = renderPullRequest?.(link, depth + 1); + if (custom != null) return {custom}; const presentation = resolvePullRequestState({ state: link.state, isDraft: link.isDraft }); return (