From 7014eda9a303e5b9984c41ad53c34bf22ae0b5fe Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:42:03 -0700 Subject: [PATCH 01/54] feat(issues): add issue tracking core, contracts, and GitHub tracker Issue tracking gets its own extension point beside source control so non-git trackers slot in without posing as source-control hosts. This layer adds the `@t3tools/issue-tracker-core` port and driver shape, the `issues.*` contracts and RPCs, an `IssueService` that resolves a project's tracker from its git remote and treats issues outside the project's own scope as read-only, and a GitHub tracker over the existing `GitHubApi`. Web UI, MCP tools, thread links, and the other trackers follow in later layers. Co-Authored-By: Claude Fable 5.1 --- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.test.ts | 21 + apps/server/src/auth/RpcAuthorization.ts | 5 + apps/server/src/issue/IssueService.test.ts | 268 +++++++ apps/server/src/issue/IssueService.ts | 563 ++++++++++++++ apps/server/src/issue/IssueTrackerRegistry.ts | 40 + apps/server/src/issue/builtInIssueTrackers.ts | 20 + .../src/observability/RpcInstrumentation.ts | 10 + apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 14 + .../contracts/src/clientRpcPermissions.ts | 5 + packages/contracts/src/index.ts | 1 + packages/contracts/src/issue.ts | 477 ++++++++++++ packages/contracts/src/rpc.ts | 109 +++ packages/issue-tracker-core/package.json | 25 + .../src/server/IssueTracker.ts | 190 +++++ .../issue-tracker-core/src/server/driver.ts | 26 + packages/issue-tracker-core/tsconfig.json | 7 + packages/source-control-github/package.json | 13 + .../src/server/GitHubIssueTracker.test.ts | 188 +++++ .../src/server/GitHubIssueTracker.ts | 504 ++++++++++++ .../src/server/gitHubIssueJson.ts | 736 ++++++++++++++++++ .../src/server/gitHubPullRequestJson.ts | 6 +- .../src/server/issueTrackerDriver.ts | 15 + pnpm-lock.yaml | 19 + 25 files changed, 3265 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/issue/IssueService.test.ts create mode 100644 apps/server/src/issue/IssueService.ts create mode 100644 apps/server/src/issue/IssueTrackerRegistry.ts create mode 100644 apps/server/src/issue/builtInIssueTrackers.ts create mode 100644 packages/contracts/src/issue.ts create mode 100644 packages/issue-tracker-core/package.json create mode 100644 packages/issue-tracker-core/src/server/IssueTracker.ts create mode 100644 packages/issue-tracker-core/src/server/driver.ts create mode 100644 packages/issue-tracker-core/tsconfig.json create mode 100644 packages/source-control-github/src/server/GitHubIssueTracker.test.ts create mode 100644 packages/source-control-github/src/server/GitHubIssueTracker.ts create mode 100644 packages/source-control-github/src/server/gitHubIssueJson.ts create mode 100644 packages/source-control-github/src/server/issueTrackerDriver.ts diff --git a/apps/server/package.json b/apps/server/package.json index 715fb3097986..82d344b10498 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,6 +37,7 @@ "@muse-code/sdk": "1.4.6", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index ce90ad31fea8..a88c4e66c874 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -127,6 +127,27 @@ describe("RPC authorization scopes", () => { ); }); + it("reads issues under the orchestration read scope and writes them like pull requests", () => { + for (const method of [ + WS_METHODS.issuesList, + WS_METHODS.issuesDetail, + WS_METHODS.issuesComments, + WS_METHODS.issuesTemplates, + WS_METHODS.issuesCandidates, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + for (const method of [ + WS_METHODS.issuesCreate, + WS_METHODS.issuesComment, + WS_METHODS.issuesUpdate, + WS_METHODS.issuesSetState, + WS_METHODS.issuesReact, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); + } + }); + it("requires source control writes to start, retry, or cancel project clones", () => { for (const method of [ WS_METHODS.projectCloneStart, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..8a619f461676 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -129,6 +129,11 @@ 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.pullRequestsPreview]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts new file mode 100644 index 000000000000..e52f3d142b38 --- /dev/null +++ b/apps/server/src/issue/IssueService.test.ts @@ -0,0 +1,268 @@ +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 { + IssueTrackerKind, + type OrchestrationProjectShell, + type ProjectId, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, + type TrackerIssueDetail, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +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"); + +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: `${host}/${input.repository}`, + 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 }), + getViewerPermissions: () => Effect.die("unused"), + comment: () => Effect.void, + update: () => Effect.void, + setState: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly trackers: ReadonlyArray; +}) { + return IssueService.make.pipe( + Effect.provide( + Layer.mergeAll( + 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("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* Effect.flip(service.comment({ ...external, body: "no" })); + expect(refused).toMatchObject({ _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("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("lists each repository once and reports unusable trackers per host", () => + Effect.gen(function* () { + const listed: string[] = []; + 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: "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", + detail: "boom", + }); + return { + items: [issue("2", "2026-07-05T00:00:00Z"), issue("1")], + nextCursor: "next", + }; + }), + }), + ], + }); + const result = yield* service.list({ state: "open" }); + expect(listed.sort()).toEqual(["acme/api", "acme/web"]); + expect(result.entries.map((entry) => [entry.projectId, entry.key])).toEqual([ + ["web", "2"], + ["web", "1"], + ]); + expect(result.nextCursors).toEqual({ "github.com acme/web": "next" }); + expect(result.errors).toEqual([{ projectId: "api", projectTitle: "api", message: "boom" }]); + expect(result.viewers).toEqual({ "github.com": "julius" }); + expect(result.trackers).toEqual([ + expect.objectContaining({ host: "github.com", projectCount: 3, 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 next = yield* service.list({ + state: "open", + cursors: { "github.com acme/web": "next" }, + }); + expect(listed).toEqual(["acme/web"]); + expect(next.errors).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts new file mode 100644 index 000000000000..ec2638510f2b --- /dev/null +++ b/apps/server/src/issue/IssueService.ts @@ -0,0 +1,563 @@ +/** + * Issues across the workspace's projects, read through whichever tracker each project resolves + * to. The service owns resolution and permission; trackers own their APIs. + * + * Resolution: a git-host project resolves to the tracker of the same kind as its remote, with the + * remote's host and `owner/repo` as the scope. A standalone tracker (Linear, Jira) will resolve + * from a per-project binding in settings through the same `resolveProjectTracker` hook; only the + * git-host path exists today. + * + * An issue whose scope is not any project's own can still be read through a project on the same + * tracker and host — a frontend project reading a backend issue, or an external URL — but is + * read-only: every write checks `writable` first. + * + * @module issue/IssueService + */ +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, +} from "@t3tools/contracts"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +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; + +/** One project's resolved tracker: which driver, where it is served from, and its container. */ +export interface ProjectTrackerBinding { + 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; +} + +/** + * 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 = sourceControlRepositorySelector(identity); + const kind = identity?.provider; + if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; + return { + tracker: IssueTrackerKind.make(kind), + host: pullRequestHostOf(identity, SourceControlProviderKind.make(kind)), + 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: error.detail, + ...(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; + } +>()("t3/issue/IssueService") {} + +export const make = Effect.gen(function* () { + const registry = yield* IssueTrackerRegistry.IssueTrackerRegistry; + const projects = yield* ProjectService.ProjectService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + + /** Where a project's issues live. The one place a standalone tracker's binding will plug in. */ + const resolveProjectTracker = ( + project: OrchestrationProjectShell, + ): Effect.Effect => + (project.repositoryIdentity != null + ? Effect.succeed(project.repositoryIdentity) + : repositoryIdentities.resolve(project.workspaceRoot) + ).pipe( + Effect.map((repositoryIdentity) => resolveGitHostBinding({ ...project, repositoryIdentity })), + ); + + 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((binding) => (binding === null ? [] : [{ 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. + */ + const requireProject = ( + ref: IssueScopeRef, + ): Effect.Effect => + listProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }) => { + const own = supported[0]; + if (own === undefined || own.tracker !== ref.tracker) { + 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"] = (input) => + Effect.gen(function* () { + 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) { + const key = listCursorKey(project.host, project.scope); + if (!scopes.has(key)) scopes.set(key, project); + } + const continuing = input.cursors; + const viewers = new Map(); + const trackers = new Map(); + const entries: IssueListEntry[] = []; + const errors: IssueListProjectError[] = []; + const nextCursors: Record = {}; + + const viewerFor = (project: SupportedProject) => + Effect.suspend(() => { + const known = viewers.get(project.host); + return known === undefined + ? project.api + .getViewer({ cwd: project.project.workspaceRoot, host: project.host }) + .pipe(Effect.tap((viewer) => Effect.sync(() => viewers.set(project.host, viewer)))) + : Effect.succeed(known); + }); + + // 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: failure.detail }); + }; + + yield* Effect.forEach( + scopes, + ([key, project]) => + Effect.gen(function* () { + // 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* viewerFor(project).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") { + recordFailure(project, page.failure); + errors.push({ + projectId: project.project.id, + projectTitle: project.project.title, + message: page.failure.detail, + }); + 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"))), + ), + ); + + return IssueService.of({ + list, + detail, + comments, + templates, + candidates, + create, + comment, + update, + setState, + react, + }); +}); + +export const layer = Layer.effect(IssueService, make); diff --git a/apps/server/src/issue/IssueTrackerRegistry.ts b/apps/server/src/issue/IssueTrackerRegistry.ts new file mode 100644 index 000000000000..1647a9ea1ffa --- /dev/null +++ b/apps/server/src/issue/IssueTrackerRegistry.ts @@ -0,0 +1,40 @@ +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 type { IssueTrackerApi } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import * as BuiltInIssueTrackers from "./builtInIssueTrackers.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; + } +>()("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), + }; +} + +export 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(BuiltInDrivers.layer), +); diff --git a/apps/server/src/issue/builtInIssueTrackers.ts b/apps/server/src/issue/builtInIssueTrackers.ts new file mode 100644 index 000000000000..6fccbcfd57e9 --- /dev/null +++ b/apps/server/src/issue/builtInIssueTrackers.ts @@ -0,0 +1,20 @@ +/** + * The issue tracker drivers this build ships with. Git hosts contribute theirs from their source + * control package; a standalone tracker (Linear, Jira) adds its own package and a row here. A + * kind with no driver shows up as unsupported rather than missing. + * + * The services these drivers need are the same ones `sourceControl/builtInDrivers.ts` provides, + * so the server reuses that layer rather than building a second `GitHubApi`. + * + * @module issue/builtInIssueTrackers + */ +import * as GitHubIssueTrackerDriver from "@t3tools/source-control-github/server/issueTrackerDriver"; +import type { IssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +const drivers = [GitHubIssueTrackerDriver.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/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..4f6921c380e9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -128,6 +128,16 @@ 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.sourceControlLookupRepository]: "source-control", [WS_METHODS.sourceControlCloneRepository]: "source-control", [WS_METHODS.sourceControlPublishRepository]: "source-control", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3c1539a70985..fb5cac8eeb96 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,6 +37,8 @@ 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 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"; @@ -347,6 +349,8 @@ const layerPullRequestService = PullRequestService.layer.pipe( Layer.provide(SourceControlRateLimit.layer), ); +const layerIssueService = IssueService.layer.pipe(Layer.provide(IssueTrackerRegistry.layer)); + const layerGitManager = GitManager.layer.pipe( // Per-project git settings resolve the acting thread's project. Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), @@ -732,6 +736,7 @@ const layerMakeRoutes = Layer.mergeAll( // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Provided // after the guide service so it reaches that too. Layer.provide(layerPullRequestService), + Layer.provide(layerIssueService), // The stream route and the WebSocket RPCs share one browser. Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), // Server browser tabs and HTML render previews install and run the same headless browser. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 96994ee37c13..7feab0cab72a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -228,6 +228,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.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 PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; @@ -1242,6 +1243,7 @@ const layerWsRpc = ( const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -2575,6 +2577,16 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.labelCandidates(input)), [WS_METHODS.pullRequestsSetLabels]: (input) => withPullRequestViewer(input, pullRequests.setLabels(input)), + [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.sourceControlLookupRepository]: (input) => sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => @@ -3167,6 +3179,7 @@ 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 reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( @@ -3234,6 +3247,7 @@ 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(OtlpEndpointCheck.layer), // Shared like the pull request service, so concurrent generations single-flight. Layer.provide( diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index e85e5df7769c..a07798316fdd 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -25,6 +25,11 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsSetFilesViewed]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, + [WS_METHODS.issuesCreate]: AuthSourceControlWriteScope, + [WS_METHODS.issuesComment]: AuthSourceControlWriteScope, + [WS_METHODS.issuesUpdate]: AuthSourceControlWriteScope, + [WS_METHODS.issuesSetState]: AuthSourceControlWriteScope, + [WS_METHODS.issuesReact]: AuthSourceControlWriteScope, // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 600d503ade39..8e788c7af95c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -34,6 +34,7 @@ export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; export * from "./pullRequestReviewGuide.ts"; +export * from "./issue.ts"; export * from "./orchestrationDispatch.ts"; export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.ts"; diff --git a/packages/contracts/src/issue.ts b/packages/contracts/src/issue.ts new file mode 100644 index 000000000000..9a8db9532eed --- /dev/null +++ b/packages/contracts/src/issue.ts @@ -0,0 +1,477 @@ +import * as Schema from "effect/Schema"; +import * as HttpServerRespondable from "effect/http/HttpServerRespondable"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ProjectId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { PullRequestActor, PullRequestLabel, PullRequestState } from "./pullRequest.ts"; + +/** + * Which tracker an issue lives in. Git hosts reuse their `SourceControlProviderKind` spelling + * (`github`, `gitlab`); standalone trackers get their own (`linear`, `jira`). Branded apart from + * the source control kind because the two sets are not the same: Bitbucket is a git host with no + * tracker, and Linear is a tracker with no git host. + */ +export const IssueTrackerKind = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][a-z0-9-]*$/), +).pipe(Schema.brand("IssueTrackerKind")); +export type IssueTrackerKind = typeof IssueTrackerKind.Type; + +export const IssueActor = PullRequestActor; +export type IssueActor = typeof IssueActor.Type; + +export const IssueLabel = PullRequestLabel; +export type IssueLabel = typeof IssueLabel.Type; + +/** + * Where an issue stands, in two parts: the category every tracker has, and the name this tracker + * gives it. GitHub only has the two; Linear and Jira have a workflow of their own ("In Review", + * "Backlog"), which `name` carries verbatim so the page can show the tracker's word while every + * filter and badge keys off `category`. + */ +export const IssueStateCategory = Schema.Literals(["open", "closed"]); +export type IssueStateCategory = typeof IssueStateCategory.Type; + +export const IssueState = Schema.Struct({ + category: IssueStateCategory, + name: TrimmedNonEmptyString, + /** Why it was closed, where the tracker records one (GitHub: `completed` / `not-planned`). */ + closeReason: Schema.optional(TrimmedNonEmptyString), +}); +export type IssueState = typeof IssueState.Type; + +/** What a listing asks for: either category, or both. */ +export const IssueListState = Schema.Literals(["all", "open", "closed"]); +export type IssueListState = typeof IssueListState.Type; + +export const IssueInvolvement = Schema.Literals(["all", "assigned", "authored", "mentioned"]); +export type IssueInvolvement = typeof IssueInvolvement.Type; + +export const IssueReactionContent = Schema.Literals([ + "thumbs-up", + "thumbs-down", + "laugh", + "hooray", + "confused", + "heart", + "rocket", + "eyes", +]); +export type IssueReactionContent = typeof IssueReactionContent.Type; + +export const IssueReaction = Schema.Struct({ + content: IssueReactionContent, + count: PositiveInt, + /** Who reacted, as far as the tracker named them; the viewer is left out and shown as "You". */ + actors: Schema.Array(TrimmedNonEmptyString), + viewerHasReacted: Schema.Boolean, +}); +export type IssueReaction = typeof IssueReaction.Type; + +/** + * Addresses one issue. `tracker` picks the driver, `scope` is the tracker's own container — the + * `owner/repo` path on a git host, a team or project key on Linear and Jira — and `key` is the + * issue's native id within it: `"123"` on GitHub, `"ENG-123"` on Linear. `host` is where the + * tracker is served from, for a kind that can be self-hosted (GitHub Enterprise, GitLab, Forgejo); + * absent means the kind's public host. + * + * `projectId` names the project whose checkout and credentials serve the read. An issue outside + * every project's own scope can still be read through any project on the same tracker and host, + * but the service refuses to write to it. + */ +export const IssueRef = Schema.Struct({ + projectId: ProjectId, + tracker: IssueTrackerKind, + host: Schema.optional(TrimmedNonEmptyString), + scope: TrimmedNonEmptyString, + key: TrimmedNonEmptyString, +}); +export type IssueRef = typeof IssueRef.Type; + +/** A scope with no issue in it, for reads about the container itself: templates, candidates. */ +export const IssueScopeRef = Schema.Struct({ + projectId: ProjectId, + tracker: IssueTrackerKind, + host: Schema.optional(TrimmedNonEmptyString), + scope: TrimmedNonEmptyString, +}); +export type IssueScopeRef = typeof IssueScopeRef.Type; + +/** One row of a listing. Carries everything a list needs and nothing a detail needs. */ +export const IssueSummary = Schema.Struct({ + tracker: IssueTrackerKind, + host: TrimmedNonEmptyString, + scope: TrimmedNonEmptyString, + key: TrimmedNonEmptyString, + /** How the tracker writes the key for people: `#123` on a git host, `ENG-123` on Linear. */ + displayKey: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.NullOr(IssueActor), + state: IssueState, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + closedAt: Schema.NullOr(IsoDateTime), + assignees: Schema.Array(IssueActor), + labels: Schema.Array(IssueLabel), + commentCount: NonNegativeInt, +}); +export type IssueSummary = typeof IssueSummary.Type; + +export const IssueListEntry = Schema.Struct({ + ...IssueSummary.fields, + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, +}); +export type IssueListEntry = typeof IssueListEntry.Type; + +export const IssueComment = Schema.Struct({ + id: TrimmedNonEmptyString, + author: Schema.NullOr(IssueActor), + body: Schema.String, + createdAt: IsoDateTime, + url: Schema.NullOr(Schema.String), + /** Absent from a tracker with no reactions, which differs from none on this comment. */ + reactions: Schema.optional(Schema.Array(IssueReaction)), +}); +export type IssueComment = typeof IssueComment.Type; + +/** + * Something that happened to an issue that nobody wrote words for: it was closed, labelled, + * assigned, renamed. Kinds are the common vocabulary; a tracker drops what it cannot map rather + * than guessing. + */ +export const IssueEventKind = Schema.Literals([ + "closed", + "reopened", + "labeled", + "unlabeled", + "assigned", + "unassigned", + "renamed", + "referenced", + "state-changed", +]); +export type IssueEventKind = typeof IssueEventKind.Type; + +export const IssueEvent = Schema.Struct({ + id: TrimmedNonEmptyString, + kind: IssueEventKind, + actor: Schema.NullOr(IssueActor), + createdAt: IsoDateTime, + /** What the event was about, where it has a subject: a label name, a login, a new title. */ + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueEvent = typeof IssueEvent.Type; + +/** A change request the tracker says closes or mentions this issue. */ +export const IssueLinkedPullRequest = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: PullRequestState, + isDraft: Schema.Boolean, + /** Merging it closes this issue, rather than only citing it. */ + closesIssue: Schema.Boolean, +}); +export type IssueLinkedPullRequest = typeof IssueLinkedPullRequest.Type; + +/** + * What a tracker can do at all, so a surface never offers an action that would fail. The + * service refuses a call outside these before it reaches the driver. + */ +export const IssueCapabilities = Schema.Struct({ + create: Schema.Boolean, + /** Title and body can be rewritten after filing. */ + edit: Schema.Boolean, + comment: Schema.Boolean, + labels: Schema.Boolean, + assignees: Schema.Boolean, + /** Reactions can be read and written on the issue and its comments. */ + reactions: Schema.Boolean, + /** The scope offers starting points for a new issue. */ + templates: Schema.Boolean, + /** The tracker reports linked change requests of its own accord. */ + linkedPullRequests: Schema.Boolean, + /** State changes arrive as events beside the comments, not only as comments. */ + events: Schema.Boolean, + /** The tracker narrows a listing by free text itself. */ + search: Schema.Boolean, + /** + * The states this tracker can be asked to move an issue into, by name. A git host offers + * `open` and `closed`; a workflow tracker offers its own columns. Empty means state cannot be + * changed from here. + */ + states: Schema.Array(IssueState), + /** Reasons a close can carry. Empty means a close takes none. */ + closeReasons: Schema.Array(TrimmedNonEmptyString), +}); +export type IssueCapabilities = typeof IssueCapabilities.Type; + +/** + * What the signed-in account may do with this issue, which `capabilities` says nothing about. + * A permission the tracker reports nothing about is granted, so a refusal comes from the + * tracker, which says why, rather than from a missing control. + */ +export const IssueViewerPermissions = Schema.Struct({ + setState: Schema.Boolean, + comment: Schema.Boolean, + edit: Schema.Boolean, + labels: Schema.Boolean, + assignees: Schema.Boolean, +}); +export type IssueViewerPermissions = typeof IssueViewerPermissions.Type; + +export const IssueDetail = Schema.Struct({ + ...IssueSummary.fields, + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + /** False for an issue outside every project's scope, which is read-only through this server. */ + writable: Schema.Boolean, + capabilities: IssueCapabilities, + viewerPermissions: IssueViewerPermissions, + /** The signed-in account, so a comment edit is offered only on the viewer's own remarks. */ + viewer: Schema.NullOr(TrimmedNonEmptyString), + body: Schema.String, + reactions: Schema.optional(Schema.Array(IssueReaction)), + comments: Schema.Array(IssueComment), + /** Where older comments carry on; null once the conversation is whole. */ + commentsCursor: Schema.NullOr(TrimmedNonEmptyString), + events: Schema.Array(IssueEvent), + linkedPullRequests: Schema.Array(IssueLinkedPullRequest), +}); +export type IssueDetail = typeof IssueDetail.Type; + +/** Older comments, one page past a cursor the detail or a previous page handed over. */ +export const IssueCommentsPageInput = Schema.Struct({ + ...IssueRef.fields, + cursor: TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)), +}); +export type IssueCommentsPageInput = typeof IssueCommentsPageInput.Type; + +export const IssueCommentsPage = Schema.Struct({ + comments: Schema.Array(IssueComment), + nextCursor: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueCommentsPage = typeof IssueCommentsPage.Type; + +/** Where each scope a listing already reached carries on, keyed `" "`. */ +export const IssueListCursors = Schema.Record( + TrimmedNonEmptyString, + TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)), +); +export type IssueListCursors = typeof IssueListCursors.Type; + +export const IssueListInput = Schema.Struct({ + state: IssueListState, + involvement: Schema.optional(IssueInvolvement), + projectId: Schema.optional(ProjectId), + /** Narrows to one host, named as the host itself. Absent means every host the workspace has. */ + host: Schema.optional(TrimmedNonEmptyString), + /** Rows per scope, which with a continuation is rows per slice. */ + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 }))), + /** Carry on from an earlier answer: only the scopes named here are read, one slice each. */ + cursors: Schema.optional(IssueListCursors), + /** Free text the trackers themselves are asked to match. */ + query: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), +}); +export type IssueListInput = typeof IssueListInput.Type; + +/** One tracker account the workspace has projects on, and whether it can be read right now. */ +export const IssueTrackerSummary = Schema.Struct({ + tracker: IssueTrackerKind, + host: TrimmedNonEmptyString, + projectCount: PositiveInt, + capabilities: Schema.NullOr(IssueCapabilities), + /** False when the tracker's tool or credentials are missing, with `detail` saying which. */ + configured: Schema.Boolean, + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueTrackerSummary = typeof IssueTrackerSummary.Type; + +export const IssueListProjectError = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type IssueListProjectError = typeof IssueListProjectError.Type; + +export const IssueListResult = Schema.Struct({ + /** The signed-in account per host, which is what involvement compares against. */ + viewers: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), + trackers: Schema.Array(IssueTrackerSummary), + /** By update, newest first, across every scope this answer covers. */ + entries: Schema.Array(IssueListEntry), + errors: Schema.Array(IssueListProjectError), + /** Where each scope carries on, sent straight back as `cursors`. Absent once a scope is done. */ + nextCursors: IssueListCursors, +}); +export type IssueListResult = typeof IssueListResult.Type; + +const IssueTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +// Not trimmed: a body is markdown, where leading spaces open a code block. The bound keeps +// oversized payloads off the wire; the service rejects a comment that is only whitespace. +const IssueBody = Schema.String.check(Schema.isMaxLength(65_536)); +const CommentBody = Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)); + +export const IssueCreateInput = Schema.Struct({ + ...IssueScopeRef.fields, + title: IssueTitle, + /** May be empty: an issue with a title and nothing else is a legitimate one. */ + body: IssueBody, + labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(50)), + assignees: Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(25)), +}); +export type IssueCreateInput = typeof IssueCreateInput.Type; + +export const IssueCreateResult = Schema.Struct({ + key: TrimmedNonEmptyString, + displayKey: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, +}); +export type IssueCreateResult = typeof IssueCreateResult.Type; + +export const IssueCommentInput = Schema.Struct({ + ...IssueRef.fields, + body: CommentBody, +}); +export type IssueCommentInput = typeof IssueCommentInput.Type; + +/** + * Rewrites the issue's own fields. Every field is optional and only the present ones are + * written; `labels` and `assignees` are the whole set, which is how every tracker writes them. + * The service refuses a request with nothing present. + */ +export const IssueUpdateInput = Schema.Struct({ + ...IssueRef.fields, + title: Schema.optional(IssueTitle), + body: Schema.optional(IssueBody), + labels: Schema.optional(Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(50))), + assignees: Schema.optional(Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(25))), +}); +export type IssueUpdateInput = typeof IssueUpdateInput.Type; + +/** Moves the issue to one of the states the tracker declared in `capabilities.states`. */ +export const IssueSetStateInput = Schema.Struct({ + ...IssueRef.fields, + state: TrimmedNonEmptyString, + /** Only meaningful for a closing state on a tracker that declared `closeReasons`. */ + closeReason: Schema.optional(TrimmedNonEmptyString), +}); +export type IssueSetStateInput = typeof IssueSetStateInput.Type; + +export const IssueReactionInput = Schema.Struct({ + ...IssueRef.fields, + /** Absent reacts to the issue itself; present names a comment id as the detail carried it. */ + subjectId: Schema.optional(TrimmedNonEmptyString), + content: IssueReactionContent, + reacted: Schema.Boolean, +}); +export type IssueReactionInput = typeof IssueReactionInput.Type; + +/** + * One starting point a scope offers for a new issue. Only the body-shaped kind travels here: + * GitHub's typed issue forms are flattened to a body by the driver, so a composer on any + * tracker is one text box with a draft in it. + */ +export const IssueTemplate = Schema.Struct({ + key: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + about: Schema.String, + title: Schema.String, + body: Schema.String, + labels: Schema.Array(TrimmedNonEmptyString), + assignees: Schema.Array(TrimmedNonEmptyString), +}); +export type IssueTemplate = typeof IssueTemplate.Type; + +export const IssueTemplateList = Schema.Struct({ + templates: Schema.Array(IssueTemplate), + /** False where the scope's config forbids an issue filed from nothing. */ + blankIssuesEnabled: Schema.Boolean, +}); +export type IssueTemplateList = typeof IssueTemplateList.Type; + +export const IssueLabelCandidate = Schema.Struct({ + ...IssueLabel.fields, + description: Schema.NullOr(Schema.String), +}); +export type IssueLabelCandidate = typeof IssueLabelCandidate.Type; + +export const IssueAssigneeCandidate = Schema.Struct({ + ...IssueActor.fields, + /** How the tracker addresses this person on a write, which is not always the login. */ + id: TrimmedNonEmptyString, +}); +export type IssueAssigneeCandidate = typeof IssueAssigneeCandidate.Type; + +/** The labels and people a scope offers, read when a picker opens rather than with every issue. */ +export const IssueCandidates = Schema.Struct({ + labels: Schema.Array(IssueLabelCandidate), + assignees: Schema.Array(IssueAssigneeCandidate), + /** The tracker has more than were read, so somebody missing is not necessarily ineligible. */ + truncated: Schema.Boolean, +}); +export type IssueCandidates = typeof IssueCandidates.Type; + +export const IssueUnavailableReason = Schema.Literals([ + "tool-missing", + "unauthenticated", + "tracker-unsupported", +]); +export type IssueUnavailableReason = typeof IssueUnavailableReason.Type; + +/** The tracker cannot be read at all for this project, as opposed to one request failing. */ +export class IssueUnavailableError extends Schema.TaggedError()( + "IssueUnavailableError", + { + reason: IssueUnavailableReason, + tracker: Schema.optional(IssueTrackerKind), + cause: Schema.optional(Schema.Defect()), + }, + { httpApiStatus: 503 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(IssueUnavailableError)(this, { status: 503 }); + } + + override get message(): string { + switch (this.reason) { + case "tool-missing": + return "The tool this tracker is read through is not installed or set up."; + case "unauthenticated": + return "This tracker has no working credentials."; + case "tracker-unsupported": + return "Issues cannot be browsed for this project yet."; + } + } +} + +export class IssueOperationError extends Schema.TaggedError()( + "IssueOperationError", + { + operation: Schema.String, + detail: TrimmedNonEmptyString, + reason: Schema.optional(Schema.Literals(["not-found", "read-only", "rate-limited"])), + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, + { httpApiStatus: 502 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(IssueOperationError)(this, { status: 502 }); + } + + override get message(): string { + return `Issue operation ${this.operation} failed: ${this.detail}`; + } +} + +export type IssueError = IssueUnavailableError | IssueOperationError; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 61304c52f860..2ccd7a6437b5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -161,6 +161,25 @@ import { ProviderUploadFeedbackResult, } from "./provider.ts"; import { ProviderInstanceId, ProviderInstanceMutation } from "./providerInstance.ts"; +import { + IssueCandidates, + IssueCommentInput, + IssueCommentsPage, + IssueCommentsPageInput, + IssueCreateInput, + IssueCreateResult, + IssueDetail, + IssueListInput, + IssueListResult, + IssueOperationError, + IssueReactionInput, + IssueRef, + IssueScopeRef, + IssueSetStateInput, + IssueTemplateList, + IssueUnavailableError, + IssueUpdateInput, +} from "./issue.ts"; import { PullRequestReviewGuideResult } from "./pullRequestReviewGuide.ts"; import { PullRequestActionInput, @@ -561,6 +580,18 @@ export const WS_METHODS = { pullRequestsLabelCandidates: "pullRequests.labelCandidates", pullRequestsSetLabels: "pullRequests.setLabels", + // Issue tracking methods + issuesList: "issues.list", + issuesDetail: "issues.detail", + issuesComments: "issues.comments", + issuesTemplates: "issues.templates", + issuesCandidates: "issues.candidates", + issuesCreate: "issues.create", + issuesComment: "issues.comment", + issuesUpdate: "issues.update", + issuesSetState: "issues.setState", + issuesReact: "issues.react", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -1160,6 +1191,74 @@ const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { error: PullRequestRpcError, }); +const IssueRpcError = Schema.Union([ + IssueUnavailableError, + IssueOperationError, + EnvironmentAuthorizationError, +]); + +const WsIssuesListRpc = Rpc.make(WS_METHODS.issuesList, { + payload: IssueListInput, + success: IssueListResult, + error: IssueRpcError, +}); + +const WsIssuesDetailRpc = Rpc.make(WS_METHODS.issuesDetail, { + payload: IssueRef, + success: IssueDetail, + error: IssueRpcError, +}); + +/** Older comments, one page at a time, past the cursor the detail handed over. */ +const WsIssuesCommentsRpc = Rpc.make(WS_METHODS.issuesComments, { + payload: IssueCommentsPageInput, + success: IssueCommentsPage, + error: IssueRpcError, +}); + +const WsIssuesTemplatesRpc = Rpc.make(WS_METHODS.issuesTemplates, { + payload: IssueScopeRef, + success: IssueTemplateList, + error: IssueRpcError, +}); + +/** Read when a label or assignee picker opens, not with every issue. */ +const WsIssuesCandidatesRpc = Rpc.make(WS_METHODS.issuesCandidates, { + payload: IssueScopeRef, + success: IssueCandidates, + error: IssueRpcError, +}); + +const WsIssuesCreateRpc = Rpc.make(WS_METHODS.issuesCreate, { + payload: IssueCreateInput, + success: IssueCreateResult, + error: IssueRpcError, +}); + +const WsIssuesCommentRpc = Rpc.make(WS_METHODS.issuesComment, { + payload: IssueCommentInput, + success: Schema.Void, + error: IssueRpcError, +}); + +const WsIssuesUpdateRpc = Rpc.make(WS_METHODS.issuesUpdate, { + payload: IssueUpdateInput, + success: Schema.Void, + error: IssueRpcError, +}); + +const WsIssuesSetStateRpc = Rpc.make(WS_METHODS.issuesSetState, { + payload: IssueSetStateInput, + success: Schema.Void, + error: IssueRpcError, +}); + +const WsIssuesReactRpc = Rpc.make(WS_METHODS.issuesReact, { + payload: IssueReactionInput, + success: Schema.Void, + error: IssueRpcError, +}); + const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLookupRepository, { payload: SourceControlRepositoryLookupInput, success: SourceControlRepositoryInfo, @@ -1973,6 +2072,16 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsRequestReviewersRpc, WsPullRequestsLabelCandidatesRpc, WsPullRequestsSetLabelsRpc, + WsIssuesListRpc, + WsIssuesDetailRpc, + WsIssuesCommentsRpc, + WsIssuesTemplatesRpc, + WsIssuesCandidatesRpc, + WsIssuesCreateRpc, + WsIssuesCommentRpc, + WsIssuesUpdateRpc, + WsIssuesSetStateRpc, + WsIssuesReactRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, diff --git a/packages/issue-tracker-core/package.json b/packages/issue-tracker-core/package.json new file mode 100644 index 000000000000..334d65ac3aaa --- /dev/null +++ b/packages/issue-tracker-core/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/issue-tracker-core", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/IssueTracker": { + "types": "./src/server/IssueTracker.ts", + "import": "./src/server/IssueTracker.ts" + } + }, + "scripts": { + "typecheck": "tsc-rs --noEmit" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/issue-tracker-core/src/server/IssueTracker.ts b/packages/issue-tracker-core/src/server/IssueTracker.ts new file mode 100644 index 000000000000..d376a01c45dd --- /dev/null +++ b/packages/issue-tracker-core/src/server/IssueTracker.ts @@ -0,0 +1,190 @@ +/** + * The port every issue tracker implements. Implementations own their own tool and JSON shapes + * and hand back the neutral contract types; anything a tracker cannot do is declared in + * `capabilities` rather than failing at call time, and the service refuses a call outside them + * before it gets here. + * + * @module issue-tracker-core/server/IssueTracker + */ +import * as Schema from "effect/Schema"; +import type * as Effect from "effect/Effect"; +import type { + IssueCandidates, + IssueCapabilities, + IssueComment, + IssueCommentsPage, + IssueCreateResult, + IssueEvent, + IssueInvolvement, + IssueLinkedPullRequest, + IssueListState, + IssueReaction, + IssueReactionContent, + IssueSummary, + IssueTemplateList, + IssueTrackerKind, + IssueViewerPermissions, +} from "@t3tools/contracts"; +import { IssueTrackerKind as IssueTrackerKindSchema } from "@t3tools/contracts"; + +/** + * The one failure shape every tracker reports, so the service can decide what a failure means + * without knowing which CLI or API produced it. A missing or unauthenticated tool disables the + * tracker for the whole workspace; everything else is specific to the request. + */ +export class IssueTrackerError extends Schema.TaggedError()( + "IssueTrackerError", + { + tracker: IssueTrackerKindSchema, + operation: Schema.String, + reason: Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", + ]), + detail: Schema.String, + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.tracker} failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * Where a tracker's issues are addressed from. `cwd` is a checkout whose credentials serve the + * read; `host` is where the tracker is served from (`github.com`, a GHE install, `linear.app`); + * `scope` is the tracker's own container — a repository path, a team or project key. + */ +export interface IssueScopeTarget { + readonly cwd: string; + readonly host: string; + readonly scope: string; +} + +export interface IssueTarget extends IssueScopeTarget { + /** The issue's native id within the scope: `"123"` on GitHub, `"ENG-123"` on Linear. */ + readonly key: string; +} + +/** An issue as the tracker sees it, before the service attaches project context. */ +export type TrackerIssueSummary = Omit; + +export interface TrackerIssuePage { + readonly items: ReadonlyArray; + /** Where the next slice starts, or null once the tracker has nothing more. Opaque. */ + readonly nextCursor: string | null; +} + +export interface TrackerIssueDetail extends TrackerIssueSummary { + readonly body: string; + readonly viewer: string | null; + readonly viewerPermissions: IssueViewerPermissions; + readonly reactions?: ReadonlyArray; + readonly comments: ReadonlyArray; + /** Where older comments carry on; null once the conversation is whole. */ + readonly commentsCursor: string | null; + readonly events: ReadonlyArray; + readonly linkedPullRequests: ReadonlyArray; +} + +export interface IssueTrackerApi { + readonly kind: IssueTrackerKind; + readonly capabilities: IssueCapabilities; + + /** The signed-in account, which is what involvement filtering compares against. */ + readonly getViewer: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect; + + readonly listIssues: ( + input: IssueScopeTarget & { + readonly state: IssueListState; + readonly involvement: IssueInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text as the tracker understands it; ignored by a tracker without `search`. */ + readonly query?: string | undefined; + /** A cursor this tracker handed back from an earlier page. Absent asks for the first. */ + readonly cursor?: string | undefined; + }, + ) => Effect.Effect; + + /** The issue with its first page of conversation, in one read where the tracker allows. */ + readonly getIssue: (input: IssueTarget) => Effect.Effect; + + /** One page of older comments past a cursor `getIssue` or a previous page handed over. */ + readonly getComments: ( + input: IssueTarget & { readonly cursor: string }, + ) => Effect.Effect; + + /** + * The same permissions `getIssue` carries, asked freshly before a write so access taken away + * since the page loaded is what decides. A tracker with nothing to say makes no request. + */ + readonly getViewerPermissions: ( + input: IssueTarget, + ) => Effect.Effect; + + /** Only called when `capabilities.create` is true. */ + readonly create?: ( + input: IssueScopeTarget & { + readonly title: string; + readonly body: string; + readonly labels: ReadonlyArray; + readonly assignees: ReadonlyArray; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.comment` is true. */ + readonly comment?: ( + input: IssueTarget & { readonly body: string }, + ) => Effect.Effect; + + /** + * Rewrites the present fields. Only called with a field the tracker declared it can write + * (`edit` for title and body, `labels`, `assignees`), and never with all of them absent. + */ + readonly update?: ( + input: IssueTarget & { + readonly title?: string | undefined; + readonly body?: string | undefined; + readonly labels?: ReadonlyArray | undefined; + readonly assignees?: ReadonlyArray | undefined; + }, + ) => Effect.Effect; + + /** Only called with a state name from `capabilities.states`. */ + readonly setState?: ( + input: IssueTarget & { + readonly state: string; + readonly closeReason?: string | undefined; + }, + ) => Effect.Effect; + + /** + * Adds a reaction or takes it back. `subjectId` is a comment id as the detail carried it; + * absent means the issue itself. Only called when `capabilities.reactions` is true. + */ + readonly setReaction?: ( + input: IssueTarget & { + readonly subjectId?: string | undefined; + readonly content: IssueReactionContent; + readonly reacted: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.templates` is true. */ + readonly listTemplates?: ( + input: IssueScopeTarget, + ) => Effect.Effect; + + /** Only called when `capabilities.labels` or `capabilities.assignees` is true. */ + readonly listCandidates?: ( + input: IssueScopeTarget, + ) => Effect.Effect; +} diff --git a/packages/issue-tracker-core/src/server/driver.ts b/packages/issue-tracker-core/src/server/driver.ts new file mode 100644 index 000000000000..6579b8092a07 --- /dev/null +++ b/packages/issue-tracker-core/src/server/driver.ts @@ -0,0 +1,26 @@ +/** + * IssueTrackerDriver — what an issue tracker package hands the server. + * + * One driver per tracker kind. The server lists its drivers once and the registry iterates that + * list, so adding a tracker means writing its package and adding its driver to the list. Git + * hosts ship their driver inside their source control package; a standalone tracker (Linear, + * Jira) ships its own. + * + * `make` yields what it needs from the environment, and its `R` channel is the full list the + * server must provide. + * + * @module issue-tracker-core/server/driver + */ +import type { IssueTrackerKind } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; + +import type { IssueTrackerApi } from "./IssueTracker.ts"; + +export interface IssueTrackerDriver { + readonly kind: IssueTrackerKind; + readonly make: Effect.Effect; +} + +/** Declares a driver with its `R` inferred from `make`. */ +export const defineIssueTrackerDriver = (driver: IssueTrackerDriver): IssueTrackerDriver => + driver; diff --git a/packages/issue-tracker-core/tsconfig.json b/packages/issue-tracker-core/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/issue-tracker-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json index d7be6f20ce15..4bdbd28fb892 100644 --- a/packages/source-control-github/package.json +++ b/packages/source-control-github/package.json @@ -31,6 +31,14 @@ "types": "./src/server/GitHubCredentials.ts", "import": "./src/server/GitHubCredentials.ts" }, + "./server/GitHubIssueTracker": { + "types": "./src/server/GitHubIssueTracker.ts", + "import": "./src/server/GitHubIssueTracker.ts" + }, + "./server/gitHubIssueJson": { + "types": "./src/server/gitHubIssueJson.ts", + "import": "./src/server/gitHubIssueJson.ts" + }, "./server/githubGraphQl": { "types": "./src/server/githubGraphQl.ts", "import": "./src/server/githubGraphQl.ts" @@ -70,6 +78,10 @@ "./server/githubStackRebase": { "types": "./src/server/githubStackRebase.ts", "import": "./src/server/githubStackRebase.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" } }, "scripts": { @@ -78,6 +90,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts new file mode 100644 index 000000000000..963caba36623 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubIssueTracker from "./GitHubIssueTracker.ts"; + +/** A detail answer as GitHub's GraphQL API shapes it, with every section populated once. */ +const DETAIL_RESPONSE = JSON.stringify({ + data: { + viewer: { login: "julius" }, + repository: { + viewerPermission: "TRIAGE", + issue: { + number: 42, + title: "Composer loses focus", + url: "https://github.com/acme/web/issues/42", + author: { login: "octocat", avatarUrl: "https://avatars/octocat", name: "Octo Cat" }, + state: "CLOSED", + stateReason: "NOT_PLANNED", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z", + closedAt: "2026-07-03T00:00:00Z", + comments: { + totalCount: 3, + pageInfo: { hasPreviousPage: true, startCursor: "Y3Vyc29y" }, + nodes: [ + { + id: "IC_1", + author: { login: "julius", avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00Z", + url: "https://github.com/acme/web/issues/42#issuecomment-1", + reactionGroups: [ + { + content: "THUMBS_UP", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "theo" }] }, + }, + { + content: "EYES", + viewerHasReacted: false, + reactors: { totalCount: 0, nodes: [] }, + }, + ], + }, + ], + }, + assignees: { nodes: [{ login: "theo", name: null, avatarUrl: null }] }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }, null] }, + body: "Typing in the composer drops focus after a paste.", + viewerCanUpdate: false, + reactionGroups: [], + closedByPullRequestsReferences: { + nodes: [ + { + number: 50, + title: "fix(web): keep composer focus", + url: "https://github.com/acme/web/pull/50", + state: "MERGED", + isDraft: false, + repository: { nameWithOwner: "acme/web" }, + }, + ], + }, + timelineItems: { + nodes: [ + { + __typename: "LabeledEvent", + id: "LE_1", + createdAt: "2026-07-01T01:00:00Z", + actor: { login: "theo" }, + label: { name: "bug" }, + }, + { + __typename: "CrossReferencedEvent", + id: "CRE_1", + createdAt: "2026-07-02T01:00:00Z", + actor: { login: "julius" }, + source: { + __typename: "PullRequest", + number: 50, + title: "fix(web): keep composer focus", + url: "https://github.com/acme/web/pull/50", + state: "MERGED", + isDraft: false, + repository: { nameWithOwner: "acme/web" }, + }, + }, + { __typename: "MentionedEvent", id: "ME_1", createdAt: "2026-07-02T02:00:00Z" }, + { + __typename: "ClosedEvent", + id: "CE_1", + createdAt: "2026-07-03T00:00:00Z", + actor: null, + }, + ], + }, + }, + }, + }, +}); + +function tracker(graphql: (query: string, variables: unknown) => string) { + return GitHubIssueTracker.make.pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + graphql: ({ query, variables }) => Effect.succeed(graphql(query, variables)), + }), + ), + ); +} + +describe("GitHubIssueTracker", () => { + it.effect("maps a detail read to the neutral issue shape", () => + Effect.gen(function* () { + let seen: unknown; + const api = yield* tracker((_query, variables) => { + seen = variables; + return DETAIL_RESPONSE; + }); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "github.com", + scope: "acme/web", + key: "42", + }); + expect(seen).toEqual({ owner: "acme", name: "web", number: 42 }); + expect(issue).toMatchObject({ + key: "42", + displayKey: "#42", + title: "Composer loses focus", + state: { category: "closed", name: "closed", closeReason: "not-planned" }, + author: { login: "octocat", name: "Octo Cat", avatarUrl: "https://avatars/octocat" }, + assignees: [{ login: "theo" }], + labels: [{ name: "bug", color: "d73a4a" }], + commentCount: 3, + viewer: "julius", + // Triage may label and assign; `viewerCanUpdate: false` withholds state and edits. + viewerPermissions: { + setState: false, + edit: false, + labels: true, + assignees: true, + comment: true, + }, + commentsCursor: "Y3Vyc29y", + }); + // The viewer is left out of `actors` and shown as "You"; an empty group is dropped. + expect(issue.comments[0]?.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["theo"], viewerHasReacted: true }, + ]); + // The closing reference wins over the cross-reference sighting of the same pull request. + expect(issue.linkedPullRequests).toEqual([ + { + repository: "acme/web", + number: 50, + title: "fix(web): keep composer focus", + url: "https://github.com/acme/web/pull/50", + state: "merged", + isDraft: false, + closesIssue: true, + }, + ]); + // An event kind this page has no vocabulary for is dropped rather than guessed at. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["labeled", "bug"], + ["referenced", "acme/web#50"], + ["closed", null], + ]); + }), + ); + + it.effect("refuses a key that names no GitHub issue number without a request", () => + Effect.gen(function* () { + let requests = 0; + const api = yield* tracker(() => { + requests += 1; + return DETAIL_RESPONSE; + }); + const error = yield* Effect.flip( + api.getIssue({ cwd: "/w", host: "github.com", scope: "acme/web", key: "ENG-42" }), + ); + expect(error.reason).toBe("not-found"); + expect(requests).toBe(0); + }), + ); +}); diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts new file mode 100644 index 000000000000..1f18d4ecc200 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -0,0 +1,504 @@ +/** + * GitHub's issue tracker, over the same `GitHubApi` the pull request reads use: one credential, + * one quota reserve, one rate-limit pause per host. + * + * Reads go through GraphQL, which is the only API that answers the viewer's standing, the + * timeline and reactions. Writes to the issue's own fields go through REST `PATCH`, which takes + * the whole label and assignee sets in one call, the way the port asks for them. + * + * @module source-control-github/server/GitHubIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as GitHubApi from "./GitHubApi.ts"; +import { + ADD_REACTION_GRAPHQL_MUTATION, + gitHubReactionContent, + REMOVE_REACTION_GRAPHQL_MUTATION, +} from "./gitHubPullRequestJson.ts"; +import { + decodeCreatedIssueJson, + decodeIssueCandidatesJson, + decodeIssueCommentScopeJson, + decodeIssueCommentsJson, + decodeIssueDetailJson, + decodeIssueNodeIdJson, + decodeIssueSearchJson, + decodeIssueTemplatesJson, + decodeIssueViewerPermissionsJson, + decodeViewerLoginJson, + ISSUE_CANDIDATES_GRAPHQL_QUERY, + ISSUE_COMMENT_SCOPE_GRAPHQL_QUERY, + ISSUE_COMMENTS_GRAPHQL_QUERY, + ISSUE_DETAIL_GRAPHQL_QUERY, + ISSUE_NODE_ID_GRAPHQL_QUERY, + ISSUE_TEMPLATES_GRAPHQL_QUERY, + ISSUE_VIEWER_PERMISSIONS_GRAPHQL_QUERY, + issueSearchGraphQlQuery, + SEARCH_MAX_ROWS, +} from "./gitHubIssueJson.ts"; + +const KIND = IssueTrackerKind.make("github"); + +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: true, + templates: true, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + closeReasons: ["completed", "not-planned"], +}; + +/** Where a repository configures the rest of its issue chooser, as GitHub spells the path. */ +const TEMPLATE_CONFIG_PATH = ".github/ISSUE_TEMPLATE/config.yml"; + +/** What a repository selector may hold before it goes into a search query as itself. */ +const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** The GraphQL and REST APIs take owner and name apart, so `owner/repo` is split here. */ +function parseRepository(scope: string): { readonly owner: string; readonly name: string } { + const parts = scope.trim().split("/").filter(Boolean); + return { owner: parts.at(-2) ?? "", name: parts.at(-1) ?? "" }; +} + +/** An issue key is its number; anything else names no GitHub issue. */ +function parseNumber(key: string): number | null { + return /^[1-9]\d*$/.test(key) ? Number(key) : null; +} + +/** + * The reader's words as one literal phrase of a search. Outside quotes GitHub reads `is:closed` + * as a qualifier, so text typed into a box could widen the listing it narrows; inside them it is + * only text. The two characters that could end the phrase early are escaped. + */ +function searchPhrase(query: string): string { + return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +const INVOLVEMENT_QUALIFIER: Record = { + all: null, + assigned: "assignee", + authored: "author", + mentioned: "mentions", +}; + +function searchQuery(input: { + readonly scope: string; + readonly state: "all" | "open" | "closed"; + readonly involvement: IssueInvolvement; + readonly viewer: string; + readonly query?: string | undefined; +}): string { + const text = input.query?.trim() ?? ""; + const qualifier = INVOLVEMENT_QUALIFIER[input.involvement]; + return [ + // The search index holds pull requests as issues; `is:issue` keeps them out. + "is:issue", + `repo:${input.scope.trim()}`, + ...(input.state === "all" ? [] : [`is:${input.state}`]), + ...(qualifier === null ? [] : [`${qualifier}:${input.viewer}`]), + "sort:updated-desc", + ...(text.length === 0 ? [] : [searchPhrase(text)]), + ].join(" "); +} + +/** The REST close reasons, which the capability names in the contract's own spelling. */ +const CLOSE_REASON: Record = { + completed: "completed", + "not-planned": "not_planned", +}; + +/** The tags that mean GitHub is out of reach for this account rather than one request failing. */ +function reasonFor(error: GitHubApi.GitHubApiError): IssueTrackerError["reason"] { + switch (error._tag) { + case "GitHubCliMissingError": + return "missing-tool"; + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + case "GitHubApiAuthenticationError": + return "unauthenticated"; + case "GitHubApiRateLimitError": + case "SourceControlRateLimitPausedError": + return "rate-limited"; + case "GitHubApiNotFoundError": + return "not-found"; + default: + return "failed"; + } +} + +export const make: Effect.Effect = Effect.gen( + function* () { + const api = yield* GitHubApi.GitHubApi; + + const fail = (operation: string) => (error: GitHubApi.GitHubApiError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.message, + ...("retryAt" in error && error.retryAt !== undefined ? { retryAt: error.retryAt } : {}), + cause: error, + }); + + const unreadable = (operation: string, cause: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail: `GitHub returned an unreadable ${operation} response.`, + cause, + }); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "GitHub could not find the requested issue.", + }); + + /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ + const graphqlRead = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables?: Readonly>; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .graphql({ + host: input.host, + operation: input.operation, + query: input.query, + ...(input.variables === undefined ? {} : { variables: input.variables }), + }) + .pipe( + Effect.mapError(fail(input.operation)), + Effect.flatMap((raw) => { + const decoded = input.decode(raw.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(unreadable(input.operation, decoded.failure)); + }), + ); + + const mutate = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables: Readonly>; + }) => api.graphql(input).pipe(Effect.asVoid, Effect.mapError(fail(input.operation))); + + /** The issue as REST addresses it, or a not-found for a key that names no number. */ + const restIssue = ( + input: { readonly scope: string; readonly key: string }, + operation: string, + ): Effect.Effect< + { + readonly owner: string; + readonly name: string; + readonly number: number; + readonly path: string; + }, + IssueTrackerError + > => { + const number = parseNumber(input.key); + const { owner, name } = parseRepository(input.scope); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ owner, name, number, path: `repos/${owner}/${name}/issues/${number}` }); + }; + + /** Every write to an issue's own fields is one `PATCH`; only its body differs. */ + const patchIssue = ( + input: { readonly host: string; readonly scope: string; readonly key: string }, + operation: string, + body: Record, + ) => + restIssue(input, operation).pipe( + Effect.flatMap(({ path }) => + api + .rest({ host: input.host, operation, method: "PATCH", path, body }) + .pipe(Effect.asVoid, Effect.mapError(fail(operation))), + ), + ); + + /** + * A client-given comment id is trusted to be whatever node it names, which can hang off any + * issue on the host; the mutation would write wherever it belongs unless this agrees first. + */ + const commentBelongsToIssue = (input: { + readonly host: string; + readonly scope: string; + readonly key: string; + readonly commentId: string; + readonly operation: string; + }) => + restIssue(input, input.operation).pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: input.operation, + query: ISSUE_COMMENT_SCOPE_GRAPHQL_QUERY, + variables: { owner, name, number, commentId: input.commentId }, + decode: decodeIssueCommentScopeJson, + }), + ), + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.commentId) + : Effect.fail( + new IssueTrackerError({ + tracker: KIND, + operation: input.operation, + reason: "failed", + detail: "The comment does not belong to the selected issue.", + }), + ), + ), + ); + + return { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => + graphqlRead({ + host: input.host, + operation: "getViewer", + query: "query { viewer { login } }", + decode: decodeViewerLoginJson, + }), + + listIssues: (input) => { + if (!SEARCH_REPOSITORY.test(input.scope.trim())) { + // A name holding a space could end the `repo:` qualifier and start one of its own, so + // an unaddressable one refuses the read rather than being escaped into something GitHub + // might still read. + return Effect.fail( + new IssueTrackerError({ + tracker: KIND, + operation: "listIssues", + reason: "failed", + detail: "A repository was named that GitHub cannot address.", + }), + ); + } + return graphqlRead({ + host: input.host, + operation: "listIssues", + query: issueSearchGraphQlQuery(Math.min(input.limit, SEARCH_MAX_ROWS)), + variables: + input.cursor === undefined + ? { q: searchQuery(input) } + : { q: searchQuery(input), cursor: input.cursor }, + decode: decodeIssueSearchJson, + }); + }, + + getIssue: (input) => + restIssue(input, "getIssue").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "getIssue", + query: ISSUE_DETAIL_GRAPHQL_QUERY, + variables: { owner, name, number }, + decode: decodeIssueDetailJson, + }), + ), + Effect.filterOrFail( + (detail) => detail !== null, + () => notFound("getIssue"), + ), + ), + + getComments: (input) => + restIssue(input, "getComments").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "getComments", + query: ISSUE_COMMENTS_GRAPHQL_QUERY, + variables: { owner, name, number, cursor: input.cursor }, + decode: decodeIssueCommentsJson, + }), + ), + ), + + getViewerPermissions: (input) => + restIssue(input, "getViewerPermissions").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "getViewerPermissions", + query: ISSUE_VIEWER_PERMISSIONS_GRAPHQL_QUERY, + variables: { owner, name, number }, + decode: decodeIssueViewerPermissionsJson, + }), + ), + ), + + create: (input) => { + const { owner, name } = parseRepository(input.scope); + return api + .rest({ + host: input.host, + operation: "create", + method: "POST", + path: `repos/${owner}/${name}/issues`, + body: { + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }, + }) + .pipe( + Effect.mapError(fail("create")), + Effect.flatMap((response) => { + const decoded = decodeCreatedIssueJson(response.body); + return Result.isSuccess(decoded) + ? Effect.succeed({ + key: String(decoded.success.number), + displayKey: `#${decoded.success.number}`, + url: decoded.success.url, + }) + : Effect.fail(unreadable("create", decoded.failure)); + }), + ); + }, + + comment: (input) => + restIssue(input, "comment").pipe( + Effect.flatMap(({ path }) => + api + .rest({ + host: input.host, + operation: "comment", + method: "POST", + path: `${path}/comments`, + body: { body: input.body }, + }) + .pipe(Effect.asVoid, Effect.mapError(fail("comment"))), + ), + ), + + update: (input) => + patchIssue(input, "update", { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.labels === undefined ? {} : { labels: input.labels }), + ...(input.assignees === undefined ? {} : { assignees: input.assignees }), + }), + + setState: (input) => + patchIssue(input, "setState", { + state: input.state, + ...(input.state === "closed" && input.closeReason !== undefined + ? { state_reason: CLOSE_REASON[input.closeReason] } + : {}), + }), + + setReaction: (input) => + (input.subjectId === undefined + ? restIssue(input, "setReaction").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "setReaction", + query: ISSUE_NODE_ID_GRAPHQL_QUERY, + variables: { owner, name, number }, + decode: decodeIssueNodeIdJson, + }), + ), + ) + : commentBelongsToIssue({ + ...input, + commentId: input.subjectId, + operation: "setReaction", + }) + ).pipe( + Effect.flatMap((subjectId) => + mutate({ + host: input.host, + operation: "setReaction", + query: input.reacted + ? ADD_REACTION_GRAPHQL_MUTATION + : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId, content: gitHubReactionContent(input.content) }, + }), + ), + ), + + listTemplates: (input) => { + const { owner, name } = parseRepository(input.scope); + return Effect.all( + [ + graphqlRead({ + host: input.host, + operation: "listTemplates", + query: ISSUE_TEMPLATES_GRAPHQL_QUERY, + variables: { owner, name }, + decode: decodeIssueTemplatesJson, + }), + // Only an explicit `blank_issues_enabled: false` takes the blank issue away; a missing + // config, or one this account may not read, leaves it where GitHub puts it. + api + .rest({ + host: input.host, + operation: "listTemplateConfig", + accept: "application/vnd.github.raw", + path: `repos/${owner}/${name}/contents/${TEMPLATE_CONFIG_PATH}`, + }) + .pipe( + Effect.map( + (response) => !/^\s*blank_issues_enabled\s*:\s*false\s*$/m.test(response.body), + ), + Effect.catch((error) => + error._tag === "GitHubApiRateLimitError" || + error._tag === "SourceControlRateLimitPausedError" + ? Effect.fail(fail("listTemplateConfig")(error)) + : Effect.succeed(true), + ), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([templates, blankIssuesEnabled]) => ({ templates, blankIssuesEnabled })), + ); + }, + + listCandidates: (input) => { + const { owner, name } = parseRepository(input.scope); + return graphqlRead({ + host: input.host, + operation: "listCandidates", + query: ISSUE_CANDIDATES_GRAPHQL_QUERY, + variables: { owner, name }, + decode: decodeIssueCandidatesJson, + }); + }, + }; + }, +); diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts new file mode 100644 index 000000000000..3504da2db960 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -0,0 +1,736 @@ +/** + * GraphQL documents and decoders for GitHub issues. Enum-ish fields are decoded as plain strings + * and normalized here, so a schema addition GitHub ships never fails a whole payload. + * + * @module source-control-github/server/gitHubIssueJson + */ +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + IssueActor, + IssueComment, + IssueEvent, + IssueEventKind, + IssueLabel, + IssueLinkedPullRequest, + IssueState, + IssueTemplate, + IssueViewerPermissions, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import { + REACTION_GROUPS_FIELDS, + RawReactionGroupsSchema, + toReactions, +} from "./gitHubPullRequestJson.ts"; + +/** GitHub's ceiling on a connection page, which is what every bounded read below asks for. */ +const PAGE_SIZE = 100; +/** GitHub refuses a search page larger than this with EXCESSIVE_PAGINATION. */ +export const SEARCH_MAX_ROWS = PAGE_SIZE; + +const RawActorSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPageInfoSchema = Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + hasPreviousPage: Schema.optional(Schema.Boolean), + startCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const nodes = (schema: S) => + Schema.optional( + Schema.NullOr( + Schema.Struct({ nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(schema)))) }), + ), + ); + +/** One issue row as both the search and the detail read answer with it. */ +const RawIssueSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), + /** The empty string on an open issue, so it is normalized rather than mapped. */ + stateReason: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + updatedAt: Schema.String, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + comments: Schema.optional( + Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), + ), + assignees: nodes(RawActorSchema), + labels: nodes(RawLabelSchema), +}); + +const ISSUE_ROW_FIELDS = `number title url state stateReason createdAt updatedAt closedAt + author { login avatarUrl ... on User { name } } + comments { totalCount } + assignees(first: 20) { nodes { login name avatarUrl } } + labels(first: 20) { nodes { name color } }`; + +/** + * One repository's issues as a search, which is the only read that takes free text and an + * updated-desc order with a cursor. `is:issue` keeps pull requests out of the shared index. + */ +export function issueSearchGraphQlQuery(rows: number): string { + return `query($q: String!, $cursor: String) { + search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), SEARCH_MAX_ROWS)}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { ... on Issue { ${ISSUE_ROW_FIELDS} } } + } +}`; +} + +const RawSearchSchema = Schema.Struct({ + data: Schema.Struct({ + search: Schema.Struct({ + pageInfo: Schema.optional(Schema.NullOr(RawPageInfoSchema)), + // Row by row, so a node that is not an issue is skipped rather than blanking the page. + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + }), +}); + +const RawReferenceSchema = Schema.Struct({ + __typename: Schema.optional(Schema.NullOr(Schema.String)), + number: Schema.optional(Schema.NullOr(Schema.Int)), + title: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + repository: Schema.optional( + Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +/** Every timeline union member as one flat shape; `__typename` decides which fields were meant. */ +const RawTimelineItemSchema = Schema.Struct({ + __typename: Schema.String, + id: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.NullOr(Schema.String)), + actor: Schema.optional(Schema.NullOr(RawActorSchema)), + label: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + assignee: Schema.optional(Schema.NullOr(RawActorSchema)), + currentTitle: Schema.optional(Schema.NullOr(Schema.String)), + source: Schema.optional(Schema.NullOr(RawReferenceSchema)), + subject: Schema.optional(Schema.NullOr(RawReferenceSchema)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, +}); + +const RawCommentsSchema = Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawCommentSchema)))), +}); + +const RawViewerFieldsSchema = Schema.Struct({ + viewerCanUpdate: Schema.optional(Schema.Boolean), +}); + +const COMMENT_FIELDS = `id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS}`; + +/** + * The whole issue in one read: its row, body, the newest page of comments, the newest hundred + * state changes, the change requests that close or mention it, and where the viewer stands. + * Comments are read newest-last so the first page is the recent end; older pages follow + * `pageInfo.startCursor` backwards. + */ +export const ISSUE_DETAIL_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + viewer { login } + repository(owner: $owner, name: $name) { + viewerPermission + issue(number: $number) { + ${ISSUE_ROW_FIELDS} + body + viewerCanUpdate + ${REACTION_GROUPS_FIELDS} + comments(last: ${PAGE_SIZE}) { + totalCount + pageInfo { hasPreviousPage startCursor } + nodes { ${COMMENT_FIELDS} } + } + closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { + nodes { number title url state isDraft repository { nameWithOwner } } + } + timelineItems(last: ${PAGE_SIZE}, itemTypes: [CLOSED_EVENT, REOPENED_EVENT, LABELED_EVENT, UNLABELED_EVENT, ASSIGNED_EVENT, UNASSIGNED_EVENT, RENAMED_TITLE_EVENT, CROSS_REFERENCED_EVENT]) { + nodes { + __typename + ... on ClosedEvent { id createdAt actor { login avatarUrl } } + ... on ReopenedEvent { id createdAt actor { login avatarUrl } } + ... on LabeledEvent { id createdAt actor { login avatarUrl } label { name } } + ... on UnlabeledEvent { id createdAt actor { login avatarUrl } label { name } } + ... on AssignedEvent { id createdAt actor { login avatarUrl } assignee { ... on User { login name } ... on Bot { login } } } + ... on UnassignedEvent { id createdAt actor { login avatarUrl } assignee { ... on User { login name } ... on Bot { login } } } + ... on RenamedTitleEvent { id createdAt actor { login avatarUrl } currentTitle } + ... on CrossReferencedEvent { + id createdAt actor { login avatarUrl } + source { __typename ... on PullRequest { number title url state isDraft repository { nameWithOwner } } ... on Issue { number repository { nameWithOwner } } } + } + } + } + } + } +}`; + +const RawDetailSchema = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + repository: Schema.Struct({ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + /** Null for a number that names no issue the viewer can see. */ + issue: Schema.NullOr( + Schema.Struct({ + ...RawIssueSchema.fields, + ...RawViewerFieldsSchema.fields, + body: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, + /** Overrides the row's count-only connection with the page the detail reads. */ + comments: Schema.optional(Schema.NullOr(RawCommentsSchema)), + closedByPullRequestsReferences: nodes(RawReferenceSchema), + timelineItems: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + ), + ), + }), + ), + }), + }), +}); + +/** The rest of a long conversation, read backwards from a cursor the detail handed over. */ +export const ISSUE_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String!) { + viewer { login } + repository(owner: $owner, name: $name) { + issue(number: $number) { + comments(last: ${PAGE_SIZE}, before: $cursor) { + pageInfo { hasPreviousPage startCursor } + nodes { ${COMMENT_FIELDS} } + } + } + } +}`; + +const RawCommentPageSchema = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + repository: Schema.Struct({ + issue: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawCommentsSchema) })), + }), + }), +}); + +/** + * The viewer's standing on its own, for deciding a write by what GitHub says now rather than by + * what the page was told when it loaded. + */ +export const ISSUE_VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + viewerPermission + issue(number: $number) { viewerCanUpdate } + } +}`; + +const RawViewerPermissionsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + issue: Schema.NullOr(RawViewerFieldsSchema), + }), + }), +}); + +export const ISSUE_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { issue(number: $number) { id } } +}`; + +const RawIssueNodeIdSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ issue: Schema.Struct({ id: Schema.String }) }), + }), +}); + +/** Whether a comment id, which is global, hangs off the issue the request names. */ +export const ISSUE_COMMENT_SCOPE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $commentId: ID!) { + repository(owner: $owner, name: $name) { issue(number: $number) { id } } + node(id: $commentId) { ... on IssueComment { issue { id } } } +}`; + +const RawCommentScopeSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ issue: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + node: Schema.NullOr( + Schema.Struct({ issue: Schema.optional(Schema.Struct({ id: Schema.String })) }), + ), + }), +}); + +/** + * The labels a repository has and the people it may assign, in one read. `assignableUsers` is + * the list GitHub's own picker is built from, where `collaborators` is refused to anyone without + * push access. + */ +export const ISSUE_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + labels(first: ${PAGE_SIZE}) { pageInfo { hasNextPage } nodes { name color description } } + assignableUsers(first: ${PAGE_SIZE}) { pageInfo { hasNextPage } nodes { login name avatarUrl } } + } +}`; + +const RawCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + labels: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), + }), + assignableUsers: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawActorSchema)))), + }), + }), + }), +}); + +/** + * The templates a repository offers, from the host rather than the checkout: a working tree sits + * on whatever branch its reader left it on, and the templates are the default branch's. + */ +export const ISSUE_TEMPLATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issueTemplates { + filename name about title body + assignees(first: 25) { nodes { login } } + labels(first: 50) { nodes { name } } + } + } +}`; + +const RawTemplateSchema = Schema.Struct({ + filename: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + about: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.NullOr(Schema.String)), + assignees: nodes(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + labels: nodes(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), +}); + +const RawTemplatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + issueTemplates: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + }), +}); + +const RawCreatedIssueSchema = Schema.Struct({ number: Schema.Int, html_url: Schema.String }); + +export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } +}`; + +const decodeSearch = decodeJsonResult(RawSearchSchema); +const decodeIssueRow = Schema.decodeUnknownExit(RawIssueSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeCommentPage = decodeJsonResult(RawCommentPageSchema); +const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); +const decodeNodeId = decodeJsonResult(RawIssueNodeIdSchema); +const decodeCommentScope = decodeJsonResult(RawCommentScopeSchema); +const decodeCandidates = decodeJsonResult(RawCandidatesSchema); +const decodeTemplates = decodeJsonResult(RawTemplatesSchema); +const decodeTemplate = Schema.decodeUnknownExit(RawTemplateSchema); +const decodeTimelineItem = Schema.decodeUnknownExit(RawTimelineItemSchema); +const decodeCreatedIssue = decodeJsonResult(RawCreatedIssueSchema); +const decodeViewerLogin = decodeJsonResult( + Schema.Struct({ data: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) }) }), +); + +type DecodeFailure = + ReturnType extends Result.Result ? E : never; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: typeof RawActorSchema.Type | null | undefined): IssueActor | null { + const login = trimmed(raw?.login); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) }; +} + +function toActors( + raw: ReadonlyArray | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((entry) => toActor(entry) ?? []); +} + +function toLabels( + raw: ReadonlyArray | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null ? [] : [{ name, color: trimmed(label?.color) }]; + }); +} + +/** GitHub spells a close reason `COMPLETED` / `NOT_PLANNED`; an open issue has `""` or `REOPENED`. */ +export function toIssueState( + state: string | null | undefined, + stateReason: string | null | undefined, +): IssueState { + if (state?.trim().toUpperCase() !== "CLOSED") return { category: "open", name: "open" }; + const reason = stateReason?.trim().toUpperCase(); + return { + category: "closed", + name: "closed", + ...(reason === "COMPLETED" + ? { closeReason: "completed" } + : reason === "NOT_PLANNED" + ? { closeReason: "not-planned" } + : {}), + }; +} + +function toSummary(raw: typeof RawIssueSchema.Type): TrackerIssueSummary { + return { + key: String(raw.number), + displayKey: `#${raw.number}`, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + state: toIssueState(raw.state, raw.stateReason), + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + closedAt: trimmed(raw.closedAt), + assignees: toActors(raw.assignees?.nodes), + labels: toLabels(raw.labels?.nodes), + commentCount: Math.max(0, raw.comments?.totalCount ?? 0), + }; +} + +export interface GitHubIssueSearchPage { + readonly items: ReadonlyArray; + readonly nextCursor: string | null; +} + +export function decodeIssueSearchJson( + raw: string, +): Result.Result { + const decoded = decodeSearch(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const search = decoded.success.data.search; + const items = (search.nodes ?? []).flatMap((entry) => { + const row = decodeIssueRow(entry); + return Exit.isSuccess(row) ? [toSummary(row.value)] : []; + }); + return Result.succeed({ + items, + nextCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, + }); +} + +function toPullRequestState(value: string | null | undefined): PullRequestState { + switch (value?.trim().toUpperCase()) { + case "MERGED": + return "merged"; + case "CLOSED": + return "closed"; + default: + return "open"; + } +} + +function toLinkedPullRequest( + raw: typeof RawReferenceSchema.Type | null | undefined, + closesIssue: boolean, +): IssueLinkedPullRequest | null { + if (raw == null) return null; + const typename = trimmed(raw.__typename); + if (typename !== null && typename !== "PullRequest") return null; + const repository = trimmed(raw.repository?.nameWithOwner); + const title = trimmed(raw.title); + const url = trimmed(raw.url); + const number = raw.number ?? 0; + if (repository === null || title === null || url === null || number <= 0) return null; + return { + repository, + number, + title, + url, + state: toPullRequestState(raw.state), + isDraft: raw.isDraft ?? false, + closesIssue, + }; +} + +/** The change GitHub recorded, or null for a kind this page has no vocabulary for. */ +function toEventFields( + raw: typeof RawTimelineItemSchema.Type, +): { readonly kind: IssueEventKind; readonly detail: string | null } | null { + switch (raw.__typename) { + case "ClosedEvent": + return { kind: "closed", detail: null }; + case "ReopenedEvent": + return { kind: "reopened", detail: null }; + case "LabeledEvent": + return { kind: "labeled", detail: trimmed(raw.label?.name) }; + case "UnlabeledEvent": + return { kind: "unlabeled", detail: trimmed(raw.label?.name) }; + case "AssignedEvent": + return { kind: "assigned", detail: trimmed(raw.assignee?.login) }; + case "UnassignedEvent": + return { kind: "unassigned", detail: trimmed(raw.assignee?.login) }; + case "RenamedTitleEvent": + return { kind: "renamed", detail: trimmed(raw.currentTitle) }; + case "CrossReferencedEvent": { + const repository = trimmed(raw.source?.repository?.nameWithOwner); + const number = raw.source?.number ?? 0; + return { + kind: "referenced", + detail: repository === null || number <= 0 ? null : `${repository}#${number}`, + }; + } + default: + return null; + } +} + +function toComments( + raw: typeof RawCommentsSchema.Type | null | undefined, + viewer: string | null, +): ReadonlyArray { + return (raw?.nodes ?? []).flatMap((comment) => { + const id = trimmed(comment?.id); + if (comment == null || id === null) return []; + return [ + { + id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), + }, + ]; + }); +} + +/** Where a newest-last connection carries on into older rows; null once it is whole. */ +function previousCursorOf(pageInfo: typeof RawPageInfoSchema.Type | undefined): string | null { + return pageInfo?.hasPreviousPage === true ? trimmed(pageInfo.startCursor) : null; +} + +/** + * What the signed-in account may do. Closing and editing go by `viewerCanUpdate`, which GitHub + * grants the author as well as anyone with write; labelling and assigning need the triage role + * or better. Commenting is not gated: seeing the repository is enough to say something in it. + * An install that reports nothing grants rather than withholds, so the host's own refusal says why. + */ +export function toViewerPermissions(raw: { + readonly viewerPermission?: string | null | undefined; + readonly issue: typeof RawViewerFieldsSchema.Type | null; +}): IssueViewerPermissions { + const canUpdate = raw.issue?.viewerCanUpdate !== false; + const canTriage = ["ADMIN", "MAINTAIN", "WRITE", "TRIAGE"].includes( + raw.viewerPermission?.trim().toUpperCase() ?? "", + ); + return { + setState: canUpdate, + comment: true, + edit: canUpdate, + labels: canTriage, + assignees: canTriage, + }; +} + +export interface GitHubIssueDetail extends TrackerIssueSummary { + readonly body: string; + readonly viewer: string | null; + readonly viewerPermissions: IssueViewerPermissions; + readonly reactions: ReturnType; + readonly comments: ReadonlyArray; + readonly commentsCursor: string | null; + readonly events: ReadonlyArray; + readonly linkedPullRequests: ReadonlyArray; +} + +/** Null for a number that names no issue the viewer can see. */ +export function decodeIssueDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const { viewer: rawViewer, repository } = decoded.success.data; + const issue = repository.issue; + if (issue === null) return Result.succeed(null); + const viewer = trimmed(rawViewer?.login); + + // Closing references lead; a cross-reference seen twice is filed under the stronger relation. + const links = new Map(); + for (const node of issue.closedByPullRequestsReferences?.nodes ?? []) { + const link = toLinkedPullRequest(node, true); + if (link !== null) links.set(`${link.repository.toLowerCase()}#${link.number}`, link); + } + const events: IssueEvent[] = []; + for (const entry of issue.timelineItems?.nodes ?? []) { + const item = decodeTimelineItem(entry); + if (!Exit.isSuccess(item)) continue; + if (item.value.__typename === "CrossReferencedEvent") { + const link = toLinkedPullRequest(item.value.source, false); + const linkKey = link === null ? null : `${link.repository.toLowerCase()}#${link.number}`; + if (link !== null && linkKey !== null && !links.has(linkKey)) links.set(linkKey, link); + } + const fields = toEventFields(item.value); + const id = trimmed(item.value.id); + const createdAt = trimmed(item.value.createdAt); + if (fields === null || id === null || createdAt === null) continue; + events.push({ id, ...fields, actor: toActor(item.value.actor), createdAt }); + } + return Result.succeed({ + ...toSummary(issue), + body: issue.body ?? "", + viewer, + viewerPermissions: toViewerPermissions({ + viewerPermission: repository.viewerPermission, + issue, + }), + reactions: toReactions(issue.reactionGroups, viewer), + comments: toComments(issue.comments, viewer), + commentsCursor: previousCursorOf(issue.comments?.pageInfo), + events, + linkedPullRequests: [...links.values()], + }); +} + +export function decodeIssueCommentsJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly nextCursor: string | null }, + DecodeFailure +> { + const decoded = decodeCommentPage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const comments = decoded.success.data.repository.issue?.comments; + const viewer = trimmed(decoded.success.data.viewer?.login); + return Result.succeed({ + comments: toComments(comments, viewer), + nextCursor: previousCursorOf(comments?.pageInfo), + }); +} + +export function decodeIssueViewerPermissionsJson( + raw: string, +): Result.Result { + return Result.map(decodeViewerPermissions(raw), ({ data }) => + toViewerPermissions(data.repository), + ); +} + +export function decodeIssueNodeIdJson(raw: string): Result.Result { + return Result.map(decodeNodeId(raw), ({ data }) => data.repository.issue.id); +} + +export function decodeIssueCommentScopeJson(raw: string): Result.Result { + return Result.map(decodeCommentScope(raw), ({ data }) => { + const expected = data.repository?.issue?.id ?? null; + return expected !== null && expected === (data.node?.issue?.id ?? null); + }); +} + +export function decodeViewerLoginJson(raw: string): Result.Result { + return Result.map(decodeViewerLogin(raw), ({ data }) => data.viewer.login); +} + +export function decodeIssueCandidatesJson(raw: string): Result.Result< + { + readonly labels: ReadonlyArray; + readonly assignees: ReadonlyArray; + readonly truncated: boolean; + }, + DecodeFailure +> { + return Result.map(decodeCandidates(raw), ({ data }) => { + const { labels, assignableUsers } = data.repository; + return { + labels: (labels.nodes ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null + ? [] + : [{ name, color: trimmed(label?.color), description: trimmed(label?.description) }]; + }), + // GitHub addresses an assignee by the login it shows, so the id is the handle itself. + assignees: toActors(assignableUsers.nodes).map((actor) => ({ ...actor, id: actor.login })), + truncated: + labels.pageInfo?.hasNextPage === true || assignableUsers.pageInfo?.hasNextPage === true, + }; + }); +} + +/** A template that cannot be read is skipped: one malformed form must not take the others. */ +export function decodeIssueTemplatesJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeTemplates(raw), ({ data }) => + (data.repository.issueTemplates ?? []).flatMap((entry) => { + const template = decodeTemplate(entry); + if (Exit.isFailure(template)) return []; + const key = trimmed(template.value.filename); + if (key === null) return []; + return [ + { + key, + name: trimmed(template.value.name) ?? key, + about: template.value.about ?? "", + title: template.value.title ?? "", + body: template.value.body ?? "", + labels: (template.value.labels?.nodes ?? []).flatMap((node) => trimmed(node?.name) ?? []), + assignees: (template.value.assignees?.nodes ?? []).flatMap( + (node) => trimmed(node?.login) ?? [], + ), + }, + ]; + }), + ); +} + +export function decodeCreatedIssueJson( + raw: string, +): Result.Result<{ readonly number: number; readonly url: string }, DecodeFailure> { + return Result.map(decodeCreatedIssue(raw), (issue) => ({ + number: issue.number, + url: issue.html_url, + })); +} diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index e3768b04d7bc..61e7ea3ba522 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -287,7 +287,7 @@ const REACTORS_PER_GROUP = 10; * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: * a hover says who reacted, and a hundred and forty names is a count, not a sentence. */ -const REACTION_GROUPS_FIELDS = `reactionGroups { +export const REACTION_GROUPS_FIELDS = `reactionGroups { content viewerHasReacted reactors(first: ${REACTORS_PER_GROUP}) { @@ -328,7 +328,7 @@ export function gitHubReactionContent(content: PullRequestReactionContent): stri return GITHUB_REACTION_BY_CONTENT[content]; } -const RawReactionGroupsSchema = Schema.optional( +export const RawReactionGroupsSchema = Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ @@ -363,7 +363,7 @@ type RawReactionGroups = typeof RawReactionGroupsSchema.Type; * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it * in would name them twice — but `count` still counts them along with everyone else. */ -function toReactions( +export function toReactions( groups: RawReactionGroups, viewer: string | null, ): ReadonlyArray { diff --git a/packages/source-control-github/src/server/issueTrackerDriver.ts b/packages/source-control-github/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..b986988e899e --- /dev/null +++ b/packages/source-control-github/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The GitHub issue tracker driver, registered beside the source control driver so the server's + * issue registry can list it without knowing the package. + * + * @module source-control-github/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as GitHubIssueTracker from "./GitHubIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("github"), + make: GitHubIssueTracker.make, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acce8eb89284..63d6b1fcfbac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -630,6 +630,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../../packages/issue-tracker-core '@t3tools/provider-acp': specifier: workspace:* version: link:../../packages/provider-acp @@ -1182,6 +1185,19 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/issue-tracker-core: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + packages/provider-acp: dependencies: '@t3tools/contracts': @@ -1712,6 +1728,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core From d344e4bbd73db365852bbb65df1dd2939bd448fa Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:50:46 -0700 Subject: [PATCH 02/54] chore(issues): refresh the lockfile for Effect 4.0.4 Co-Authored-By: Claude Opus 5.5 --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63d6b1fcfbac..57482137904a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1191,8 +1191,8 @@ importers: specifier: workspace:* version: link:../contracts effect: - specifier: 4.0.2 - version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + specifier: 4.0.4 + version: 4.0.4 devDependencies: '@types/node': specifier: 24.12.4 From cf13507f0510a83f21d411010bd9ee2b630027f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:03:40 -0700 Subject: [PATCH 03/54] fix(server): give issue RPCs their own handler layer With Effect 4.0.4 and the ten issue RPCs, typing `toLayer` over the whole WebSocket RPC group hits TypeScript's instantiation depth limit (TS2589, only reported when a single checker sees ws.ts after the orchestration runtime). The checker then gives up and types the handler layer's requirements as `any`, which spreads through the route and server layers into binCli.ts and the CLI tests. The issue RPCs now form `WsIssuesRpcGroup`, implemented by their own `toLayer`, and `WsRpcGroup` merges it with the rest. The wire surface, authorization, and instrumentation are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/ws.ts | 54 ++++++++++++++++++++++------------- packages/contracts/src/rpc.ts | 34 +++++++++++++++------- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7feab0cab72a..dad035b50515 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -100,6 +100,8 @@ import { type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, + WsCoreRpcGroup, + WsIssuesRpcGroup, WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -550,6 +552,8 @@ 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); // When a resuming client's cursor is more than this many events behind the // current head, skip the per-event catch-up replay and send a fresh shell // snapshot instead. Replaying each intervening event costs a shell refetch; @@ -1198,14 +1202,32 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); -const layerWsRpc = ( +const layerWsIssuesRpc = ServerWsIssuesRpcGroup.toLayer( + Effect.gen(function* () { + const issues = yield* IssueService.IssueService; + 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), + }); + }), +); + +const layerWsCoreRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], serverBrowser: ServerBrowser.ServerBrowser["Service"], ) => - ServerWsRpcGroup.toLayer( + ServerWsCoreRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const sql = yield* SqlClient.SqlClient; @@ -1243,7 +1265,6 @@ const layerWsRpc = ( const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; - const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -1834,7 +1855,7 @@ 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, @@ -2577,16 +2598,6 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.labelCandidates(input)), [WS_METHODS.pullRequestsSetLabels]: (input) => withPullRequestViewer(input, pullRequests.setLabels(input)), - [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.sourceControlLookupRepository]: (input) => sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => @@ -3229,12 +3240,15 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc( - session, - clientOrigin, - clientAnalyticsProps, - previewAutomationBroker, - serverBrowser, + Layer.merge( + layerWsCoreRpc( + session, + clientOrigin, + clientAnalyticsProps, + previewAutomationBroker, + serverBrowser, + ), + layerWsIssuesRpc, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 2ccd7a6437b5..d9f0aaefd94e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1974,7 +1974,27 @@ export class RpcScopeAuthorization extends RpcMiddleware.Service Date: Wed, 14 Oct 2026 01:16:25 -0700 Subject: [PATCH 04/54] chore(issues): drop unused exports and register new packages in CI lists Co-Authored-By: Claude Opus 5.5 --- apps/server/src/issue/IssueService.ts | 2 +- apps/server/src/issue/IssueTrackerRegistry.ts | 2 +- package.json | 2 +- .../src/server/GitHubIssueTracker.ts | 2 +- .../source-control-github/src/server/gitHubIssueJson.ts | 8 ++------ scripts/release-smoke.ts | 1 + 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index ec2638510f2b..ad438b713817 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -74,7 +74,7 @@ interface SupportedProject extends ProjectTrackerBinding { * 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( +function resolveGitHostBinding( project: OrchestrationProjectShell, ): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; diff --git a/apps/server/src/issue/IssueTrackerRegistry.ts b/apps/server/src/issue/IssueTrackerRegistry.ts index 1647a9ea1ffa..7e0664527b4b 100644 --- a/apps/server/src/issue/IssueTrackerRegistry.ts +++ b/apps/server/src/issue/IssueTrackerRegistry.ts @@ -27,7 +27,7 @@ export function fromTrackers( }; } -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const trackers = yield* Effect.forEach( BuiltInIssueTrackers.BUILT_IN_ISSUE_TRACKERS, (driver) => driver.make, diff --git a/package.json b/package.json index af62f0e08a73..b726e47eb847 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/issue-tracker-core --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts index 1f18d4ecc200..c75f58c54982 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -50,7 +50,7 @@ import { const KIND = IssueTrackerKind.make("github"); -export const CAPABILITIES: IssueCapabilities = { +const CAPABILITIES: IssueCapabilities = { create: true, edit: true, comment: true, diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts index 3504da2db960..109be5cfb670 100644 --- a/packages/source-control-github/src/server/gitHubIssueJson.ts +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -356,10 +356,6 @@ const RawTemplatesSchema = Schema.Struct({ const RawCreatedIssueSchema = Schema.Struct({ number: Schema.Int, html_url: Schema.String }); -export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { - updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } -}`; - const decodeSearch = decodeJsonResult(RawSearchSchema); const decodeIssueRow = Schema.decodeUnknownExit(RawIssueSchema); const decodeDetail = decodeJsonResult(RawDetailSchema); @@ -407,7 +403,7 @@ function toLabels( } /** GitHub spells a close reason `COMPLETED` / `NOT_PLANNED`; an open issue has `""` or `REOPENED`. */ -export function toIssueState( +function toIssueState( state: string | null | undefined, stateReason: string | null | undefined, ): IssueState { @@ -559,7 +555,7 @@ function previousCursorOf(pageInfo: typeof RawPageInfoSchema.Type | undefined): * or better. Commenting is not gated: seeing the repository is enough to say something in it. * An install that reports nothing grants rather than withholds, so the host's own refusal says why. */ -export function toViewerPermissions(raw: { +function toViewerPermissions(raw: { readonly viewerPermission?: string | null | undefined; readonly issue: typeof RawViewerFieldsSchema.Type | null; }): IssueViewerPermissions { diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index cceda3374220..e8507af86228 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,7 @@ const workspaceFiles = [ "packages/provider-grok/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/issue-tracker-core/package.json", "packages/source-control-azure-devops/package.json", "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", From c30673523d5993b2b1fa1674a86a30fc5532207c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:43:06 -0700 Subject: [PATCH 05/54] fix(issues): match GitHub search words apart, not as one phrase Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitHubIssueTracker.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts index c75f58c54982..c6e4e132a332 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -86,12 +86,15 @@ function parseNumber(key: string): number | null { } /** - * The reader's words as one literal phrase of a search. Outside quotes GitHub reads `is:closed` - * as a qualifier, so text typed into a box could widen the listing it narrows; inside them it is - * only text. The two characters that could end the phrase early are escaped. + * The reader's words as quoted search terms. Outside quotes GitHub reads `is:closed` as a + * qualifier, so text typed into a box could widen the listing it narrows; inside them it is only + * text. Each word is quoted on its own so the words still match apart, not only as a phrase. */ -function searchPhrase(query: string): string { - return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +function searchTerms(query: string): ReadonlyArray { + return query + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word) => `"${word.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`); } const INVOLVEMENT_QUALIFIER: Record = { @@ -108,7 +111,6 @@ function searchQuery(input: { readonly viewer: string; readonly query?: string | undefined; }): string { - const text = input.query?.trim() ?? ""; const qualifier = INVOLVEMENT_QUALIFIER[input.involvement]; return [ // The search index holds pull requests as issues; `is:issue` keeps them out. @@ -117,7 +119,7 @@ function searchQuery(input: { ...(input.state === "all" ? [] : [`is:${input.state}`]), ...(qualifier === null ? [] : [`${qualifier}:${input.viewer}`]), "sort:updated-desc", - ...(text.length === 0 ? [] : [searchPhrase(text)]), + ...searchTerms(input.query ?? ""), ].join(" "); } From 0e1f41c3572f39e86661bce7df8d3ce9271fe878 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:47:11 -0700 Subject: [PATCH 06/54] style(issues): format the issue service Co-Authored-By: Claude Opus 5.5 --- apps/server/src/issue/IssueService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index ad438b713817..a3c168497039 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -74,9 +74,7 @@ interface SupportedProject extends ProjectTrackerBinding { * 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. */ -function resolveGitHostBinding( - project: OrchestrationProjectShell, -): ProjectTrackerBinding | null { +function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; const scope = sourceControlRepositorySelector(identity); const kind = identity?.provider; From b72ded81a733bae28ee94f33d7e464183119ac38 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:02:17 -0700 Subject: [PATCH 07/54] fix(issues): select GitHub issue comments once in the detail query Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitHubIssueTracker.test.ts | 6 +++++- .../source-control-github/src/server/gitHubIssueJson.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index 963caba36623..9dcd6e6184bc 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -115,8 +115,10 @@ describe("GitHubIssueTracker", () => { it.effect("maps a detail read to the neutral issue shape", () => Effect.gen(function* () { let seen: unknown; - const api = yield* tracker((_query, variables) => { + let sentQuery = ""; + const api = yield* tracker((query, variables) => { seen = variables; + sentQuery = query; return DETAIL_RESPONSE; }); const issue = yield* api.getIssue({ @@ -126,6 +128,8 @@ describe("GitHubIssueTracker", () => { key: "42", }); expect(seen).toEqual({ owner: "acme", name: "web", number: 42 }); + // GitHub rejects a query that selects one field twice with different arguments. + expect(sentQuery.match(/\bcomments\b/g)).toHaveLength(1); expect(issue).toMatchObject({ key: "42", displayKey: "#42", diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts index 109be5cfb670..99ad56776443 100644 --- a/packages/source-control-github/src/server/gitHubIssueJson.ts +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -78,12 +78,15 @@ const RawIssueSchema = Schema.Struct({ labels: nodes(RawLabelSchema), }); -const ISSUE_ROW_FIELDS = `number title url state stateReason createdAt updatedAt closedAt +/** A row without its comment count, which the detail reads from its own comment page. */ +const ISSUE_ROW_BASE_FIELDS = `number title url state stateReason createdAt updatedAt closedAt author { login avatarUrl ... on User { name } } - comments { totalCount } assignees(first: 20) { nodes { login name avatarUrl } } labels(first: 20) { nodes { name color } }`; +const ISSUE_ROW_FIELDS = `${ISSUE_ROW_BASE_FIELDS} + comments { totalCount }`; + /** * One repository's issues as a search, which is the only read that takes free text and an * updated-desc order with a cursor. `is:issue` keeps pull requests out of the shared index. @@ -166,7 +169,7 @@ export const ISSUE_DETAIL_GRAPHQL_QUERY = `query($owner: String!, $name: String! repository(owner: $owner, name: $name) { viewerPermission issue(number: $number) { - ${ISSUE_ROW_FIELDS} + ${ISSUE_ROW_BASE_FIELDS} body viewerCanUpdate ${REACTION_GROUPS_FIELDS} From 06b7b6c5ae76bd060eb333b324b2f9cf9b06594e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:36:19 -0700 Subject: [PATCH 08/54] feat(client-runtime): add issue tracking atoms and an issues capability Clients need a way to read and write `issues.*` without probing an older server, and a shared place for the atoms every client will build on. This adds an `issues` environment capability (set by the server, surfaced as `issuesSupportedAtom`) and `state/issues` with queries for list, detail, templates and candidates, guarded commands for every write, a list-page command for cursor continuations, and the pure merge helpers the web list and comment pager use. Co-Authored-By: Claude Fable 5.1 --- .../src/environment/ServerEnvironment.ts | 1 + apps/web/src/state/environments.ts | 4 + packages/client-runtime/package.json | 4 + .../client-runtime/src/state/issues.test.ts | 71 ++++++++ packages/client-runtime/src/state/issues.ts | 164 ++++++++++++++++++ .../client-runtime/src/state/presentation.ts | 8 + packages/contracts/src/environment.ts | 3 + 7 files changed, 255 insertions(+) create mode 100644 packages/client-runtime/src/state/issues.test.ts create mode 100644 packages/client-runtime/src/state/issues.ts diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b2667a9edd3f..a5bf58d8a77f 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/web/src/state/environments.ts b/apps/web/src/state/environments.ts index b7105c2adf9c..717509292b44 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -102,6 +102,10 @@ export function usePullRequestsSupported() { return useAtomValue(environmentSummaries.pullRequestsSupportedAtom); } +export function useIssuesSupported() { + return useAtomValue(environmentSummaries.issuesSupportedAtom); +} + export function useEnvironmentMachines() { return useAtomValue(environmentSummaries.machineByIdAtom); } diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0366c65b1ed2..0b2f518697e9 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -199,6 +199,10 @@ "types": "./src/state/providerInstanceDisplay.ts", "default": "./src/state/providerInstanceDisplay.ts" }, + "./state/issues": { + "types": "./src/state/issues.ts", + "default": "./src/state/issues.ts" + }, "./state/pull-requests": { "types": "./src/state/pullRequests.ts", "default": "./src/state/pullRequests.ts" diff --git a/packages/client-runtime/src/state/issues.test.ts b/packages/client-runtime/src/state/issues.test.ts new file mode 100644 index 000000000000..cc4bcb8eb149 --- /dev/null +++ b/packages/client-runtime/src/state/issues.test.ts @@ -0,0 +1,71 @@ +import type { IssueComment, IssueListEntry, IssueTrackerKind, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { issueEntryKey, mergeIssueListPage, prependIssueComments } from "./issues.ts"; + +function entry(overrides: Partial & { key: string }): IssueListEntry { + return { + tracker: "github" as IssueTrackerKind, + host: "github.com", + scope: "acme/app", + displayKey: `#${overrides.key}`, + title: `Issue ${overrides.key}`, + url: `https://github.com/acme/app/issues/${overrides.key}`, + author: null, + state: { category: "open", name: "open" }, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + closedAt: null, + assignees: [], + labels: [], + commentCount: 0, + projectId: "project-1" as ProjectId, + projectTitle: "App", + ...overrides, + }; +} + +describe("mergeIssueListPage", () => { + it("keeps rows from scopes the page did not continue and orders by update", () => { + const held = [ + entry({ key: "1", updatedAt: "2026-10-03T00:00:00.000Z" }), + entry({ key: "9", scope: "acme/other", updatedAt: "2026-10-02T00:00:00.000Z" }), + ]; + const page = [entry({ key: "2", updatedAt: "2026-10-04T00:00:00.000Z" })]; + expect(mergeIssueListPage(held, page).map((row) => row.key)).toEqual(["2", "1", "9"]); + }); + + it("replaces a repeated row with the page's newer words", () => { + const held = [entry({ key: "1", title: "Old title" })]; + const page = [ + entry({ key: "1", title: "New title", state: { category: "closed", name: "closed" } }), + ]; + const merged = mergeIssueListPage(held, page); + expect(merged).toHaveLength(1); + expect(merged[0]?.title).toBe("New title"); + expect(merged[0]?.state.category).toBe("closed"); + }); + + it("keys rows case-insensitively by host and scope, exactly by key", () => { + expect(issueEntryKey(entry({ key: "1", host: "GitHub.com", scope: "Acme/App" }))).toBe( + issueEntryKey(entry({ key: "1" })), + ); + expect(issueEntryKey(entry({ key: "ENG-1" }))).not.toBe(issueEntryKey(entry({ key: "eng-1" }))); + }); +}); + +describe("prependIssueComments", () => { + const comment = (id: string): IssueComment => ({ + id, + author: null, + body: id, + createdAt: "2026-10-01T00:00:00.000Z", + url: null, + }); + + it("puts older comments first and drops ones already shown", () => { + const current = [comment("c3"), comment("c4")]; + const older = [comment("c1"), comment("c2"), comment("c3")]; + expect(prependIssueComments(current, older).map((c) => c.id)).toEqual(["c1", "c2", "c3", "c4"]); + }); +}); diff --git a/packages/client-runtime/src/state/issues.ts b/packages/client-runtime/src/state/issues.ts new file mode 100644 index 000000000000..ac0ccd10644a --- /dev/null +++ b/packages/client-runtime/src/state/issues.ts @@ -0,0 +1,164 @@ +import { + WS_METHODS, + type EnvironmentId, + type IssueComment, + type IssueListEntry, + type IssueListResult, + type IssueRef, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom, type AtomRegistry } from "effect/reactivity"; + +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +/** One row's identity across list pages and detail reads; hosts compare case-insensitively. */ +export function issueEntryKey( + entry: Pick, +): string { + return `${entry.projectId}\u0000${entry.host.toLowerCase()}\u0000${entry.scope.toLowerCase()}\u0000${entry.key}`; +} + +/** + * Lay a continuation page over the rows already held. A page only covers the scopes it was asked + * to continue, so rows from other scopes stay; a row the page repeats replaces the held one, so a + * title or state that moved between reads shows the newer words. Order stays newest-updated first, + * which is how every tracker hands them over. + */ +export function mergeIssueListPage( + held: ReadonlyArray, + page: ReadonlyArray, +): ReadonlyArray { + if (held.length === 0) return page; + if (page.length === 0) return held; + const byKey = new Map(); + for (const entry of held) byKey.set(issueEntryKey(entry), entry); + for (const entry of page) byKey.set(issueEntryKey(entry), entry); + return [...byKey.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); +} + +/** Older comments go in front: the detail carries the newest page and pages walk backwards. */ +export function prependIssueComments( + current: ReadonlyArray, + older: ReadonlyArray, +): ReadonlyArray { + if (older.length === 0) return current; + const seen = new Set(current.map((comment) => comment.id)); + return [...older.filter((comment) => !seen.has(comment.id)), ...current]; +} + +/** + * Every read goes to a tracker API over the environment, so results are reused for a short while + * and refreshed explicitly. Mutations run serially per environment: changes to one issue are + * order-sensitive. Each confirmed write refreshes the detail it touched; the list is refreshed by + * the page, which knows which listings it is showing. + */ +export function createIssueEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const commandScheduler = createAtomCommandScheduler(); + const serialPerEnvironment = { + mode: "serial", + key: ({ environmentId }: { readonly environmentId: string }) => environmentId, + } as const; + const detail = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:detail", + tag: WS_METHODS.issuesDetail, + staleTimeMs: 30_000, + }); + const detailRef = ({ projectId, tracker, host, scope, key }: IssueRef): IssueRef => ({ + projectId, + tracker, + ...(host === undefined ? {} : { host }), + scope, + key, + }); + const refreshDetail = ( + { environmentId, input }: { readonly environmentId: EnvironmentId; readonly input: IssueRef }, + registry: AtomRegistry.AtomRegistry, + ) => Effect.sync(() => registry.refresh(detail({ environmentId, input: detailRef(input) }))); + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:list", + tag: WS_METHODS.issuesList, + staleTimeMs: 30_000, + }), + /** + * A continuation of the listing, run as a command rather than read as a query: a page is + * asked for once and laid over the rows held, and a refresh of the first page forgets it. + */ + listPage: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:list-page", + tag: WS_METHODS.issuesList, + scheduler: commandScheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), + }, + }), + detail, + /** Older comments past the cursor the detail handed over; the panel prepends what arrives. */ + comments: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:comments", + tag: WS_METHODS.issuesComments, + scheduler: commandScheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.projectId, input.scope, input.key, input.cursor]), + }, + }), + /** Read when the composer opens: what a scope offers a new issue changes with a commit. */ + templates: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:templates", + tag: WS_METHODS.issuesTemplates, + staleTimeMs: 5 * 60_000, + }), + /** Read when a label or assignee picker opens, not with every issue. */ + candidates: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:candidates", + tag: WS_METHODS.issuesCandidates, + staleTimeMs: 60_000, + }), + create: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:create", + tag: WS_METHODS.issuesCreate, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), + comment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:comment", + tag: WS_METHODS.issuesComment, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + onSuccess: refreshDetail, + }), + update: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:update", + tag: WS_METHODS.issuesUpdate, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + onSuccess: refreshDetail, + }), + setState: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:set-state", + tag: WS_METHODS.issuesSetState, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + onSuccess: refreshDetail, + }), + react: createEnvironmentRpcCommand(runtime, { + label: "environment-data:issues:react", + tag: WS_METHODS.issuesReact, + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + onSuccess: refreshDetail, + }), + }; +} + +export type IssueListAnswer = IssueListResult; diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index deca7ff162b3..4f9b296e0e4a 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -211,6 +211,13 @@ export function createEnvironmentSummaryAtoms(input: { .pullRequests === true, ), ); + const issuesSupportedAtom = Atom.make((get) => + get(environmentIdsAtom).some( + (environmentId) => + get(input.presentationAtom(environmentId))?.serverConfig?.environment.capabilities + .issues === true, + ), + ); return { environmentIdsAtom, connectedEnvironmentIdsAtom, @@ -218,5 +225,6 @@ export function createEnvironmentSummaryAtoms(input: { environmentsAtom, machineByIdAtom, pullRequestsSupportedAtom, + issuesSupportedAtom, }; } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4763076639b..b01ef6440a7b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -112,6 +112,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ servers from before the pull-request workspace shipped, so clients must not probe them. */ pullRequests: Schema.optionalKey(Schema.Boolean), pullRequestChecks: Schema.optionalKey(Schema.Boolean), + /** Server exposes the `issues.*` list, detail, and mutation APIs. Absent before issue tracking + shipped, so clients must not probe them. */ + issues: Schema.optionalKey(Schema.Boolean), /** Server understands canonical inline context links plus their message context records. Absent on servers from before inline context shipped, which drop the records and forward the links as literal text -- so a client must serialize context the legacy way for them. */ From 7e98ff84d657c420971d7106cdf708f96fc8beb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:51 -0700 Subject: [PATCH 09/54] feat(issues): read and edit GitLab issues GitLab issues come through the same `glab api` the merge request reads use, so one login per host serves both. Listing is GitLab's own REST issues query paged by page number; the detail joins the issue, its notes (split into comments and the system notes GitLab writes as events), label events, closing and related merge requests, and award emoji read through one GraphQL query shaped like the merge request one. Co-Authored-By: Claude Fable 5.1 --- packages/source-control-gitlab/package.json | 13 + .../src/server/GitLabIssueTracker.test.ts | 221 +++++++ .../src/server/GitLabIssueTracker.ts | 585 ++++++++++++++++++ .../src/server/gitLabIssueJson.ts | 410 ++++++++++++ .../src/server/issueTrackerDriver.ts | 15 + pnpm-lock.yaml | 9 + 6 files changed, 1253 insertions(+) create mode 100644 packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts create mode 100644 packages/source-control-gitlab/src/server/GitLabIssueTracker.ts create mode 100644 packages/source-control-gitlab/src/server/gitLabIssueJson.ts create mode 100644 packages/source-control-gitlab/src/server/issueTrackerDriver.ts diff --git a/packages/source-control-gitlab/package.json b/packages/source-control-gitlab/package.json index aafefa73fb4b..0ebd55f06287 100644 --- a/packages/source-control-gitlab/package.json +++ b/packages/source-control-gitlab/package.json @@ -19,6 +19,18 @@ "types": "./src/server/GitLabCli.ts", "import": "./src/server/GitLabCli.ts" }, + "./server/gitLabIssueJson": { + "types": "./src/server/gitLabIssueJson.ts", + "import": "./src/server/gitLabIssueJson.ts" + }, + "./server/GitLabIssueTracker": { + "types": "./src/server/GitLabIssueTracker.ts", + "import": "./src/server/GitLabIssueTracker.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" + }, "./server/gitLabMergeRequestJson": { "types": "./src/server/gitLabMergeRequestJson.ts", "import": "./src/server/gitLabMergeRequestJson.ts" @@ -46,6 +58,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", "effect": "catalog:" diff --git a/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts b/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts new file mode 100644 index 000000000000..3fbcde96f3b1 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabIssueTracker.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabIssueTracker from "./GitLabIssueTracker.ts"; + +const encode = (value: unknown) => JSON.stringify(value); + +const ISSUE = { + iid: 42, + title: "Composer loses focus", + web_url: "https://gitlab.com/acme/web/-/issues/42", + description: "Typing in the composer drops focus after a paste.", + author: { id: 1, username: "octocat", name: "Octo Cat", avatar_url: "https://avatars/octocat" }, + state: "closed", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-03T00:00:00Z", + closed_at: "2026-07-03T00:00:00Z", + assignees: [{ id: 2, username: "theo", name: null }], + labels: ["bug", " "], + user_notes_count: 3, +}; + +/** Every endpoint the detail read touches, answered by the path `glab api` is given. */ +function answer(path: string): string { + if (path === "user") return encode({ username: "julius" }); + if (path === "projects/acme%2Fweb/issues/42") return encode(ISSUE); + if (path.startsWith("projects/acme%2Fweb/issues/42/notes")) { + return encode([ + { + id: 7, + body: "Repro attached.", + author: { username: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { + id: 8, + body: "closed", + author: { username: "theo" }, + created_at: "2026-07-03T00:00:00Z", + system: true, + }, + { + id: 9, + body: "added ~7 label", + author: { username: "theo" }, + created_at: "2026-07-03T00:00:00Z", + system: true, + }, + ]); + } + if (path.startsWith("projects/acme%2Fweb/issues/42/resource_label_events")) { + return encode([ + { + id: 3, + action: "add", + created_at: "2026-07-01T01:00:00Z", + user: { username: "theo" }, + label: { name: "bug" }, + }, + ]); + } + if (path.startsWith("projects/acme%2Fweb/issues/42/closed_by")) { + return encode([ + { + iid: 50, + title: "fix(web): keep composer focus", + web_url: "https://gitlab.com/acme/web/-/merge_requests/50", + state: "merged", + references: { full: "acme/web!50" }, + }, + ]); + } + if (path.startsWith("projects/acme%2Fweb/issues/42/related_merge_requests")) { + return encode([ + { + iid: 50, + title: "fix(web): keep composer focus", + web_url: "https://gitlab.com/acme/web/-/merge_requests/50", + state: "merged", + references: { full: "acme/web!50" }, + }, + { + iid: 51, + title: "chore: mention", + web_url: "https://gitlab.com/acme/web/-/merge_requests/51", + state: "opened", + draft: true, + references: { full: "acme/web!51" }, + }, + ]); + } + if (path === "graphql") { + return encode({ + data: { + currentUser: { username: "julius" }, + project: { + mergeRequest: { + awardEmoji: { nodes: [{ name: "eyes", user: { username: "theo" } }] }, + notes: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + id: "gid://gitlab/Note/7", + awardEmoji: { + nodes: [ + { name: "thumbsup", user: { username: "julius" } }, + { name: "thumbsup", user: { username: "theo" } }, + ], + }, + }, + ], + }, + }, + }, + }, + }); + } + throw new Error(`unexpected path ${path}`); +} + +function tracker(onExecute: (args: ReadonlyArray) => string) { + return GitLabIssueTracker.make.pipe( + Effect.provide( + Layer.mock(GitLabCli.GitLabCli)({ + execute: ({ args }) => Effect.succeed(TestSourceControlHost.processOutput(onExecute(args))), + }), + ), + ); +} + +describe("GitLabIssueTracker", () => { + it.effect("joins the detail's endpoints into the neutral issue shape", () => + Effect.gen(function* () { + const api = yield* tracker((args) => answer(args[1]!)); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "gitlab.com", + scope: "acme/web", + key: "42", + }); + expect(issue).toMatchObject({ + key: "42", + displayKey: "#42", + state: { category: "closed", name: "closed" }, + author: { login: "octocat", name: "Octo Cat" }, + assignees: [{ login: "theo" }], + labels: [{ name: "bug", color: null }], + commentCount: 3, + viewer: "julius", + viewerPermissions: { setState: true, edit: true, labels: true, assignees: true }, + commentsCursor: null, + reactions: [{ content: "eyes", count: 1, actors: ["theo"], viewerHasReacted: false }], + }); + expect(issue.comments).toEqual([ + { + id: "7", + author: { login: "julius", name: null, avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00Z", + url: null, + reactions: [{ content: "thumbs-up", count: 2, actors: ["theo"], viewerHasReacted: true }], + }, + ]); + // System notes become events; label notes are dropped in favour of the label events. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["labeled", "bug"], + ["closed", null], + ]); + // A closing merge request is not repeated as a related one. + expect( + issue.linkedPullRequests.map((link) => [link.number, link.closesIssue, link.isDraft]), + ).toEqual([ + [50, true, false], + [51, false, true], + ]); + }), + ); + + it.effect("pages a listing by GitLab's page number and stops on a short page", () => + Effect.gen(function* () { + const paths: string[] = []; + const api = yield* tracker((args) => { + paths.push(args[1]!); + return encode([ISSUE]); + }); + const page = yield* api.listIssues({ + cwd: "/w", + host: "gitlab.com", + scope: "acme/web", + state: "open", + involvement: "assigned", + viewer: "julius", + limit: 1, + cursor: "3", + }); + expect(paths[0]).toBe( + "projects/acme%2Fweb/issues?state=opened&assignee_username=julius&order_by=updated_at&sort=desc&per_page=1&page=3", + ); + expect(page.items[0]?.key).toBe("42"); + expect(page.nextCursor).toBe("4"); + }), + ); + + it.effect("refuses a key that names no GitLab issue without a request", () => + Effect.gen(function* () { + let requests = 0; + const api = yield* tracker(() => { + requests += 1; + return "{}"; + }); + const error = yield* Effect.flip( + api.getIssue({ cwd: "/w", host: "gitlab.com", scope: "acme/web", key: "ENG-42" }), + ); + expect(error.reason).toBe("not-found"); + expect(requests).toBe(0); + }), + ); +}); diff --git a/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts new file mode 100644 index 000000000000..d83e739610d4 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabIssueTracker.ts @@ -0,0 +1,585 @@ +/** + * GitLab's issue tracker over `glab api`, which signs every request with the credentials the + * checkout's remote resolves to, the way the merge request reads do. + * + * GitLab pages by offset and reports no total, so every cursor here is a page number and a full + * page is the only sign that more exists. Reads that GitLab keeps on separate endpoints (notes, + * label events, linked merge requests, awards) are read one page each and joined into the detail. + * + * @module source-control-gitlab/server/GitLabIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, + type IssueViewerPermissions, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as GitLabCli from "./GitLabCli.ts"; +import { + decodeCreatedIssueJson, + decodeIssueDetailJson, + decodeIssueListJson, + decodeIssueNotesJson, + decodeLabelEventsJson, + decodeLinkedMergeRequestsJson, + decodeProjectLabelsJson, + decodeProjectMembersJson, + decodeTemplateEntriesJson, + decodeTemplateJson, + ISSUE_AWARD_EMOJI_GRAPHQL_QUERY, + PAGE_SIZE, + type GitLabTemplateEntry, +} from "./gitLabIssueJson.ts"; +import { + decodeAwardEmojiJson, + decodeOwnAwardIdJson, + decodeViewerJson, + gitLabAwardName, +} from "./gitLabMergeRequestJson.ts"; + +const KIND = IssueTrackerKind.make("gitlab"); + +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: true, + templates: true, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + // GitLab records nothing about why an issue was closed. + closeReasons: [], +}; + +/** + * Everything granted. GitLab's REST API reports the viewer's role on the project but never whether + * they opened this issue, and an issue's author may close, reopen and edit it whatever their role. + * Withholding those controls from the one person entitled to them is the worse mistake, so they + * are offered and GitLab explains any refusal itself. + */ +const VIEWER_PERMISSIONS: IssueViewerPermissions = { + setState: true, + comment: true, + edit: true, + labels: true, + assignees: true, +}; + +/** How many of a scope's templates are read in full; each one is a request of its own. */ +const TEMPLATE_LIMIT = 20; + +const encodeBody = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** The REST API addresses a project by its URL-encoded full path. */ +function projectPath(scope: string): string { + return encodeURIComponent(scope.trim()); +} + +/** An issue key is its iid; anything else names no GitLab issue. */ +function parseNumber(key: string): number | null { + return /^[1-9]\d*$/.test(key) ? Number(key) : null; +} + +/** A page cursor this tracker handed out, or null for anything it could not have issued. */ +function parsePage(cursor: string | undefined): number | null { + if (cursor === undefined) return 1; + return /^[1-9]\d{0,6}$/.test(cursor) ? Number(cursor) : null; +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +function involvementParams(input: { + readonly involvement: IssueInvolvement; + readonly viewer: string; +}): ReadonlyArray { + switch (input.involvement) { + case "authored": + return [["author_username", input.viewer]]; + case "assigned": + return [["assignee_username", input.viewer]]; + // GitLab's `my_reaction_emoji` and `iteration` filters aside, nothing narrows to mentions, so + // the listing stays as wide as `all` rather than guessing. + case "mentioned": + case "all": + return []; + } +} + +function reasonFor(error: GitLabCli.GitLabCliError): IssueTrackerError["reason"] { + switch (error._tag) { + case "GitLabCliUnavailableError": + return "missing-tool"; + case "GitLabCliAuthenticationError": + return "unauthenticated"; + case "GitLabCliRateLimitError": + return "rate-limited"; + default: + return "failed"; + } +} + +export const make: Effect.Effect = Effect.gen( + function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const fail = (operation: string) => (error: GitLabCli.GitLabCliError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.message, + cause: error, + }); + + const unreadable = (operation: string, cause: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail: `GitLab returned an unreadable ${operation} response.`, + cause, + }); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "GitLab could not find the requested issue.", + }); + + const refused = (operation: string, detail: string) => + new IssueTrackerError({ tracker: KIND, operation, reason: "failed", detail }); + + const api = (input: { + readonly cwd: string; + readonly operation: string; + readonly path: string; + readonly method?: string; + readonly body?: unknown; + }) => + gitlab + .execute({ + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + // A JSON body from stdin: argv is visible in process listings, and `glab api --input` + // sends no Content-Type of its own, which GitLab answers with HTTP 415. + ...(input.body === undefined + ? [] + : ["--input", "-", "--header", "Content-Type: application/json"]), + ], + ...(input.body === undefined ? {} : { stdin: encodeBody(input.body) }), + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(fail(input.operation)), + ); + + const read = (input: { + readonly cwd: string; + readonly operation: string; + readonly path: string; + readonly method?: string; + readonly body?: unknown; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api(input).pipe( + Effect.flatMap((raw) => { + const decoded = input.decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(unreadable(input.operation, decoded.failure)); + }), + ); + + const write = (input: { + readonly cwd: string; + readonly operation: string; + readonly path: string; + readonly method: string; + readonly body?: unknown; + }) => api(input).pipe(Effect.asVoid); + + /** The signed-in account; an answer naming nobody is a failure, since every comparison needs a login. */ + const viewer = (input: { + readonly cwd: string; + readonly operation: string; + }): Effect.Effect => + read({ + cwd: input.cwd, + operation: input.operation, + path: "user", + decode: decodeViewerJson, + }).pipe( + Effect.filterOrFail( + (login) => login !== null, + () => refused(input.operation, "GitLab returned no username for the signed-in account."), + ), + ); + + /** The issue as REST addresses it, or a not-found for a key that names no iid. */ + const issuePath = ( + input: { readonly scope: string; readonly key: string }, + operation: string, + ): Effect.Effect<{ readonly number: number; readonly path: string }, IssueTrackerError> => { + const number = parseNumber(input.key); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ number, path: `projects/${projectPath(input.scope)}/issues/${number}` }); + }; + + /** GitLab names assignees by numeric id, which is what the candidates carry as `id`. */ + const assigneeIds = ( + operation: string, + assignees: ReadonlyArray, + ): Effect.Effect, IssueTrackerError> => { + const ids = assignees.map((id) => (/^[1-9]\d*$/.test(id) ? Number(id) : null)); + return ids.every((id) => id !== null) + ? Effect.succeed(ids) + : Effect.fail(refused(operation, "GitLab assignees are named by their user id.")); + }; + + const paged = (path: string, page: number) => + `${path}?${query([ + ["per_page", String(PAGE_SIZE)], + ["page", String(page)], + ])}`; + + const notesPage = (input: { + readonly cwd: string; + readonly path: string; + readonly page: number; + readonly operation: string; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + path: `${input.path}/notes?${query([ + ["per_page", String(PAGE_SIZE)], + ["page", String(input.page)], + ["order_by", "created_at"], + ["sort", "asc"], + ])}`, + decode: decodeIssueNotesJson, + }).pipe( + Effect.map((notes) => ({ + ...notes, + nextCursor: notes.rawCount < PAGE_SIZE ? null : String(input.page + 1), + })), + ); + + const awards = (input: { + readonly cwd: string; + readonly scope: string; + readonly number: number; + readonly operation: string; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + path: "graphql", + method: "POST", + body: { + query: ISSUE_AWARD_EMOJI_GRAPHQL_QUERY, + variables: { fullPath: input.scope.trim(), iid: String(input.number) }, + }, + decode: decodeAwardEmojiJson, + }); + + const linked = (input: { + readonly cwd: string; + readonly path: string; + readonly endpoint: "closed_by" | "related_merge_requests"; + }) => + read({ + cwd: input.cwd, + operation: "getIssue", + path: paged(`${input.path}/${input.endpoint}`, 1), + decode: (raw) => decodeLinkedMergeRequestsJson(raw, input.endpoint === "closed_by"), + }); + + const template = + (input: { readonly cwd: string; readonly scope: string }) => (entry: GitLabTemplateEntry) => + read({ + cwd: input.cwd, + operation: "listTemplates", + path: `projects/${projectPath(input.scope)}/templates/issues/${encodeURIComponent(entry.key)}`, + decode: (raw) => decodeTemplateJson(entry, raw), + }); + + return { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => viewer({ cwd: input.cwd, operation: "getViewer" }), + + listIssues: (input) => { + const page = parsePage(input.cursor); + if (page === null) { + return Effect.fail(refused("listIssues", "The cursor was not one GitLab handed out.")); + } + const perPage = Math.min(Math.max(input.limit, 1), PAGE_SIZE); + const text = input.query?.trim() ?? ""; + return read({ + cwd: input.cwd, + operation: "listIssues", + path: `projects/${projectPath(input.scope)}/issues?${query([ + ["state", input.state === "open" ? "opened" : input.state], + ...involvementParams(input), + // GitLab's own `search` matches title and description, URL-encoded like every other + // value here, so no text in it can become a parameter of its own. + ...(text.length === 0 ? [] : [["search", text] as const]), + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(perPage)], + ["page", String(page)], + ])}`, + decode: decodeIssueListJson, + }).pipe( + Effect.map((list) => ({ + items: list.items, + nextCursor: list.rawCount < perPage ? null : String(page + 1), + })), + ); + }, + + getIssue: (input) => + issuePath(input, "getIssue").pipe( + Effect.flatMap(({ number, path }) => + Effect.all( + [ + read({ + cwd: input.cwd, + operation: "getIssue", + path, + decode: decodeIssueDetailJson, + }), + viewer({ cwd: input.cwd, operation: "getIssue" }), + notesPage({ cwd: input.cwd, path, page: 1, operation: "getIssue" }), + read({ + cwd: input.cwd, + operation: "getIssue", + path: paged(`${path}/resource_label_events`, 1), + decode: decodeLabelEventsJson, + }), + linked({ cwd: input.cwd, path, endpoint: "closed_by" }), + linked({ cwd: input.cwd, path, endpoint: "related_merge_requests" }), + awards({ cwd: input.cwd, scope: input.scope, number, operation: "getIssue" }), + ], + { concurrency: 4 }, + ), + ), + Effect.map(([issue, viewerLogin, notes, labelEvents, closing, related, awardPage]) => { + const closingKeys = new Set(closing.map((link) => `${link.repository}!${link.number}`)); + return { + ...issue, + viewer: viewerLogin, + viewerPermissions: VIEWER_PERMISSIONS, + reactions: awardPage.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awardPage.reactionsByNoteId.get(comment.id) ?? [], + })), + commentsCursor: notes.nextCursor, + events: [...notes.events, ...labelEvents].sort((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + // A merge request that closes the issue outranks its sighting as a mere mention. + linkedPullRequests: [ + ...closing, + ...related.filter((link) => !closingKeys.has(`${link.repository}!${link.number}`)), + ], + }; + }), + ), + + getComments: (input) => { + const page = parsePage(input.cursor); + return page === null + ? Effect.fail(refused("getComments", "The cursor was not one GitLab handed out.")) + : issuePath(input, "getComments").pipe( + Effect.flatMap(({ path }) => + notesPage({ cwd: input.cwd, path, page, operation: "getComments" }), + ), + Effect.map((notes) => ({ comments: notes.comments, nextCursor: notes.nextCursor })), + ); + }, + + getViewerPermissions: () => Effect.succeed(VIEWER_PERMISSIONS), + + create: (input) => + assigneeIds("create", input.assignees).pipe( + Effect.flatMap((assignee_ids) => + read({ + cwd: input.cwd, + operation: "create", + path: `projects/${projectPath(input.scope)}/issues`, + method: "POST", + body: { + title: input.title, + description: input.body, + labels: input.labels.join(","), + assignee_ids, + }, + decode: decodeCreatedIssueJson, + }), + ), + Effect.map((issue) => ({ + key: String(issue.number), + displayKey: `#${issue.number}`, + url: issue.url, + })), + ), + + comment: (input) => + issuePath(input, "comment").pipe( + Effect.flatMap(({ path }) => + write({ + cwd: input.cwd, + operation: "comment", + path: `${path}/notes`, + method: "POST", + body: { body: input.body }, + }), + ), + ), + + update: (input) => + Effect.all([ + issuePath(input, "update"), + input.assignees === undefined ? Effect.void : assigneeIds("update", input.assignees), + ]).pipe( + Effect.flatMap(([{ path }, assignee_ids]) => + write({ + cwd: input.cwd, + operation: "update", + path, + method: "PUT", + body: { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + ...(input.labels === undefined ? {} : { labels: input.labels.join(",") }), + ...(assignee_ids === undefined ? {} : { assignee_ids }), + }, + }), + ), + ), + + setState: (input) => + issuePath(input, "setState").pipe( + Effect.flatMap(({ path }) => + write({ + cwd: input.cwd, + operation: "setState", + path, + method: "PUT", + body: { state_event: input.state === "closed" ? "close" : "reopen" }, + }), + ), + ), + + setReaction: (input) => + issuePath(input, "setReaction").pipe( + Effect.flatMap(({ path }) => { + if (input.subjectId !== undefined && !/^[1-9]\d*$/.test(input.subjectId)) { + return Effect.fail(refused("setReaction", "The comment id names no GitLab note.")); + } + const subject = + input.subjectId === undefined + ? `${path}/award_emoji` + : `${path}/notes/${input.subjectId}/award_emoji`; + if (input.reacted) { + return write({ + cwd: input.cwd, + operation: "setReaction", + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", + }); + } + // GitLab deletes an award by its id and takes no emoji name there, so the reader's own + // award of that name is looked up first. Nothing to delete is success. + return viewer({ cwd: input.cwd, operation: "setReaction" }).pipe( + Effect.flatMap((login) => + read({ + cwd: input.cwd, + operation: "setReaction", + path: paged(subject, 1), + decode: (raw) => + decodeOwnAwardIdJson(raw, { content: input.content, viewer: login }), + }), + ), + Effect.flatMap((own) => + own === null + ? Effect.void + : write({ + cwd: input.cwd, + operation: "setReaction", + path: `${subject}/${own}`, + method: "DELETE", + }), + ), + ); + }), + ), + + listTemplates: (input) => + read({ + cwd: input.cwd, + operation: "listTemplates", + path: `projects/${projectPath(input.scope)}/templates/issues`, + decode: decodeTemplateEntriesJson, + }).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries.slice(0, TEMPLATE_LIMIT), template(input), { concurrency: 4 }), + ), + // GitLab has no switch that forbids an issue filed from nothing. + Effect.map((templates) => ({ templates, blankIssuesEnabled: true })), + ), + + listCandidates: (input) => + Effect.all( + [ + read({ + cwd: input.cwd, + operation: "listCandidates", + path: paged(`projects/${projectPath(input.scope)}/labels`, 1), + decode: decodeProjectLabelsJson, + }), + read({ + cwd: input.cwd, + operation: "listCandidates", + path: paged(`projects/${projectPath(input.scope)}/members/all`, 1), + decode: decodeProjectMembersJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([labels, assignees]) => ({ + labels, + assignees, + truncated: labels.length >= PAGE_SIZE || assignees.length >= PAGE_SIZE, + })), + ), + }; + }, +); diff --git a/packages/source-control-gitlab/src/server/gitLabIssueJson.ts b/packages/source-control-gitlab/src/server/gitLabIssueJson.ts new file mode 100644 index 000000000000..6ff373a13617 --- /dev/null +++ b/packages/source-control-gitlab/src/server/gitLabIssueJson.ts @@ -0,0 +1,410 @@ +/** + * Decoders for GitLab issues as `glab api` answers them. REST enums are decoded as plain strings + * and normalized here, so a GitLab release that adds a state or a label action never fails a + * whole payload; a row that cannot be read is skipped rather than failing its page. + * + * @module source-control-gitlab/server/gitLabIssueJson + */ +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + IssueActor, + IssueAssigneeCandidate, + IssueComment, + IssueEvent, + IssueEventKind, + IssueLabel, + IssueLabelCandidate, + IssueLinkedPullRequest, + IssueTemplate, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +/** GitLab's ceiling on `per_page`, which is what every bounded read asks for. */ +export const PAGE_SIZE = 100; + +const RawUserSchema = Schema.Struct({ + /** GitLab writes an issue's assignees as numeric ids, so the id travels with the handle. */ + id: Schema.optional(Schema.Int), + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssueSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.String, + updated_at: Schema.String, + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + assignees: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + user_notes_count: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +const RawNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + /** True for the notes GitLab writes itself ("closed", "assigned to @x"), which are events. */ + system: Schema.optional(Schema.Boolean), +}); + +const RawLabelEventSchema = Schema.Struct({ + id: Schema.Int, + action: Schema.String, + created_at: Schema.String, + user: Schema.optional(Schema.NullOr(RawUserSchema)), + /** Null once the label itself has been deleted, which leaves the event with no subject. */ + label: Schema.optional(Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.String) }))), +}); + +const RawLinkedMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + /** `full` is `group/project!12`, which is the only place the merge request names its project. */ + references: Schema.optional( + Schema.NullOr(Schema.Struct({ full: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const RawProjectLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A row of `GET /projects/:id/templates/issues`, which names a template but carries none of it. */ +const RawTemplateEntrySchema = Schema.Struct({ + key: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawTemplateSchema = Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)) }); + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeIssue = decodeJsonResult(RawIssueSchema); +const decodeIssueEntry = Schema.decodeUnknownExit(RawIssueSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); +const decodeLabelEventEntry = Schema.decodeUnknownExit(RawLabelEventSchema); +const decodeLinkedMergeRequestEntry = Schema.decodeUnknownExit(RawLinkedMergeRequestSchema); +const decodeProjectLabelEntry = Schema.decodeUnknownExit(RawProjectLabelSchema); +const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); +const decodeTemplateEntry = Schema.decodeUnknownExit(RawTemplateEntrySchema); +const decodeTemplate = decodeJsonResult(RawTemplateSchema); + +type DecodeFailure = + ReturnType extends Result.Result ? E : never; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: typeof RawUserSchema.Type | null | undefined): IssueActor | null { + const login = trimmed(raw?.username); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; +} + +/** GitLab returns label names only on an issue, so there is no colour to carry. */ +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label); + return name === null ? [] : [{ name, color: null }]; + }); +} + +function toSummary(raw: typeof RawIssueSchema.Type): TrackerIssueSummary { + return { + key: String(raw.iid), + displayKey: `#${raw.iid}`, + title: raw.title, + url: raw.web_url, + author: toActor(raw.author), + // GitLab has exactly the two states, calls the open one `opened`, and records no reason. + state: + raw.state?.trim().toLowerCase() === "closed" + ? { category: "closed", name: "closed" } + : { category: "open", name: "open" }, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + closedAt: trimmed(raw.closed_at), + assignees: (raw.assignees ?? []).flatMap((user) => toActor(user) ?? []), + labels: toLabels(raw.labels), + commentCount: Math.max(0, raw.user_notes_count ?? 0), + }; +} + +interface GitLabIssueList { + readonly items: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +export function decodeIssueListJson(raw: string): Result.Result { + return Result.map(decodeUnknownList(raw), (rows) => ({ + items: rows.flatMap((entry) => { + const issue = decodeIssueEntry(entry); + return Exit.isSuccess(issue) ? [toSummary(issue.value)] : []; + }), + rawCount: rows.length, + })); +} + +interface GitLabIssueDetail extends TrackerIssueSummary { + readonly body: string; +} + +export function decodeIssueDetailJson( + raw: string, +): Result.Result { + return Result.map(decodeIssue(raw), (issue) => ({ + ...toSummary(issue), + body: issue.description ?? "", + })); +} + +export function decodeCreatedIssueJson( + raw: string, +): Result.Result<{ readonly number: number; readonly url: string }, DecodeFailure> { + return Result.map(decodeIssue(raw), (issue) => ({ number: issue.iid, url: issue.web_url })); +} + +/** + * The events GitLab records as notes of its own. A label note names its labels by id ("added ~7 + * label"), which is why labellings come from `/resource_label_events` instead and are dropped here. + */ +function toSystemEvent( + body: string, +): { readonly kind: IssueEventKind; readonly detail: string | null } | null { + const text = body.trim(); + if (text === "closed") return { kind: "closed", detail: null }; + if (text === "reopened") return { kind: "reopened", detail: null }; + if (text.startsWith("assigned to ")) { + return { + kind: "assigned", + detail: trimmed(text.slice("assigned to ".length).replace(/^@/, "")), + }; + } + if (text.startsWith("unassigned ")) { + return { + kind: "unassigned", + detail: trimmed(text.slice("unassigned ".length).replace(/^@/, "")), + }; + } + if (text.startsWith("mentioned in ")) { + return { kind: "referenced", detail: trimmed(text.slice("mentioned in ".length)) }; + } + if (text.startsWith("changed title")) { + // GitLab marks the new title with its own inline-diff braces. + return { kind: "renamed", detail: trimmed(/\{\+(.+)\+\}/.exec(text)?.[1]) }; + } + return null; +} + +interface GitLabIssueNotes { + readonly comments: ReadonlyArray; + readonly events: ReadonlyArray; + readonly rawCount: number; +} + +/** One page of notes, split into what people wrote and what GitLab wrote about the issue. */ +export function decodeIssueNotesJson(raw: string): Result.Result { + return Result.map(decodeUnknownList(raw), (rows) => { + const comments: IssueComment[] = []; + const events: IssueEvent[] = []; + for (const entry of rows) { + const note = decodeNoteEntry(entry); + if (Exit.isFailure(note)) continue; + const value = note.value; + const body = value.body ?? ""; + if (value.system === true) { + const event = toSystemEvent(body); + if (event === null) continue; + events.push({ + // Notes and label events are numbered separately, so each carries where it came from. + id: `note-${value.id}`, + kind: event.kind, + actor: toActor(value.author), + createdAt: value.created_at, + detail: event.detail, + }); + continue; + } + comments.push({ + id: String(value.id), + author: toActor(value.author), + body, + createdAt: value.created_at, + url: null, + }); + } + return { comments, events, rawCount: rows.length }; + }); +} + +export function decodeLabelEventsJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const event = decodeLabelEventEntry(entry); + if (Exit.isFailure(event)) return []; + const action = event.value.action.trim().toLowerCase(); + if (action !== "add" && action !== "remove") return []; + return [ + { + id: `label-${event.value.id}`, + kind: action === "add" ? ("labeled" as const) : ("unlabeled" as const), + actor: toActor(event.value.user), + createdAt: event.value.created_at, + detail: trimmed(event.value.label?.name), + }, + ]; + }), + ); +} + +function toPullRequestState(value: string | null | undefined): PullRequestState { + switch (value?.trim().toLowerCase()) { + case "merged": + return "merged"; + case "closed": + return "closed"; + default: + // `locked` is an open merge request whose discussion is locked. + return "open"; + } +} + +export function decodeLinkedMergeRequestsJson( + raw: string, + closesIssue: boolean, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const mergeRequest = decodeLinkedMergeRequestEntry(entry); + if (Exit.isFailure(mergeRequest)) return []; + const value = mergeRequest.value; + const repository = trimmed(value.references?.full?.split("!")[0]); + const title = trimmed(value.title); + const url = trimmed(value.web_url); + if (repository === null || title === null || url === null || value.iid <= 0) return []; + return [ + { + repository, + number: value.iid, + title, + url, + state: toPullRequestState(value.state), + isDraft: value.draft ?? value.work_in_progress ?? false, + closesIssue, + }, + ]; + }), + ); +} + +export function decodeProjectLabelsJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const label = decodeProjectLabelEntry(entry); + if (Exit.isFailure(label)) return []; + const name = trimmed(label.value.name); + return name === null + ? [] + : [ + { + name, + color: trimmed(label.value.color), + description: label.value.description ?? null, + }, + ]; + }), + ); +} + +/** + * The people with access to the project as assignees. GitLab addresses an assignment by numeric + * id and by nothing else, so a member it named no id for is dropped rather than offered under a + * handle the write would discard. + */ +export function decodeProjectMembersJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const user = decodeUserEntry(entry); + if (Exit.isFailure(user) || user.value.id === undefined) return []; + const actor = toActor(user.value); + return actor === null ? [] : [{ ...actor, id: String(user.value.id) }]; + }), + ); +} + +export interface GitLabTemplateEntry { + readonly key: string; + readonly name: string; +} + +export function decodeTemplateEntriesJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw), (rows) => + rows.flatMap((entry) => { + const template = decodeTemplateEntry(entry); + if (Exit.isFailure(template)) return []; + const key = trimmed(template.value.key); + return key === null ? [] : [{ key, name: trimmed(template.value.name) ?? key }]; + }), + ); +} + +/** One template's own answer is the markdown a description starts out as; GitLab adds nothing else. */ +export function decodeTemplateJson( + entry: GitLabTemplateEntry, + raw: string, +): Result.Result { + return Result.map(decodeTemplate(raw), (template) => ({ + key: entry.key, + name: entry.name, + about: "", + title: "", + body: template.content ?? "", + labels: [], + assignees: [], + })); +} + +/** + * Awards on the issue and on every note of it, in one read, shaped exactly like the merge request + * query so its decoder answers for both: `mergeRequest` is an alias for the issue field. + */ +export const ISSUE_AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { + currentUser { username } + project(fullPath: $fullPath) { + mergeRequest: issue(iid: $iid) { + awardEmoji { nodes { name user { username } } } + notes(first: ${PAGE_SIZE}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id awardEmoji { nodes { name user { username } } } } + } + } + } +}`; diff --git a/packages/source-control-gitlab/src/server/issueTrackerDriver.ts b/packages/source-control-gitlab/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..1470bf5bd23f --- /dev/null +++ b/packages/source-control-gitlab/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The GitLab issue tracker driver, registered beside the source control driver so the server's + * issue registry can list it without knowing the package. + * + * @module source-control-gitlab/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as GitLabIssueTracker from "./GitLabIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("gitlab"), + make: GitLabIssueTracker.make, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57482137904a..32b43f37eeef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1582,6 +1582,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/shared': specifier: workspace:* version: link:../shared @@ -1666,6 +1669,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core @@ -1765,6 +1771,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/shared': specifier: workspace:* version: link:../shared From 48753f60d51c74c5120d6dda407beec8158e2d96 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:54 -0700 Subject: [PATCH 10/54] feat(issues): read and edit Azure DevOps work items Work items belong to a project rather than a repository, so the git-host binding scopes Azure projects to `organization/project` out of the remote path, and every `az` call names the organization instead of detecting it. Listing is a WIQL query paged by a changed-date/id keyset cursor; states are the work item's own names mapped onto open and closed categories, with the stock process states offered as targets. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/issue/IssueService.test.ts | 20 + apps/server/src/issue/IssueService.ts | 17 +- .../source-control-azure-devops/package.json | 13 + .../server/AzureDevOpsIssueTracker.test.ts | 195 ++++ .../src/server/AzureDevOpsIssueTracker.ts | 892 ++++++++++++++++++ .../src/server/azureDevOpsIssueJson.ts | 366 +++++++ .../src/server/issueTrackerDriver.ts | 15 + 7 files changed, 1517 insertions(+), 1 deletion(-) create mode 100644 packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts create mode 100644 packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts create mode 100644 packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts create mode 100644 packages/source-control-azure-devops/src/server/issueTrackerDriver.ts diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index e52f3d142b38..1b44f0d7c8a7 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -132,6 +132,26 @@ function makeService(input: { ); } +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({ 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* () { diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index a3c168497039..0d4a06840afe 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -70,13 +70,28 @@ interface SupportedProject extends ProjectTrackerBinding { 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. */ function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; - const scope = sourceControlRepositorySelector(identity); + 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; return { diff --git a/packages/source-control-azure-devops/package.json b/packages/source-control-azure-devops/package.json index 456c9b1d0148..f19a2a2f0459 100644 --- a/packages/source-control-azure-devops/package.json +++ b/packages/source-control-azure-devops/package.json @@ -15,6 +15,14 @@ "types": "./src/server/azureDevOpsDiff.ts", "import": "./src/server/azureDevOpsDiff.ts" }, + "./server/azureDevOpsIssueJson": { + "types": "./src/server/azureDevOpsIssueJson.ts", + "import": "./src/server/azureDevOpsIssueJson.ts" + }, + "./server/AzureDevOpsIssueTracker": { + "types": "./src/server/AzureDevOpsIssueTracker.ts", + "import": "./src/server/AzureDevOpsIssueTracker.ts" + }, "./server/AzureDevOpsPullRequestCli": { "types": "./src/server/AzureDevOpsPullRequestCli.ts", "import": "./src/server/AzureDevOpsPullRequestCli.ts" @@ -38,6 +46,10 @@ "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" } }, "scripts": { @@ -46,6 +58,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", "diff": "8.0.3", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts new file mode 100644 index 000000000000..d17c2a27ce68 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import * as AzureDevOpsIssueTracker from "./AzureDevOpsIssueTracker.ts"; + +const encode = (value: unknown) => JSON.stringify(value); + +const WORK_ITEM = { + id: 7, + url: "https://dev.azure.com/acme/_apis/wit/workItems/7", + fields: { + "System.Title": "Composer loses focus", + "System.State": "Resolved", + "System.CreatedDate": "2026-07-01T00:00:00Z", + "System.ChangedDate": "2026-07-03T00:00:00Z", + "Microsoft.VSTS.Common.ClosedDate": "2026-07-03T00:00:00Z", + "System.CreatedBy": { uniqueName: "octo@acme.test", displayName: "Octo Cat" }, + "System.AssignedTo": "Theo ", + "System.Tags": "bug; ui; bug", + "System.TeamProject": "Web App", + "System.CommentCount": 2, + "System.Description": "

Typing drops focus.

", + }, + relations: [ + { rel: "ArtifactLink", url: "vstfs:///Git/PullRequestId/p-guid%2Fr-guid%2F50" }, + { rel: "System.LinkTypes.Related", url: "https://dev.azure.com/acme/_apis/wit/workItems/8" }, + ], +}; + +/** Every `az` command the detail read runs, told apart by its first few arguments. */ +function answer(args: ReadonlyArray): string { + const resource = args[args.indexOf("--resource") + 1]; + if (args[0] === "boards") return encode(WORK_ITEM); + if (args[0] === "repos") { + return encode({ + pullRequestId: 50, + title: "fix(web): keep composer focus", + status: "completed", + isDraft: false, + sourceRefName: "refs/heads/fix", + targetRefName: "refs/heads/main", + creationDate: "2026-07-02T00:00:00Z", + closedDate: "2026-07-03T00:00:00Z", + mergeStatus: "succeeded", + repository: { + id: "r-guid", + name: "web", + webUrl: "https://dev.azure.com/acme/Web%20App/_git/web", + project: { id: "p-guid", name: "Web App" }, + }, + createdBy: { uniqueName: "theo@acme.test", displayName: "Theo" }, + description: "", + reviewers: [], + _links: { web: { href: "https://dev.azure.com/acme/Web%20App/_git/web/pullrequest/50" } }, + }); + } + if (resource === "comments") { + return encode({ + continuationToken: "next-token", + comments: [ + { + id: 1, + text: "Repro attached.", + createdDate: "2026-07-02T00:00:00Z", + createdBy: { uniqueName: "julius@acme.test", displayName: "Julius" }, + reactions: [ + { type: "like", count: 2, isCurrentUserEngaged: true }, + { type: "dislike", count: 0 }, + ], + }, + { id: 2, text: "gone", createdDate: "2026-07-02T00:00:00Z", isDeleted: true }, + ], + }); + } + if (resource === "updates") { + return encode({ + value: [ + { + id: 1, + revisedDate: "2026-07-01T00:00:00Z", + fields: { "System.Title": { newValue: "x" } }, + }, + { + id: 2, + revisedDate: "2026-07-03T00:00:00Z", + revisedBy: { uniqueName: "theo@acme.test" }, + fields: { + "System.State": { oldValue: "Active", newValue: "Resolved" }, + "System.Tags": { oldValue: "ui", newValue: "bug; ui" }, + }, + }, + ], + }); + } + throw new Error(`unexpected az invocation ${args.join(" ")}`); +} + +function tracker(onExecute: (args: ReadonlyArray) => string) { + return AzureDevOpsIssueTracker.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeFileSystem.layer, + Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ + execute: ({ args }) => + Effect.succeed(TestSourceControlHost.processOutput(onExecute(args))), + }), + ), + ), + ); +} + +describe("AzureDevOpsIssueTracker", () => { + it.effect("maps a work item, its conversation and revisions to the neutral issue shape", () => + Effect.gen(function* () { + const seen: string[][] = []; + const api = yield* tracker((args) => { + seen.push([...args]); + return answer(args); + }); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "dev.azure.com", + scope: "acme/Web App", + key: "7", + }); + // Every command names the organization rather than detecting it from the checkout. + for (const args of seen) { + expect(args).toContain("https://dev.azure.com/acme"); + } + expect(issue).toMatchObject({ + key: "7", + displayKey: "#7", + title: "Composer loses focus", + url: "https://dev.azure.com/acme/Web%20App/_workitems/edit/7", + // The project's own state name survives; the category is what the list filters by. + state: { category: "closed", name: "Resolved" }, + author: { login: "octo@acme.test", name: "Octo Cat" }, + assignees: [{ login: "Theo " }], + labels: [ + { name: "bug", color: null }, + { name: "ui", color: null }, + ], + commentCount: 2, + body: "

Typing drops focus.

", + commentsCursor: "next-token", + viewerPermissions: { setState: true, edit: true, labels: true, assignees: true }, + }); + expect(issue.comments).toEqual([ + { + id: "1", + author: { login: "julius@acme.test", name: "Julius", avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00Z", + url: null, + reactions: [{ content: "thumbs-up", count: 2, actors: [], viewerHasReacted: true }], + }, + ]); + // The first revision is the creation; a state move across the category line is a close. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["closed", "Resolved"], + ["labeled", "bug"], + ]); + expect(issue.linkedPullRequests).toEqual([ + { + repository: "Web App/web", + number: 50, + title: "fix(web): keep composer focus", + url: "https://dev.azure.com/acme/Web%20App/_git/web/pullrequest/50", + state: "merged", + isDraft: false, + closesIssue: false, + }, + ]); + }), + ); + + it.effect("refuses a scope that is not organization/project without a request", () => + Effect.gen(function* () { + let requests = 0; + const api = yield* tracker(() => { + requests += 1; + return "{}"; + }); + const error = yield* Effect.flip( + api.getIssue({ cwd: "/w", host: "dev.azure.com", scope: "web", key: "7" }), + ); + expect(error.reason).toBe("failed"); + expect(requests).toBe(0); + }), + ); +}); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts new file mode 100644 index 000000000000..1122ef4aafd6 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsIssueTracker.ts @@ -0,0 +1,892 @@ +/** + * Azure DevOps work items as an issue tracker, over the same `az` the pull request reads use. + * + * Work items belong to a project rather than a repository, so the scope is `organization/project` + * and every call names the organization explicitly instead of detecting it from the checkout. + * Listing is a WIQL query with `timePrecision`, paged by a `(changedDate, id)` keyset cursor + * because WIQL has no offset; the rest of the reads are `az boards` commands and `wit` REST + * routes reached through `az devops invoke`, which signs in the way the extension does. + * + * @module source-control-azure-devops/server/AzureDevOpsIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, + type IssueLinkedPullRequest, + type IssueViewerPermissions, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; +import { + azureDevOpsReactionType, + decodeCommentsJson, + decodeProfileJson, + decodeUpdatesJson, + decodeWorkItemJson, + decodeWorkItemsJson, + pullRequestNumberOf, + type AzureDevOpsWorkItem, +} from "./azureDevOpsIssueJson.ts"; +import { decodePullRequestJson } from "./azureDevOpsPullRequestJson.ts"; + +const KIND = IssueTrackerKind.make("azure-devops"); + +/** + * The states the stock Basic, Agile and Scrum processes carry, which is what a project that has + * not customised its workflow can be moved between. A project with states of its own refuses a + * name it lacks at write time, with Azure's own message. + */ +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + // On comments only; a work item itself takes none, and `setReaction` says so. + reactions: true, + // Azure has no issue templates. Its work item types are picked here, not by the reader. + templates: false, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "New" }, + { category: "open", name: "To Do" }, + { category: "open", name: "Active" }, + { category: "open", name: "Doing" }, + { category: "closed", name: "Resolved" }, + { category: "closed", name: "Done" }, + { category: "closed", name: "Closed" }, + { category: "closed", name: "Removed" }, + ], + closeReasons: [], +}; + +/** + * Everything granted. Azure states no permission anywhere `az boards` reaches: the answer lives + * in the security namespaces, behind identity descriptors that would be several calls per work + * item to resolve. Controls stay live and a viewer who may not use one is told so by Azure. + */ +const VIEWER_PERMISSIONS: IssueViewerPermissions = { + setState: true, + comment: true, + edit: true, + labels: true, + assignees: true, +}; + +const REST_API_VERSION = "7.1"; +const PAGE_SIZE = 100; +/** Linked pull requests are read one `az` process each, so a work item's links are bounded. */ +const LINKED_PULL_REQUEST_LIMIT = 10; +/** Which work item type a new issue is filed as, in order of preference across the stock processes. */ +const CREATE_TYPES = ["Issue", "Bug", "Task", "User Story", "Product Backlog Item"]; + +const LIST_FIELDS = [ + "System.Id", + "System.Title", + "System.State", + "System.CreatedDate", + "System.ChangedDate", + "System.CreatedBy", + "System.AssignedTo", + "System.CommentCount", + "System.Tags", + "System.TeamProject", + "Microsoft.VSTS.Common.ClosedDate", +]; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeWiql = Schema.decodeUnknownResult( + Schema.Struct({ + workItems: Schema.optional(Schema.NullOr(Schema.Array(Schema.Struct({ id: Schema.Int })))), + }), +); +const decodeTypes = Schema.decodeUnknownResult( + Schema.Struct({ + value: Schema.Array( + Schema.Struct({ name: Schema.String, isDisabled: Schema.optional(Schema.Boolean) }), + ), + }), +); +const decodeTags = Schema.decodeUnknownResult( + Schema.Struct({ value: Schema.Array(Schema.Struct({ name: Schema.String })) }), +); +const decodeTeams = Schema.decodeUnknownResult(Schema.Array(Schema.Struct({ id: Schema.String }))); +const decodeMembers = Schema.decodeUnknownResult( + Schema.Array( + Schema.Struct({ + identity: Schema.Struct({ + uniqueName: Schema.String, + displayName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), + isContainer: Schema.optional(Schema.Boolean), + }), + }), + ), +); +const decodeUnknown = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)); + +/** The parts of an `organization/project` scope, as the organization URL and the project name. */ +function parseScope( + host: string, + scope: string, +): { readonly organization: string; readonly project: string } | null { + const parts = scope.split("/").flatMap((part) => part.trim() || []); + if (parts.length !== 2) return null; + const [organization, project] = parts as [string, string]; + return { + organization: host.toLowerCase().endsWith(".visualstudio.com") + ? `https://${host}` + : `https://${host}/${encodeURIComponent(organization)}`, + project, + }; +} + +function parseNumber(key: string): number | null { + return /^[1-9]\d{0,9}$/.test(key) ? Number(key) : null; +} + +/** WIQL string literals are single-quoted and escape a quote by doubling it. */ +function quoted(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function involvementClause(involvement: IssueInvolvement): string { + switch (involvement) { + case "assigned": + return " AND [System.AssignedTo] = @Me"; + case "authored": + return " AND [System.CreatedBy] = @Me"; + // Azure records no mention of a person on a work item, so a mention filter has nothing to narrow by. + case "mentioned": + case "all": + return ""; + } +} + +function stateClause(state: "all" | "open" | "closed"): string { + const closed = "('Closed', 'Completed', 'Done', 'Removed', 'Resolved')"; + switch (state) { + case "open": + return ` AND [System.State] NOT IN ${closed}`; + case "closed": + return ` AND [System.State] IN ${closed}`; + case "all": + return ""; + } +} + +/** `|` of the last row handed over, which is where the next page starts. */ +function parseCursor(cursor: string): { readonly changedAt: string; readonly id: number } | null { + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\|([1-9]\d{0,9})$/.exec(cursor); + return match === null ? null : { changedAt: match[1]!, id: Number(match[2]) }; +} + +function reasonFor(error: AzureDevOpsCli.AzureDevOpsCliError): IssueTrackerError["reason"] { + switch (error._tag) { + case "AzureDevOpsCliUnavailableError": + return "missing-tool"; + case "AzureDevOpsCliAuthenticationError": + return "unauthenticated"; + case "AzureDevOpsCliRateLimitError": + return "rate-limited"; + case "AzureDevOpsPullRequestNotFoundError": + return "not-found"; + default: + return "failed"; + } +} + +export const make: Effect.Effect< + IssueTrackerApi, + never, + AzureDevOpsCli.AzureDevOpsCli | FileSystem.FileSystem +> = Effect.gen(function* () { + const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + const fs = yield* FileSystem.FileSystem; + + const fail = (operation: string) => (error: AzureDevOpsCli.AzureDevOpsCliError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.message, + cause: error, + }); + + const refused = (operation: string, detail: string, cause?: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + const unreadable = (operation: string, cause: unknown) => + refused(operation, `Azure DevOps returned an unreadable ${operation} response.`, cause); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "Azure DevOps could not find the requested work item.", + }); + + const scoped = ( + operation: string, + input: { readonly host: string; readonly scope: string }, + ): Effect.Effect< + { readonly organization: string; readonly project: string }, + IssueTrackerError + > => { + const parsed = parseScope(input.host, input.scope); + return parsed === null + ? Effect.fail(refused(operation, "An Azure DevOps scope is named `organization/project`.")) + : Effect.succeed(parsed); + }; + + const run = (input: { + readonly cwd: string; + readonly operation: string; + readonly args: ReadonlyArray; + }) => + azure + .execute({ cwd: input.cwd, args: [...input.args, "--only-show-errors", "--output", "json"] }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(fail(input.operation)), + ); + + const read =
(input: { + readonly cwd: string; + readonly operation: string; + readonly args: ReadonlyArray; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + run(input).pipe( + Effect.flatMap((raw) => { + const decoded = input.decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(unreadable(input.operation, decoded.failure)); + }), + ); + + const decodeAs = + (schema: (value: unknown) => Result.Result) => + (raw: string): Result.Result => + Result.flatMap(decodeUnknown(raw), schema); + + /** A `wit` REST route, addressed by resource and route parameters rather than by URL. */ + const invokeArgs = (input: { + readonly organization: string; + readonly project: string; + readonly resource: string; + readonly route?: ReadonlyArray; + readonly query?: ReadonlyArray; + readonly method?: string; + readonly apiVersion?: string; + }) => [ + "devops", + "invoke", + "--detect", + "false", + "--organization", + input.organization, + "--area", + "wit", + "--resource", + input.resource, + "--api-version", + input.apiVersion ?? REST_API_VERSION, + "--route-parameters", + `project=${input.project}`, + ...(input.route ?? []), + ...(input.query === undefined || input.query.length === 0 + ? [] + : ["--query-parameters", ...input.query]), + ...(input.method === undefined ? [] : ["--http-method", input.method]), + ]; + + /** A request with a body, which `az devops invoke` only takes from a file. */ + const invokeWithBody = (input: { + readonly cwd: string; + readonly operation: string; + readonly args: ReadonlyArray; + readonly body: unknown; + }) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .makeTempFileScoped({ prefix: "t3-azure-issue-" }) + .pipe(Effect.mapError((cause) => refused(input.operation, cause.message, cause))); + yield* fs + .writeFileString(file, encodeJson(input.body)) + .pipe(Effect.mapError((cause) => refused(input.operation, cause.message, cause))); + return yield* run({ + cwd: input.cwd, + operation: input.operation, + args: [...input.args, "--in-file", file, "--media-type", "application/json"], + }); + }), + ); + + const workItem = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly number: number; + }): Effect.Effect => + read({ + cwd: input.cwd, + operation: input.operation, + args: [ + "boards", + "work-item", + "show", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(input.number), + "--expand", + "relations", + ], + decode: decodeWorkItemJson, + }).pipe( + Effect.filterOrFail( + (item) => item !== null, + () => notFound(input.operation), + ), + ); + + /** The work item as `az` addresses it, or a not-found for a key that names no id. */ + const target = ( + operation: string, + input: { readonly host: string; readonly scope: string; readonly key: string }, + ) => + scoped(operation, input).pipe( + Effect.flatMap((scope) => { + const number = parseNumber(input.key); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ ...scope, number }); + }), + ); + + const comments = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly project: string; + readonly number: number; + readonly cursor?: string | undefined; + }) => + read({ + cwd: input.cwd, + operation: input.operation, + args: invokeArgs({ + ...input, + resource: "comments", + route: [`workItemId=${input.number}`], + apiVersion: "7.1-preview", + query: [ + `$top=${PAGE_SIZE}`, + "order=asc", + "$expand=reactions", + "api-version=7.1-preview.4", + ...(input.cursor === undefined ? [] : [`continuationToken=${input.cursor}`]), + ], + }), + decode: decodeCommentsJson, + }); + + const linkedPullRequests = (input: { + readonly cwd: string; + readonly organization: string; + readonly item: AzureDevOpsWorkItem; + }): Effect.Effect, IssueTrackerError> => { + const numbers = [ + ...new Set(input.item.relations.flatMap((relation) => pullRequestNumberOf(relation) ?? [])), + ].slice(0, LINKED_PULL_REQUEST_LIMIT); + return Effect.forEach( + numbers, + (number) => + read({ + cwd: input.cwd, + operation: "getIssue", + args: [ + "repos", + "pr", + "show", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(number), + ], + decode: decodePullRequestJson, + }).pipe( + Effect.map((pullRequest) => + pullRequest === null || pullRequest.location === null + ? [] + : [ + { + repository: `${pullRequest.location.project}/${pullRequest.location.repository}`, + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + // Azure links a pull request to a work item but never says whether completing + // it resolves the item; that lives in a completion option nothing reports. + closesIssue: false, + }, + ], + ), + // A link to a pull request this account cannot see is left out, not a failed detail. + Effect.catchIf( + (error) => error.reason === "not-found", + () => Effect.succeed([]), + ), + ), + { concurrency: 2 }, + ).pipe(Effect.map((links) => links.flat())); + }; + + const update = (input: { + readonly cwd: string; + readonly operation: string; + readonly organization: string; + readonly number: number; + readonly args: ReadonlyArray; + }) => + run({ + cwd: input.cwd, + operation: input.operation, + args: [ + "boards", + "work-item", + "update", + "--detect", + "false", + "--organization", + input.organization, + "--id", + String(input.number), + ...input.args, + ], + }).pipe(Effect.asVoid); + + /** Azure keeps tags as one `; `-joined field and takes exactly one assignee. */ + const fieldArgs = (input: { + readonly labels?: ReadonlyArray | undefined; + readonly assignees?: ReadonlyArray | undefined; + }): ReadonlyArray => { + const fields = [ + ...(input.labels === undefined ? [] : [`System.Tags=${input.labels.join("; ")}`]), + ...(input.assignees === undefined ? [] : [`System.AssignedTo=${input.assignees[0] ?? ""}`]), + ]; + return fields.length === 0 ? [] : ["--fields", ...fields]; + }; + + return { + kind: KIND, + capabilities: CAPABILITIES, + + // The profile the token belongs to, which is what WIQL's `@Me` resolves to. Its email is the + // `uniqueName` every identity on a work item carries, so the two compare. + getViewer: (input) => + read({ + cwd: input.cwd, + operation: "getViewer", + args: [ + "devops", + "invoke", + "--detect", + "true", + "--area", + "profile", + "--resource", + "profiles", + "--api-version", + REST_API_VERSION, + "--route-parameters", + "id=me", + ], + decode: decodeProfileJson, + }).pipe( + Effect.filterOrFail( + (login) => login !== null, + () => refused("getViewer", "Azure DevOps could not name the signed-in account."), + ), + ), + + listIssues: (input) => { + const cursor = input.cursor === undefined ? undefined : parseCursor(input.cursor); + if (cursor === null) { + return Effect.fail( + refused("listIssues", "The cursor was not one Azure DevOps handed out."), + ); + } + const limit = Math.min(Math.max(input.limit, 1), PAGE_SIZE); + const text = input.query?.trim() ?? ""; + return scoped("listIssues", input).pipe( + Effect.flatMap((scope) => + invokeWithBody({ + cwd: input.cwd, + operation: "listIssues", + args: invokeArgs({ + ...scope, + resource: "wiql", + method: "POST", + // One more than the page, which is how a next page reveals itself. + query: ["timePrecision=true", `$top=${limit + 1}`], + }), + body: { + query: + `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = ${quoted(scope.project)}` + + stateClause(input.state) + + involvementClause(input.involvement) + + (text.length === 0 ? "" : ` AND [System.Title] CONTAINS ${quoted(text)}`) + + (cursor === undefined + ? "" + : ` AND ([System.ChangedDate] < ${quoted(cursor.changedAt)} OR ([System.ChangedDate] = ${quoted(cursor.changedAt)} AND [System.Id] < ${cursor.id}))`) + + " ORDER BY [System.ChangedDate] DESC, [System.Id] DESC", + }, + }).pipe( + Effect.flatMap((raw) => { + const decoded = decodeAs(decodeWiql)(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success.workItems ?? []) + : Effect.fail(unreadable("listIssues", decoded.failure)); + }), + Effect.flatMap((ids) => + ids.length === 0 + ? Effect.succeed({ items: [], nextCursor: null }) + : read({ + cwd: input.cwd, + operation: "listIssues", + args: invokeArgs({ + ...scope, + resource: "workitems", + query: [ + `ids=${ids + .slice(0, limit) + .map((row) => row.id) + .join(",")}`, + `fields=${LIST_FIELDS.join(",")}`, + ], + }), + decode: decodeWorkItemsJson, + }).pipe( + Effect.map((items) => { + const last = items.at(-1); + return { + items, + nextCursor: + ids.length > limit && last !== undefined + ? `${last.updatedAt}|${last.key}` + : null, + }; + }), + ), + ), + ), + ), + ); + }, + + getIssue: (input) => + target("getIssue", input).pipe( + Effect.flatMap((scope) => + workItem({ ...scope, cwd: input.cwd, operation: "getIssue" }).pipe( + Effect.flatMap((item) => + Effect.all( + [ + comments({ ...scope, cwd: input.cwd, operation: "getIssue" }), + read({ + cwd: input.cwd, + operation: "getIssue", + args: invokeArgs({ + ...scope, + resource: "updates", + route: [`id=${scope.number}`], + query: [`$top=${PAGE_SIZE}`], + }), + decode: decodeUpdatesJson, + }), + linkedPullRequests({ cwd: input.cwd, organization: scope.organization, item }), + ], + { concurrency: 3 }, + ).pipe( + Effect.map(([conversation, events, links]) => ({ + ...item, + // Nothing cheap names the viewer here; the service already read it per host. + viewer: null, + viewerPermissions: VIEWER_PERMISSIONS, + comments: conversation.comments, + commentsCursor: conversation.nextCursor, + events, + linkedPullRequests: links, + })), + ), + ), + ), + ), + ), + + getComments: (input) => + target("getComments", input).pipe( + Effect.flatMap((scope) => + comments({ ...scope, cwd: input.cwd, operation: "getComments", cursor: input.cursor }), + ), + ), + + getViewerPermissions: () => Effect.succeed(VIEWER_PERMISSIONS), + + create: (input) => + scoped("create", input).pipe( + Effect.flatMap((scope) => + read({ + cwd: input.cwd, + operation: "create", + args: invokeArgs({ ...scope, resource: "workitemtypes" }), + decode: decodeAs(decodeTypes), + }).pipe( + Effect.flatMap((types) => { + const names = types.value.flatMap((type) => (type.isDisabled ? [] : [type.name])); + const type = CREATE_TYPES.find((name) => names.includes(name)) ?? names[0]; + return type === undefined + ? Effect.fail(refused("create", "The project offers no work item type to file.")) + : Effect.succeed(type); + }), + Effect.flatMap((type) => + read({ + cwd: input.cwd, + operation: "create", + args: [ + "boards", + "work-item", + "create", + "--detect", + "false", + "--organization", + scope.organization, + "--project", + scope.project, + "--type", + type, + "--title", + input.title, + ...(input.body.length === 0 ? [] : ["--description", input.body]), + ...fieldArgs({ + ...(input.labels.length === 0 ? {} : { labels: input.labels }), + ...(input.assignees.length === 0 ? {} : { assignees: input.assignees }), + }), + ], + decode: decodeWorkItemJson, + }), + ), + Effect.filterOrFail( + (item) => item !== null, + () => unreadable("create", "incomplete-response"), + ), + Effect.map((item) => ({ key: item.key, displayKey: item.displayKey, url: item.url })), + ), + ), + ), + + comment: (input) => + target("comment", input).pipe( + Effect.flatMap((scope) => + invokeWithBody({ + cwd: input.cwd, + operation: "comment", + args: invokeArgs({ + ...scope, + resource: "comments", + route: [`workItemId=${scope.number}`], + method: "POST", + apiVersion: "7.1-preview", + query: ["format=markdown", "api-version=7.1-preview.4"], + }), + body: { text: input.body }, + }), + ), + Effect.asVoid, + ), + + update: (input) => + target("update", input).pipe( + Effect.flatMap((scope) => + update({ + ...scope, + cwd: input.cwd, + operation: "update", + args: [ + ...(input.title === undefined ? [] : ["--title", input.title]), + ...(input.body === undefined ? [] : ["--description", input.body]), + ...fieldArgs(input), + ], + }), + ), + ), + + setState: (input) => + target("setState", input).pipe( + Effect.flatMap((scope) => + update({ + ...scope, + cwd: input.cwd, + operation: "setState", + args: ["--state", input.state], + }), + ), + ), + + setReaction: (input) => + target("setReaction", input).pipe( + Effect.flatMap((scope) => { + const type = azureDevOpsReactionType(input.content); + if (input.subjectId === undefined) { + return Effect.fail( + refused("setReaction", "Azure DevOps reactions go on comments only."), + ); + } + if (!/^[1-9]\d{0,9}$/.test(input.subjectId)) { + return Effect.fail( + refused("setReaction", "The comment id names no Azure DevOps comment."), + ); + } + if (type === null) { + return Effect.fail(refused("setReaction", "Azure DevOps has no such reaction.")); + } + return run({ + cwd: input.cwd, + operation: "setReaction", + args: invokeArgs({ + ...scope, + resource: "commentsReactions", + route: [ + `workItemId=${scope.number}`, + `commentId=${input.subjectId}`, + `reactionType=${type}`, + ], + method: input.reacted ? "PUT" : "DELETE", + apiVersion: "7.1-preview", + query: ["api-version=7.1-preview.1"], + }), + }).pipe(Effect.asVoid); + }), + ), + + listCandidates: (input) => + scoped("listCandidates", input).pipe( + Effect.flatMap((scope) => + Effect.all( + [ + read({ + cwd: input.cwd, + operation: "listCandidates", + args: invokeArgs({ ...scope, resource: "tags" }), + decode: decodeAs(decodeTags), + }).pipe( + Effect.map((tags) => + tags.value.map((tag) => ({ name: tag.name, color: null, description: null })), + ), + ), + // Nothing in `az boards` lists who a work item may go to; the project's teams are + // the nearest thing, read a handful at a time. + read({ + cwd: input.cwd, + operation: "listCandidates", + args: [ + "devops", + "team", + "list", + "--detect", + "false", + "--organization", + scope.organization, + "--project", + scope.project, + "--top", + "10", + ], + decode: decodeAs(decodeTeams), + }).pipe( + Effect.flatMap((teams) => + Effect.forEach( + teams, + (team) => + read({ + cwd: input.cwd, + operation: "listCandidates", + args: [ + "devops", + "team", + "list-member", + "--detect", + "false", + "--organization", + scope.organization, + "--project", + scope.project, + "--team", + team.id, + "--top", + String(PAGE_SIZE), + ], + decode: decodeAs(decodeMembers), + }), + { concurrency: 3 }, + ), + ), + Effect.map((members) => { + const byLogin = new Map< + string, + { id: string; login: string; name: string | null; avatarUrl: string | null } + >(); + for (const { identity } of members.flat()) { + const login = identity.uniqueName.trim(); + if ( + identity.isContainer || + login.length === 0 || + byLogin.has(login.toLowerCase()) + ) { + continue; + } + byLogin.set(login.toLowerCase(), { + id: login, + login, + name: identity.displayName?.trim() || null, + avatarUrl: identity.imageUrl?.trim() || null, + }); + } + return [...byLogin.values()]; + }), + ), + ], + { concurrency: 2 }, + ), + ), + // Teams are a sample of who can be assigned, so somebody missing is not ineligible. + Effect.map(([labels, assignees]) => ({ labels, assignees, truncated: true })), + ), + }; +}); diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts new file mode 100644 index 000000000000..9ca14afbcebe --- /dev/null +++ b/packages/source-control-azure-devops/src/server/azureDevOpsIssueJson.ts @@ -0,0 +1,366 @@ +/** + * Decoders for Azure DevOps work items as `az boards` and the `wit` REST area answer them. Azure + * keeps everything under `fields`, keyed by reference name, and a project may add fields of its + * own, so only the ones every work item type carries are read. + * + * @module source-control-azure-devops/server/azureDevOpsIssueJson + */ +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + IssueActor, + IssueComment, + IssueEvent, + IssueLabel, + IssueReaction, + IssueReactionContent, + IssueState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +/** + * The states Azure's stock processes treat as finished. A project may rename its own columns, so + * anything not named here reads as open: an item wrongly shown as open is one the reader can + * still close, while one wrongly shown as closed disappears from the list they were looking at. + */ +const CLOSED_STATES: ReadonlySet = new Set([ + "closed", + "done", + "removed", + "resolved", + "completed", +]); + +const Identity = Schema.Struct({ + displayName: Schema.optional(Schema.NullOr(Schema.String)), + uniqueName: Schema.optional(Schema.NullOr(Schema.String)), + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** WIQL answers an identity as an object; a project rule can leave a plain "Name " string. */ +const IdentityValue = Schema.NullOr(Schema.Union([Identity, Schema.String])); + +const Fields = Schema.Struct({ + "System.CommentCount": Schema.optional(Schema.NullOr(Schema.Int)), + "System.Title": Schema.optional(Schema.NullOr(Schema.String)), + "System.State": Schema.optional(Schema.NullOr(Schema.String)), + "System.CreatedDate": Schema.optional(Schema.NullOr(Schema.String)), + "System.ChangedDate": Schema.optional(Schema.NullOr(Schema.String)), + "Microsoft.VSTS.Common.ClosedDate": Schema.optional(Schema.NullOr(Schema.String)), + "System.Description": Schema.optional(Schema.NullOr(Schema.String)), + "System.Tags": Schema.optional(Schema.NullOr(Schema.String)), + "System.TeamProject": Schema.optional(Schema.NullOr(Schema.String)), + "System.CreatedBy": Schema.optional(IdentityValue), + "System.AssignedTo": Schema.optional(IdentityValue), +}); + +const Relation = Schema.Struct({ rel: Schema.String, url: Schema.String }); + +const WorkItem = Schema.Struct({ + id: Schema.Int, + url: Schema.optional(Schema.NullOr(Schema.String)), + fields: Schema.optional(Schema.NullOr(Fields)), + relations: Schema.optional(Schema.NullOr(Schema.Array(Relation))), +}); + +const decodeWorkItemEntry = Schema.decodeUnknownExit(WorkItem); +const decodeUnknownList = decodeJsonResult(Schema.NullOr(Schema.Array(Schema.Unknown))); +const decodeWorkItem = decodeJsonResult(WorkItem); + +type DecodeFailure = + ReturnType extends Result.Result ? E : never; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * The organization link `az` returns addresses the REST resource, which is not a page anybody can + * open. The one a reader wants is the board's own item, which Azure spells this way. + */ +function browserUrl( + apiUrl: string | null | undefined, + id: number, + project: string | null, +): string | null { + if (apiUrl === null || apiUrl === undefined || project === null) return null; + const match = /^(https?:\/\/[^/]+\/[^/]+)\/(?:[^/]+\/)?_apis\/wit\/workItems\//i.exec(apiUrl); + return match === null ? null : `${match[1]}/${encodeURIComponent(project)}/_workitems/edit/${id}`; +} + +function toActor(value: typeof IdentityValue.Type | undefined): IssueActor | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") { + const login = trimmed(value); + return login === null ? null : { login, name: null, avatarUrl: null }; + } + const login = trimmed(value.uniqueName) ?? trimmed(value.displayName); + return login === null + ? null + : { login, name: trimmed(value.displayName), avatarUrl: trimmed(value.imageUrl) }; +} + +function toIssueState(name: string | null | undefined): IssueState { + const state = trimmed(name) ?? "New"; + return { category: CLOSED_STATES.has(state.toLowerCase()) ? "closed" : "open", name: state }; +} + +/** Azure keeps tags as one `; `-separated string, with no colour anywhere. */ +function toLabels(tags: string | null | undefined): ReadonlyArray { + return [...new Set((tags ?? "").split(";").flatMap((tag) => trimmed(tag) ?? []))].map((name) => ({ + name, + color: null, + })); +} + +export interface AzureDevOpsWorkItem extends TrackerIssueSummary { + readonly body: string; + readonly project: string | null; + readonly relations: ReadonlyArray; +} + +/** Null for a row this cannot place: no title, no dates, or no project to build a link from. */ +function toWorkItem(raw: typeof WorkItem.Type): AzureDevOpsWorkItem | null { + const fields = raw.fields ?? {}; + const title = trimmed(fields["System.Title"]); + const createdAt = trimmed(fields["System.CreatedDate"]); + const updatedAt = trimmed(fields["System.ChangedDate"]) ?? createdAt; + const project = trimmed(fields["System.TeamProject"]); + const url = browserUrl(raw.url, raw.id, project); + if (raw.id <= 0 || title === null || createdAt === null || updatedAt === null || url === null) { + return null; + } + const assignee = toActor(fields["System.AssignedTo"]); + return { + key: String(raw.id), + displayKey: `#${raw.id}`, + title, + url, + author: toActor(fields["System.CreatedBy"]), + state: toIssueState(fields["System.State"]), + createdAt, + updatedAt, + closedAt: trimmed(fields["Microsoft.VSTS.Common.ClosedDate"]), + assignees: assignee === null ? [] : [assignee], + labels: toLabels(fields["System.Tags"]), + commentCount: Math.max(0, fields["System.CommentCount"] ?? 0), + body: fields["System.Description"] ?? "", + project, + relations: raw.relations ?? [], + }; +} + +/** A flat WIQL answer; a row this cannot place is skipped rather than failing the page. */ +export function decodeWorkItemsJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUnknownList(raw.trim().length === 0 ? "[]" : raw), (rows) => + (rows ?? []).flatMap((entry) => { + const item = decodeWorkItemEntry(entry); + if (Exit.isFailure(item)) return []; + return toWorkItem(item.value) ?? []; + }), + ); +} + +/** Null where az answered with a work item this cannot place, which the caller reports as such. */ +export function decodeWorkItemJson( + raw: string, +): Result.Result { + return Result.map(decodeWorkItem(raw), toWorkItem); +} + +/** Azure names comment reactions after its own icons; the six it has map onto the contract's. */ +const CONTENT_BY_REACTION: Readonly> = { + like: "thumbs-up", + dislike: "thumbs-down", + heart: "heart", + hooray: "hooray", + smile: "laugh", + confused: "confused", +}; + +export function azureDevOpsReactionType(content: IssueReactionContent): string | null { + return Object.entries(CONTENT_BY_REACTION).find(([, mapped]) => mapped === content)?.[0] ?? null; +} + +const Comment = Schema.Struct({ + id: Schema.Int, + text: Schema.optional(Schema.NullOr(Schema.String)), + createdDate: Schema.String, + createdBy: Schema.optional(Schema.NullOr(Identity)), + isDeleted: Schema.optional(Schema.Boolean), + reactions: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + type: Schema.String, + count: Schema.Int, + isCurrentUserEngaged: Schema.optional(Schema.Boolean), + }), + ), + ), + ), +}); + +const CommentsPage = Schema.Struct({ + comments: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + continuationToken: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const decodeCommentsPage = decodeJsonResult(CommentsPage); +const decodeCommentEntry = Schema.decodeUnknownExit(Comment); + +function toReactions(raw: (typeof Comment.Type)["reactions"]): ReadonlyArray { + return (raw ?? []).flatMap((reaction) => { + const content = CONTENT_BY_REACTION[reaction.type.toLowerCase()]; + return content === undefined || reaction.count <= 0 + ? [] + : [ + { + content, + count: reaction.count, + // Azure counts reactions and says whether the reader is among them, but names nobody. + actors: [], + viewerHasReacted: reaction.isCurrentUserEngaged ?? false, + }, + ]; + }); +} + +export function decodeCommentsJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly nextCursor: string | null }, + DecodeFailure +> { + return Result.map(decodeCommentsPage(raw), (page) => ({ + comments: (page.comments ?? []).flatMap((entry) => { + const comment = decodeCommentEntry(entry); + if (Exit.isFailure(comment) || comment.value.isDeleted === true) return []; + return [ + { + id: String(comment.value.id), + author: toActor(comment.value.createdBy), + body: comment.value.text ?? "", + createdAt: comment.value.createdDate, + url: null, + reactions: toReactions(comment.value.reactions), + }, + ]; + }), + nextCursor: trimmed(page.continuationToken), + })); +} + +const FieldChange = Schema.Struct({ + oldValue: Schema.optional(Schema.Unknown), + newValue: Schema.optional(Schema.Unknown), +}); + +const Update = Schema.Struct({ + id: Schema.Int, + revisedDate: Schema.String, + revisedBy: Schema.optional(Schema.NullOr(Identity)), + fields: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, FieldChange))), +}); + +const decodeUpdatesPage = decodeJsonResult( + Schema.Struct({ value: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), +); +const decodeUpdateEntry = Schema.decodeUnknownExit(Update); +const decodeIdentityValue = Schema.decodeUnknownExit(IdentityValue); + +function identityLogin(value: unknown): string | null { + const decoded = decodeIdentityValue(value); + return Exit.isSuccess(decoded) ? (toActor(decoded.value)?.login ?? null) : null; +} + +/** + * The timeline, read out of each revision's field changes. Azure reads `9999-01-01` as the changed + * date of the newest revision, so the revision's own date is what every event carries. + */ +export function decodeUpdatesJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeUpdatesPage(raw), (page) => + (page.value ?? []).flatMap((entry) => { + const update = decodeUpdateEntry(entry); + if (Exit.isFailure(update) || update.value.id === 1) return []; + const { id, revisedBy, revisedDate } = update.value; + const fields = update.value.fields ?? {}; + const events: IssueEvent[] = []; + const add = (kind: IssueEvent["kind"], detail: string | null) => + events.push({ + id: `update-${id}-${events.length}`, + kind, + actor: toActor(revisedBy), + createdAt: revisedDate, + detail, + }); + const state = fields["System.State"]; + if (typeof state?.oldValue === "string" && typeof state.newValue === "string") { + const was = toIssueState(state.oldValue); + const now = toIssueState(state.newValue); + add( + was.category === now.category + ? "state-changed" + : now.category === "closed" + ? "closed" + : "reopened", + now.name, + ); + } + const title = fields["System.Title"]; + if (typeof title?.oldValue === "string" && typeof title.newValue === "string") { + add("renamed", trimmed(title.newValue)); + } + const tags = fields["System.Tags"]; + if (tags !== undefined) { + const before = toLabels(typeof tags.oldValue === "string" ? tags.oldValue : null); + const after = toLabels(typeof tags.newValue === "string" ? tags.newValue : null); + for (const tag of before) + if (!after.some((label) => label.name === tag.name)) add("unlabeled", tag.name); + for (const tag of after) + if (!before.some((label) => label.name === tag.name)) add("labeled", tag.name); + } + const assigned = fields["System.AssignedTo"]; + if (assigned !== undefined) { + const before = identityLogin(assigned.oldValue); + const after = identityLogin(assigned.newValue); + if (before !== after) { + if (before !== null) add("unassigned", before); + if (after !== null) add("assigned", after); + } + } + return events; + }), + ); +} + +/** A relation that links a pull request, as Azure spells it: `vstfs:///Git/PullRequestId/{project}/{repo}/{id}`. */ +export function pullRequestNumberOf(relation: typeof Relation.Type): number | null { + if (relation.rel !== "ArtifactLink") return null; + const match = /^vstfs:\/\/\/Git\/PullRequestId\/(.+)$/.exec(relation.url); + if (match === null) return null; + const number = decodeURIComponent(match[1]!).split("/").at(-1) ?? ""; + return /^[1-9]\d{0,9}$/.test(number) ? Number(number) : null; +} + +const Profile = Schema.Struct({ + emailAddress: Schema.optional(Schema.NullOr(Schema.String)), + displayName: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const decodeProfile = decodeJsonResult(Profile); + +/** `GET profile/profiles/me` names the signed-in account, which WIQL's `@Me` compares against. */ +export function decodeProfileJson(raw: string): Result.Result { + return Result.map( + decodeProfile(raw), + (profile) => trimmed(profile.emailAddress) ?? trimmed(profile.displayName), + ); +} diff --git a/packages/source-control-azure-devops/src/server/issueTrackerDriver.ts b/packages/source-control-azure-devops/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..ae19557b4544 --- /dev/null +++ b/packages/source-control-azure-devops/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The Azure DevOps issue tracker driver, registered beside the source control driver so the + * server's issue registry can list it without knowing the package. + * + * @module source-control-azure-devops/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as AzureDevOpsIssueTracker from "./AzureDevOpsIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("azure-devops"), + make: AzureDevOpsIssueTracker.make, +}); From 3996bbbc396a7a8c606ce71c56a6d90c7455a75f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:36:24 -0700 Subject: [PATCH 11/54] feat(web): add an Issues page with detail panel and composer Lists the issues of every connected environment's projects, grouped per tracker account with that tracker's unavailable state and per-project errors in place, filtered by state, involvement and tracker, searched where the tracker can search and narrowed locally where it cannot, and continued per tracker with "Load more". Selecting a row opens the issue in the page's shared right panel, which reuses the pull-request panel's presentation primitives: body, comments with older pages, events, linked pull requests that open as peer tabs, labels, assignees and reactions. Every write is offered only when the tracker capability, the viewer's permission and the issue's writability all allow it. "New issue" files through a template picker where the scope offers templates; close and reopen carry a close reason where the tracker records one. Reachable from the sidebar utility row and the command palette, both gated on the `issues` capability. Mobile has no issue screen yet. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/closedViewStore.ts | 10 + apps/web/src/components/CommandPalette.tsx | 19 + .../components/ReopenClosedViewShortcut.tsx | 11 +- apps/web/src/components/RightPanelTabs.tsx | 5 + .../components/issue/IssueCreateDialog.tsx | 270 +++++ .../src/components/issue/IssueDetailPanel.tsx | 936 ++++++++++++++++++ apps/web/src/components/issue/IssueRow.tsx | 117 +++ .../components/issue/issueListPreferences.ts | 74 ++ .../components/issue/issuePresentation.tsx | 75 ++ .../src/components/sidebar/SidebarChrome.tsx | 16 +- .../src/components/sidebar/mainAppLocation.ts | 3 +- apps/web/src/reopenClosedView.ts | 3 + apps/web/src/rightPanelStore.ts | 104 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.issues.tsx | 857 ++++++++++++++++ apps/web/src/state/issues.ts | 68 ++ docs/user/source-control.md | 19 + 17 files changed, 2597 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/issue/IssueCreateDialog.tsx create mode 100644 apps/web/src/components/issue/IssueDetailPanel.tsx create mode 100644 apps/web/src/components/issue/IssueRow.tsx create mode 100644 apps/web/src/components/issue/issueListPreferences.ts create mode 100644 apps/web/src/components/issue/issuePresentation.tsx create mode 100644 apps/web/src/routes/_chat.issues.tsx create mode 100644 apps/web/src/state/issues.ts 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/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6596e451e3fc..0775b7c047ab 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -245,6 +245,8 @@ import { import type { Project } from "../types"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; +import { readIssueListPreferences } from "~/components/issue/issueListPreferences"; +import { IssueGlyph } from "~/components/issue/issuePresentation"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -2221,6 +2223,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/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index 41cc2144deec..d25143b5350d 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"; /** Layout commands a focused desktop browser page hands back to the app. */ const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ @@ -52,6 +54,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(); @@ -85,7 +89,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, @@ -108,7 +112,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 }))) { @@ -125,6 +130,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 5c8323a7ae34..fb6350905b7e 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, @@ -618,6 +619,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": { @@ -701,6 +704,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "issue": + return ; case "device": return surface.target?.platform === "ios" ? ( 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" + /> +
+
+ +