From 65dd7964ec89f97a114c1cd6cbd9c4f02661907d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:26:56 -0800 Subject: [PATCH] feat(issues): link issues to threads and expose issue tools to agents A thread can hold links to issues, shown in its details beside its pull requests. A link records how the tracker named the issue when it was linked; opening it reads the live state. Issue links in a conversation open beside the thread, recognised by each host's own definition. Agents get tools to list, read, file, comment on and close issues, and to link them to a thread. Co-Authored-By: Claude Fable 5.2 --- apps/server/src/issue/IssueService.test.ts | 23 ++ apps/server/src/issue/IssueService.ts | 65 +++ apps/server/src/mcp/McpHttpServer.ts | 5 + apps/server/src/mcp/McpInvocationContext.ts | 1 + .../server/src/mcp/McpSessionRegistry.test.ts | 5 +- apps/server/src/mcp/McpSessionRegistry.ts | 1 + apps/server/src/mcp/toolkits/core.test.ts | 2 +- .../src/mcp/toolkits/issues/handlers.test.ts | 277 +++++++++++++ .../src/mcp/toolkits/issues/handlers.ts | 300 ++++++++++++++ apps/server/src/mcp/toolkits/issues/tools.ts | 388 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 39 ++ .../src/orchestration-v2/ProjectionStore.ts | 5 + .../ProviderSessionManager.test.ts | 4 +- .../ProviderSessionManager.ts | 2 +- .../orchestration-v2/ThreadIssueService.ts | 108 +++++ .../ThreadSettlementService.ts | 1 + .../src/orchestration-v2/runtimeLayer.test.ts | 84 ++++ .../testkit/OrchestratorScenario.ts | 2 + .../src/provider/AgentContextBudget.test.ts | 4 + .../src/provider/agentContextBudget.json | 2 +- apps/server/src/relay/AgentAwarenessRelay.ts | 1 + apps/server/src/server.ts | 5 + .../sourceControlDefinitions.test.ts | 31 ++ .../BranchToolbarBranchSelector.tsx | 18 + apps/web/src/components/ChatMarkdown.tsx | 7 +- apps/web/src/components/ChatView.tsx | 1 + .../chat/RightPanelSurfaceContent.tsx | 37 ++ .../chat/ThreadDetailsIssueRows.tsx | 43 ++ .../src/components/issue/IssueDetailPanel.tsx | 89 +++- apps/web/src/lib/openIssueLink.ts | 106 +++++ docs/user/source-control.md | 5 + .../client-runtime/src/operations/commands.ts | 28 ++ packages/client-runtime/src/state/models.ts | 3 + .../src/state/orchestrationV2Projection.ts | 1 + .../src/state/threadCommands.ts | 14 + packages/client-runtime/src/t3ToolSummary.ts | 28 ++ packages/contracts/src/issue.ts | 41 ++ packages/contracts/src/orchestrationV2.ts | 24 ++ packages/shared/src/t3McpToolPresentation.ts | 19 + .../src/shared/definition.ts | 10 + .../src/shared/definition.ts | 37 ++ .../src/shared/definition.ts | 17 +- .../src/shared/definition.ts | 3 + .../src/shared/definition.ts | 3 + 44 files changed, 1878 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/issues/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/issues/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/issues/tools.ts create mode 100644 apps/server/src/orchestration-v2/ThreadIssueService.ts create mode 100644 apps/web/src/components/chat/ThreadDetailsIssueRows.tsx create mode 100644 apps/web/src/lib/openIssueLink.ts diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 133f51234838c..c038c7a238577 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -142,6 +142,29 @@ function makeService(input: { } describe("IssueService", () => { + it.effect("places a project where its host keeps its issues", () => + Effect.gen(function* () { + const azure = IssueTrackerKind.make("azure-devops"); + const service = yield* makeService({ + projects: [ + project({ + id: "p", + provider: "azure-devops", + host: "dev.azure.com", + repository: "acme/Web App/_git/web", + }), + ], + trackers: [fakeTracker({ kind: azure })], + }); + expect(yield* service.projectScope("p" as ProjectId)).toEqual({ + projectId: "p", + tracker: "azure-devops", + host: "dev.azure.com", + scope: "acme/Web App", + }); + }), + ); + it.effect("reads another repository on the same host but refuses to write to it", () => Effect.gen(function* () { const writes: string[] = []; diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index c317ea47f1849..17c851fd4a392 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -28,6 +28,7 @@ import { type IssueTrackerSummary, type IssueUpdateInput, type OrchestrationProjectShell, + type ProjectId, } from "@t3tools/contracts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { @@ -140,6 +141,22 @@ export class IssueService extends Context.Service< readonly update: (input: IssueUpdateInput) => Effect.Effect; readonly setState: (input: IssueSetStateInput) => Effect.Effect; readonly react: (input: IssueReactionInput) => Effect.Effect; + /** + * The reference behind an issue URL: served by the project whose binding is that host and + * scope, else read-only through any project on the same tracker and host. `preferredProjectId` + * wins among equals, so a thread's own project serves its own issues. + */ + /** The tracker and container a project's own issues live in. */ + readonly projectScope: ( + projectId: ProjectId, + ) => Effect.Effect; + readonly resolveUrl: ( + url: string, + preferredProjectId?: ProjectId, + ) => Effect.Effect< + IssueRef & { readonly host: string; readonly writable: boolean }, + IssueError + >; } >()("t3/issue/IssueService") {} @@ -552,6 +569,52 @@ const make = Effect.gen(function* () { ), ); + const projectScope: IssueService["Service"]["projectScope"] = Effect.fn( + "IssueService.projectScope", + )(function* (projectId) { + const { supported } = yield* listProjects({ projectId }); + const [own] = supported; + if (own === undefined) { + return yield* new IssueUnavailableError({ reason: "tracker-unsupported" }); + } + return { projectId, tracker: own.tracker, host: own.host, scope: own.scope }; + }); + + const resolveUrl: IssueService["Service"]["resolveUrl"] = Effect.fn("IssueService.resolveUrl")( + function* (url, preferredProjectId) { + const parsed = sourceControlDefinitions.parseIssueUrl(url); + if (parsed === null) { + return yield* new IssueOperationError({ + operation: "resolveUrl", + reason: "not-found", + detail: "This is not a recognised issue URL.", + }); + } + const { definition, link } = parsed; + const { supported } = yield* listProjects({ host: link.host }); + const onTracker = supported.filter( + (project) => project.tracker === IssueTrackerKind.make(definition.kind), + ); + const preferFirst = (projects: ReadonlyArray) => + projects.find((project) => project.project.id === preferredProjectId) ?? projects[0]; + const own = preferFirst( + onTracker.filter((project) => project.scope.toLowerCase() === link.scope), + ); + const lender = own ?? preferFirst(onTracker); + if (lender === undefined) { + return yield* new IssueUnavailableError({ reason: "tracker-unsupported" }); + } + return { + projectId: lender.project.id, + tracker: lender.tracker, + host: lender.host, + scope: own === undefined ? link.scope : lender.scope, + key: link.key, + writable: own !== undefined, + }; + }, + ); + return IssueService.of({ list, detail: traced("detail", detail), @@ -563,6 +626,8 @@ const make = Effect.gen(function* () { update: interactive(traced("update", update)), setState: interactive(traced("setState", setState)), react: interactive(traced("react", react)), + projectScope, + resolveUrl, }); }); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ecbd1ba39e3c3..7bafd55fa0b06 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -79,6 +79,8 @@ import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts"; import * as ToolSearchHandlers from "./toolkits/toolSearch/handlers.ts"; import { ToolSearchToolkit } from "./toolkits/toolSearch/tools.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import * as IssuesHandlers from "./toolkits/issues/handlers.ts"; +import { IssuesToolkit } from "./toolkits/issues/tools.ts"; import * as DeviceHandlers from "./toolkits/device/handlers.ts"; import { DeviceScreenshotTool, @@ -1289,6 +1291,8 @@ export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsHandlers.layer, ).pipe(Layer.provide(PullRequestWatchService.layer)); +const layerIssuesToolkit = toolkitRegistration(IssuesToolkit, IssuesHandlers.layer); + const layerDeviceStandardToolkitRegistration = toolkitRegistration( DeviceStandardToolkit, DeviceHandlers.layerStandard, @@ -1346,6 +1350,7 @@ export const layerTools = Layer.mergeAll( layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, + layerIssuesToolkit, layerDeviceToolkit, layerHtmlToolkit, layerMessageToolkit, diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 37323863e8b72..09e757961d704 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -21,6 +21,7 @@ const ALL_MCP_CAPABILITIES = [ "worktree", "device", "pull-requests", + "issues", ] as const; export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 457679f87de71..146a7b584897a 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -53,7 +53,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const resolved = yield* registry.resolve(token); expect(resolved?.thread.threadId).toBe(threadId); expect(resolved?.capabilities).toEqual( - new Set(["preview", "orchestration", "worktree", "pull-requests"]), + new Set(["preview", "orchestration", "worktree", "pull-requests", "issues"]), ); yield* registry.revokeThread(threadId); @@ -87,18 +87,21 @@ it.effect("always grants pull-requests and gates browser and device access indep .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort())); expect(yield* capabilitiesOf(withPreview)).toEqual([ + "issues", "orchestration", "preview", "pull-requests", "worktree", ]); expect(yield* capabilitiesOf(withoutPreview)).toEqual([ + "issues", "orchestration", "pull-requests", "worktree", ]); expect(yield* capabilitiesOf(withDevice)).toEqual([ "device", + "issues", "orchestration", "pull-requests", "worktree", diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index ebc8ed74b9385..9a7142cea3dfa 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -139,6 +139,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( "orchestration", "worktree", "pull-requests", + "issues", ...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])), ]), issuedAt: issuedAlive.wallMs, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 35a18f5f0b460..a320b53ac248d 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -693,7 +693,7 @@ const clientScope = ( thread: undefined, client: { sessionId: "session-1", label: "Claude Code", access }, issuedAt: 0, - capabilities: new Set(["orchestration", "worktree", "pull-requests"]), + capabilities: new Set(["orchestration", "worktree", "pull-requests", "issues"]), }); it.effect("a client caller targets any thread within its ceiling and cannot act as a thread", () => diff --git a/apps/server/src/mcp/toolkits/issues/handlers.test.ts b/apps/server/src/mcp/toolkits/issues/handlers.test.ts new file mode 100644 index 0000000000000..ac77c2411ade2 --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.test.ts @@ -0,0 +1,277 @@ +import { + EnvironmentId, + IssueTrackerKind, + ProjectId, + ProviderInstanceId, + ThreadId, + type IssueDetail, + type OrchestrationProjectShell, + type ThreadIssueLink, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import type { Tool } from "effect/ai"; + +import * as IssueService from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadIssueService from "../../../orchestration-v2/ThreadIssueService.ts"; +import { v2PullRequestThread } from "../../../orchestration-v2/testkit/pullRequestFixtures.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; +import * as IssuesHandlers from "./handlers.ts"; +import { IssuesToolkit } from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const GITHUB = IssueTrackerKind.make("github"); + +const invocation = ( + capabilities: ReadonlyArray, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment-1"), + requestNamespace: "provider-session-1", + thread: { + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(capabilities), + issuedAt: 1, +}); + +const project: OrchestrationProjectShell = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity: { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/T3Code.git", + }, + provider: SourceControlProviderKind.make("github"), + displayName: "T3Tools/T3Code", + owner: "T3Tools", + name: "T3Code", + }, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +function makeThread(issues: ReadonlyArray) { + return { + ...v2PullRequestThread({ + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + }), + issues, + }; +} + +const detail: IssueDetail = { + tracker: GITHUB, + host: "github.com", + scope: "t3tools/t3code", + key: "7", + displayKey: "#7", + title: "Seven", + url: "https://github.com/T3Tools/T3Code/issues/7", + author: null, + state: { category: "open", name: "open" }, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-02T00:00:00.000Z", + closedAt: null, + assignees: [], + labels: [], + commentCount: 0, + projectId: PROJECT_ID, + projectTitle: "Project", + writable: true, + capabilities: { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: false, + templates: false, + linkedPullRequests: true, + events: true, + search: true, + states: [], + closeReasons: [], + }, + viewerPermissions: { setState: true, comment: true, edit: true, labels: true, assignees: true }, + viewer: null, + body: "", + comments: [], + commentsCursor: null, + events: [], + linkedPullRequests: [], +}; + +const makeHarness = Effect.fn("makeIssuesToolkitHarness")(function* ( + issues: ReadonlyArray = [], +) { + const links = yield* Ref.make>([]); + const layerDependencies = Layer.mergeAll( + Layer.mock(ProjectService.ProjectService)({ + getShell: (id) => Effect.succeed(id === PROJECT_ID ? Option.some(project) : Option.none()), + listShells: () => Effect.succeed([project]), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadShell: (id) => Effect.succeed(id === THREAD_ID ? makeThread(issues) : null), + }), + Layer.mock(IssueService.IssueService)({ + detail: () => Effect.succeed(detail), + projectScope: (projectId) => + Effect.succeed({ + projectId, + tracker: GITHUB, + host: "github.com", + scope: "t3tools/t3code", + }), + resolveUrl: (url) => + Effect.succeed({ + projectId: PROJECT_ID, + tracker: GITHUB, + host: "github.com", + scope: "t3tools/t3code", + key: url.split("/").at(-1)!, + writable: true, + }), + }), + Layer.mock(ThreadIssueService.ThreadIssueService)({ + link: (input) => + Ref.update(links, (recorded) => [...recorded, input]).pipe( + Effect.as({ alreadyLinked: false }), + ), + unlink: () => Effect.succeed({ wasLinked: true }), + }), + McpToolAccessTestkit.liveThreadsLayer, + ); + const toolkit = yield* IssuesToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(IssuesHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), + ); + const call = ( + name: Name, + params: Parameters>[1], + capabilities: ReadonlyArray = ["issues"], + ) => + toolkit.handle(name, params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map( + (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof IssuesToolkit.tools)[Name]>, + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)), + Effect.provide(layerDependencies), + ); + return { links, call }; +}); + +describe("issue toolkit handlers", () => { + it.effect("refuses a credential without the issues capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("list_thread_issues", {}, ["pull-requests"]) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "McpCapabilityUnavailableError", capability: "issues" }); + }), + ); + + it.effect("links by URL with source agent on the token's thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_issue", { + url: "https://github.com/T3Tools/T3Code/issues/7", + }); + expect(result).toEqual({ + projectId: PROJECT_ID, + tracker: "github", + host: "github.com", + scope: "t3tools/t3code", + key: "7", + url: "https://github.com/T3Tools/T3Code/issues/7", + alreadyLinked: false, + }); + expect(yield* Ref.get(harness.links)).toEqual([ + { + threadId: THREAD_ID, + ref: { + projectId: PROJECT_ID, + tracker: "github", + host: "github.com", + scope: "t3tools/t3code", + key: "7", + }, + url: "https://github.com/T3Tools/T3Code/issues/7", + displayKey: detail.displayKey, + title: detail.title, + source: "agent", + }, + ]); + }), + ); + + it.effect("links by key through the thread's project and reads the URL from the tracker", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_issue", { key: "7" }); + expect(result.url).toBe(detail.url); + expect((yield* Ref.get(harness.links))[0]?.url).toBe(detail.url); + }), + ); + + it.effect("lists linked issues from the thread shell as they were named when linked", () => + Effect.gen(function* () { + const harness = yield* makeHarness([ + { + tracker: GITHUB, + host: "github.com", + scope: "t3tools/t3code", + key: "7", + projectId: PROJECT_ID, + url: detail.url, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + displayKey: "#7", + title: "Seven", + }, + ]); + const result = yield* harness.call("list_thread_issues", {}); + expect(result.issues).toMatchObject([{ key: "7", title: "Seven", displayKey: "#7" }]); + const unlinked = yield* harness.call("unlink_issue", { url: detail.url }); + expect(unlinked.wasLinked).toBe(true); + }), + ); +}); diff --git a/apps/server/src/mcp/toolkits/issues/handlers.ts b/apps/server/src/mcp/toolkits/issues/handlers.ts new file mode 100644 index 0000000000000..a9eb216831cba --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.ts @@ -0,0 +1,300 @@ +import { + threadIssueKeysEqual, + type IssueDetail, + type IssueRef, + type OrchestrationV2ThreadShell, + type ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as IssueService from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadIssueService from "../../../orchestration-v2/ThreadIssueService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { + IssueLinkFailedError, + IssueProjectNotFoundError, + IssueProjectRequiredError, + IssuesToolkit, + IssueTargetIncompleteError, + type IssueTargetInput, + IssueThreadNotFoundError, + IssueThreadRequiredError, + type ListThreadIssuesResult, +} from "./tools.ts"; + +/** A resolved reference, with the host the link stores and whether it may be written. */ +interface ResolvedTarget extends IssueRef { + readonly host: string; + readonly url: string | null; + readonly writable: boolean; +} + +/** What the tools report from a thread shell; exported so the shape is testable without a layer. */ +function listThreadIssues( + thread: Pick, +): ListThreadIssuesResult { + return { + issues: (thread.issues ?? []).map((link) => ({ + projectId: link.projectId, + tracker: link.tracker, + host: link.host, + scope: link.scope, + key: link.key, + url: link.url, + source: link.source, + displayKey: link.displayKey, + title: link.title, + })), + }; +} + +const make = Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const issues = yield* IssueService.IssueService; + const threadIssues = yield* ThreadIssueService.ThreadIssueService; + + /** The caller's own thread, read when a tool defaults to it. */ + const callerThread = Effect.fn("IssuesToolkit.callerThread")(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("issues"); + return scope.thread === undefined + ? undefined + : yield* engine.getThreadShell(scope.thread.threadId).pipe( + Effect.orElseSucceed(() => null), + Effect.map((shell) => shell ?? undefined), + ); + }); + + const requireThread = Effect.fn("IssuesToolkit.requireThread")(function* ( + requested: ThreadId | undefined, + ) { + const scope = yield* McpInvocationContext.requireMcpCapability("issues"); + const threadId = requested ?? scope.thread?.threadId; + if (threadId === undefined) return yield* new IssueThreadRequiredError(); + const thread = yield* engine.getThreadShell(threadId).pipe( + Effect.map(Option.fromNullishOr), + Effect.mapError((cause) => new IssueLinkFailedError({ cause })), + ); + if (Option.isNone(thread) || thread.value.deletedAt !== null) { + return yield* new IssueThreadNotFoundError({ threadId }); + } + return thread.value; + }); + + /** Where a key-only reference is read: the named project's tracker, or the caller's thread's. */ + const requireProjectScope = Effect.fn("IssuesToolkit.requireProjectScope")(function* ( + requested: ProjectId | undefined, + ) { + const projectId = requested ?? (yield* callerThread())?.projectId; + if (projectId === undefined) return yield* new IssueProjectRequiredError(); + return yield* issues + .projectScope(projectId) + .pipe(Effect.mapError(() => new IssueProjectNotFoundError({ projectId }))); + }); + + /** + * Turns whichever shape the agent passed into one reference. A URL resolves through whichever + * project here is a checkout of its repository; a key is completed with the named project's + * binding, or the caller's thread's project. + */ + const resolveTarget = Effect.fn("IssuesToolkit.resolveTarget")(function* ( + input: IssueTargetInput, + ) { + if (input.url !== undefined) { + const preferred = input.projectId ?? (yield* callerThread())?.projectId; + const resolved = yield* issues.resolveUrl(input.url, preferred); + return { ...resolved, url: input.url } satisfies ResolvedTarget; + } + if (input.key === undefined) return yield* new IssueTargetIncompleteError(); + const scope = yield* requireProjectScope(input.projectId); + return { + ...scope, + key: input.key, + url: null, + writable: true, + } satisfies ResolvedTarget; + }); + + const identityOf = (target: ResolvedTarget, detail?: Pick) => ({ + projectId: target.projectId, + tracker: target.tracker, + host: target.host, + scope: target.scope, + key: target.key, + url: target.url ?? detail?.url ?? "", + }); + + const refOf = (target: ResolvedTarget): IssueRef & { readonly host: string } => ({ + projectId: target.projectId, + tracker: target.tracker, + host: target.host, + scope: target.scope, + key: target.key, + }); + + /** A link needs the issue's URL, which a key-only reference does not carry; one read fills it. */ + const linkFailure = (cause: Cause.Cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail(new IssueLinkFailedError({ cause })); + + /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ + const writesThread =

( + handle: (params: P) => Effect.Effect, + ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); + + return { + list_issues: McpToolAccess.reads((input) => + Effect.gen(function* () { + const { projectId } = yield* requireProjectScope(input.projectId); + const result = yield* issues.list({ + projectId, + state: input.state ?? "open", + ...(input.query === undefined ? {} : { query: input.query }), + ...(input.limit === undefined ? {} : { limit: input.limit }), + ...(input.cursors === undefined ? {} : { cursors: input.cursors }), + }); + return { + issues: result.entries.map((entry) => ({ + projectId: entry.projectId, + tracker: entry.tracker, + host: entry.host, + scope: entry.scope, + key: entry.key, + url: entry.url, + displayKey: entry.displayKey, + title: entry.title, + state: entry.state, + author: entry.author?.login ?? null, + assignees: entry.assignees.map((actor) => actor.login), + labels: entry.labels.map((label) => label.name), + updatedAt: entry.updatedAt, + commentCount: entry.commentCount, + })), + nextCursors: result.nextCursors, + errors: result.errors.map((error) => ({ + projectId: error.projectId, + message: error.message, + })), + }; + }), + ), + read_issue: McpToolAccess.reads((input) => + Effect.gen(function* () { + const target = yield* resolveTarget(input); + const detail = yield* issues.detail(refOf(target)); + return { + ...identityOf(target, detail), + displayKey: detail.displayKey, + title: detail.title, + state: detail.state, + writable: detail.writable, + capabilities: detail.capabilities, + author: detail.author?.login ?? null, + assignees: detail.assignees.map((actor) => actor.login), + labels: detail.labels.map((label) => label.name), + createdAt: detail.createdAt, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt, + body: detail.body, + comments: detail.comments.map((comment) => ({ + id: comment.id, + author: comment.author?.login ?? null, + body: comment.body, + createdAt: comment.createdAt, + })), + commentsTruncated: detail.commentsCursor !== null, + linkedPullRequests: detail.linkedPullRequests.map((pullRequest) => ({ + url: pullRequest.url, + title: pullRequest.title, + state: pullRequest.state, + closesIssue: pullRequest.closesIssue, + })), + }; + }), + ), + create_issue: McpToolAccess.writesWithCapability((input) => + Effect.gen(function* () { + const scope = yield* requireProjectScope(input.projectId); + const created = yield* issues.create({ + ...scope, + title: input.title, + body: input.body ?? "", + labels: input.labels ?? [], + assignees: input.assignees ?? [], + }); + return { + ...scope, + key: created.key, + url: created.url, + displayKey: created.displayKey, + }; + }), + ), + comment_on_issue: McpToolAccess.writesWithCapability((input) => + Effect.gen(function* () { + const target = yield* resolveTarget(input); + yield* issues.comment({ ...refOf(target), body: input.body }); + return identityOf(target); + }), + ), + set_issue_state: McpToolAccess.writesWithCapability((input) => + Effect.gen(function* () { + const target = yield* resolveTarget(input); + yield* issues.setState({ + ...refOf(target), + state: input.state, + ...(input.closeReason === undefined ? {} : { closeReason: input.closeReason }), + }); + return identityOf(target); + }), + ), + link_issue: writesThread((input) => + Effect.gen(function* () { + const thread = yield* requireThread(input.threadId); + const target = yield* resolveTarget({ + ...input, + projectId: input.projectId ?? thread.projectId, + }); + // Reading the issue proves it exists and names it for the thread's row. + const detail = yield* issues.detail(refOf(target)); + const url = target.url ?? detail.url; + const { alreadyLinked } = yield* threadIssues + .link({ + threadId: thread.id, + ref: refOf(target), + url, + displayKey: detail.displayKey, + title: detail.title, + source: "agent", + }) + .pipe(Effect.catchCause(linkFailure)); + return { ...identityOf(target), url, alreadyLinked }; + }), + ), + unlink_issue: writesThread((input) => + Effect.gen(function* () { + const thread = yield* requireThread(input.threadId); + const target = yield* resolveTarget({ + ...input, + projectId: input.projectId ?? thread.projectId, + }); + const existing = (thread.issues ?? []).find((link) => threadIssueKeysEqual(link, target)); + if (existing === undefined) return { ...identityOf(target), wasLinked: false }; + const { wasLinked } = yield* threadIssues + .unlink({ threadId: thread.id, key: target }) + .pipe(Effect.catchCause(linkFailure)); + return { ...identityOf(target), url: existing.url, wasLinked }; + }), + ), + list_thread_issues: McpToolAccess.reads((input) => + requireThread(input.threadId).pipe(Effect.map(listThreadIssues)), + ), + } satisfies McpToolAccess.Handlers; +}); + +export const layer = McpToolAccess.toLayer(IssuesToolkit, make); diff --git a/apps/server/src/mcp/toolkits/issues/tools.ts b/apps/server/src/mcp/toolkits/issues/tools.ts new file mode 100644 index 0000000000000..9f9488b65ee0e --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/tools.ts @@ -0,0 +1,388 @@ +import { + IssueCapabilities, + IssueListState, + IssueOperationError, + IssueState, + IssueTrackerKind, + IssueUnavailableError, + McpCapabilityUnavailableError, + OrchestratorMcpFailure, + ProjectId, + ThreadId, + ThreadIssueLinkSource, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/ai/Tool"; +import * as Toolkit from "effect/ai/Toolkit"; + +import * as IssueService from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadIssueService from "../../../orchestration-v2/ThreadIssueService.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + Orchestrator.OrchestratorV2, + ProjectService.ProjectService, + IssueService.IssueService, + ThreadIssueService.ThreadIssueService, +]; + +/** + * Either the issue's URL, or its key within a project's tracker. A key alone is read through the + * project named, or the caller's own thread's project; a URL is read through whichever project + * here is a checkout of that repository, read-only when none is. + */ +export const IssueTargetInput = Schema.Struct({ + url: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "The issue's web URL. Preferred when you have it.", + }), + ), + key: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "The issue's key in the project's tracker, such as its number.", + }), + ), + projectId: Schema.optional( + ProjectId.annotate({ + description: "Project whose tracker holds key. Defaults to this thread's project.", + }), + ), +}); +export type IssueTargetInput = typeof IssueTargetInput.Type; + +const ThreadTargetInput = Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ description: "Thread to act on. Omit for this thread." }), + ), +}); + +export class IssueTargetIncompleteError extends Schema.TaggedError()( + "IssueTargetIncompleteError", + {}, +) { + override get message(): string { + return "Pass either url, or key (with projectId when not running inside a T3 thread)."; + } +} + +export class IssueProjectRequiredError extends Schema.TaggedError()( + "IssueProjectRequiredError", + {}, +) { + override get message(): string { + return "Pass projectId: this MCP client is not running inside a T3 thread."; + } +} + +export class IssueThreadRequiredError extends Schema.TaggedError()( + "IssueThreadRequiredError", + {}, +) { + override get message(): string { + return "Pass threadId: this MCP client is not running inside a T3 thread."; + } +} + +export class IssueThreadNotFoundError extends Schema.TaggedError()( + "IssueThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class IssueProjectNotFoundError extends Schema.TaggedError()( + "IssueProjectNotFoundError", + { projectId: Schema.String }, +) { + override get message(): string { + return `Project ${this.projectId} was not found or has no issue tracker.`; + } +} + +export class IssueLinkFailedError extends Schema.TaggedError()( + "IssueLinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not change the issue link."; + } +} + +export const IssueToolError = Schema.Union([ + OrchestratorMcpFailure, + McpCapabilityUnavailableError, + IssueUnavailableError, + IssueOperationError, + IssueTargetIncompleteError, + IssueProjectRequiredError, + IssueThreadRequiredError, + IssueThreadNotFoundError, + IssueProjectNotFoundError, + IssueLinkFailedError, +]); +export type IssueToolError = typeof IssueToolError.Type; + +/** How a tool names an issue back: enough to pass to any other issue tool. */ +const IssueIdentity = { + projectId: ProjectId, + tracker: IssueTrackerKind, + host: Schema.String, + scope: Schema.String, + key: Schema.String, + url: Schema.String, +}; + +export const IssueEntry = Schema.Struct({ + ...IssueIdentity, + displayKey: Schema.String, + title: Schema.String, + state: IssueState, + author: Schema.NullOr(Schema.String), + assignees: Schema.Array(Schema.String), + labels: Schema.Array(Schema.String), + updatedAt: Schema.String, + commentCount: Schema.Int, +}); +export type IssueEntry = typeof IssueEntry.Type; + +export const ListIssuesResult = Schema.Struct({ + issues: Schema.Array(IssueEntry), + /** Pass back as `cursors` to read the next slice of each scope that has one. */ + nextCursors: Schema.Record(Schema.String, Schema.String), + errors: Schema.Array(Schema.Struct({ projectId: ProjectId, message: Schema.String })), +}); +export type ListIssuesResult = typeof ListIssuesResult.Type; + +export const ReadIssueResult = Schema.Struct({ + ...IssueIdentity, + displayKey: Schema.String, + title: Schema.String, + state: IssueState, + /** False for an issue outside every project's own scope: it can be read, not changed. */ + writable: Schema.Boolean, + capabilities: IssueCapabilities, + author: Schema.NullOr(Schema.String), + assignees: Schema.Array(Schema.String), + labels: Schema.Array(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + closedAt: Schema.NullOr(Schema.String), + body: Schema.String, + comments: Schema.Array( + Schema.Struct({ + id: Schema.String, + author: Schema.NullOr(Schema.String), + body: Schema.String, + createdAt: Schema.String, + }), + ), + /** Older comments exist beyond the page returned. */ + commentsTruncated: Schema.Boolean, + linkedPullRequests: Schema.Array( + Schema.Struct({ + url: Schema.String, + title: Schema.String, + state: Schema.String, + closesIssue: Schema.Boolean, + }), + ), +}); +export type ReadIssueResult = typeof ReadIssueResult.Type; + +export const CreateIssueResult = Schema.Struct({ + ...IssueIdentity, + displayKey: Schema.String, +}); +export type CreateIssueResult = typeof CreateIssueResult.Type; + +export const IssueWriteResult = Schema.Struct({ ...IssueIdentity }); + +export const ThreadIssueEntry = Schema.Struct({ + ...IssueIdentity, + source: ThreadIssueLinkSource, + /** As the tracker named the issue when it was linked; `read_issue` has its current state. */ + displayKey: Schema.String, + title: Schema.String, +}); +export type ThreadIssueEntry = typeof ThreadIssueEntry.Type; + +export const LinkIssueResult = Schema.Struct({ + ...IssueIdentity, + alreadyLinked: Schema.Boolean.annotate({ + description: "True when the issue was linked to this thread before the call.", + }), +}); +export type LinkIssueResult = typeof LinkIssueResult.Type; + +export const UnlinkIssueResult = Schema.Struct({ + ...IssueIdentity, + wasLinked: Schema.Boolean.annotate({ + description: "False when the issue was not linked to this thread to begin with.", + }), +}); +export type UnlinkIssueResult = typeof UnlinkIssueResult.Type; + +export const ListThreadIssuesResult = Schema.Struct({ + issues: Schema.Array(ThreadIssueEntry), +}); +export type ListThreadIssuesResult = typeof ListThreadIssuesResult.Type; + +const ListIssuesTool = Tool.make("list_issues", { + description: + "List issues in a project's tracker, newest updated first. Pass the returned nextCursors as cursors for the next page.", + parameters: Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ description: "Defaults to this thread's project." }), + ), + state: Schema.optional(IssueListState.annotate({ description: "Defaults to open." })), + query: Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({ + description: "Free text the tracker matches itself.", + }), + ), + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), + cursors: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), + success: ListIssuesResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "List issues") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const ReadIssueTool = Tool.make("read_issue", { + description: + "Read an issue: body, state, labels, assignees, recent comments and linked pull requests. Pass its url, or its key.", + parameters: IssueTargetInput, + success: ReadIssueResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Read issue") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const CreateIssueTool = Tool.make("create_issue", { + description: "File an issue in a project's tracker.", + parameters: Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ description: "Defaults to this thread's project." }), + ), + title: TrimmedNonEmptyString.check(Schema.isMaxLength(1024)), + body: Schema.optional(Schema.String.check(Schema.isMaxLength(65_536))), + labels: Schema.optional(Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(50))), + assignees: Schema.optional(Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(25))), + }), + success: CreateIssueResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Create issue") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +const CommentOnIssueTool = Tool.make("comment_on_issue", { + description: "Post a comment on an issue.", + parameters: Schema.Struct({ + ...IssueTargetInput.fields, + body: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), + }), + success: IssueWriteResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Comment on issue") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +const SetIssueStateTool = Tool.make("set_issue_state", { + description: + "Move an issue to a state its tracker offers. read_issue lists them under capabilities.states.", + parameters: Schema.Struct({ + ...IssueTargetInput.fields, + state: TrimmedNonEmptyString.annotate({ + description: "A state name from capabilities.states.", + }), + closeReason: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "From capabilities.closeReasons, for a closing state.", + }), + ), + }), + success: IssueWriteResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Set issue state") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const LinkIssueTool = Tool.make("link_issue", { + description: + "Link an issue to a thread so T3 Code shows it beside the thread. Link every issue you work on or file.", + parameters: Schema.Struct({ ...IssueTargetInput.fields, ...ThreadTargetInput.fields }), + success: LinkIssueResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Link issue to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnlinkIssueTool = Tool.make("unlink_issue", { + description: "Remove an issue link from a thread. The issue itself is not changed.", + parameters: Schema.Struct({ ...IssueTargetInput.fields, ...ThreadTargetInput.fields }), + success: UnlinkIssueResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Unlink issue from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListThreadIssuesTool = Tool.make("list_thread_issues", { + description: "List the issues linked to a thread.", + parameters: ThreadTargetInput, + success: ListThreadIssuesResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "List thread issues") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const IssuesToolkit = Toolkit.make( + ListIssuesTool, + ReadIssueTool, + CreateIssueTool, + CommentOnIssueTool, + SetIssueStateTool, + LinkIssueTool, + UnlinkIssueTool, + ListThreadIssuesTool, +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de632876f76a7..365421a4b10f6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -13,6 +13,7 @@ import { runRanAfter, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; +import { threadIssueKeysEqual } from "@t3tools/contracts"; import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import { @@ -644,6 +645,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.issue.link": + case "thread.issue.unlink": case "thread.pull-request.watch": case "thread.pull-request-watch.sync": case "thread.pull-request-watch.checks-reopen": @@ -4121,6 +4124,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.pull-request.link" | "thread.pull-request.unlink" | "thread.pull-request-link.sync" + | "thread.issue.link" + | "thread.issue.unlink" | "thread.pull-request.watch" | "thread.pull-request-watch.sync" | "thread.pull-request-watch.checks-reopen" @@ -4999,6 +5004,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, }; } + case "thread.issue.link": + case "thread.issue.unlink": { + const links = thread.issues ?? []; + const existing = links.find((link) => threadIssueKeysEqual(link, command)); + if (command.type === "thread.issue.unlink") { + if (!existing) return thread; + return { ...thread, issues: links.filter((link) => link !== existing), updatedAt: now }; + } + if (existing) return thread; + return { + ...thread, + issues: [ + ...links, + { + tracker: command.tracker, + host: command.host.trim().toLowerCase(), + scope: command.scope.trim(), + key: command.key.trim(), + projectId: command.projectId, + url: command.url, + displayKey: command.displayKey, + title: command.title, + source: command.source, + linkedAt: DateTime.formatIso(now), + }, + ], + updatedAt: now, + }; + } case "thread.pull-request-watch.checks-reopen": { const linked = threadPullRequestsOf(thread); if (!linked.some((link) => link.watch?.checksPending !== undefined)) return thread; @@ -5239,6 +5273,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request.watch": case "thread.pull-request.sync": return "thread.pull-request-synced" as const; + case "thread.issue.link": + case "thread.issue.unlink": + return "thread.issue-synced" as const; case "thread.runtime-mode.set": return "thread.runtime-mode-updated" as const; case "thread.interaction-mode.set": @@ -15030,6 +15067,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": + case "thread.issue.link": + case "thread.issue.unlink": case "thread.pull-request.watch": case "thread.pull-request.sync": case "thread.title.regeneration.complete": diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 0b0528d1ad269..065a09f825d33 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -947,6 +947,7 @@ export function applyToProjection( case "thread.active-reordered": case "thread.tags-updated": case "thread.pull-request-synced": + case "thread.issue-synced": return { ...base, thread: event.payload, @@ -1795,6 +1796,7 @@ export function threadShellFromProjection( ...(projection.thread.branchPullRequest === undefined ? {} : { branchPullRequest: projection.thread.branchPullRequest }), + ...(projection.thread.issues === undefined ? {} : { issues: projection.thread.issues }), ...(projection.thread.activeOrderKey === undefined ? {} : { activeOrderKey: projection.thread.activeOrderKey }), @@ -2108,6 +2110,7 @@ function shellFromState(input: { ...(input.state.thread.branchPullRequest === undefined ? {} : { branchPullRequest: input.state.thread.branchPullRequest }), + ...(input.state.thread.issues === undefined ? {} : { issues: input.state.thread.issues }), ...(input.state.thread.activeOrderKey === undefined ? {} : { activeOrderKey: input.state.thread.activeOrderKey }), @@ -2327,6 +2330,7 @@ export const layer: Layer.Layer = case "thread.marked-unread": case "thread.metadata-updated": case "thread.pull-request-synced": + case "thread.issue-synced": case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "thread.model-selection-updated": @@ -3145,6 +3149,7 @@ export const layer: Layer.Layer = event.type !== "thread.marked-unread" && event.type !== "thread.metadata-updated" && event.type !== "thread.pull-request-synced" && + event.type !== "thread.issue-synced" && event.type !== "thread.pull-request-link-updated" && event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 09441e2659166..e000ea3e17d1e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -2461,7 +2461,7 @@ it.effect( assert.equal(resolved?.thread.threadId, threadId); assert.deepEqual( resolved?.capabilities, - new Set(["preview", "orchestration", "worktree", "pull-requests"]), + new Set(["preview", "orchestration", "worktree", "pull-requests", "issues"]), ); assert.isFalse(yield* captured!.hasPendingWake!); @@ -2573,7 +2573,7 @@ it.effect( const resolved = yield* registry.resolve(token!); assert.deepEqual( resolved?.capabilities, - new Set(["orchestration", "worktree", "pull-requests"]), + new Set(["orchestration", "worktree", "pull-requests", "issues"]), ); yield* manager.close(providerSessionId); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index ca54c14b2cb95..dabe0624e581e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -564,7 +564,7 @@ export const layerWithOptions = ( yield* agentAccessSettings(threadId); const capabilities = new Set< import("../mcp/McpInvocationContext.ts").McpCapability - >(["orchestration", "worktree", "pull-requests"]); + >(["orchestration", "worktree", "pull-requests", "issues"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); const subagent = yield* projectionStore.getThread(threadId).pipe( diff --git a/apps/server/src/orchestration-v2/ThreadIssueService.ts b/apps/server/src/orchestration-v2/ThreadIssueService.ts new file mode 100644 index 0000000000000..9e5ef43f2cd28 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadIssueService.ts @@ -0,0 +1,108 @@ +/** + * Issues linked to threads: the link and unlink commands the server's own surfaces (MCP) go + * through. A link records how the tracker named the issue when it was made; nothing refreshes + * it, and opening the issue reads its live state. + * + * @module orchestration-v2/ThreadIssueService + */ +import { + CommandId, + threadIssueKeysEqual, + type IssueRef, + type ThreadId, + type ThreadIssueKey, + type ThreadIssueLinkSource, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Orchestrator from "./Orchestrator.ts"; + +export interface LinkThreadIssueInput { + readonly threadId: ThreadId; + readonly ref: IssueRef & { readonly host: string }; + readonly url: string; + readonly displayKey: string; + readonly title: string; + readonly source: ThreadIssueLinkSource; +} + +export interface UnlinkThreadIssueInput { + readonly threadId: ThreadId; + readonly key: ThreadIssueKey; +} + +export class ThreadIssueService extends Context.Service< + ThreadIssueService, + { + /** Linking a linked issue is a no-op. */ + readonly link: ( + input: LinkThreadIssueInput, + ) => Effect.Effect<{ readonly alreadyLinked: boolean }, Orchestrator.OrchestratorV2Error>; + readonly unlink: ( + input: UnlinkThreadIssueInput, + ) => Effect.Effect<{ readonly wasLinked: boolean }, Orchestrator.OrchestratorV2Error>; + } +>()("t3/orchestration-v2/ThreadIssueService") {} + +const make = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const random = yield* SecureRandom.SecureRandom; + + const commandId = (tag: string, threadId: ThreadId) => + random.uuidV4().pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:thread-issue-${tag}:${threadId}:${uuid}`)), + ); + + const isLinked = (threadId: ThreadId, key: ThreadIssueKey) => + orchestrator.getThreadShell(threadId).pipe( + Effect.map( + (thread) => thread?.issues?.some((link) => threadIssueKeysEqual(link, key)) === true, + ), + Effect.orElseSucceed(() => false), + ); + + const link: ThreadIssueService["Service"]["link"] = Effect.fn("ThreadIssueService.link")( + function* (input) { + if (yield* isLinked(input.threadId, input.ref)) return { alreadyLinked: true }; + yield* orchestrator.dispatch({ + type: "thread.issue.link", + commandId: yield* commandId("link", input.threadId), + threadId: input.threadId, + tracker: input.ref.tracker, + host: input.ref.host, + scope: input.ref.scope, + key: input.ref.key, + projectId: input.ref.projectId, + url: input.url, + displayKey: input.displayKey, + title: input.title, + source: input.source, + }); + return { alreadyLinked: false }; + }, + ); + + const unlink: ThreadIssueService["Service"]["unlink"] = Effect.fn("ThreadIssueService.unlink")( + function* (input) { + if (!(yield* isLinked(input.threadId, input.key))) return { wasLinked: false }; + yield* orchestrator.dispatch({ + type: "thread.issue.unlink", + commandId: yield* commandId("unlink", input.threadId), + threadId: input.threadId, + tracker: input.key.tracker, + host: input.key.host, + scope: input.key.scope, + key: input.key.key, + }); + return { wasLinked: true }; + }, + ); + + return ThreadIssueService.of({ link, unlink }); +}); + +export const layer = Layer.effect(ThreadIssueService, make); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 512648e6657d2..119e308ca14ae 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -756,6 +756,7 @@ export const make = Effect.gen(function* () { return enqueueFullSweep(); case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.issue-synced": case "provider-session.detached": case "run.background-work-cancelled": return enqueueThread(event.threadId); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c3c9919988ab4..d7717bdd3da97 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -16,6 +16,7 @@ import { ContextTransferId, EnvironmentId, EventId, + IssueTrackerKind, MessageId, NodeId, RuntimeRequestId, @@ -3688,6 +3689,89 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("links an issue, syncs its snapshot, and unlinks it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("runtime-issue-links"); + const projectId = ProjectId.make("issue-link-project"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("issue-link-create"), + threadId, + projectId, + title: "Issue links", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { + tracker: IssueTrackerKind.make("github"), + host: "GitHub.com", + scope: "Pingdotgg/T3code", + key: "42", + }; + yield* orchestrator.dispatch({ + type: "thread.issue.link", + commandId: CommandId.make("issue-link-1"), + threadId, + ...key, + projectId, + url: "https://github.com/pingdotgg/t3code/issues/42", + displayKey: "#42", + title: "Issue links", + source: "manual", + }); + // A second link of the same issue, spelled differently, is a no-op. + yield* orchestrator.dispatch({ + type: "thread.issue.link", + commandId: CommandId.make("issue-link-2"), + threadId, + ...key, + host: "github.com", + scope: "pingdotgg/t3code", + projectId, + url: "https://github.com/pingdotgg/t3code/issues/42", + displayKey: "#42", + title: "Issue links, renamed", + source: "agent", + }); + yield* worker.drain(); + const linked = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual( + linked?.issues?.map(({ host, scope, key, source, title }) => ({ + host, + scope, + key, + source, + title, + })), + [ + { + host: "github.com", + scope: "Pingdotgg/T3code", + key: "42", + source: "manual", + title: "Issue links", + }, + ], + ); + yield* orchestrator.dispatch({ + type: "thread.issue.unlink", + commandId: CommandId.make("issue-unlink"), + threadId, + ...key, + }); + yield* worker.drain(); + assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.issues, []); + assert.deepEqual((yield* orchestrator.getThreadProjection(threadId)).thread.issues, []); + }), + ); + it.effect("starts, records, and stops a pull request watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 0d428e1a6e1e0..15ac7ecb8d3fe 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -161,6 +161,8 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray { expect(sourceControlDefinitions.recognizeRemote(remoteUrl)).toBeNull(); }); + +it.each([ + ["https://github.com/Acme/Web/issues/42", "github", "github.com", "acme/web", "42"], + [ + "https://gitlab.acme.test/group/sub/web/-/issues/7", + "gitlab", + "gitlab.acme.test", + "group/sub/web", + "7", + ], + ["https://codeberg.org/acme/web/issues/3", "forgejo", "codeberg.org", "acme/web", "3"], + [ + "https://dev.azure.com/acme/Web%20App/_workitems/edit/9", + "azure-devops", + "dev.azure.com", + "acme/web app", + "9", + ], +] as const)("reads %s as an issue on its host", (url, kind, host, scope, key) => { + const parsed = sourceControlDefinitions.parseIssueUrl(url); + expect(parsed?.definition.kind).toBe(kind); + expect(parsed?.link).toEqual({ host, scope, key }); +}); + +it.each([ + "https://github.com/acme/web/pull/42", + "https://example.test/acme/web/issues/42", + "https://github.com/acme/web/issues/0", +])("does not read %s as an issue", (url) => { + expect(sourceControlDefinitions.parseIssueUrl(url)).toBeNull(); +}); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 535a075202019..d39fab7efad77 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -42,6 +42,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles"; import { ThreadDetailsThreadPrRows } from "./chat/ThreadDetailsPrRows"; +import { ThreadDetailsIssueRows } from "./chat/ThreadDetailsIssueRows"; import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-link-input"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useComposerMenuProps } from "./chat/composerEventScope"; @@ -688,6 +689,7 @@ export function BranchToolbarBranchSelector({ branchStatusQuery.data?.sourceControlProvider, ); const openPrLink = useOpenPrLink(threadRef); + const linkedIssues = serverThread?.issues ?? []; function selectPickerItem(itemValue: string) { if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { @@ -874,6 +876,22 @@ export function BranchToolbarBranchSelector({ onActed={() => branchStatusQuery.refresh()} /> ) : null} + {displayMode === "panel" ? ( + { + event.stopPropagation(); + useRightPanelStore.getState().openIssue(threadRef, { + projectId: link.projectId, + tracker: link.tracker, + host: link.host, + scope: link.scope, + key: link.key, + url: link.url, + }); + }} + /> + ) : null} ); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b48485fc61212..eb3e32e4d507f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -159,6 +159,7 @@ import { hostEnvironmentIdAtom } from "../state/primaryEnvironment"; import { cn } from "../lib/utils"; import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; +import { useOpenIssueLink } from "../lib/openIssueLink"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; @@ -697,6 +698,7 @@ function useChatMarkdownState({ ); }; const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); + const openIssueLink = useOpenIssueLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); // Subscribe so hydrated settings apply before the anchor synchronously decides whether to intercept _blank. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); @@ -993,6 +995,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, onRunShellCommand, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, openExternalLinkInPreview, openMarkdownMedia, @@ -1158,6 +1161,7 @@ const CHAT_MARKDOWN_COMPONENTS = { threadRef, openMarkdownMedia, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, linkTargetPreference, openExternalLinkInPreview, @@ -1273,7 +1277,8 @@ const CHAT_MARKDOWN_COMPONENTS = { } if ( !href || - openChangeRequestLink(event, href, undefined, environmentId ?? undefined) + openChangeRequestLink(event, href, undefined, environmentId ?? undefined) || + openIssueLink(event, href, environmentId ?? undefined) ) { return; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5db96b73fc6b8..7d7d46a60b405 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3270,6 +3270,7 @@ export default function ChatView(props: ChatViewProps) { ? addPullRequestsSurfaceStable : undefined, }} + issuesSupported={serverConfig?.environment.capabilities.issues === true} files={{ projectId: activeProject?.id, projectName: activeProject?.title ?? null, diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 5ba6985e19d21..6b8de5aca8ecc 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -1,6 +1,7 @@ import type { EditorId, EnvironmentId, + IssueTrackerKind, PreviewAnnotationPayload, ProjectId, ResolvedKeybindingsConfig, @@ -19,6 +20,7 @@ import { PersistentThreadTerminalPanel, type PersistentThreadTerminalPanelProps, } from "../PersistentThreadTerminals"; +import { IssueDetailPanel } from "../issue/IssueDetailPanel"; import { pullRequestPanelContext } from "../pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "../pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "../pullRequest/PullRequestGhosts"; @@ -77,6 +79,7 @@ export function RightPanelSurfaceContent(props: { readonly onCloseSurface: (surface: RightPanelSurface) => void; readonly terminal: RightPanelTerminalProps; readonly pullRequest: RightPanelPullRequestProps; + readonly issuesSupported: boolean; readonly files: RightPanelFileProps; readonly sideChatParentPanel: SideChatParentPanel; }) { @@ -171,6 +174,40 @@ export function RightPanelSurfaceContent(props: { /> ); } + case "issue": + if (!props.pullRequest.capabilityKnown) return ; + if (!props.issuesSupported) { + return ( + + ); + } + return ( + { + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }); + }} + /> + ); case "pull-requests": return ; case "side-chat": diff --git a/apps/web/src/components/chat/ThreadDetailsIssueRows.tsx b/apps/web/src/components/chat/ThreadDetailsIssueRows.tsx new file mode 100644 index 0000000000000..327b8a0afa3fb --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsIssueRows.tsx @@ -0,0 +1,43 @@ +import { threadIssueKeyOf, type ThreadIssueLink } from "@t3tools/contracts"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import { cn } from "~/lib/utils"; + +import { IssueGlyph } from "../issue/issuePresentation"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, +} from "./threadDetailsPanelStyles"; + +/** + * The thread details panel's issue rows, one per linked issue, beside the pull request rows. A + * row names the issue as it was when linked and opens it in the right panel, which reads its + * live state. + */ +export function ThreadDetailsIssueRows({ + links, + onOpenLink, +}: { + links: ReadonlyArray; + onOpenLink: (event: ReactMouseEvent, link: ThreadIssueLink) => void; +}) { + if (links.length === 0) return null; + return ( + <> + {links.map((link) => ( + onOpenLink(event, link)} + > + + + {link.displayKey}: {link.title} + + + ))} + + ); +} diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx index b7ae136c020c3..b790c1f5b71bc 100644 --- a/apps/web/src/components/issue/IssueDetailPanel.tsx +++ b/apps/web/src/components/issue/IssueDetailPanel.tsx @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { prependIssueComments } from "@t3tools/client-runtime/state/issues"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { AuthOrchestrationOperateScope, threadIssueKeysEqual } from "@t3tools/contracts"; import type { EnvironmentId, IssueAssigneeCandidate, @@ -19,6 +20,8 @@ import { CheckIcon, ChevronDownIcon, ExternalLinkIcon, + Link2Icon, + Link2OffIcon, SendIcon, TagIcon, UserPlusIcon, @@ -27,8 +30,10 @@ import { useState, type ReactNode } from "react"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { issueEnvironment } from "~/state/issues"; -import { useProjects } from "~/state/entities"; +import { useProjects, useThreadShell } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; +import { useEnvironmentScope } from "~/state/session"; +import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -37,6 +42,7 @@ import { Input } from "../ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestCandidatePicker } from "../pullRequest/PullRequestCandidatePicker"; import { PullRequestCommentBody } from "../pullRequest/PullRequestCommentBody"; import { PullRequestDetailTitleRow } from "../pullRequest/PullRequestDetailLayout"; @@ -59,6 +65,79 @@ import { issueTrackerPresentation, } from "./issuePresentation"; +/** + * Links or unlinks the issue to the thread it is shown beside. Dispatches the orchestration + * command directly, as pull request linking does. + */ +function IssueThreadLinkButton({ + environmentId, + reference, + issue, + threadRef, +}: { + environmentId: EnvironmentId; + reference: IssueRef; + issue: Pick; + threadRef: ScopedThreadRef; +}) { + const thread = useThreadShell(threadRef.environmentId === environmentId ? threadRef : null); + const canOperate = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const link = useAtomCommand(threadEnvironment.linkIssue, { reportFailure: false }); + const unlink = useAtomCommand(threadEnvironment.unlinkIssue, { reportFailure: false }); + const [pending, setPending] = useState(false); + if (thread === null || !canOperate) return null; + const key = { ...reference, host: reference.host ?? "" }; + const linked = (thread.issues ?? []).some((existing) => threadIssueKeysEqual(existing, key)); + const toggle = async () => { + if (pending || reference.host === undefined) return; + setPending(true); + const result = await (linked + ? unlink({ environmentId, input: { threadId: threadRef.threadId, ...key } }) + : link({ + environmentId, + input: { + threadId: threadRef.threadId, + ...key, + url: issue.url, + displayKey: issue.displayKey, + title: issue.title, + source: "manual", + }, + })); + setPending(false); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: linked ? "Could not unlink the issue" : "Could not link the issue", + description: readableFailure(squashAtomCommandFailure(result), "The server refused it."), + }); + } + }; + return ( + + void toggle()} + /> + } + > + {linked ? ( + + ) : ( + + )} + + {linked ? "Unlink from this thread" : "Link to this thread"} + + ); +} + /** Why a control is off, said once so each disabled affordance can point at it. */ function writeBlockReason(detail: IssueDetail, canWrite: boolean): string | null { if (!canWrite) return "Writing to this tracker needs the source-control write scope."; @@ -555,6 +634,14 @@ export function IssueDetailPanel({ {detail.scope} {stateControl} + {threadRef ? ( + + ) : null}