diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index b7e63cc80642..41dcccdd2ae2 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -554,6 +554,7 @@ export const layerMcpClientAuthenticator = Layer.effect( "orchestration", "worktree", "pull-requests", + "issues", ]), issuedAt, })), diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 06ba77173f9f..beadf97d578c 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -30,9 +30,11 @@ import { type IssueTrackerSummary, type IssueUpdateInput, type OrchestrationProjectShell, + type ProjectId, } from "@t3tools/contracts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { parseIssueUrl } from "@t3tools/shared/issueUrl"; import { IssueTrackerError, type IssueTrackerApi, @@ -164,6 +166,18 @@ 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. + */ + readonly resolveUrl: ( + url: string, + preferredProjectId?: ProjectId, + ) => Effect.Effect< + IssueRef & { readonly host: string; readonly writable: boolean }, + IssueError + >; } >()("t3/issue/IssueService") {} @@ -573,6 +587,38 @@ const make = Effect.gen(function* () { ), ); + const resolveUrl: IssueService["Service"]["resolveUrl"] = Effect.fn("IssueService.resolveUrl")( + function* (url, preferredProjectId) { + const parsed = parseIssueUrl(url); + if (parsed === null) { + return yield* new IssueOperationError({ + operation: "resolveUrl", + reason: "not-found", + detail: "This is not a recognised issue URL.", + }); + } + const { supported } = yield* listProjects({ host: parsed.host }); + const onTracker = supported.filter((project) => project.tracker === parsed.tracker); + const preferFirst = (projects: ReadonlyArray) => + projects.find((project) => project.project.id === preferredProjectId) ?? projects[0]; + const own = preferFirst( + onTracker.filter((project) => project.scope.toLowerCase() === parsed.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 ? parsed.scope : lender.scope, + key: parsed.key, + writable: own !== undefined, + }; + }, + ); + return IssueService.of({ list, detail: traced("detail", detail), @@ -584,6 +630,7 @@ const make = Effect.gen(function* () { update: interactive(traced("update", update)), setState: interactive(traced("setState", setState)), react: interactive(traced("react", react)), + resolveUrl, }); }); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5c6c4fece44b..8dd8e0fc92d4 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -64,6 +64,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.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, @@ -1042,6 +1044,8 @@ export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsHandlers.layer, ); +const layerIssuesToolkit = toolkitRegistration(IssuesToolkit, IssuesHandlers.layer); + const layerDeviceStandardToolkitRegistration = toolkitRegistration( DeviceStandardToolkit, DeviceHandlers.layerStandard, @@ -1075,6 +1079,7 @@ export const layer = Layer.mergeAll( layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, + layerIssuesToolkit, layerDeviceToolkit, layerHtmlToolkit, ).pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03..ef172a3627c3 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -17,6 +17,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 282c3dffd6d6..ddcc274f5a8b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -49,7 +49,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); @@ -83,18 +83,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 04c33a1509e9..2ad0cbd164a7 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -115,6 +115,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( "orchestration", "worktree", "pull-requests", + "issues", ...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])), ]), issuedAt, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 02216cec576d..77125f9c58e1 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -375,7 +375,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 000000000000..58f2fff66c9e --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.test.ts @@ -0,0 +1,272 @@ +import { + EnvironmentId, + IssueTrackerKind, + ProjectId, + ProviderInstanceId, + ThreadId, + type IssueDetail, + type OrchestrationProjectShell, + type ThreadIssueLink, +} 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: "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), + 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", + 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(result.scope).toBe("T3Tools/T3Code"); + expect((yield* Ref.get(harness.links))[0]?.url).toBe(detail.url); + }), + ); + + it.effect("lists linked issues from the thread shell with their snapshots", () => + 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", + snapshot: { + displayKey: "#7", + title: "Seven", + state: { category: "open", name: "open" }, + syncedAt: "2026-08-10T00:00:00.000Z", + }, + }, + ]); + 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 000000000000..b6b55330051f --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.ts @@ -0,0 +1,310 @@ +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 { resolveGitHostBinding } from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadIssueService from "../../../orchestration-v2/ThreadIssueService.ts"; +import * as ProjectService from "../../../project/ProjectService.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.snapshot?.displayKey ?? null, + title: link.snapshot?.title ?? null, + state: link.snapshot?.state ?? null, + })), + }; +} + +const make = Effect.gen(function* () { + const engine = yield* Orchestrator.OrchestratorV2; + const issues = yield* IssueService.IssueService; + const threadIssues = yield* ThreadIssueService.ThreadIssueService; + const projects = yield* ProjectService.ProjectService; + + /** 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)) + .pipe(Effect.mapError((cause) => new IssueLinkFailedError({ cause }))); + if (Option.isNone(thread) || thread.value.deletedAt !== null) { + return yield* new IssueThreadNotFoundError({ threadId }); + } + return thread.value; + }); + + /** The project a key-only reference is read through: named, or the caller's thread's own. */ + const requireProject = Effect.fn("IssuesToolkit.requireProject")(function* ( + requested: ProjectId | undefined, + ) { + const projectId = requested ?? (yield* callerThread())?.projectId; + if (projectId === undefined) return yield* new IssueProjectRequiredError(); + const project = yield* projects + .getShell(projectId) + .pipe(Effect.mapError((cause) => new IssueLinkFailedError({ cause }))); + const binding = Option.isSome(project) ? resolveGitHostBinding(project.value) : null; + if (Option.isNone(project) || binding === null) { + return yield* new IssueProjectNotFoundError({ projectId }); + } + return { project: project.value, binding }; + }); + + /** + * 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 { project, binding } = yield* requireProject(input.projectId); + return { + projectId: project.id, + tracker: binding.tracker, + host: binding.host, + scope: binding.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 urlFor = (target: ResolvedTarget) => + target.url !== null + ? Effect.succeed(target.url) + : issues.detail(refOf(target)).pipe(Effect.map((detail) => detail.url)); + + 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 { project } = yield* requireProject(input.projectId); + const result = yield* issues.list({ + projectId: project.id, + 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 { project, binding } = yield* requireProject(input.projectId); + const created = yield* issues.create({ + projectId: project.id, + ...binding, + title: input.title, + body: input.body ?? "", + labels: input.labels ?? [], + assignees: input.assignees ?? [], + }); + return { + projectId: project.id, + ...binding, + 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, + }); + const url = yield* urlFor(target); + const { alreadyLinked } = yield* threadIssues + .link({ threadId: thread.id, ref: refOf(target), url, 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 000000000000..3851540b2cf6 --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/tools.ts @@ -0,0 +1,396 @@ +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, for example https://github.com/owner/repo/issues/123. Preferred when you have it.", + }), + ), + key: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "The issue's key within the project's tracker: the number on a git host. Required when url is omitted.", + }), + ), + projectId: Schema.optional( + ProjectId.annotate({ + description: + "Project whose tracker holds the issue. Defaults to this thread's project when key is used.", + }), + ), +}); +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, + /** Null until the server has read the issue once. */ + displayKey: Schema.NullOr(Schema.String), + title: Schema.NullOr(Schema.String), + state: Schema.NullOr(IssueState), +}); +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 LINK_EVERY_ISSUE = + "Link every issue you are asked to work on, or file, to this thread with link_issue."; + +const ListIssuesTool = Tool.make("list_issues", { + description: + "List issues in a project's tracker, newest updated first. Omit projectId for this thread's project. Pass the returned nextCursors back as cursors for the next slice.", + parameters: Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ description: "Project to list. Omit for 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: its body, state, labels, assignees, the newest page of comments, and the pull requests the tracker links to it. Pass the URL, or key with projectId. A URL from a repository no project here is a checkout of is read through any project on that host, read-only.", + 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. Omit projectId for this thread's project. ${LINK_EVERY_ISSUE}`, + parameters: Schema.Struct({ + projectId: Schema.optional( + ProjectId.annotate({ description: "Project to file in. Omit for 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. Pass the URL, or key with projectId. Fails on an issue outside every project's own repository.", + 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 one of the states its tracker offers (read_issue reports them under capabilities.states): open or closed on a git host, with a closeReason where the tracker takes one.", + parameters: Schema.Struct({ + ...IssueTargetInput.fields, + state: TrimmedNonEmptyString.annotate({ + description: "A state name from capabilities.states.", + }), + closeReason: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "A reason from capabilities.closeReasons, for a closing state only.", + }), + ), + }), + 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_EVERY_ISSUE} Links an issue to a thread so T3 Code shows it beside the thread with its current state. Pass the URL, or key with projectId. Linking an already-linked issue succeeds with alreadyLinked=true.`, + 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. Unlinking an issue that is not linked succeeds with wasLinked=false.", + 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 (omit threadId for this thread) with their last known tracker state. ${LINK_EVERY_ISSUE}`, + 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 35e706b9775c..cb323bb475cc 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10,6 +10,7 @@ import { usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { threadIssueKeysEqual } from "@t3tools/contracts"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, @@ -460,6 +461,9 @@ 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.issue-link.sync": case "thread.pull-request.watch": case "thread.pull-request-watch.sync": case "thread.pull-request.sync": @@ -2637,6 +2641,9 @@ 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.issue-link.sync" | "thread.pull-request.watch" | "thread.pull-request-watch.sync" | "thread.pull-request.sync" @@ -3345,6 +3352,43 @@ 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": + case "thread.issue-link.sync": { + const links = thread.issues ?? []; + const existing = links.find((link) => threadIssueKeysEqual(link, command)); + let issues = links; + if (command.type === "thread.issue.link") { + if (existing) 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, + source: command.source, + linkedAt: DateTime.formatIso(now), + snapshot: null, + }, + ]; + } else if (command.type === "thread.issue.unlink") { + if (!existing) return thread; + issues = links.filter((link) => link !== existing); + } else { + if (!existing) return thread; + issues = links.map((link) => + link === existing ? { ...link, snapshot: command.snapshot } : link, + ); + } + return { + ...thread, + issues, + updatedAt: command.type === "thread.issue-link.sync" ? thread.updatedAt : now, + }; + } case "thread.pull-request.watch": case "thread.pull-request-watch.sync": { const key = normalizeThreadPullRequestKey(command); @@ -3533,6 +3577,10 @@ 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": + case "thread.issue-link.sync": + return "thread.issue-synced" as const; case "thread.runtime-mode.set": return "thread.runtime-mode-updated" as const; case "thread.interaction-mode.set": @@ -11340,6 +11388,9 @@ 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.issue-link.sync": case "thread.pull-request.watch": case "thread.pull-request.sync": case "thread.title.regeneration.complete": diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 6c5835d8f40e..9cfed6a23fad 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -213,6 +213,7 @@ export const layer: Layer.Layer< "thread.active-reordered", "thread.metadata-updated", "thread.pull-request-synced", + "thread.issue-synced", "thread.runtime-mode-updated", "thread.interaction-mode-updated", "thread.model-selection-updated", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 92ef73ebf1ba..06ed47901584 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -798,6 +798,7 @@ export function applyToProjection( // Reordering and PR refreshes preserve the orchestrator's activity timestamp. case "thread.active-reordered": case "thread.pull-request-synced": + case "thread.issue-synced": return { ...base, thread: event.payload, @@ -1591,6 +1592,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 }), @@ -1850,6 +1852,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 }), @@ -1952,6 +1955,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": @@ -2784,6 +2788,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 b60758e80c72..4d7ea56a335f 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1543,7 +1543,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"]), ); yield* manager.close(providerSessionId); @@ -1600,7 +1600,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 e82995d1db77..085689568fef 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -453,7 +453,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 existing = yield* mcpSessions.read(threadId); diff --git a/apps/server/src/orchestration-v2/ThreadIssueService.ts b/apps/server/src/orchestration-v2/ThreadIssueService.ts new file mode 100644 index 000000000000..fe10d72512f1 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadIssueService.ts @@ -0,0 +1,237 @@ +/** + * Issues linked to threads: the link and unlink commands every surface (web, MCP) goes through, + * and the snapshot each link carries so the thread can show a title and state without a tracker + * read. + * + * There is no poller. A snapshot is written when the link is made, refreshed when the thread's + * turn ends — the agent may have closed or renamed the issue — and refreshed when the user opens + * the thread after the snapshot has gone stale. Those are the moments the state can have changed + * and someone is looking; a sweep would spend tracker reads on threads nobody is reading. + * + * @module orchestration-v2/ThreadIssueService + */ +import { + CommandId, + threadIssueKeysEqual, + threadIssueRefOf, + type IssueRef, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadShell, + type ThreadId, + type ThreadIssueKey, + type ThreadIssueLink, + type ThreadIssueLinkSource, + type ThreadIssueSnapshot, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as IssueService from "../issue/IssueService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as Orchestrator from "./Orchestrator.ts"; + +/** A snapshot older than this is re-read when the thread is opened. */ +const STALE_AFTER_MS = 5 * 60_000; +const REFRESH_CONCURRENCY = 4; + +export interface LinkThreadIssueInput { + readonly threadId: ThreadId; + readonly ref: IssueRef & { readonly host: string }; + readonly url: string; + readonly source: ThreadIssueLinkSource; +} + +export interface UnlinkThreadIssueInput { + readonly threadId: ThreadId; + readonly key: ThreadIssueKey; +} + +export class ThreadIssueService extends Context.Service< + ThreadIssueService, + { + /** Links the issue and schedules its first snapshot. 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>; + /** Re-reads every link's snapshot now; `staleOnly` skips ones read recently. */ + readonly refresh: ( + threadId: ThreadId, + options?: { readonly staleOnly?: boolean }, + ) => Effect.Effect; + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration-v2/ThreadIssueService") {} + +function sameSnapshot(left: ThreadIssueSnapshot | null, right: ThreadIssueSnapshot): boolean { + return ( + left !== null && + left.displayKey === right.displayKey && + left.title === right.title && + left.state.category === right.state.category && + left.state.name === right.state.name && + left.state.closeReason === right.state.closeReason + ); +} + +function isStale(link: ThreadIssueLink, nowMs: number): boolean { + return link.snapshot === null || nowMs - Date.parse(link.snapshot.syncedAt) > STALE_AFTER_MS; +} + +const make = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const issues = yield* IssueService.IssueService; + const crypto = yield* Crypto.Crypto; + + const commandId = (tag: string, threadId: ThreadId) => + crypto.randomUUIDv4().pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:thread-issue-${tag}:${threadId}:${uuid}`)), + ); + + const shellOf = (threadId: ThreadId): Effect.Effect => + orchestrator.getThreadShell(threadId).pipe(Effect.orElseSucceed(() => null)); + + const syncLink = Effect.fn("ThreadIssueService.syncLink")(function* ( + threadId: ThreadId, + link: ThreadIssueLink, + ) { + const detail = yield* issues.detail(threadIssueRefOf(link)); + const now = yield* DateTime.now; + const snapshot: ThreadIssueSnapshot = { + displayKey: detail.displayKey, + title: detail.title, + state: detail.state, + syncedAt: DateTime.formatIso(now), + }; + if (sameSnapshot(link.snapshot, snapshot)) return; + yield* orchestrator.dispatch({ + type: "thread.issue-link.sync", + commandId: yield* commandId("sync", threadId), + threadId, + tracker: link.tracker, + host: link.host, + scope: link.scope, + key: link.key, + snapshot, + }); + }); + + const refreshThread = Effect.fn("ThreadIssueService.refreshThread")(function* (request: { + readonly threadId: ThreadId; + readonly staleOnly: boolean; + }) { + const thread = yield* shellOf(request.threadId); + if (thread === null || thread.deletedAt !== null) return; + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const links = (thread.issues ?? []).filter( + (link) => !request.staleOnly || isStale(link, nowMs), + ); + yield* Effect.forEach( + links, + (link) => + syncLink(thread.id, link).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread issue snapshot refresh failed", { + threadId: thread.id, + issue: `${link.host}/${link.scope}#${link.key}`, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: REFRESH_CONCURRENCY, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(refreshThread); + + const link: ThreadIssueService["Service"]["link"] = Effect.fn("ThreadIssueService.link")( + function* (input) { + const thread = yield* shellOf(input.threadId); + if (thread?.issues?.some((existing) => threadIssueKeysEqual(existing, 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, + source: input.source, + }); + yield* worker.enqueue({ threadId: input.threadId, staleOnly: true }); + return { alreadyLinked: false }; + }, + ); + + const unlink: ThreadIssueService["Service"]["unlink"] = Effect.fn("ThreadIssueService.unlink")( + function* (input) { + const thread = yield* shellOf(input.threadId); + if (!thread?.issues?.some((existing) => threadIssueKeysEqual(existing, 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 }; + }, + ); + + const refresh: ThreadIssueService["Service"]["refresh"] = (threadId, options) => + worker.enqueue({ threadId, staleOnly: options?.staleOnly ?? false }); + + const processEvent = (event: OrchestrationV2DomainEvent) => { + switch (event.type) { + // A link made from a client (web dispatches the command directly) has no snapshot yet. + case "thread.issue-synced": + return (event.payload.issues ?? []).some((link) => link.snapshot === null) + ? worker.enqueue({ threadId: event.threadId, staleOnly: true }) + : Effect.void; + case "thread.visited": + return (event.payload.issues ?? []).length > 0 + ? worker.enqueue({ threadId: event.threadId, staleOnly: true }) + : Effect.void; + case "run.updated": + return event.payload.status === "completed" || + event.payload.status === "failed" || + event.payload.status === "cancelled" || + event.payload.status === "interrupted" + ? worker.enqueue({ threadId: event.threadId, staleOnly: false }) + : Effect.void; + default: + return Effect.void; + } + }; + + const start: ThreadIssueService["Service"]["start"] = Effect.fn("ThreadIssueService.start")( + function* () { + yield* forkParked(Stream.runForEach(orchestrator.streamDomainEvents, processEvent)); + }, + ); + + return ThreadIssueService.of({ link, unlink, refresh, start, drain: worker.drain }); +}); + +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 7d95dfd035a3..c20bec5dae02 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -590,6 +590,7 @@ export const make = Effect.gen(function* () { return cleanUpSettledThread(event.threadId); case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.issue-synced": case "provider-session.detached": return worker.enqueue(event.threadId); case "provider-session.updated": diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 650fbfc6b0b5..e872970b6c5b 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -12,6 +12,7 @@ import { CommandId, ContextTransferId, EventId, + IssueTrackerKind, MessageId, NodeId, RuntimeRequestId, @@ -2210,6 +2211,102 @@ 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", + 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", + source: "agent", + }); + yield* worker.drain(); + const linked = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual( + linked?.issues?.map(({ host, scope, key, source, snapshot }) => ({ + host, + scope, + key, + source, + snapshot, + })), + [ + { + host: "github.com", + scope: "Pingdotgg/T3code", + key: "42", + source: "manual", + snapshot: null, + }, + ], + ); + const snapshot = { + displayKey: "#42", + title: "Issue links", + state: { category: "open" as const, name: "open" }, + syncedAt: "2026-09-17T00:00:00.000Z", + }; + yield* orchestrator.dispatch({ + type: "thread.issue-link.sync", + commandId: CommandId.make("issue-link-sync"), + threadId, + ...key, + snapshot, + }); + const synced = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(synced?.issues?.[0]?.snapshot, snapshot); + // A snapshot write is server bookkeeping, not thread activity. + assert.deepEqual(synced?.updatedAt, linked?.updatedAt); + 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 4e98e20949c9..82cf3dbcb064 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -151,6 +151,9 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray service.start())), ).pipe(Layer.provide(layerPullRequestService)); +const layerThreadIssueWorker = Layer.effectDiscard( + ThreadIssueService.ThreadIssueService.pipe(Effect.flatMap((service) => service.start())), +).pipe(Layer.provide(layerThreadIssueService)); + const layerProviderInstallationRefresh = Layer.effectDiscard( Effect.gen(function* () { const antigravity = yield* AntigravityInstallation.AntigravityInstallation; @@ -576,6 +584,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ), ), layerThreadPullRequestWorker, + layerThreadIssueWorker, DelegatedTaskAnalytics.layer, Layer.effectDiscard( Effect.gen(function* () { @@ -726,6 +735,7 @@ const layerMakeRoutes = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(layerPullRequestService), + Layer.provide(layerThreadIssueService), Layer.provide(layerIssueService), Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 84cb807f45fb..a53bb713b689 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 { ThreadDetailsPrRows } 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"; @@ -561,6 +562,7 @@ export function BranchToolbarBranchSelector({ const prNumber = currentLinkedPr?.number ?? displayedPr?.number; const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); + const linkedIssues = serverThread?.issues ?? []; const panelPrLabel = prNumber === undefined ? "" @@ -753,6 +755,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 91acb81d1730..4248faadf46b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -145,6 +145,7 @@ import { readLocalApi } from "../localApi"; 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"; @@ -664,6 +665,7 @@ function useChatMarkdownState({ ); }; const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); + const openIssueLink = useOpenIssueLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); // Subscribed rather than read at click time: the anchor has to decide // synchronously whether to intercept its `_blank`, and a subscription is what @@ -903,6 +905,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, onRunShellCommand, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, openExternalLinkInPreview, openMarkdownMedia, @@ -1057,6 +1060,7 @@ const CHAT_MARKDOWN_COMPONENTS = { threadRef, openMarkdownMedia, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, linkTargetPreference, openExternalLinkInPreview, @@ -1161,7 +1165,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 92707af8ca11..8006d31ae42d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3104,6 +3104,7 @@ export default function ChatView(props: ChatViewProps) { ? addPullRequestsSurfaceStable : undefined, }} + issuesSupported={serverConfig?.environment.capabilities.issues === true} files={{ projectName: activeProject?.title ?? null, workspaceRoot: activeWorkspaceRoot, diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 1940a0bf80a6..ec8477c03dae 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, @@ -17,6 +18,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"; @@ -76,6 +78,7 @@ export function RightPanelSurfaceContent(props: { readonly onCloseSurface: (surface: RightPanelSurface) => void; readonly terminal: RightPanelTerminalProps; readonly pullRequest: RightPanelPullRequestProps; + readonly issuesSupported: boolean; readonly files: RightPanelFileProps; }) { const { surface, threadRef, visible } = props; @@ -165,6 +168,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 "device": diff --git a/apps/web/src/components/chat/ThreadDetailsIssueRows.tsx b/apps/web/src/components/chat/ThreadDetailsIssueRows.tsx new file mode 100644 index 000000000000..2dd3a452f860 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsIssueRows.tsx @@ -0,0 +1,65 @@ +import { threadIssueKeyOf, type ThreadIssueLink } from "@t3tools/contracts"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import { cn } from "~/lib/utils"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { IssueGlyph, resolveIssueStatePresentation } 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 opens the issue in the right panel; the snapshot the server keeps on the link supplies the + * key, title and state, so no tracker read happens on render. Until the first snapshot lands the + * row shows what the link itself carries. + */ +export function ThreadDetailsIssueRows({ + links, + onOpenLink, +}: { + links: ReadonlyArray; + onOpenLink: (event: ReactMouseEvent, link: ThreadIssueLink) => void; +}) { + if (links.length === 0) return null; + return ( + <> + {links.map((link) => { + const state = + link.snapshot === null ? null : resolveIssueStatePresentation(link.snapshot.state); + const label = + link.snapshot === null + ? `${link.scope}#${link.key}` + : `${link.snapshot.displayKey}: ${link.snapshot.title}`; + return ( + + onOpenLink(event, link)} + /> + } + > + {state === null ? ( + + ) : ( + + )} + + {label} + + + + {state === null ? "Issue not read yet" : state.label} + + + ); + })} + + ); +} diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx index 96b6180302c0..722b7970dbc9 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, SmilePlusIcon, TagIcon, @@ -29,8 +32,10 @@ import { useState, type ReactNode } from "react"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { cn } from "~/lib/utils"; 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"; @@ -69,6 +74,72 @@ 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; the server reads the snapshot afterwards. + */ +function IssueThreadLinkButton({ + environmentId, + reference, + url, + threadRef, +}: { + environmentId: EnvironmentId; + reference: IssueRef; + url: string; + 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, 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."; @@ -644,6 +715,14 @@ export function IssueDetailPanel({ {detail.scope} {stateControl} + {threadRef ? ( + + ) : null}