diff --git a/.gitattributes b/.gitattributes index 24860868b59b..ab74efd46bf7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,8 @@ # Batch files need CRLF for cmd.exe to parse them reliably. *.cmd text eol=crlf *.bat text eol=crlf -# GitHub's and Expo's GraphQL schemas and the operations graphqlgen writes from them. +# GitHub's, Linear's and Expo's GraphQL schemas and the operations graphqlgen writes from them. packages/source-control-github/schema/** linguist-generated +packages/issue-tracker-linear/schema/** linguist-generated apps/server/schema/** linguist-generated **/*.graphql.ts linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6707b0151bb3..05d099cda76a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: # open: if the diff cannot be resolved, everything runs. changes: name: Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: typecheck: ${{ steps.detect.outputs.typecheck }} @@ -60,7 +60,7 @@ jobs: # when every other job passes or was skipped by `changes`. lint: name: Lint - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -104,6 +104,9 @@ jobs: - name: Check generated Expo GraphQL operations run: vp run --filter t3 generate --check + - name: Check generated Linear GraphQL operations + run: vp run --filter @t3tools/issue-tracker-linear generate --check + - name: Check run: vp check @@ -111,7 +114,7 @@ jobs: name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -200,7 +203,7 @@ jobs: name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -281,7 +284,7 @@ jobs: name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -334,7 +337,7 @@ jobs: # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 strategy: fail-fast: false @@ -435,7 +438,7 @@ jobs: name: Rust needs: changes if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -468,7 +471,7 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: macos-latest timeout-minutes: 10 steps: - name: Checkout @@ -498,7 +501,7 @@ jobs: name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -536,7 +539,7 @@ jobs: mobile_native_static_analysis, release_smoke, ] - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Require every job to pass diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index b8b872f019f6..5849b71ddbb8 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -24,7 +24,7 @@ concurrency: jobs: fingerprint: name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write diff --git a/apps/server/package.json b/apps/server/package.json index 8b6aebfa4d37..62a11178bad6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,8 @@ "@muse-code/sdk": "1.4.6", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", + "@t3tools/issue-tracker-core": "workspace:*", + "@t3tools/issue-tracker-linear": "workspace:*", "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", 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/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 581517de719d..599add186c2f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -6,6 +6,8 @@ import { AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, ProviderInstanceId, + ProjectId, + IssueTrackerKind, ThreadId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -129,6 +131,57 @@ describe("RPC scope middleware", () => { ); }); +it.effect("allows issue reads but requires a source-control grant before commenting", () => + Effect.gen(function* () { + const tested = [WS_METHODS.issuesList, WS_METHODS.issuesComment] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + const comments: string[] = []; + const handlers = Layer.mergeAll( + group.toLayerHandler(WS_METHODS.issuesList, () => + Effect.succeed({ entries: [], trackers: [], errors: [], viewers: {}, nextCursors: {} }), + ), + group.toLayerHandler(WS_METHODS.issuesComment, ({ body }) => + Effect.sync(() => { + comments.push(body); + }), + ), + ); + const reader = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthOrchestrationReadScope]))), + ); + const comment = { + projectId: ProjectId.make("web"), + tracker: IssueTrackerKind.make("github"), + scope: "acme/web", + key: "42", + body: "Repro attached.", + }; + + expect((yield* reader[WS_METHODS.issuesList]({ state: "open" })).entries).toEqual([]); + expect(yield* reader[WS_METHODS.issuesComment](comment).pipe(Effect.flip)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthSourceControlWriteScope, + }); + expect(comments).toEqual([]); + + const writer = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.merge( + handlers, + RpcAuthorization.layer([AuthOrchestrationReadScope, AuthSourceControlWriteScope]), + ), + ), + ); + yield* writer[WS_METHODS.issuesComment](comment); + expect(comments).toEqual(["Repro attached."]); + }).pipe(Effect.scoped), +); + describe("settings mutation authorization", () => { const group = WsRpcGroup.omit( ...[...WsRpcGroup.requests.keys()].filter( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3f007b190592..8aeb5070392a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -127,6 +127,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.issuesList]: AuthOrchestrationReadScope, + [WS_METHODS.issuesDetail]: AuthOrchestrationReadScope, + [WS_METHODS.issuesComments]: AuthOrchestrationReadScope, + [WS_METHODS.issuesTemplates]: AuthOrchestrationReadScope, + [WS_METHODS.issuesCandidates]: AuthOrchestrationReadScope, + [WS_METHODS.issueTrackersList]: AuthOrchestrationReadScope, + [WS_METHODS.issueTrackersConnect]: AuthSettingsWriteScope, + [WS_METHODS.issueTrackersBindProject]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index fbb5970560bc..b6f254e4ad36 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -223,6 +223,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, pullRequestChecks: true, + issues: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts new file mode 100644 index 000000000000..09714b1fe478 --- /dev/null +++ b/apps/server/src/issue/IssueService.test.ts @@ -0,0 +1,522 @@ +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 Redacted from "effect/Redacted"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; +import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota"; +import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; +import * as GitHubIssueTracker from "@t3tools/source-control-github/server/GitHubIssueTracker"; +import { + IssueTrackerKind, + type IssueTrackerProjectBinding, + SourceControlProviderKind, + type OrchestrationProjectShell, + type ProjectId, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, + type StandaloneIssueTrackerApi, + type TrackerIssueDetail, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as IssueTrackerBindings from "../persistence/IssueTrackerBindings.ts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as IssueService from "./IssueService.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const GITHUB = IssueTrackerKind.make("github"); +const LINEAR = IssueTrackerKind.make("linear"); + +/** A standalone tracker shaped like Linear: its own host, team keys for scopes, `ENG-1` keys. */ +function fakeStandaloneTracker( + overrides: Partial = {}, +): StandaloneIssueTrackerApi { + return { + ...fakeTracker({ + getViewer: () => Effect.succeed("usr_julius"), + getIssue: (input) => + Effect.succeed({ ...issue(input.key), displayKey: input.key, viewer: "usr_julius" }), + ...overrides, + }), + kind: LINEAR, + standalone: { + tracker: LINEAR, + displayName: "Linear", + host: "linear.app", + scopeNoun: "team", + credential: { label: "Personal API key", helpUrl: null }, + }, + probeCredential: () => Effect.succeed({ account: "julius", scopes: [] }), + }; +} + +function project(input: { + readonly id: string; + readonly repository?: string; + readonly provider?: string; + readonly host?: string; +}): OrchestrationProjectShell { + const host = input.host ?? "github.com"; + return { + id: input.id as ProjectId, + title: input.id, + workspaceRoot: `/w/${input.id}`, + ...(input.repository + ? { + repositoryIdentity: { + canonicalKey: normalizeGitRemoteUrl(`https://${host}/${input.repository}.git`), + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: input.provider ?? "github", + displayName: input.repository, + }, + } + : {}), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }; +} + +function issue(key: string, updatedAt = "2026-07-02T00:00:00Z"): TrackerIssueDetail { + return { + key, + displayKey: `#${key}`, + title: `Issue ${key}`, + url: `https://github.com/acme/web/issues/${key}`, + author: null, + state: { category: "open", name: "open" }, + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + closedAt: null, + assignees: [], + labels: [], + commentCount: 0, + body: "", + viewer: "julius", + viewerPermissions: { setState: true, comment: true, edit: true, labels: true, assignees: true }, + comments: [], + commentsCursor: null, + events: [], + linkedPullRequests: [], + }; +} + +function fakeTracker(overrides: Partial = {}): IssueTrackerApi { + return { + kind: GITHUB, + capabilities: { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: false, + templates: false, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + closeReasons: ["completed"], + }, + getViewer: () => Effect.succeed("julius"), + listIssues: () => Effect.succeed({ items: [], nextCursor: null }), + getIssue: (input) => Effect.succeed(issue(input.key)), + getComments: () => Effect.succeed({ comments: [], nextCursor: null }), + comment: () => Effect.void, + update: () => Effect.void, + setState: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly trackers: ReadonlyArray; + readonly bindings?: Record; +}) { + return IssueService.IssueService.pipe( + Effect.provide( + IssueService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(IssueTrackerBindings.IssueTrackerBindingsRepository)({ + list: Effect.succeed( + new Map( + Object.entries(input.bindings ?? {}).map(([id, binding]) => [ + id as ProjectId, + binding, + ]), + ), + ), + }), + Layer.succeed( + IssueTrackerRegistry.IssueTrackerRegistry, + IssueTrackerRegistry.fromTrackers(input.trackers), + ), + Layer.mock(ProjectService.ProjectService)({ + listShells: () => Effect.succeed(input.projects), + getShell: (projectId) => + Effect.succeed( + Option.fromNullishOr(input.projects.find((p) => p.id === projectId)), + ), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + }), + ), + ), + ), + ), + ); +} + +describe("resolveGitHostBinding", () => { + it("scopes Azure DevOps work items to the project rather than the repository", () => { + expect( + IssueService.resolveGitHostBinding( + project({ + id: "p", + provider: "azure-devops", + host: "dev.azure.com", + repository: "acme/Web App/_git/web", + }), + ), + ).toEqual({ + canonicalKey: "dev.azure.com/acme/web app", + tracker: "azure-devops", + host: "dev.azure.com", + scope: "acme/Web App", + }); + expect( + IssueService.resolveGitHostBinding( + project({ id: "p", provider: "azure-devops", host: "dev.azure.com", repository: "web" }), + ), + ).toBeNull(); + }); +}); + +describe("IssueService", () => { + it.effect("reads another repository on the same host but refuses to write to it", () => + Effect.gen(function* () { + const writes: string[] = []; + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [ + fakeTracker({ + comment: (input) => + Effect.sync(() => { + writes.push(`${input.scope}#${input.key}`); + }), + }), + ], + }); + const own = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" }; + const external = { ...own, scope: "other/backend", key: "7" }; + + expect((yield* service.detail(own)).writable).toBe(true); + // The external issue is served by the project's checkout and credentials, read-only. + const detail = yield* service.detail(external); + expect(detail).toMatchObject({ writable: false, scope: "other/backend", key: "7" }); + + yield* service.comment({ ...own, body: "ok" }); + const refused = yield* service.comment({ ...external, body: "no" }).pipe(Effect.result); + expect(refused).toMatchObject({ + _tag: "Failure", + failure: { _tag: "IssueOperationError", reason: "read-only" }, + }); + expect(writes).toEqual(["acme/web#1"]); + + // Another host is not reachable through this project's credentials at all. + const elsewhere = yield* Effect.flip(service.detail({ ...own, host: "ghe.example.test" })); + expect(elsewhere).toMatchObject({ + _tag: "IssueUnavailableError", + reason: "tracker-unsupported", + }); + }), + ); + + it.effect("posts a comment while background requests to GitHub are paused", () => { + const requests: string[] = []; + return Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: SourceControlProviderKind.make("github"), host: "github.com" }; + yield* limits + .recordRateLimit({ ...key, lease: 0 }) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "test")); + const tracker = yield* GitHubIssueTracker.make; + expect( + yield* Effect.flip(tracker.getViewer({ cwd: "/w/web", host: key.host })), + ).toMatchObject({ reason: "rate-limited" }); + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [tracker], + }); + yield* service.comment({ + projectId: "web" as ProjectId, + tracker: GITHUB, + scope: "acme/web", + key: "42", + body: "Repro attached.", + }); + expect(requests).toEqual([ + "POST https://api.github.com/graphql", + "POST https://api.github.com/repos/acme/web/issues/42/comments", + ]); + }).pipe( + Effect.provide( + GitHubRest.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + SourceControlRateLimit.layer, + GitHubQuota.layer, + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("test-token"), + source: "gh" as const, + fingerprint: "test", + }), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(`${request.method} ${request.url}`); + const body = request.url.endsWith("/graphql") + ? JSON.stringify({ data: { repository: { issue: { id: "I_42" } } } }) + : "{}"; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body, { headers: { "content-type": "application/json" } }), + ), + ); + }), + ), + ), + ), + ), + ), + ); + }); + + it.effect("refuses what the tracker did not declare before calling it", () => + Effect.gen(function* () { + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [ + fakeTracker({ + setState: () => + Effect.sync(() => { + calls += 1; + }), + setReaction: () => + Effect.sync(() => { + calls += 1; + }), + }), + ], + }); + const ref = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" }; + + yield* service.setState({ ...ref, state: "closed", closeReason: "completed" }); + const unknownState = yield* Effect.flip(service.setState({ ...ref, state: "In Review" })); + expect(unknownState._tag).toBe("IssueOperationError"); + const unknownReason = yield* Effect.flip( + service.setState({ ...ref, state: "closed", closeReason: "not-planned" }), + ); + expect(unknownReason._tag).toBe("IssueOperationError"); + const reaction = yield* Effect.flip( + service.react({ ...ref, content: "heart", reacted: true }), + ); + expect(reaction._tag).toBe("IssueOperationError"); + expect(calls).toBe(1); + }), + ); + + it.effect("deduplicates and pages repositories despite partial failures", () => + Effect.gen(function* () { + const listed: string[] = []; + let failContinuation = true; + const service = yield* makeService({ + projects: [ + project({ id: "web", repository: "Acme/Web" }), + project({ id: "web-worktree", repository: "acme/web" }), + project({ id: "api", repository: "acme/api" }), + project({ id: "docs", repository: "acme/docs" }), + project({ + id: "gitlab", + repository: "acme/gl", + provider: "gitlab", + host: "gitlab.com", + }), + project({ id: "plain" }), + ], + trackers: [ + fakeTracker({ + listIssues: (input) => + Effect.gen(function* () { + listed.push(input.scope); + if (input.scope === "acme/api") + return yield* new IssueTrackerError({ + tracker: GITHUB, + operation: "listIssues", + reason: "failed", + // A tracker may fail without a detail; the wire error still needs one. + detail: " ", + }); + if (input.scope === "acme/docs") + return { items: [issue("3", "2026-07-04T00:00:00Z")], nextCursor: null }; + if (input.cursor === "next" && failContinuation) { + failContinuation = false; + return yield* new IssueTrackerError({ + tracker: GITHUB, + operation: "listIssues", + reason: "failed", + detail: "try again", + }); + } + if (input.cursor === "next") + return { items: [issue("4", "2026-07-01T00:00:00Z")], nextCursor: null }; + return { + items: [issue("1"), issue("2", "2026-07-05T00:00:00Z")], + nextCursor: "next", + }; + }), + }), + ], + }); + const result = yield* service.list({ state: "open" }); + expect(listed.sort()).toEqual(["Acme/Web", "acme/api", "acme/docs"]); + expect(result.entries.map((entry) => [entry.projectId, entry.key])).toEqual([ + ["web", "2"], + ["docs", "3"], + ["web", "1"], + ]); + expect( + result.entries.filter((entry) => entry.projectId === "web").map((entry) => entry.scope), + ).toEqual(["Acme/Web", "Acme/Web"]); + expect(result.nextCursors).toEqual({ "github.com Acme/Web": "next" }); + expect(result.errors).toEqual([ + { projectId: "api", projectTitle: "api", message: "The issue tracker request failed." }, + ]); + expect(result.viewers).toEqual({ "github.com": "julius" }); + expect(result.trackers).toEqual([ + expect.objectContaining({ host: "github.com", projectCount: 4, configured: true }), + expect.objectContaining({ + host: "gitlab.com", + tracker: "gitlab", + projectCount: 1, + configured: false, + capabilities: null, + }), + ]); + + // A continuation only reads the scopes it names. + listed.length = 0; + const failed = yield* service.list({ + state: "open", + cursors: result.nextCursors, + }); + expect(listed).toEqual(["Acme/Web"]); + expect(failed.nextCursors).toEqual(result.nextCursors); + expect(failed.errors).toEqual([ + { projectId: "web", projectTitle: "web", message: "try again" }, + ]); + const next = yield* service.list({ state: "open", cursors: failed.nextCursors }); + expect(next.entries.map((entry) => [entry.projectId, entry.key])).toEqual([["web", "4"]]); + expect(next.nextCursors).toEqual({}); + expect(next.errors).toEqual([]); + }), + ); +}); + +describe("IssueService with a standalone tracker", () => { + it.effect("lists a bound project's standalone scope beside its git host and serves both", () => + Effect.gen(function* () { + const listed: string[] = []; + const record = (tracker: IssueTrackerKind): Partial => ({ + listIssues: (input) => + Effect.sync(() => { + listed.push(`${tracker} ${input.host} ${input.scope}`); + return { items: [issue(tracker === LINEAR ? "ENG-1" : "1")], nextCursor: null }; + }), + }); + const projects = [project({ id: "web", repository: "acme/web" }), project({ id: "notes" })]; + const trackers = [fakeTracker(record(GITHUB)), fakeStandaloneTracker(record(LINEAR))]; + const bound = yield* makeService({ + projects, + trackers, + bindings: { ["web" as ProjectId]: { tracker: LINEAR, scope: "ENG" } }, + }); + + const result = yield* bound.list({ state: "open" }); + expect(listed.sort()).toEqual(["github github.com acme/web", "linear linear.app ENG"]); + expect(result.entries.map((entry) => [entry.tracker, entry.scope, entry.key]).sort()).toEqual( + [ + ["github", "acme/web", "1"], + ["linear", "ENG", "ENG-1"], + ], + ); + expect(result.trackers.map((tracker) => [tracker.tracker, tracker.host]).sort()).toEqual([ + ["github", "github.com"], + ["linear", "linear.app"], + ]); + // A reference picks the project's tracker by kind; both are the project's own, so writable. + const linearRef = { + projectId: "web" as ProjectId, + tracker: LINEAR, + scope: "ENG", + key: "ENG-1", + }; + expect(yield* bound.detail(linearRef)).toMatchObject({ host: "linear.app", writable: true }); + expect( + yield* bound.detail({ + projectId: "web" as ProjectId, + tracker: GITHUB, + scope: "acme/web", + key: "1", + }), + ).toMatchObject({ host: "github.com", writable: true }); + + // Unbound, the standalone scope is gone and its references are unsupported. + listed.length = 0; + const unbound = yield* makeService({ projects, trackers }); + const after = yield* unbound.list({ state: "open" }); + expect(listed).toEqual(["github github.com acme/web"]); + expect(after.trackers.map((tracker) => tracker.tracker)).toEqual(["github"]); + const refused = yield* Effect.flip(unbound.detail(linearRef)); + expect(refused).toMatchObject({ + _tag: "IssueUnavailableError", + reason: "tracker-unsupported", + }); + + // A project with no remote at all can still be bound, and then has only the standalone one. + const standaloneOnly = yield* makeService({ + projects, + trackers, + bindings: { ["notes" as ProjectId]: { tracker: LINEAR, scope: "OPS" } }, + }); + const notes = yield* standaloneOnly.list({ state: "open", projectId: "notes" as ProjectId }); + expect(notes.entries.map((entry) => [entry.projectId, entry.scope])).toEqual([ + ["notes", "OPS"], + ]); + }), + ); +}); diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts new file mode 100644 index 000000000000..281a146256a2 --- /dev/null +++ b/apps/server/src/issue/IssueService.ts @@ -0,0 +1,667 @@ +// A project bound to a standalone tracker (Linear) in settings lists it beside its git host's. +// References outside the project's canonical repository may be read on the same host, +// but every write must pass through requireWritable. +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { + IssueOperationError, + IssueTrackerKind, + IssueUnavailableError, + pullRequestHostOf, + SourceControlProviderKind, + type IssueCandidates, + type IssueCommentInput, + type IssueCommentsPage, + type IssueCommentsPageInput, + type IssueCreateInput, + type IssueCreateResult, + type IssueDetail, + type IssueError, + type IssueListEntry, + type IssueListInput, + type IssueListProjectError, + type IssueListResult, + type IssueReactionInput, + type IssueRef, + type IssueScopeRef, + type IssueSetStateInput, + type IssueTemplateList, + 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, + isStandaloneIssueTracker, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as IssueTrackerBindings from "../persistence/IssueTrackerBindings.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const DEFAULT_LIST_LIMIT = 50; +const SCOPE_CONCURRENCY = 4; + +const interactive = + (call: (input: I) => Effect.Effect) => + (input: I) => + call(input).pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); + +const traced = + (operation: string, call: (input: I) => Effect.Effect) => + (input: I) => + call(input).pipe(Effect.withSpan(`IssueService.${operation}`)); + +/** A tracker may fail with no detail; the wire errors carry a non-empty one. */ +export function trackerDetail(error: IssueTrackerError): string { + return error.detail.trim() || "The issue tracker request failed."; +} + +/** One project's resolved tracker: which driver, where it is served from, and its container. */ +interface ProjectTrackerBinding { + readonly canonicalKey: string; + readonly tracker: IssueTrackerKind; + readonly host: string; + readonly scope: string; +} + +/** A project the registry has a tracker for, ready to serve reads and writes. */ +interface SupportedProject extends ProjectTrackerBinding { + readonly project: OrchestrationProjectShell; + readonly api: IssueTrackerApi; +} + +/** + * Azure work items belong to the project, not the repository, so the scope is + * `organization/project` out of the remote's `organization/project/_git/repository` path. The + * source control selector answers only the repository name there, which addresses no work item. + */ +function azureDevOpsProjectScope(displayName: string | undefined): string | null { + const segments = (displayName ?? "").split("/").filter((part) => part.length > 0); + const marker = segments.indexOf("_git"); + const [organization, project] = marker === 2 ? segments : []; + return organization && project ? `${organization}/${project}` : null; +} + +/** + * The git-host path of resolution: the tracker is the host's own, named like the remote's + * provider kind. Null for a project with no remote or a kind no tracker claims. + */ +export function resolveGitHostBinding( + project: OrchestrationProjectShell, +): ProjectTrackerBinding | null { + const identity = project.repositoryIdentity; + const scope = + identity?.provider === "azure-devops" + ? azureDevOpsProjectScope(identity.displayName) + : sourceControlRepositorySelector(identity); + const kind = identity?.provider; + if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; + const host = pullRequestHostOf(identity, SourceControlProviderKind.make(kind)); + return { + // Every repository of an Azure DevOps project shares its work items. + canonicalKey: + kind === "azure-devops" ? `${host}/${scope}`.toLowerCase() : identity.canonicalKey, + tracker: IssueTrackerKind.make(kind), + host, + scope, + }; +} + +function listCursorKey(host: string, scope: string): string { + return `${host} ${scope}`; +} + +function isTrackerUnusable(error: IssueTrackerError): boolean { + return error.reason === "missing-tool" || error.reason === "unauthenticated"; +} + +function toIssueError(operation: string): (error: IssueTrackerError) => IssueError { + return (error) => + isTrackerUnusable(error) + ? new IssueUnavailableError({ + reason: error.reason === "missing-tool" ? "tool-missing" : "unauthenticated", + tracker: error.tracker, + cause: error, + }) + : new IssueOperationError({ + operation, + detail: trackerDetail(error), + ...(error.reason === "not-found" || error.reason === "rate-limited" + ? { reason: error.reason } + : {}), + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + cause: error, + }); +} + +const readOnly = (operation: string) => + new IssueOperationError({ + operation, + reason: "read-only", + detail: + "This issue belongs to a repository outside the selected project and cannot be changed from here.", + }); + +const unsupported = (operation: string, what: string) => + new IssueOperationError({ operation, detail: `This tracker does not support ${what}.` }); + +export class IssueService extends Context.Service< + IssueService, + { + readonly list: (input: IssueListInput) => Effect.Effect; + readonly detail: (input: IssueRef) => Effect.Effect; + readonly comments: ( + input: IssueCommentsPageInput, + ) => Effect.Effect; + readonly templates: (input: IssueScopeRef) => Effect.Effect; + readonly candidates: (input: IssueScopeRef) => Effect.Effect; + readonly create: (input: IssueCreateInput) => Effect.Effect; + readonly comment: (input: IssueCommentInput) => Effect.Effect; + 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") {} + +const make = Effect.gen(function* () { + const registry = yield* IssueTrackerRegistry.IssueTrackerRegistry; + const projects = yield* ProjectService.ProjectService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const bindings = yield* IssueTrackerBindings.IssueTrackerBindingsRepository; + + // Read per resolution so a binding saved in Settings applies without a restart. + const resolveStandaloneBinding = ( + project: OrchestrationProjectShell, + ): Effect.Effect => + bindings.list.pipe( + Effect.map((bound) => { + const binding = bound.get(project.id); + const api = binding === undefined ? null : registry.get(binding.tracker); + if (binding === undefined || api === null || !isStandaloneIssueTracker(api)) return null; + return { + canonicalKey: `${binding.tracker}:${api.standalone.host}/${binding.scope}`.toLowerCase(), + tracker: binding.tracker, + host: api.standalone.host, + scope: binding.scope, + }; + }), + Effect.orElseSucceed(() => null), + ); + + /** Where a project's issues live: its git host's tracker, its bound standalone one, or both. */ + const resolveProjectTracker = ( + project: OrchestrationProjectShell, + ): Effect.Effect> => + Effect.all([ + (project.repositoryIdentity != null + ? Effect.succeed(project.repositoryIdentity) + : repositoryIdentities.resolve(project.workspaceRoot) + ).pipe( + Effect.map((repositoryIdentity) => + resolveGitHostBinding({ ...project, repositoryIdentity }), + ), + ), + resolveStandaloneBinding(project), + ]).pipe(Effect.map((resolved) => resolved.filter((binding) => binding !== null))); + + const listProjects = (filter: { + readonly projectId?: IssueListInput["projectId"]; + readonly host?: string | undefined; + }) => + (filter.projectId === undefined + ? projects.listShells() + : projects.getShell(filter.projectId).pipe(Effect.map(Option.toArray)) + ).pipe( + Effect.mapError( + (cause) => + new IssueOperationError({ + operation: "listProjects", + detail: "The project list could not be read.", + cause, + }), + ), + Effect.flatMap((shells) => + Effect.forEach( + shells, + (project) => + resolveProjectTracker(project).pipe( + Effect.map((bindings) => bindings.map((binding) => ({ project, ...binding }))), + ), + { concurrency: SCOPE_CONCURRENCY }, + ), + ), + Effect.map((bound) => { + const host = filter.host?.trim().toLowerCase(); + const supported: SupportedProject[] = []; + const unimplemented = new Map< + string, + { tracker: IssueTrackerKind; projectCount: number } + >(); + for (const candidate of bound.flat()) { + if (host !== undefined && candidate.host !== host) continue; + const api = registry.get(candidate.tracker); + if (api === null) { + const counted = unimplemented.get(candidate.host); + if (counted === undefined) + unimplemented.set(candidate.host, { tracker: candidate.tracker, projectCount: 1 }); + else counted.projectCount += 1; + continue; + } + supported.push({ ...candidate, api }); + } + return { supported, unimplemented }; + }), + ); + + /** + * The project whose checkout and credentials serve a reference, and whether the reference is + * the project's own scope. A reference naming another scope on the same tracker and host is + * served by the project but marked read-only. A project with two trackers serves the one the + * reference names. + */ + const requireProject = ( + ref: IssueScopeRef, + ): Effect.Effect => + listProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }) => { + const own = supported.find((candidate) => candidate.tracker === ref.tracker); + if (own === undefined) { + return Effect.fail(new IssueUnavailableError({ reason: "tracker-unsupported" })); + } + const host = ref.host?.trim().toLowerCase() ?? own.host; + if (host !== own.host) { + return Effect.fail(new IssueUnavailableError({ reason: "tracker-unsupported" })); + } + const scope = ref.scope.trim(); + const writable = scope.toLowerCase() === own.scope.toLowerCase(); + return Effect.succeed({ ...own, scope, writable }); + }), + ); + + const requireWritable = (ref: IssueScopeRef, operation: string) => + requireProject(ref).pipe( + Effect.flatMap((project) => + project.writable ? Effect.succeed(project) : Effect.fail(readOnly(operation)), + ), + ); + + const list: IssueService["Service"]["list"] = Effect.fn("IssueService.list")(function* (input) { + const { supported, unimplemented } = yield* listProjects(input); + const limit = input.limit ?? DEFAULT_LIST_LIMIT; + // Worktrees of one repository are separate projects; read each scope once. + const scopes = new Map(); + for (const project of supported) { + if (!scopes.has(project.canonicalKey)) scopes.set(project.canonicalKey, project); + } + const continuing = input.cursors; + const viewers = new Map(); + const trackers = new Map(); + const entries: IssueListEntry[] = []; + const errors: IssueListProjectError[] = []; + const nextCursors: Record = {}; + + const viewerRequests = new Map>(); + for (const project of scopes.values()) { + if (viewerRequests.has(project.host)) continue; + const request = yield* Effect.cached( + project.api + .getViewer({ cwd: project.project.workspaceRoot, host: project.host }) + .pipe(Effect.tap((viewer) => Effect.sync(() => viewers.set(project.host, viewer)))), + ); + viewerRequests.set(project.host, request); + } + + // Every project counts, including worktrees of a scope read once below. + for (const project of supported) { + const existing = trackers.get(project.host); + trackers.set( + project.host, + existing === undefined + ? { + tracker: project.tracker, + host: project.host, + projectCount: 1, + capabilities: project.api.capabilities, + configured: true, + detail: null, + } + : { ...existing, projectCount: existing.projectCount + 1 }, + ); + } + const recordFailure = (project: SupportedProject, failure: IssueTrackerError) => { + const existing = trackers.get(project.host); + if (existing !== undefined && isTrackerUnusable(failure)) + trackers.set(project.host, { + ...existing, + configured: false, + detail: trackerDetail(failure), + }); + }; + + yield* Effect.forEach( + scopes.values(), + (project) => + Effect.gen(function* () { + const key = listCursorKey(project.host, project.scope); + // A continuation names the scopes it wants; one not named has nothing to add. + if (continuing !== undefined && continuing[key] === undefined) return; + const cursor = continuing?.[key]; + const page = yield* viewerRequests.get(project.host)!.pipe( + Effect.flatMap((viewer) => + project.api.listIssues({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + state: input.state, + involvement: input.involvement ?? "all", + viewer, + limit, + query: input.query, + cursor, + }), + ), + Effect.result, + ); + if (page._tag === "Failure") { + if (cursor !== undefined) nextCursors[key] = cursor; + recordFailure(project, page.failure); + errors.push({ + projectId: project.project.id, + projectTitle: project.project.title, + message: trackerDetail(page.failure), + }); + return; + } + for (const item of page.success.items) { + entries.push({ + ...item, + tracker: project.tracker, + host: project.host, + scope: project.scope, + projectId: project.project.id, + projectTitle: project.project.title, + }); + } + if (page.success.nextCursor !== null) nextCursors[key] = page.success.nextCursor; + }), + { concurrency: SCOPE_CONCURRENCY, discard: true }, + ); + + for (const [host, { tracker, projectCount }] of unimplemented) { + trackers.set(host, { + tracker, + host, + projectCount, + capabilities: null, + configured: false, + detail: "Issues cannot be browsed for this host yet.", + }); + } + entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return { + viewers: Object.fromEntries(viewers), + trackers: [...trackers.values()], + entries, + errors, + nextCursors, + }; + }); + + const detail: IssueService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getIssue({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + }) + .pipe( + Effect.mapError(toIssueError("detail")), + Effect.map((issue): IssueDetail => ({ + ...issue, + tracker: project.tracker, + host: project.host, + scope: project.scope, + projectId: project.project.id, + projectTitle: project.project.title, + writable: project.writable, + capabilities: project.api.capabilities, + })), + ), + ), + ); + + const comments: IssueService["Service"]["comments"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getComments({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + cursor: input.cursor, + }) + .pipe(Effect.mapError(toIssueError("comments"))), + ), + ); + + const templates: IssueService["Service"]["templates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.listTemplates === undefined || !project.api.capabilities.templates + ? Effect.fail(unsupported("templates", "issue templates")) + : project.api + .listTemplates({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + }) + .pipe(Effect.mapError(toIssueError("templates"))), + ), + ); + + const candidates: IssueService["Service"]["candidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.listCandidates === undefined || + !(project.api.capabilities.labels || project.api.capabilities.assignees) + ? Effect.fail(unsupported("candidates", "labels or assignees")) + : project.api + .listCandidates({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + }) + .pipe(Effect.mapError(toIssueError("candidates"))), + ), + ); + + const create: IssueService["Service"]["create"] = (input) => + requireWritable(input, "create").pipe( + Effect.flatMap((project) => + project.api.create === undefined || !project.api.capabilities.create + ? Effect.fail(unsupported("create", "filing issues")) + : project.api + .create({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }) + .pipe(Effect.mapError(toIssueError("create"))), + ), + ); + + const comment: IssueService["Service"]["comment"] = (input) => + requireWritable(input, "comment").pipe( + Effect.flatMap((project) => { + if (input.body.trim().length === 0) { + return Effect.fail( + new IssueOperationError({ operation: "comment", detail: "A comment needs some text." }), + ); + } + return project.api.comment === undefined || !project.api.capabilities.comment + ? Effect.fail(unsupported("comment", "comments")) + : project.api + .comment({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + body: input.body, + }) + .pipe(Effect.mapError(toIssueError("comment"))); + }), + ); + + const update: IssueService["Service"]["update"] = (input) => + requireWritable(input, "update").pipe( + Effect.flatMap((project) => { + const { capabilities } = project.api; + const wantsWords = input.title !== undefined || input.body !== undefined; + if (!wantsWords && input.labels === undefined && input.assignees === undefined) { + return Effect.fail( + new IssueOperationError({ operation: "update", detail: "Nothing to change." }), + ); + } + if (project.api.update === undefined) return Effect.fail(unsupported("update", "edits")); + if (wantsWords && !capabilities.edit) + return Effect.fail(unsupported("update", "editing the title or body")); + if (input.labels !== undefined && !capabilities.labels) + return Effect.fail(unsupported("update", "labels")); + if (input.assignees !== undefined && !capabilities.assignees) + return Effect.fail(unsupported("update", "assignees")); + return project.api + .update({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }) + .pipe(Effect.mapError(toIssueError("update"))); + }), + ); + + const setState: IssueService["Service"]["setState"] = (input) => + requireWritable(input, "setState").pipe( + Effect.flatMap((project) => { + const { capabilities } = project.api; + const target = capabilities.states.find((state) => state.name === input.state); + if (project.api.setState === undefined || target === undefined) + return Effect.fail(unsupported("setState", `the state "${input.state}"`)); + if ( + input.closeReason !== undefined && + (target.category !== "closed" || !capabilities.closeReasons.includes(input.closeReason)) + ) + return Effect.fail(unsupported("setState", `the close reason "${input.closeReason}"`)); + return project.api + .setState({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + state: input.state, + closeReason: input.closeReason, + }) + .pipe(Effect.mapError(toIssueError("setState"))); + }), + ); + + const react: IssueService["Service"]["react"] = (input) => + requireWritable(input, "react").pipe( + Effect.flatMap((project) => + project.api.setReaction === undefined || !project.api.capabilities.reactions + ? Effect.fail(unsupported("react", "reactions")) + : project.api + .setReaction({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + subjectId: input.subjectId, + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(toIssueError("react"))), + ), + ); + + 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), + comments: traced("comments", comments), + templates: traced("templates", templates), + candidates: interactive(traced("candidates", candidates)), + create: interactive(traced("create", create)), + comment: interactive(traced("comment", comment)), + update: interactive(traced("update", update)), + setState: interactive(traced("setState", setState)), + react: interactive(traced("react", react)), + resolveUrl, + }); +}); + +export const layer = Layer.effect(IssueService, make); diff --git a/apps/server/src/issue/IssueTrackerConnections.ts b/apps/server/src/issue/IssueTrackerConnections.ts new file mode 100644 index 000000000000..90554d1eada4 --- /dev/null +++ b/apps/server/src/issue/IssueTrackerConnections.ts @@ -0,0 +1,184 @@ +/** + * Standalone issue trackers (Linear, Jira) as the settings page manages them: one credential per + * tracker kind in the secret store, with its status read live from the tracker, and the + * per-project binding that puts a tracker's scope beside the project's git-host issues. + * Disconnecting clears the bindings that pointed at the tracker. + * + * Nothing here knows which trackers exist: the registry lists them and each driver's descriptor + * says what to call the credential and the scope. Adding a tracker adds no code here. + * + * @module issue/IssueTrackerConnections + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as DateTime from "effect/DateTime"; +import { + IssueTrackerConnectionError, + IssueTrackerKind, + type IssueTrackerBindProjectInput, + type IssueTrackerConnectInput, + type IssueTrackerConnection, + type IssueTrackerConnectionList, +} from "@t3tools/contracts"; +import type { StandaloneIssueTrackerApi } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as IssueTrackerBindings from "../persistence/IssueTrackerBindings.ts"; +import * as IssueService from "./IssueService.ts"; +import { issueTrackerSecretName } from "./IssueTrackerHostLive.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const encoder = new TextEncoder(); + +export class IssueTrackerConnections extends Context.Service< + IssueTrackerConnections, + { + /** Every standalone tracker, each probed against its stored credential, and the bindings. */ + readonly list: Effect.Effect; + /** + * Vets the credential with the tracker and stores it only when the tracker accepts it. A null + * token removes the stored one and unbinds every project that pointed at the tracker. + */ + readonly connect: ( + input: IssueTrackerConnectInput, + ) => Effect.Effect; + /** Binds a project to one of a tracker's scopes, or unbinds it with a null binding. */ + readonly bindProject: ( + input: IssueTrackerBindProjectInput, + ) => Effect.Effect; + } +>()("t3/issue/IssueTrackerConnections") {} + +const make = Effect.gen(function* () { + const registry = yield* IssueTrackerRegistry.IssueTrackerRegistry; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const bindings = yield* IssueTrackerBindings.IssueTrackerBindingsRepository; + + const requireTracker = ( + tracker: IssueTrackerKind, + ): Effect.Effect => { + const found = registry.standalone.find((api) => api.kind === tracker); + return found === undefined + ? Effect.fail( + new IssueTrackerConnectionError({ + tracker, + reason: "unknown-tracker", + detail: "This server has no such issue tracker.", + }), + ) + : Effect.succeed(found); + }; + + /** The kind an error about no tracker in particular is filed under. */ + const anyTracker = registry.standalone[0]?.kind ?? IssueTrackerKind.make("none"); + + const secretFailure = (tracker: IssueTrackerKind, detail: string) => (cause: unknown) => + new IssueTrackerConnectionError({ tracker, reason: "failed", detail, cause }); + + const disconnected = (api: StandaloneIssueTrackerApi): IssueTrackerConnection => ({ + ...api.standalone, + connected: false, + account: null, + detail: null, + scopes: [], + }); + + /** The tracker's view of a stored credential; a refusal is status, not a failure. */ + const status = (api: StandaloneIssueTrackerApi, token: string) => + api.probeCredential(token).pipe( + Effect.map((probe): IssueTrackerConnection => ({ + ...api.standalone, + connected: true, + account: probe.account, + detail: null, + scopes: probe.scopes, + })), + Effect.catch((error) => + Effect.succeed({ + ...api.standalone, + connected: true, + account: null, + detail: IssueService.trackerDetail(error), + scopes: [], + }), + ), + ); + + const current = (api: StandaloneIssueTrackerApi) => + secrets.get(issueTrackerSecretName(api.kind)).pipe( + Effect.mapError(secretFailure(api.kind, "The stored credential could not be read.")), + Effect.flatMap((stored) => + Option.isNone(stored) + ? Effect.succeed(disconnected(api)) + : status(api, new TextDecoder().decode(stored.value)), + ), + ); + + const list: IssueTrackerConnections["Service"]["list"] = Effect.all({ + trackers: Effect.forEach(registry.standalone, current, { concurrency: 4 }), + bindings: bindings.list.pipe( + Effect.map((bound) => Object.fromEntries(bound)), + // No single tracker is at fault; the first registered one carries the report. + Effect.mapError(secretFailure(anyTracker, "The project bindings could not be read.")), + ), + }); + + const disconnect = Effect.fn("IssueTrackerConnections.disconnect")(function* ( + api: StandaloneIssueTrackerApi, + ) { + yield* secrets + .remove(issueTrackerSecretName(api.kind)) + .pipe(Effect.mapError(secretFailure(api.kind, "The credential could not be removed."))); + // Bindings to a tracker nobody is signed into would only produce errors in every listing. + yield* bindings + .removeTracker(api.kind) + .pipe(Effect.mapError(secretFailure(api.kind, "The project bindings could not be cleared."))); + return disconnected(api); + }); + + const connect: IssueTrackerConnections["Service"]["connect"] = Effect.fn( + "IssueTrackerConnections.connect", + )(function* (input) { + const api = yield* requireTracker(input.tracker); + if (input.token === null) return yield* disconnect(api); + const probe = yield* api.probeCredential(input.token).pipe( + Effect.mapError( + (error) => + new IssueTrackerConnectionError({ + tracker: input.tracker, + reason: error.reason === "unauthenticated" ? "rejected" : "failed", + detail: IssueService.trackerDetail(error), + cause: error, + }), + ), + ); + yield* secrets + .set(issueTrackerSecretName(api.kind), encoder.encode(input.token)) + .pipe(Effect.mapError(secretFailure(api.kind, "The credential could not be stored."))); + return { + ...api.standalone, + connected: true, + account: probe.account, + detail: null, + scopes: probe.scopes, + }; + }); + + const bindProject: IssueTrackerConnections["Service"]["bindProject"] = Effect.fn( + "IssueTrackerConnections.bindProject", + )(function* (input) { + // Unbinding names no tracker; the first registered one carries any report. + const tracker = + input.binding === null ? anyTracker : (yield* requireTracker(input.binding.tracker)).kind; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* bindings + .set({ projectId: input.projectId, binding: input.binding, updatedAt }) + .pipe(Effect.mapError(secretFailure(tracker, "The project binding could not be saved."))); + }); + + return IssueTrackerConnections.of({ list, connect, bindProject }); +}); + +export const layer = Layer.effect(IssueTrackerConnections, make); diff --git a/apps/server/src/issue/IssueTrackerHostLive.ts b/apps/server/src/issue/IssueTrackerHostLive.ts new file mode 100644 index 000000000000..136932646fb8 --- /dev/null +++ b/apps/server/src/issue/IssueTrackerHostLive.ts @@ -0,0 +1,44 @@ +/** + * The server's `IssueTrackerHost`: standalone tracker credentials out of the secret store, one + * per tracker kind. The name is stable across releases so a saved key survives upgrades. + * + * @module issue/IssueTrackerHostLive + */ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type { IssueTrackerKind } from "@t3tools/contracts"; +import { IssueTrackerError } from "@t3tools/issue-tracker-core/server/IssueTracker"; +import * as IssueTrackerHost from "@t3tools/issue-tracker-core/server/IssueTrackerHost"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +export function issueTrackerSecretName(tracker: IssueTrackerKind): string { + return `issue-tracker-${Buffer.from(tracker, "utf8").toString("base64url")}-token`; +} + +const decoder = new TextDecoder(); + +const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + return IssueTrackerHost.IssueTrackerHost.of({ + credentials: { + get: (tracker) => + secrets.get(issueTrackerSecretName(tracker)).pipe( + Effect.map(Option.map((bytes) => decoder.decode(bytes))), + Effect.mapError( + (cause) => + new IssueTrackerError({ + tracker, + operation: "readCredential", + reason: "failed", + detail: "The stored credential could not be read.", + cause, + }), + ), + ), + }, + }); +}); + +export const layer = Layer.effect(IssueTrackerHost.IssueTrackerHost, make); diff --git a/apps/server/src/issue/IssueTrackerRegistry.ts b/apps/server/src/issue/IssueTrackerRegistry.ts new file mode 100644 index 000000000000..f7aea50af7bd --- /dev/null +++ b/apps/server/src/issue/IssueTrackerRegistry.ts @@ -0,0 +1,48 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { IssueTrackerKind } from "@t3tools/contracts"; +import { + isStandaloneIssueTracker, + type IssueTrackerApi, + type StandaloneIssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import * as BuiltInIssueTrackers from "./builtInIssueTrackers.ts"; +import * as IssueTrackerHostLive from "./IssueTrackerHostLive.ts"; + +export class IssueTrackerRegistry extends Context.Service< + IssueTrackerRegistry, + { + /** Null for a kind with no implementation, which the service reports as unsupported. */ + readonly get: (kind: IssueTrackerKind) => IssueTrackerApi | null; + readonly kinds: ReadonlyArray; + /** The trackers a user connects with a credential of their own, in registration order. */ + readonly standalone: ReadonlyArray; + } +>()("t3/issue/IssueTrackerRegistry") {} + +/** Exported for tests, which stand a registry up from trackers they supply themselves. */ +export function fromTrackers( + trackers: ReadonlyArray, +): IssueTrackerRegistry["Service"] { + const byKind = new Map(trackers.map((tracker) => [tracker.kind, tracker])); + return { + get: (kind) => byKind.get(kind) ?? null, + kinds: trackers.map((tracker) => tracker.kind), + standalone: trackers.filter(isStandaloneIssueTracker), + }; +} + +const make = Effect.gen(function* () { + const trackers = yield* Effect.forEach( + BuiltInIssueTrackers.BUILT_IN_ISSUE_TRACKERS, + (driver) => driver.make, + ); + return fromTrackers(trackers); +}); + +export const layer = Layer.effect(IssueTrackerRegistry, make).pipe( + Layer.provide(Layer.merge(BuiltInDrivers.layer, IssueTrackerHostLive.layer)), +); diff --git a/apps/server/src/issue/builtInIssueTrackers.ts b/apps/server/src/issue/builtInIssueTrackers.ts new file mode 100644 index 000000000000..a0559f33b5fb --- /dev/null +++ b/apps/server/src/issue/builtInIssueTrackers.ts @@ -0,0 +1,20 @@ +import * as AzureDevOpsIssueTrackerDriver from "@t3tools/source-control-azure-devops/server/issueTrackerDriver"; +import * as ForgejoIssueTrackerDriver from "@t3tools/source-control-forgejo/server/issueTrackerDriver"; +import * as GitHubIssueTrackerDriver from "@t3tools/source-control-github/server/issueTrackerDriver"; +import * as GitLabIssueTrackerDriver from "@t3tools/source-control-gitlab/server/issueTrackerDriver"; +import type { IssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; +import * as LinearIssueTrackerDriver from "@t3tools/issue-tracker-linear/server/issueTrackerDriver"; + +const drivers = [ + GitHubIssueTrackerDriver.driver, + GitLabIssueTrackerDriver.driver, + AzureDevOpsIssueTrackerDriver.driver, + ForgejoIssueTrackerDriver.driver, + LinearIssueTrackerDriver.driver, +]; + +export type BuiltInIssueTrackersEnv = + (typeof drivers)[number] extends IssueTrackerDriver ? R : never; + +export const BUILT_IN_ISSUE_TRACKERS: ReadonlyArray> = + drivers; 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..a5833d298450 --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.test.ts @@ -0,0 +1,286 @@ +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("refuses an explicit URL and project without the issues capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call( + "read_issue", + { url: "https://github.com/T3Tools/T3Code/issues/7", projectId: PROJECT_ID }, + ["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..2ed0fda8e0b3 --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.ts @@ -0,0 +1,312 @@ +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, + ) { + yield* McpInvocationContext.requireMcpCapability("issues"); + 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, + ) { + yield* McpInvocationContext.requireMcpCapability("issues"); + 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/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 0c018691e10b..ee2f5b5d9adb 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -131,6 +131,19 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", [WS_METHODS.pullRequestsLabelCandidates]: "pull-requests", [WS_METHODS.pullRequestsSetLabels]: "pull-requests", + [WS_METHODS.issuesList]: "issues", + [WS_METHODS.issuesDetail]: "issues", + [WS_METHODS.issuesComments]: "issues", + [WS_METHODS.issuesTemplates]: "issues", + [WS_METHODS.issuesCandidates]: "issues", + [WS_METHODS.issuesCreate]: "issues", + [WS_METHODS.issuesComment]: "issues", + [WS_METHODS.issuesUpdate]: "issues", + [WS_METHODS.issuesSetState]: "issues", + [WS_METHODS.issuesReact]: "issues", + [WS_METHODS.issueTrackersList]: "issues", + [WS_METHODS.issueTrackersConnect]: "issues", + [WS_METHODS.issueTrackersBindProject]: "issues", [WS_METHODS.sourceControlLookupRepository]: "source-control", [WS_METHODS.sourceControlCloneRepository]: "source-control", [WS_METHODS.sourceControlPublishRepository]: "source-control", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d2e9333c6999..a6637e1a1a67 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, @@ -464,6 +465,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": @@ -2657,6 +2661,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" @@ -3365,6 +3372,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); @@ -3556,6 +3600,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": @@ -11477,6 +11525,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 f62d5e447aae..52022ce05e31 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -823,6 +823,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, @@ -1616,6 +1617,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 }), @@ -1875,6 +1877,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 }), @@ -1977,6 +1980,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": @@ -2809,6 +2813,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..d024c603d146 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadIssueService.ts @@ -0,0 +1,238 @@ +/** + * 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}`)), + ); + + // Best effort for background refreshes: a thread that cannot be read is skipped, not failed. + 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* orchestrator.getThreadShell(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* orchestrator.getThreadShell(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 1c9404da6b92..10b8cc9588a6 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, + IssueTrackerBindingsRepositoryError + >; + /** Null unbinds. */ + readonly set: (input: { + readonly projectId: ProjectId; + readonly binding: IssueTrackerProjectBinding | null; + /** When the binding was saved, as an ISO instant. */ + readonly updatedAt: string; + }) => Effect.Effect; + /** Unbinds every project that pointed at the tracker, when its credential is removed. */ + readonly removeTracker: ( + tracker: IssueTrackerKind, + ) => Effect.Effect; + } +>()("t3/persistence/IssueTrackerBindings/IssueTrackerBindingsRepository") {} + +function toSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): IssueTrackerBindingsRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const listRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: BindingRow, + execute: () => + sql` + SELECT project_id AS "projectId", tracker AS "tracker", scope AS "scope" + FROM issue_tracker_bindings + `, + }); + + return IssueTrackerBindingsRepository.of({ + list: listRows(undefined).pipe( + Effect.map( + (rows) => + new Map( + rows.map((row) => [row.projectId, { tracker: row.tracker, scope: row.scope }] as const), + ), + ), + Effect.mapError(toSqlOrDecodeError("listIssueTrackerBindings", "IssueTrackerBinding")), + ), + set: (input) => + (input.binding === null + ? sql`DELETE FROM issue_tracker_bindings WHERE project_id = ${input.projectId}` + : sql` + INSERT INTO issue_tracker_bindings (project_id, tracker, scope, updated_at) + VALUES (${input.projectId}, ${input.binding.tracker}, ${input.binding.scope}, ${input.updatedAt}) + ON CONFLICT (project_id) + DO UPDATE SET tracker = excluded.tracker, scope = excluded.scope, updated_at = excluded.updated_at + ` + ).pipe( + Effect.asVoid, + Effect.mapError( + (cause) => new PersistenceSqlError({ operation: "setIssueTrackerBinding", cause }), + ), + ), + removeTracker: (tracker) => + sql`DELETE FROM issue_tracker_bindings WHERE tracker = ${tracker}`.pipe( + Effect.asVoid, + Effect.mapError( + (cause) => new PersistenceSqlError({ operation: "removeIssueTrackerBindings", cause }), + ), + ), + }); +}); + +export const layer = Layer.effect(IssueTrackerBindingsRepository, make); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 5b8ca15241ba..a860d562b2f4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,7 @@ import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; +import Migration0069 from "./Migrations/069_IssueTrackerBindings.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -142,6 +143,7 @@ const migrationEntries = [ [66, "RepairClientSettlementTimestamps", Migration0066], [67, "WebhookDedupe", Migration0067], [68, "ShellStopRequestIndex", Migration0068], + [69, "IssueTrackerBindings", Migration0069], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/069_IssueTrackerBindings.ts b/apps/server/src/persistence/Migrations/069_IssueTrackerBindings.ts new file mode 100644 index 000000000000..6eb450c0cc83 --- /dev/null +++ b/apps/server/src/persistence/Migrations/069_IssueTrackerBindings.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // One row per project bound to a standalone issue tracker (Linear, Jira): which tracker and + // which of its scopes (a team or project key). Unbinding deletes the row; a project with no row + // reads only its git host's issues. The credential itself lives in the secret store. + yield* sql` + CREATE TABLE IF NOT EXISTS issue_tracker_bindings ( + project_id TEXT PRIMARY KEY, + tracker TEXT NOT NULL, + scope TEXT NOT NULL, + updated_at TEXT NOT NULL + ) WITHOUT ROWID + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 64f1d9e3ae2f..af8e8e8fedb2 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -48,6 +48,7 @@ describe("V2 preview upgrade", () => { [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], + [69, "IssueTrackerBindings"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -139,6 +140,7 @@ describe("V2 preview upgrade", () => { [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], + [69, "IssueTrackerBindings"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c6a61817f69b..a985e0d46c2e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -93,6 +93,7 @@ function shouldPublishAgentAwarenessEvent( case "thread.metadata-updated": case "thread.pull-request-link-updated": case "thread.pull-request-synced": + case "thread.issue-synced": case "thread.settled": case "thread.model-selection-updated": case "thread.provider-switched": diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff0aca4126bc..c0a4bcd806e7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -40,6 +40,10 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as IssueService from "./issue/IssueService.ts"; +import * as IssueTrackerConnections from "./issue/IssueTrackerConnections.ts"; +import * as IssueTrackerBindings from "./persistence/IssueTrackerBindings.ts"; +import * as IssueTrackerRegistry from "./issue/IssueTrackerRegistry.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; @@ -180,6 +184,7 @@ import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as ThreadSettlementService from "./orchestration-v2/ThreadSettlementService.ts"; import * as IdleCompactionService from "./orchestration-v2/IdleCompactionService.ts"; import * as ThreadPullRequestService from "./orchestration-v2/ThreadPullRequestService.ts"; +import * as ThreadIssueService from "./orchestration-v2/ThreadIssueService.ts"; import * as RunFinalizationService from "./orchestration-v2/RunFinalizationService.ts"; import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; @@ -358,6 +363,15 @@ const layerPullRequestService = PullRequestService.layer.pipe( Layer.provide(SourceControlRateLimit.layer), ); +// Both read the one registry, so a standalone tracker connected in Settings is the same +// instance the listing reads through. +const layerIssueService = Layer.merge(IssueService.layer, IssueTrackerConnections.layer).pipe( + Layer.provide(Layer.merge(IssueTrackerRegistry.layer, IssueTrackerBindings.layer)), +); + +// One instance serves MCP link calls and refreshes snapshots on thread events. +const layerThreadIssueService = ThreadIssueService.layer.pipe(Layer.provide(layerIssueService)); + const layerGitManager = GitManager.layer.pipe( Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), @@ -529,6 +543,10 @@ const layerThreadPullRequestWorker = Layer.effectDiscard( ThreadPullRequestService.make.pipe(Effect.flatMap((service) => 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; @@ -574,6 +592,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ), ), layerThreadPullRequestWorker, + layerThreadIssueWorker, DelegatedTaskAnalytics.layer, Layer.effectDiscard( Effect.gen(function* () { @@ -724,6 +743,8 @@ 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), Layer.provide(PreviewAutomationBroker.layer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9a72a501f43f..b22cd1ff3374 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -96,6 +96,8 @@ import { type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, + WsCoreRpcGroup, + WsIssuesRpcGroup, WsRpcGroup, } from "@t3tools/contracts"; import { @@ -233,6 +235,8 @@ import type { TelemetryOwner } from "./telemetry/Identify.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as IssueService from "./issue/IssueService.ts"; +import * as IssueTrackerConnections from "./issue/IssueTrackerConnections.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; @@ -548,12 +552,14 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Middleware added later wraps middleware added earlier, so instrumentation wraps authorization. const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); +const ServerWsCoreRpcGroup = WsCoreRpcGroup.middleware(RpcInstrumentation); +const ServerWsIssuesRpcGroup = WsIssuesRpcGroup.middleware(RpcInstrumentation); // RpcGroup.toLayer derives each handler's requirements by scanning every RPC in // the group, so its type cost grows with handlers × RPCs. Near 200 methods that // crosses TypeScript's instantiation limit and the layer's requirements degrade to // `any`, so the handlers are registered as two disjoint, smaller groups. -type ServerWsRpc = RpcGroup.Rpcs; +type ServerWsRpc = RpcGroup.Rpcs; const SPLIT_WS_RPC_PREFIXES = ["server", "pullRequests", "provider", "orchestration"] as const; type SplitWsRpc = Extract< ServerWsRpc, @@ -563,7 +569,7 @@ const isSplitWsRpc = (rpc: ServerWsRpc): rpc is SplitWsRpc => SPLIT_WS_RPC_PREFIXES.some((prefix) => rpc._tag.startsWith(`${prefix}.`)); const isOtherWsRpc = (rpc: ServerWsRpc): rpc is Exclude => !isSplitWsRpc(rpc); -const serverWsRpcs = Array.from(ServerWsRpcGroup.requests.values()); +const serverWsRpcs = Array.from(ServerWsCoreRpcGroup.requests.values()); const SplitWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isSplitWsRpc)); const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); // Replaying each intervening event costs a shell refetch; past this gap a @@ -1215,6 +1221,28 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); +const layerWsIssuesRpc = ServerWsIssuesRpcGroup.toLayer( + Effect.gen(function* () { + const issues = yield* IssueService.IssueService; + const issueTrackers = yield* IssueTrackerConnections.IssueTrackerConnections; + return ServerWsIssuesRpcGroup.of({ + [WS_METHODS.issuesList]: (input) => issues.list(input), + [WS_METHODS.issuesDetail]: (input) => issues.detail(input), + [WS_METHODS.issuesComments]: (input) => issues.comments(input), + [WS_METHODS.issuesTemplates]: (input) => issues.templates(input), + [WS_METHODS.issuesCandidates]: (input) => issues.candidates(input), + [WS_METHODS.issuesCreate]: (input) => issues.create(input), + [WS_METHODS.issuesComment]: (input) => issues.comment(input), + [WS_METHODS.issuesUpdate]: (input) => issues.update(input), + [WS_METHODS.issuesSetState]: (input) => issues.setState(input), + [WS_METHODS.issuesReact]: (input) => issues.react(input), + [WS_METHODS.issueTrackersList]: () => issueTrackers.list, + [WS_METHODS.issueTrackersConnect]: (input) => issueTrackers.connect(input), + [WS_METHODS.issueTrackersBindProject]: (input) => issueTrackers.bindProject(input), + }); + }), +); + export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, @@ -1854,7 +1882,7 @@ export const layerWsRpc = ( return result; }); - const handlers = ServerWsRpcGroup.of({ + const handlers = ServerWsCoreRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -3198,6 +3226,8 @@ export const layer = Layer.unwrap( const serverBrowser = yield* ServerBrowser.ServerBrowser; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; + const issueTrackers = yield* IssueTrackerConnections.IssueTrackerConnections; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( @@ -3265,12 +3295,15 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc( - session, - clientOrigin, - clientAnalyticsProps, - previewAutomationBroker, - serverBrowser, + Layer.merge( + layerWsRpc( + session, + clientOrigin, + clientAnalyticsProps, + previewAutomationBroker, + serverBrowser, + ), + layerWsIssuesRpc, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees @@ -3283,6 +3316,10 @@ export const layer = Layer.unwrap( // One server-lifetime service means clients share the same PR caches, and a WS // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), + Layer.provide(Layer.succeed(IssueService.IssueService, issues)), + Layer.provide( + Layer.succeed(IssueTrackerConnections.IssueTrackerConnections, issueTrackers), + ), Layer.provide(OtlpEndpointCheck.layer), Layer.provide( Layer.succeed( diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d0..fc4c1a656a6f 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -93,6 +93,16 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { (surface.host === undefined || typeof surface.host === "string") && (surface.url === undefined || typeof surface.url === "string") ); + case "issue": + return ( + typeof surface.projectId === "string" && + typeof surface.tracker === "string" && + typeof surface.scope === "string" && + typeof surface.key === "string" && + (surface.environmentId === undefined || typeof surface.environmentId === "string") && + (surface.host === undefined || typeof surface.host === "string") && + (surface.url === undefined || typeof surface.url === "string") + ); default: return false; } 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 62223ba82096..6202d40f7fc2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3105,6 +3105,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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0d2b480e89c9..8f985c4f182e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -231,6 +231,8 @@ import type { ChatComposerHandle } from "./chat/ChatComposer"; import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; +import { readIssueListPreferences } from "~/components/issue/issueListPreferences"; +import { IssueGlyph } from "~/components/issue/issuePresentation"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -1528,6 +1530,23 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + environments.some( + (environment) => environment.serverConfig?.environment.capabilities.issues === true, + ) + ) { + actionItems.push({ + kind: "action", + value: "action:issues", + searchTerms: ["issues", "issue", "bugs", "tracker", "github", "triage"], + title: "Open issues", + icon: , + run: async () => { + await navigate({ to: "/issues", search: readIssueListPreferences() }); + }, + }); + } + actionItems.push({ kind: "action", value: "action:usage", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8faedef400f3..bb033fbdc534 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -134,6 +134,15 @@ export const JujutsuIcon: Icon = (props) => { ); }; +export const LinearIcon: Icon = (props) => ( + + + +); + export const GitLabIcon = ({ monochrome = false, ...props diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index df959252b929..da868225a43e 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -23,6 +23,7 @@ import { reopenClosedView, } from "../reopenClosedView"; import { + ISSUES_PANEL_REF, PULL_REQUESTS_PANEL_REF, selectActiveRightPanel, selectSelectedRightPanelSurface, @@ -42,6 +43,7 @@ import { resolveThreadRouteTarget, } from "../threadRoutes"; import { toastManager } from "./ui/toast"; +import { readIssueListPreferences } from "./issue/issueListPreferences"; const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ "sidebar.toggle", @@ -51,6 +53,8 @@ const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ const isGlobalPullRequests = (ref: ScopedThreadRef) => scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF); +const isGlobalIssues = (ref: ScopedThreadRef) => + scopedThreadKey(ref) === scopedThreadKey(ISSUES_PANEL_REF); export function ReopenClosedViewShortcut() { const navigate = useNavigate(); @@ -84,7 +88,7 @@ export function ReopenClosedViewShortcut() { const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => { const ref = entry.threadRef; const panel = selectThreadRightPanelState(panelsByThread, ref); - if (isGlobalPullRequests(ref)) { + if (isGlobalPullRequests(ref) || isGlobalIssues(ref)) { return { environmentKnown: true, catalogReady: true, @@ -107,7 +111,8 @@ export function ReopenClosedViewShortcut() { const ref = restore.threadRef; const globalPullRequests = isGlobalPullRequests(ref); - const thread = globalPullRequests ? null : readThreadShell(ref); + const globalIssues = isGlobalIssues(ref); + const thread = globalPullRequests || globalIssues ? null : readThreadShell(ref); const owner = thread ?? drafts.getDraftThreadByRef(ref); const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null; if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) { @@ -124,6 +129,8 @@ export function ReopenClosedViewShortcut() { to: "/pull-requests", search: (previous) => pullRequestsSearchForRestore(previous, selected), }); + } else if (globalIssues) { + await navigate({ to: "/issues", search: readIssueListPreferences() }); } else { const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null; if (draftId !== null) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4c1059eaeeb4..181dceda413a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -26,6 +26,7 @@ import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dn import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { + CircleDotIcon, Smartphone, ChevronDown, ChevronLeft, @@ -565,6 +566,8 @@ function surfaceTitle( return `#${surface.number}`; case "pull-requests": return "Pull requests"; + case "issue": + return surface.key; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "preview": { @@ -655,6 +658,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "issue": + return ; case "device": return surface.target?.platform === "ios" ? ( 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/IssueCreateDialog.tsx b/apps/web/src/components/issue/IssueCreateDialog.tsx new file mode 100644 index 000000000000..e85775bbb564 --- /dev/null +++ b/apps/web/src/components/issue/IssueCreateDialog.tsx @@ -0,0 +1,270 @@ +import { useAtomValue } from "@effect/atom-react"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, IssueCreateResult, IssueScopeRef } from "@t3tools/contracts"; +import { useState } from "react"; + +import { issueEnvironment } from "~/state/issues"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Textarea } from "../ui/textarea"; +import { toastManager } from "../ui/toast"; +import { readableFailure } from "../pullRequest/pullRequestDetail.logic"; + +export interface IssueCreateScope { + readonly environmentId: EnvironmentId; + readonly projectTitle: string; + readonly scope: IssueScopeRef; + readonly templates: boolean; +} + +const BLANK = "__blank__"; + +/** + * One text box with a draft in it, on any tracker: a chosen template fills the title, body, + * labels and assignees, and the writer takes it from there. Templates are read when the dialog + * opens, for the scope it opens on. + */ +export function IssueCreateDialog({ + open, + scopes, + onOpenChange, + onCreated, +}: { + open: boolean; + /** Every writable scope the page knows about; the first is preselected. */ + scopes: ReadonlyArray; + onOpenChange: (open: boolean) => void; + onCreated: (scope: IssueCreateScope, result: IssueCreateResult) => void; +}) { + const [scopeIndex, setScopeIndex] = useState(0); + const scope = scopes[Math.min(scopeIndex, scopes.length - 1)] ?? null; + const [templateKey, setTemplateKey] = useState(BLANK); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [extras, setExtras] = useState<{ + labels: ReadonlyArray; + assignees: ReadonlyArray; + }>({ + labels: [], + assignees: [], + }); + const [pending, setPending] = useState(false); + const create = useAtomCommand(issueEnvironment.create, { reportFailure: false }); + const canWrite = useAtomValue( + issueEnvironment.create.permissionAtom(scope?.environmentId ?? null), + ); + const templatesQuery = useEnvironmentQuery( + open && scope !== null && scope.templates + ? issueEnvironment.templates({ environmentId: scope.environmentId, input: scope.scope }) + : null, + ); + const templates = templatesQuery.data?.templates ?? []; + const blankAllowed = templatesQuery.data?.blankIssuesEnabled ?? true; + + const reset = () => { + setTemplateKey(BLANK); + setTitle(""); + setBody(""); + setExtras({ labels: [], assignees: [] }); + }; + + const chooseTemplate = (key: string | null) => { + const next = key ?? BLANK; + setTemplateKey(next); + const template = templates.find((entry) => entry.key === next); + if (!template) { + setExtras({ labels: [], assignees: [] }); + return; + } + // A template is a starting point: it only overwrites what the writer has not typed yet. + if (title.trim().length === 0) setTitle(template.title); + if (body.trim().length === 0) setBody(template.body); + setExtras({ labels: template.labels, assignees: template.assignees }); + }; + + const submit = async () => { + if (scope === null || pending) return; + const trimmedTitle = title.trim(); + if (trimmedTitle.length === 0) return; + setPending(true); + const result = await create({ + environmentId: scope.environmentId, + input: { + ...scope.scope, + title: trimmedTitle, + body, + labels: extras.labels, + assignees: extras.assignees, + }, + }); + setPending(false); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "The issue could not be created", + description: readableFailure(squashAtomCommandFailure(result), "The tracker refused it."), + }); + return; + } + toastManager.add({ type: "success", title: `Created ${result.value.displayKey}` }); + reset(); + onCreated(scope, result.value); + onOpenChange(false); + }; + + const blocked = + scope === null + ? "No project here can file issues." + : !canWrite + ? "Creating issues needs the source-control write scope." + : !blankAllowed && templateKey === BLANK && templates.length > 0 + ? "This tracker requires a template." + : null; + + return ( +

(pending ? undefined : onOpenChange(next))}> + + + New issue + File an issue on the project's tracker. + + +
+ {scopes.length > 1 ? ( +
+ + +
+ ) : null} + {scope?.templates && (templates.length > 0 || templatesQuery.isPending) ? ( +
+ + +
+ ) : null} +
+
+ + setTitle(event.target.value)} + placeholder="A short summary" + /> +
+
+ +