From 2a878effb83e9ec20ed586db2b80dbc9fdea68fe Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:21:29 -0800 Subject: [PATCH] feat(issues): add issue tracking core with GitHub issues Issue tracking is a facet of a source control driver, beside pull requests: a driver that can read its host's issues provides an IssueTracker, and IssueService resolves each project to its host's tracker. GitHub is the first, over its generated GraphQL client and REST. The service lists across projects with per-tracker cursors, reads an issue with its conversation, and creates, comments, edits, closes and reacts. Writes go out as interactive requests, and an issue outside a project's own repository is read-only. Co-Authored-By: Claude Fable 5.2 --- apps/server/src/auth/RpcAuthorization.test.ts | 53 + apps/server/src/auth/RpcAuthorization.ts | 5 + apps/server/src/issue/IssueService.test.ts | 346 ++++++ apps/server/src/issue/IssueService.ts | 569 ++++++++++ apps/server/src/issue/IssueTrackerRegistry.ts | 36 + .../src/observability/RpcInstrumentation.ts | 10 + apps/server/src/server.ts | 5 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 22 +- .../contracts/src/clientRpcPermissions.ts | 5 + packages/contracts/src/index.ts | 1 + packages/contracts/src/issue.ts | 471 ++++++++ packages/contracts/src/rpc.ts | 109 ++ packages/source-control-core/package.json | 4 + .../src/server/IssueTracker.ts | 178 +++ .../source-control-core/src/server/driver.ts | 3 + .../src/shared/definition.ts | 25 +- packages/source-control-github/package.json | 3 +- .../schema/github.graphql.ts | 29 + .../src/server/GitHubIssueTracker.test.ts | 529 +++++++++ .../src/server/GitHubIssueTracker.ts | 426 +++++++ .../src/server/driver.ts | 2 + .../src/server/gitHubIssueJson.ts | 342 ++++++ .../src/server/gitHubIssueTemplates.ts | 159 +++ .../src/server/gitHubPullRequestJson.ts | 2 +- .../src/server/issues.graphql | 388 +++++++ .../src/server/issues.graphql.ts | 1007 +++++++++++++++++ pnpm-lock.yaml | 3 + 28 files changed, 4725 insertions(+), 9 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 packages/contracts/src/issue.ts create mode 100644 packages/source-control-core/src/server/IssueTracker.ts 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/gitHubIssueTemplates.ts create mode 100644 packages/source-control-github/src/server/issues.graphql create mode 100644 packages/source-control-github/src/server/issues.graphql.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 2302170f99a97..0cd838f129340 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,6 +8,8 @@ import { DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, + ProjectId, + IssueTrackerKind, SecretProviderId, ThreadId, AuthOrchestrationOperateScope, @@ -144,6 +146,57 @@ describe("RPC scope middleware", () => { ); }); +it.effect("allows issue reads but requires a source-control grant before commenting", () => + Effect.gen(function* () { + const tested = [WS_METHODS.issuesList, WS_METHODS.issuesComment] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + const comments: string[] = []; + const handlers = Layer.mergeAll( + group.toLayerHandler(WS_METHODS.issuesList, () => + Effect.succeed({ entries: [], trackers: [], errors: [], viewers: {}, nextCursors: {} }), + ), + group.toLayerHandler(WS_METHODS.issuesComment, ({ body }) => + Effect.sync(() => { + comments.push(body); + }), + ), + ); + const reader = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthOrchestrationReadScope]))), + ); + const comment = { + projectId: ProjectId.make("web"), + tracker: IssueTrackerKind.make("github"), + scope: "acme/web", + key: "42", + body: "Repro attached.", + }; + + expect((yield* reader[WS_METHODS.issuesList]({ state: "open" })).entries).toEqual([]); + expect(yield* reader[WS_METHODS.issuesComment](comment).pipe(Effect.flip)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthSourceControlWriteScope, + }); + expect(comments).toEqual([]); + + const writer = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.merge( + handlers, + RpcAuthorization.layer([AuthOrchestrationReadScope, AuthSourceControlWriteScope]), + ), + ), + ); + yield* writer[WS_METHODS.issuesComment](comment); + expect(comments).toEqual(["Repro attached."]); + }).pipe(Effect.scoped), +); + it.effect.each([WS_METHODS.subscribeVcsBranch, WS_METHODS.subscribeVcsStatus] as const)( "enforces read permission on stream %s", (method) => diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7925fd869799f..6868a8b2e7286 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -132,6 +132,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 0000000000000..133f51234838c --- /dev/null +++ b/apps/server/src/issue/IssueService.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import { + IssueTrackerKind, + SourceControlProviderKind, + type OrchestrationProjectShell, + type ProjectId, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, + type TrackerIssueDetail, +} from "@t3tools/source-control-core/server/IssueTracker"; + +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as IssueService from "./IssueService.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const GITHUB = IssueTrackerKind.make("github"); + +function project(input: { + readonly id: string; + readonly repository?: string; + readonly provider?: string; + readonly host?: string; +}): OrchestrationProjectShell { + const host = input.host ?? "github.com"; + return { + id: input.id as ProjectId, + title: input.id, + workspaceRoot: `/w/${input.id}`, + ...(input.repository + ? { + repositoryIdentity: { + canonicalKey: normalizeGitRemoteUrl(`https://${host}/${input.repository}.git`), + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: SourceControlProviderKind.make(input.provider ?? "github"), + displayName: input.repository, + }, + } + : {}), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }; +} + +function issue(key: string, updatedAt = "2026-07-02T00:00:00Z"): TrackerIssueDetail { + return { + key, + displayKey: `#${key}`, + title: `Issue ${key}`, + url: `https://github.com/acme/web/issues/${key}`, + author: null, + state: { category: "open", name: "open" }, + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + closedAt: null, + assignees: [], + labels: [], + commentCount: 0, + body: "", + viewer: "julius", + viewerPermissions: { setState: true, comment: true, edit: true, labels: true, assignees: true }, + comments: [], + commentsCursor: null, + events: [], + linkedPullRequests: [], + }; +} + +function fakeTracker(overrides: Partial = {}): IssueTrackerApi { + return { + kind: GITHUB, + capabilities: { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: false, + templates: false, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + closeReasons: ["completed"], + }, + getViewer: () => Effect.succeed("julius"), + listIssues: () => Effect.succeed({ items: [], nextCursor: null }), + getIssue: (input) => Effect.succeed(issue(input.key)), + getComments: () => Effect.succeed({ comments: [], nextCursor: null }), + comment: () => Effect.void, + update: () => Effect.void, + setState: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly trackers: ReadonlyArray; +}) { + return IssueService.IssueService.pipe( + Effect.provide( + IssueService.layer.pipe( + Layer.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* service.comment({ ...external, body: "no" }).pipe(Effect.result); + expect(refused).toMatchObject({ + _tag: "Failure", + failure: { _tag: "IssueOperationError", reason: "read-only" }, + }); + expect(writes).toEqual(["acme/web#1"]); + + // Another host is not reachable through this project's credentials at all. + const elsewhere = yield* Effect.flip(service.detail({ ...own, host: "ghe.example.test" })); + expect(elsewhere).toMatchObject({ + _tag: "IssueUnavailableError", + reason: "tracker-unsupported", + }); + }), + ); + + it.effect("sends writes as interactive requests and reads as background ones", () => + Effect.gen(function* () { + const interactive: Array = []; + const record = (operation: string) => + Effect.gen(function* () { + interactive.push([operation, yield* SourceControlRateLimit.Interactive]); + }); + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [ + fakeTracker({ + getComments: () => + record("comments").pipe(Effect.as({ comments: [], nextCursor: null })), + comment: () => record("comment"), + }), + ], + }); + const ref = { + projectId: "web" as ProjectId, + tracker: GITHUB, + scope: "acme/web", + key: "42", + }; + yield* service.comments({ ...ref, cursor: "next" }); + yield* service.comment({ ...ref, body: "Repro attached." }); + expect(interactive).toEqual([ + ["comments", false], + ["comment", true], + ]); + }), + ); + + it.effect("refuses what the tracker did not declare before calling it", () => + Effect.gen(function* () { + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [ + fakeTracker({ + setState: () => + Effect.sync(() => { + calls += 1; + }), + setReaction: () => + Effect.sync(() => { + calls += 1; + }), + }), + ], + }); + const ref = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" }; + + yield* service.setState({ ...ref, state: "closed", closeReason: "completed" }); + const unknownState = yield* Effect.flip(service.setState({ ...ref, state: "In Review" })); + expect(unknownState._tag).toBe("IssueOperationError"); + const unknownReason = yield* Effect.flip( + service.setState({ ...ref, state: "closed", closeReason: "not-planned" }), + ); + expect(unknownReason._tag).toBe("IssueOperationError"); + const reaction = yield* Effect.flip( + service.react({ ...ref, content: "heart", reacted: true }), + ); + expect(reaction._tag).toBe("IssueOperationError"); + expect(calls).toBe(1); + }), + ); + + it.effect("deduplicates and pages repositories despite partial failures", () => + Effect.gen(function* () { + const listed: string[] = []; + let failContinuation = true; + const service = yield* makeService({ + projects: [ + project({ id: "web", repository: "Acme/Web" }), + project({ id: "web-worktree", repository: "acme/web" }), + project({ id: "api", repository: "acme/api" }), + project({ id: "docs", repository: "acme/docs" }), + project({ + id: "gitlab", + repository: "acme/gl", + provider: "gitlab", + host: "gitlab.com", + }), + project({ id: "plain" }), + ], + trackers: [ + fakeTracker({ + listIssues: (input) => + Effect.gen(function* () { + listed.push(input.scope); + if (input.scope === "acme/api") + return yield* new IssueTrackerError({ + tracker: GITHUB, + operation: "listIssues", + reason: "failed", + // A tracker may fail without a detail; the wire error still needs one. + detail: " ", + }); + if (input.scope === "acme/docs") + return { items: [issue("3", "2026-07-04T00:00:00Z")], nextCursor: null }; + if (input.cursor === "next" && failContinuation) { + failContinuation = false; + return yield* new IssueTrackerError({ + tracker: GITHUB, + operation: "listIssues", + reason: "failed", + detail: "try again", + }); + } + if (input.cursor === "next") + return { items: [issue("4", "2026-07-01T00:00:00Z")], nextCursor: null }; + return { + items: [issue("1"), issue("2", "2026-07-05T00:00:00Z")], + nextCursor: "next", + }; + }), + }), + ], + }); + const result = yield* service.list({ state: "open" }); + expect(listed.sort()).toEqual(["Acme/Web", "acme/api", "acme/docs"]); + expect(result.entries.map((entry) => [entry.projectId, entry.key])).toEqual([ + ["web", "2"], + ["docs", "3"], + ["web", "1"], + ]); + expect( + result.entries.filter((entry) => entry.projectId === "web").map((entry) => entry.scope), + ).toEqual(["Acme/Web", "Acme/Web"]); + expect(result.nextCursors).toEqual({ "github.com Acme/Web": "next" }); + expect(result.errors).toEqual([ + { projectId: "api", projectTitle: "api", message: "The issue tracker request failed." }, + ]); + expect(result.viewers).toEqual({ "github.com": "julius" }); + expect(result.trackers).toEqual([ + expect.objectContaining({ host: "github.com", projectCount: 4, configured: true }), + expect.objectContaining({ + host: "gitlab.com", + tracker: "gitlab", + projectCount: 1, + configured: false, + capabilities: null, + }), + ]); + + // A continuation only reads the scopes it names. + listed.length = 0; + const failed = yield* service.list({ + state: "open", + cursors: result.nextCursors, + }); + expect(listed).toEqual(["Acme/Web"]); + expect(failed.nextCursors).toEqual(result.nextCursors); + expect(failed.errors).toEqual([ + { projectId: "web", projectTitle: "web", message: "try again" }, + ]); + const next = yield* service.list({ state: "open", cursors: failed.nextCursors }); + expect(next.entries.map((entry) => [entry.projectId, entry.key])).toEqual([["web", "4"]]); + expect(next.nextCursors).toEqual({}); + expect(next.errors).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts new file mode 100644 index 0000000000000..4b1a9a1167432 --- /dev/null +++ b/apps/server/src/issue/IssueService.ts @@ -0,0 +1,569 @@ +// References outside the project's canonical repository may be read on the same host, +// but every write must pass through requireWritable. +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { + IssueOperationError, + IssueTrackerKind, + IssueUnavailableError, + 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 * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/source-control-core/server/IssueTracker"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const DEFAULT_LIST_LIMIT = 50; +const SCOPE_CONCURRENCY = 4; + +const interactive = + (call: (input: I) => Effect.Effect) => + (input: I) => + call(input).pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); + +const traced = + (operation: string, call: (input: I) => Effect.Effect) => + (input: I) => + call(input).pipe(Effect.withSpan(`IssueService.${operation}`)); + +/** A tracker may fail with no detail; the wire errors carry a non-empty one. */ +function trackerDetail(error: IssueTrackerError): string { + return error.detail.trim() || "The issue tracker request failed."; +} + +/** One project's resolved tracker: which driver, where it is served from, and its container. */ +interface ProjectTrackerBinding { + readonly canonicalKey: string; + readonly tracker: IssueTrackerKind; + readonly host: string; + readonly scope: string; +} + +/** A project the registry has a tracker for, ready to serve reads and writes. */ +interface SupportedProject extends ProjectTrackerBinding { + readonly project: OrchestrationProjectShell; + readonly api: IssueTrackerApi; +} + +/** + * 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 = sourceControlDefinitions.issueScope(identity); + const kind = identity?.provider; + if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; + return { + canonicalKey: identity.canonicalKey, + tracker: IssueTrackerKind.make(kind), + host: sourceControlDefinitions.changeRequestHost(identity, 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: trackerDetail(error), + ...(error.reason === "not-found" || error.reason === "rate-limited" + ? { reason: error.reason } + : {}), + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + cause: error, + }); +} + +const readOnly = (operation: string) => + new IssueOperationError({ + operation, + reason: "read-only", + detail: + "This issue belongs to a repository outside the selected project and cannot be changed from here.", + }); + +const unsupported = (operation: string, what: string) => + new IssueOperationError({ operation, detail: `This tracker does not support ${what}.` }); + +export class IssueService extends Context.Service< + IssueService, + { + readonly list: (input: IssueListInput) => Effect.Effect; + readonly detail: (input: IssueRef) => Effect.Effect; + readonly comments: ( + input: IssueCommentsPageInput, + ) => Effect.Effect; + readonly templates: (input: IssueScopeRef) => Effect.Effect; + readonly candidates: (input: IssueScopeRef) => Effect.Effect; + readonly create: (input: IssueCreateInput) => Effect.Effect; + readonly comment: (input: IssueCommentInput) => Effect.Effect; + readonly update: (input: IssueUpdateInput) => Effect.Effect; + readonly setState: (input: IssueSetStateInput) => Effect.Effect; + readonly react: (input: IssueReactionInput) => Effect.Effect; + } +>()("t3/issue/IssueService") {} + +const make = Effect.gen(function* () { + const registry = yield* IssueTrackerRegistry.IssueTrackerRegistry; + const projects = yield* ProjectService.ProjectService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + + 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.filterOrFail( + (project) => project.writable, + () => readOnly(operation), + ), + ); + + const list: IssueService["Service"]["list"] = Effect.fn("IssueService.list")(function* (input) { + const { supported, unimplemented } = yield* listProjects(input); + const limit = input.limit ?? DEFAULT_LIST_LIMIT; + // Worktrees of one repository are separate projects; read each scope once. + const scopes = new Map(); + for (const project of supported) { + if (!scopes.has(project.canonicalKey)) scopes.set(project.canonicalKey, project); + } + const continuing = input.cursors; + const viewers = new Map(); + const trackers = new Map(); + const entries: IssueListEntry[] = []; + const errors: IssueListProjectError[] = []; + const nextCursors: Record = {}; + + const viewerRequests = new Map>(); + for (const project of scopes.values()) { + if (viewerRequests.has(project.host)) continue; + const request = yield* Effect.cached( + project.api + .getViewer({ cwd: project.project.workspaceRoot, host: project.host }) + .pipe(Effect.tap((viewer) => Effect.sync(() => viewers.set(project.host, viewer)))), + ); + viewerRequests.set(project.host, request); + } + + // Every project counts, including worktrees of a scope read once below. + for (const project of supported) { + const existing = trackers.get(project.host); + trackers.set( + project.host, + existing === undefined + ? { + tracker: project.tracker, + host: project.host, + projectCount: 1, + capabilities: project.api.capabilities, + configured: true, + detail: null, + } + : { ...existing, projectCount: existing.projectCount + 1 }, + ); + } + const recordFailure = (project: SupportedProject, failure: IssueTrackerError) => { + const existing = trackers.get(project.host); + if (existing !== undefined && isTrackerUnusable(failure)) + trackers.set(project.host, { + ...existing, + configured: false, + detail: trackerDetail(failure), + }); + }; + + yield* Effect.forEach( + scopes.values(), + (project) => + Effect.gen(function* () { + const key = listCursorKey(project.host, project.scope); + // A continuation names the scopes it wants; one not named has nothing to add. + if (continuing !== undefined && continuing[key] === undefined) return; + const cursor = continuing?.[key]; + const page = yield* viewerRequests.get(project.host)!.pipe( + Effect.flatMap((viewer) => + project.api.listIssues({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + state: input.state, + involvement: input.involvement ?? "all", + viewer, + limit, + query: input.query, + cursor, + }), + ), + Effect.result, + ); + if (page._tag === "Failure") { + if (cursor !== undefined) nextCursors[key] = cursor; + recordFailure(project, page.failure); + errors.push({ + projectId: project.project.id, + projectTitle: project.project.title, + message: trackerDetail(page.failure), + }); + return; + } + for (const item of page.success.items) { + entries.push({ + ...item, + tracker: project.tracker, + host: project.host, + scope: project.scope, + projectId: project.project.id, + projectTitle: project.project.title, + }); + } + if (page.success.nextCursor !== null) nextCursors[key] = page.success.nextCursor; + }), + { concurrency: SCOPE_CONCURRENCY, discard: true }, + ); + + for (const [host, { tracker, projectCount }] of unimplemented) { + trackers.set(host, { + tracker, + host, + projectCount, + capabilities: null, + configured: false, + detail: "Issues cannot be browsed for this host yet.", + }); + } + entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return { + viewers: Object.fromEntries(viewers), + trackers: [...trackers.values()], + entries, + errors, + nextCursors, + }; + }); + + const detail: IssueService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getIssue({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + }) + .pipe( + Effect.mapError(toIssueError("detail")), + Effect.map((issue): IssueDetail => ({ + ...issue, + tracker: project.tracker, + host: project.host, + scope: project.scope, + projectId: project.project.id, + projectTitle: project.project.title, + writable: project.writable, + capabilities: project.api.capabilities, + })), + ), + ), + ); + + const comments: IssueService["Service"]["comments"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getComments({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + cursor: input.cursor, + }) + .pipe(Effect.mapError(toIssueError("comments"))), + ), + ); + + const templates: IssueService["Service"]["templates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.listTemplates === undefined || !project.api.capabilities.templates + ? Effect.fail(unsupported("templates", "issue templates")) + : project.api + .listTemplates({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + }) + .pipe(Effect.mapError(toIssueError("templates"))), + ), + ); + + const candidates: IssueService["Service"]["candidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.listCandidates === undefined || + !(project.api.capabilities.labels || project.api.capabilities.assignees) + ? Effect.fail(unsupported("candidates", "labels or assignees")) + : project.api + .listCandidates({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + }) + .pipe(Effect.mapError(toIssueError("candidates"))), + ), + ); + + const create: IssueService["Service"]["create"] = (input) => + requireWritable(input, "create").pipe( + Effect.flatMap((project) => + project.api.create === undefined || !project.api.capabilities.create + ? Effect.fail(unsupported("create", "filing issues")) + : project.api + .create({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }) + .pipe(Effect.mapError(toIssueError("create"))), + ), + ); + + const comment: IssueService["Service"]["comment"] = (input) => + requireWritable(input, "comment").pipe( + Effect.flatMap((project) => { + if (input.body.trim().length === 0) { + return Effect.fail( + new IssueOperationError({ operation: "comment", detail: "A comment needs some text." }), + ); + } + return project.api.comment === undefined || !project.api.capabilities.comment + ? Effect.fail(unsupported("comment", "comments")) + : project.api + .comment({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + body: input.body, + }) + .pipe(Effect.mapError(toIssueError("comment"))); + }), + ); + + const update: IssueService["Service"]["update"] = (input) => + requireWritable(input, "update").pipe( + Effect.flatMap((project) => { + const { capabilities } = project.api; + const wantsWords = input.title !== undefined || input.body !== undefined; + if (!wantsWords && input.labels === undefined && input.assignees === undefined) { + return Effect.fail( + new IssueOperationError({ operation: "update", detail: "Nothing to change." }), + ); + } + if (project.api.update === undefined) return Effect.fail(unsupported("update", "edits")); + if (wantsWords && !capabilities.edit) + return Effect.fail(unsupported("update", "editing the title or body")); + if (input.labels !== undefined && !capabilities.labels) + return Effect.fail(unsupported("update", "labels")); + if (input.assignees !== undefined && !capabilities.assignees) + return Effect.fail(unsupported("update", "assignees")); + return project.api + .update({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }) + .pipe(Effect.mapError(toIssueError("update"))); + }), + ); + + const setState: IssueService["Service"]["setState"] = (input) => + requireWritable(input, "setState").pipe( + Effect.flatMap((project) => { + const { capabilities } = project.api; + const target = capabilities.states.find((state) => state.name === input.state); + if (project.api.setState === undefined || target === undefined) + return Effect.fail(unsupported("setState", `the state "${input.state}"`)); + if ( + input.closeReason !== undefined && + (target.category !== "closed" || !capabilities.closeReasons.includes(input.closeReason)) + ) + return Effect.fail(unsupported("setState", `the close reason "${input.closeReason}"`)); + return project.api + .setState({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + state: input.state, + closeReason: input.closeReason, + }) + .pipe(Effect.mapError(toIssueError("setState"))); + }), + ); + + const react: IssueService["Service"]["react"] = (input) => + requireWritable(input, "react").pipe( + Effect.flatMap((project) => + project.api.setReaction === undefined || !project.api.capabilities.reactions + ? Effect.fail(unsupported("react", "reactions")) + : project.api + .setReaction({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + subjectId: input.subjectId, + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(toIssueError("react"))), + ), + ); + + return IssueService.of({ + list, + detail: traced("detail", detail), + comments: traced("comments", comments), + templates: traced("templates", templates), + candidates: interactive(traced("candidates", candidates)), + create: interactive(traced("create", create)), + comment: interactive(traced("comment", comment)), + update: interactive(traced("update", update)), + setState: interactive(traced("setState", setState)), + react: interactive(traced("react", react)), + }); +}); + +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 0000000000000..58e7dbadb0463 --- /dev/null +++ b/apps/server/src/issue/IssueTrackerRegistry.ts @@ -0,0 +1,36 @@ +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/source-control-core/server/IssueTracker"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.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), + }; +} + +const make = Effect.gen(function* () { + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return fromTrackers(drivers.flatMap(({ instance }) => instance.issues ?? [])); +}); + +export const layer = Layer.effect(IssueTrackerRegistry, make).pipe( + Layer.provide(BuiltInDrivers.layer), +); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 4549ee8a33c10..2cd5497c56bd9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -144,6 +144,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 30f003bf12891..863444f9d9a4d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -54,6 +54,8 @@ import { speechHttpApiLayer } from "./speech/http.ts"; import { speechStreamRouteLayer } from "./speech/stream.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"; @@ -441,6 +443,8 @@ const layerPullRequestService = PullRequestService.layer.pipe( Layer.provide(SourceControlRateLimit.layer), ); +const layerIssueService = IssueService.layer.pipe(Layer.provide(IssueTrackerRegistry.layer)); + const layerGitManager = GitManager.layer.pipe( Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), Layer.provideMerge(RuntimeLayer.layerProjectSetupScriptRunner), @@ -862,6 +866,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(PeerLinkRequests.layer), // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), + Layer.provide(layerIssueService), // The same activity instance RunExecutionService reports to, by layer memoization. Layer.provide( ServerBrowser.layer.pipe( diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 660548c1dd68b..31b6fca992e21 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -100,6 +100,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; +import * as IssueService from "./issue/IssueService.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; @@ -844,6 +845,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(IssueService.IssueService)({}), Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({}), Layer.mock(RelayClient.RelayClient)({}), Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 09c6d86c050fd..0439a18188289 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -247,6 +247,7 @@ import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as TriageService from "./triage/TriageService.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"; @@ -714,7 +715,13 @@ const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); // crosses TypeScript's instantiation limit and the layer's requirements degrade to // `any`, so the handlers are registered as two disjoint, smaller groups. type ServerWsRpc = RpcGroup.Rpcs; -const SPLIT_WS_RPC_PREFIXES = ["server", "pullRequests", "provider", "orchestration"] as const; +const SPLIT_WS_RPC_PREFIXES = [ + "server", + "pullRequests", + "issues", + "provider", + "orchestration", +] as const; type SplitWsRpc = Extract< ServerWsRpc, { readonly _tag: `${(typeof SPLIT_WS_RPC_PREFIXES)[number]}.${string}` } @@ -1472,6 +1479,7 @@ export const layerWsRpc = ( const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; 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; @@ -1951,6 +1959,16 @@ export const layerWsRpc = ( }); const handlers = ServerWsRpcGroup.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), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -3556,6 +3574,7 @@ export const layer = Layer.unwrap( const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const npmRelease = yield* NpmRelease.NpmRelease; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; @@ -3639,6 +3658,7 @@ export const layer = Layer.unwrap( Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // Share server-lifetime PR caches so WS mutations invalidate every client's HTTP diffs. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), + Layer.provide(Layer.succeed(IssueService.IssueService, issues)), Layer.provide(OtlpEndpointCheck.layer), Layer.provide( Layer.succeed( diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 8681c1ea8abeb..f3ff5fa5ab42c 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -40,6 +40,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.projectsCreateFromThread]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8dd47df33a21f..c005fdd03900d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -36,6 +36,7 @@ export * from "./projectClone.ts"; export * from "./speech.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 0000000000000..936c573c66e06 --- /dev/null +++ b/packages/contracts/src/issue.ts @@ -0,0 +1,471 @@ +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: `scope` is the tracker's own container (`owner/repo`, a team key) and + * `key` the issue's native id in it. `host` is absent for the kind's public host. `projectId` + * names the project whose credentials serve the read, which need not own the issue. + */ +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 e1d392cd080b6..2d4e057b7ae01 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -169,6 +169,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 { GuestShareCreateError, GuestShareCreateInput, @@ -602,6 +621,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", + sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", @@ -1283,6 +1314,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, @@ -2111,6 +2210,16 @@ export class RpcScopeAuthorization extends RpcMiddleware.Service()( + "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; + + /** 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/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index c4fb4106a2054..7f86d0fff3b9e 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -15,6 +15,7 @@ import type * as Scope from "effect/Scope"; import type { SourceControlDefinition } from "../shared/definition.ts"; import type { SourceControlProviderDiscoverySpec } from "./discovery.ts"; +import type { IssueTrackerApi } from "./IssueTracker.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; import type * as SourceControlHost from "./SourceControlHost.ts"; import type * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -53,6 +54,8 @@ export interface SourceControlDriverInstance { readonly discovery: SourceControlProviderDiscoverySpec; /** Null for a host whose pull requests this build cannot read. */ readonly pullRequests: PullRequestProviderApi | null; + /** Absent for a host whose issues this build cannot read. */ + readonly issues?: IssueTrackerApi; /** * Fetches media the definition's `mediaUrl` claimed, with the credential the host serves it * to. Null when the fetch went nowhere a response could come from, such as a redirect loop. diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 167d89a67f878..a37878af7d431 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -341,6 +341,11 @@ export interface SourceControlDefinition { * path, `owner/name`. */ readonly repositorySelector?: (identity: RepositorySelectorIdentity) => string | null; + /** + * The container the project's issues live in, where that is not its repository: Azure DevOps + * keeps work items per project. Null when the repository cannot be placed. + */ + readonly issueScope?: (identity: RepositorySelectorIdentity) => string | null; /** * The key of a remote's lower case `host/path`, `.git` suffix kept, for a host that spells it * apart from `normalizeGitRemoteUrl`: over SSH and the web, or with `.git` as part of the name. @@ -660,6 +665,8 @@ export interface SourceControlDefinitionRegistry { readonly repositorySelector: ( identity: RepositorySelectorIdentity | null | undefined, ) => string | null; + /** Where the project's issues live: the host's `issueScope`, else its repository. */ + readonly issueScope: (identity: RepositorySelectorIdentity | null | undefined) => string | null; readonly changeRequestHost: ( identity: ChangeRequestHostIdentity, kind: Kind, @@ -725,6 +732,10 @@ export function makeSourceControlDefinitionRegistry( }; const remoteOwner = (remote: GitRemoteHost) => recognitionOrder.find((definition) => definition.recognizeRemote(remote) !== null); + const repositorySelector = (identity: RepositorySelectorIdentity | null | undefined) => + identity?.provider === undefined + ? defaultRepositorySelector(identity) + : (byKind.get(identity.provider)?.repositorySelector ?? defaultRepositorySelector)(identity); const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { const url = httpUrl(value); if (url === null) return null; @@ -825,12 +836,14 @@ export function makeSourceControlDefinitionRegistry( }, canonicalRepositoryKey: (key) => repositoryKeyOwner(key)?.canonical ?? canonicalKeyPath(key), findByRepositoryKey: (key) => repositoryKeyOwner(key)?.definition, - repositorySelector: (identity) => - identity?.provider === undefined - ? defaultRepositorySelector(identity) - : (byKind.get(identity.provider)?.repositorySelector ?? defaultRepositorySelector)( - identity, - ), + repositorySelector, + issueScope: (identity) => { + const issueScope = + identity?.provider === undefined ? undefined : byKind.get(identity.provider)?.issueScope; + return issueScope === undefined || identity == null + ? repositorySelector(identity) + : issueScope(identity); + }, changeRequestHost: (identity, kind) => (kind === undefined ? undefined : byKind.get(kind)?.changeRequestHost?.(identity)) ?? projectChangeRequestHost(identity, kind), diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json index 61e4476bb2517..3fe7980d737ae 100644 --- a/packages/source-control-github/package.json +++ b/packages/source-control-github/package.json @@ -23,7 +23,8 @@ "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", - "effect": "catalog:" + "effect": "catalog:", + "yaml": "catalog:" }, "devDependencies": { "@effect/graphql-generator": "catalog:", diff --git a/packages/source-control-github/schema/github.graphql.ts b/packages/source-control-github/schema/github.graphql.ts index 332060b960ff8..a2e6cb7f92e18 100644 --- a/packages/source-control-github/schema/github.graphql.ts +++ b/packages/source-control-github/schema/github.graphql.ts @@ -95,6 +95,17 @@ export type FileViewedState = typeof FileViewedState.Type export const IssueState = Schema.Literals(["CLOSED", "OPEN"]) export type IssueState = typeof IssueState.Type +/** + * The possible state reasons of an issue. + * + * - `COMPLETED`: An issue that has been closed as completed + * - `DUPLICATE`: An issue that has been closed as a duplicate. + * - `NOT_PLANNED`: An issue that has been closed as not planned + * - `REOPENED`: An issue that has been reopened + */ +export const IssueStateReason = Schema.Literals(["COMPLETED", "DUPLICATE", "NOT_PLANNED", "REOPENED"]) +export type IssueStateReason = typeof IssueStateReason.Type + /** * Detailed status information about a pull request merge. * @@ -298,10 +309,26 @@ export interface UpdateRefsInput extends Schema.Struct.Type Effect.succeedNone, + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("test-token"), + source: "gh" as const, + fingerprint: "test", + }), + }), + Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, + }), + Layer.succeed(HttpClient.HttpClient, httpClient), + ), + ), + ); +} + +function tracker(onRequest: (variables: unknown) => void, templateRepository?: unknown) { + return GitHubIssueTracker.make.pipe( + Effect.provide( + transport( + HttpClient.make((request) => { + if (request.body._tag !== "Uint8Array") + return Effect.die("Expected a GraphQL JSON request."); + const { variables } = decodeVariables(new TextDecoder().decode(request.body.body)); + onRequest(variables); + const body = + templateRepository !== undefined + ? JSON.stringify({ data: { repository: templateRepository } }) + : "labelsCursor" in variables + ? JSON.stringify({ + data: { + repository: { + issue: { + labels: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ name: "needs repro", color: "ffffff" }], + }, + assignees: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + }, + }, + }) + : DETAIL_RESPONSE; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body, { + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ), + ), + ); +} + +const encodeTemplateList = Schema.encodeSync(IssueTemplateList); + +const decodeBody = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +describe("GitHubIssueTracker", () => { + it.effect("writes an issue lifecycle without mutating pull requests or foreign comments", () => + Effect.gen(function* () { + const writes: unknown[] = []; + const http = HttpClient.make((request) => { + if (request.body._tag !== "Uint8Array") return Effect.die("Expected a JSON request."); + const text = new TextDecoder().decode(request.body.body); + let response: unknown = {}; + if (request.url === "https://api.github.com/graphql") { + const { query, variables } = decodeVariables(text); + expect(request.method).toBe("POST"); + const operation = query.match(/(?:query|mutation)\s+(\w+)/)?.[1]; + if (operation === "IssueNodeId" || operation === "IssueCommentScope") { + expect(variables).toMatchObject({ owner: "acme", name: "web" }); + response = { + data: { + repository: { issue: variables["number"] === 42 ? { id: "I_42" } : null }, + ...(operation === "IssueCommentScope" + ? { node: { __typename: "IssueComment", issue: { id: "I_other" } } } + : {}), + }, + }; + } else { + expect(["AddReaction", "RemoveReaction"]).toContain(operation); + expect(variables).toEqual({ subjectId: "I_42", content: "THUMBS_UP" }); + writes.push(operation); + response = { + data: { + [operation === "AddReaction" ? "addReaction" : "removeReaction"]: { + reaction: { content: "THUMBS_UP" }, + }, + }, + }; + } + } else { + writes.push([request.method, request.url, decodeBody(text)]); + if (request.url.endsWith("/issues")) { + response = { number: 42, html_url: "https://github.com/acme/web/issues/42" }; + } + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(response), { + headers: { "content-type": "application/json" }, + }), + ), + ); + }); + const api = yield* GitHubIssueTracker.make.pipe(Effect.provide(transport(http))); + const target = { cwd: "/w", host: "github.com", scope: "acme/web", key: "42" }; + expect( + yield* api.create({ + ...target, + title: "Fixture", + body: "Body", + labels: ["bug"], + assignees: ["octocat"], + }), + ).toEqual({ + key: "42", + displayKey: "#42", + url: "https://github.com/acme/web/issues/42", + }); + yield* api.comment({ ...target, body: "Comment" }); + yield* api.update({ ...target, title: "Updated", body: "Edited", labels: [], assignees: [] }); + yield* api.setState({ ...target, state: "closed", closeReason: "not-planned" }); + yield* api.setState({ ...target, state: "open" }); + yield* api.setReaction({ ...target, content: "thumbs-up", reacted: true }); + yield* api.setReaction({ ...target, content: "thumbs-up", reacted: false }); + + const pullRequest = { ...target, key: "50" }; + expect( + yield* api.update({ ...pullRequest, title: "Do not edit" }).pipe(Effect.result), + ).toMatchObject({ _tag: "Failure", failure: { operation: "update", reason: "not-found" } }); + expect( + yield* api.setState({ ...pullRequest, state: "closed" }).pipe(Effect.result), + ).toMatchObject({ + _tag: "Failure", + failure: { operation: "setState", reason: "not-found" }, + }); + expect( + yield* api.comment({ ...pullRequest, body: "Do not comment" }).pipe(Effect.result), + ).toMatchObject({ _tag: "Failure", failure: { operation: "comment", reason: "not-found" } }); + expect( + yield* Effect.flip( + api.setReaction({ + ...target, + subjectId: "IC_other", + content: "thumbs-up", + reacted: true, + }), + ), + ).toMatchObject({ operation: "setReaction", reason: "failed" }); + + expect(writes).toEqual([ + [ + "POST", + "https://api.github.com/repos/acme/web/issues", + { title: "Fixture", body: "Body", labels: ["bug"], assignees: ["octocat"] }, + ], + ["POST", "https://api.github.com/repos/acme/web/issues/42/comments", { body: "Comment" }], + [ + "PATCH", + "https://api.github.com/repos/acme/web/issues/42", + { title: "Updated", body: "Edited", labels: [], assignees: [] }, + ], + [ + "PATCH", + "https://api.github.com/repos/acme/web/issues/42", + { state: "closed", state_reason: "not_planned" }, + ], + ["PATCH", "https://api.github.com/repos/acme/web/issues/42", { state: "open" }], + "AddReaction", + "RemoveReaction", + ]); + }), + ); + + it.effect("offers YAML issue forms when blank issues are disabled", () => + Effect.gen(function* () { + const form = { + __typename: "Blob", + isTruncated: false, + text: `name: Bug report +description: Report broken behavior. +title: "[Bug]: " +labels: [bug, needs-triage] +assignees: "octocat, theo" +body: + - type: markdown + attributes: + value: Search existing issues first. + - type: checkboxes + attributes: + label: Before submitting + options: + - label: I searched existing issues. + required: true + - type: dropdown + attributes: + label: Area + description: Which part is affected? + options: [Web, Server] + default: 1 + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + placeholder: Start the app. + - type: input + attributes: + label: Version + value: main + - type: upload + attributes: + label: Supporting files + description: Redact secrets before uploading. +`, + }; + const config = { + __typename: "Blob", + isTruncated: false, + text: '"blank_issues_enabled": false # use a template', + }; + const repository = { + issueTemplates: [], + templateDirectory: { + __typename: "Tree", + entries: [ + { name: "config.yml", object: config }, + { name: "bug_report.yml", object: form }, + { + name: "empty_name.yml", + object: { ...form, text: 'name: ""\ndescription: Empty name\nbody: []' }, + }, + { + name: "blank_name.yml", + object: { ...form, text: 'name: " "\ndescription: Blank name\nbody: []' }, + }, + { + name: "invalid.yml", + object: { ...form, text: "name: Missing required metadata\nbody: []" }, + }, + ], + }, + }; + const api = yield* tracker(() => {}, repository); + const input = { cwd: "/w", host: "github.com", scope: "acme/web" }; + const result = yield* api.listTemplates!(input); + expect(encodeTemplateList(result)).toEqual(result); + expect(result.blankIssuesEnabled).toBe(false); + expect(result.templates).toHaveLength(1); + expect(result.templates[0]).toMatchObject({ + key: "bug_report.yml", + name: "Bug report", + about: "Report broken behavior.", + title: "[Bug]: ", + labels: ["bug", "needs-triage"], + assignees: ["octocat", "theo"], + }); + expect(result.templates[0]?.body).toBe(`Search existing issues first. + +### Before submitting + +- [ ] I searched existing issues. (required) + +### Area + +Which part is affected? + +Required. + +Choose one: + +- Web +- Server (default) + +### Steps to reproduce + +Example: +> Start the app. + +### Version + +main + +### Supporting files + +Redact secrets before uploading.`); + config.isTruncated = true; + expect(yield* Effect.flip(api.listTemplates!(input))).toMatchObject({ + reason: "failed", + detail: "GitHub returned incomplete issue template contents.", + }); + }), + ); + + it.effect("maps a detail read to the neutral issue shape", () => + Effect.gen(function* () { + const seen: unknown[] = []; + const api = yield* tracker((variables) => { + seen.push(variables); + }); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "github.com", + scope: "acme/web", + key: "42", + }); + expect(seen).toEqual([ + { owner: "acme", name: "web", number: 42 }, + { + owner: "acme", + name: "web", + number: 42, + labelsCursor: "labels-next", + assigneesCursor: null, + }, + ]); + expect(issue).toMatchObject({ + key: "42", + displayKey: "#42", + title: "Composer loses focus", + body: "Typing in the composer drops focus after a paste.", + state: { category: "closed", name: "closed", closeReason: "duplicate" }, + author: { + login: "octocat", + name: "Octo Cat", + avatarUrl: "https://avatars/octocat", + }, + assignees: [{ login: "theo" }], + labels: [ + { name: "bug", color: "d73a4a" }, + { name: "needs repro", color: "ffffff" }, + ], + 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], + ]); + }), + ); +}); 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 0000000000000..14526d74c2c9f --- /dev/null +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -0,0 +1,426 @@ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { GraphQLClient, GraphQLClientError, GraphQLGroup } from "effect/graphql"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/source-control-core/server/IssueTracker"; + +import * as GitHubRest from "./GitHubRest.ts"; +import { readIssueTemplates } from "./gitHubIssueTemplates.ts"; +import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; +import * as GitHubGraphQlClient from "./gitHubGraphQlClient.ts"; +import { IssuesGraphQLGroup } from "./issues.graphql.ts"; +import { AddReaction, RemoveReaction } from "./pullRequestMutations.graphql.ts"; +import { gitHubReactionContent } from "./gitHubPullRequestJson.ts"; +import { + decodeCreatedIssueJson, + toIssueCandidates, + toIssueComments, + toIssueDetail, + toIssueSearch, +} from "./gitHubIssueJson.ts"; + +const KIND = IssueTrackerKind.make("github"); + +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"], +}; + +const REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9_.-]+$/; + +function searchTerms(query: string): ReadonlyArray { + return query + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word) => `"${word.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 qualifier = INVOLVEMENT_QUALIFIER[input.involvement]; + return [ + "is:issue", + `repo:${input.scope.trim()}`, + ...(input.state === "all" ? [] : [`is:${input.state}`]), + ...(qualifier === null ? [] : [`${qualifier}:${input.viewer}`]), + "sort:updated-desc", + ...searchTerms(input.query ?? ""), + ].join(" "); +} + +const CLOSE_REASON: Record = { + completed: "completed", + "not-planned": "not_planned", +}; + +function reasonFor( + error: GitHubRequestPolicy.GitHubApiError | GraphQLClientError.GraphQLClientError, +): 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"; + } +} + +const FAILURE_DETAIL: Record = { + "missing-tool": "GitHub CLI is not installed.", + unauthenticated: "GitHub authentication is unavailable. Sign in and retry.", + "rate-limited": "GitHub API rate limit exceeded.", + "not-found": "GitHub could not find the requested issue.", + failed: "The GitHub issue request failed.", +}; + +const group = GraphQLGroup.merge( + IssuesGraphQLGroup, + GraphQLGroup.make(AddReaction, RemoveReaction), +).middleware(GitHubGraphQlClient.GitHubGraphQlAuth); + +export const make = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + const client = yield* GraphQLClient.make(group).pipe(Effect.provide(GitHubGraphQlClient.layer)); + + const fail = + (operation: string) => + (error: GitHubRequestPolicy.GitHubApiError | GraphQLClientError.GraphQLClientError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: GraphQLClientError.isGraphQLClientError(error) + ? `GitHub returned an unreadable ${operation} response.` + : FAILURE_DETAIL[reasonFor(error)], + ...("retryAt" in error && error.retryAt !== undefined ? { retryAt: error.retryAt } : {}), + cause: error, + }); + + const invalid = (operation: string, detail: string, reason: "failed" | "not-found" = "failed") => + new IssueTrackerError({ tracker: KIND, operation, reason, detail }); + + const notFound = (operation: string) => + invalid(operation, "GitHub could not find the requested issue.", "not-found"); + + const repository = Effect.fn("GitHubIssueTracker.repository")(function* ( + scope: string, + operation: string, + ) { + if (!REPOSITORY.test(scope) || scope.endsWith("/.") || scope.endsWith("/..")) { + return yield* invalid(operation, "A repository was named that GitHub cannot address."); + } + const [owner = "", name = ""] = scope.split("/"); + return { owner, name }; + }); + + const issue = Effect.fn("GitHubIssueTracker.issue")(function* ( + input: { readonly scope: string; readonly key: string }, + operation: string, + ) { + const repo = yield* repository(input.scope, operation); + const number = Number(input.key); + if (!/^[1-9]\d*$/.test(input.key) || !Number.isSafeInteger(number) || number > 2_147_483_647) { + return yield* notFound(operation); + } + return { ...repo, number, path: `repos/${repo.owner}/${repo.name}/issues/${number}` }; + }); + + const graphql = + (host: string, operation: string) => + ( + effect: Effect.Effect, + ) => + effect.pipe(GitHubGraphQlClient.call({ host, operation }), Effect.mapError(fail(operation))); + + const verifiedIssue = Effect.fn("GitHubIssueTracker.verifiedIssue")(function* ( + input: { readonly host: string; readonly scope: string; readonly key: string }, + operation: string, + ) { + const target = yield* issue(input, operation); + const { owner, name, number } = target; + // REST issue endpoints also accept pull requests; resolve an Issue before writing. + const result = yield* client + .IssueNodeId({ owner, name, number }) + .pipe(graphql(input.host, operation)); + const id = result.repository?.issue?.id; + if (id === undefined) return yield* notFound(operation); + return { ...target, id }; + }); + + const patchIssue = ( + input: { readonly host: string; readonly scope: string; readonly key: string }, + operation: string, + body: Record, + ) => + verifiedIssue(input, operation).pipe( + Effect.flatMap(({ path }) => + api + .rest({ host: input.host, operation, method: "PATCH", path, body }) + .pipe(Effect.asVoid, Effect.mapError(fail(operation))), + ), + ); + + return { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => + client.IssueViewer({}).pipe( + graphql(input.host, "getViewer"), + Effect.map(({ viewer }) => viewer.login), + ), + + listIssues: (input) => + repository(input.scope, "listIssues").pipe( + Effect.flatMap(() => + client + .IssueSearch({ + q: searchQuery(input), + cursor: input.cursor, + rows: Math.min(Math.max(Math.trunc(input.limit), 1), 100), + }) + .pipe(graphql(input.host, "listIssues"), Effect.map(toIssueSearch)), + ), + ), + + getIssue: Effect.fn("GitHubIssueTracker.getIssue")(function* (input) { + const { owner, name, number } = yield* issue(input, "getIssue"); + const result = yield* client + .IssueDetail({ owner, name, number }) + .pipe(graphql(input.host, "getIssue")); + const detail = toIssueDetail(result); + const raw = result.repository?.issue; + if (detail === null || raw == null) return yield* notFound("getIssue"); + // Editing replaces these sets. Finish their pages before handing them to a client. + let labels = raw.labels; + let assignees = raw.assignees; + const seen = new Set(); + const allLabels = [...detail.labels]; + const allAssignees = [...detail.assignees]; + while (labels?.pageInfo.hasNextPage === true || assignees.pageInfo.hasNextPage) { + const labelsCursor = labels?.pageInfo.hasNextPage ? labels.pageInfo.endCursor : null; + const assigneesCursor = assignees.pageInfo.hasNextPage + ? assignees.pageInfo.endCursor + : null; + const key = `${labelsCursor ?? ""} ${assigneesCursor ?? ""}`; + if ( + (labels?.pageInfo.hasNextPage && labelsCursor === null) || + (assignees.pageInfo.hasNextPage && assigneesCursor === null) || + seen.has(key) + ) { + return yield* invalid( + "getIssue", + "GitHub did not return complete issue labels or assignees.", + ); + } + seen.add(key); + const page = yield* client + .IssueCollections({ owner, name, number, labelsCursor, assigneesCursor }) + .pipe(graphql(input.host, "getIssue")); + const next = page.repository?.issue; + if (next == null) return yield* notFound("getIssue"); + if (labels?.pageInfo.hasNextPage) { + labels = next.labels; + if (next.labels === null) { + return yield* invalid("getIssue", "GitHub did not return complete issue labels."); + } + allLabels.push( + ...(next.labels.nodes ?? []).flatMap((node) => + node === null ? [] : [{ name: node.name, color: node.color }], + ), + ); + } + if (assignees.pageInfo.hasNextPage) { + assignees = next.assignees; + allAssignees.push( + ...(next.assignees.nodes ?? []).flatMap((node) => + node === null + ? [] + : [{ login: node.login, name: node.name, avatarUrl: node.avatarUrl }], + ), + ); + } + } + return { ...detail, labels: allLabels, assignees: allAssignees }; + }), + + getComments: (input) => + issue(input, "getComments").pipe( + Effect.flatMap(({ owner, name, number }) => + client + .IssueComments({ owner, name, number, cursor: input.cursor }) + .pipe(graphql(input.host, "getComments")), + ), + Effect.flatMap((result) => + result.repository?.issue == null + ? Effect.fail(notFound("getComments")) + : Effect.succeed(toIssueComments(result)), + ), + ), + + create: (input) => + repository(input.scope, "create").pipe( + Effect.flatMap(({ owner, name }) => + 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( + new IssueTrackerError({ + tracker: KIND, + operation: "create", + reason: "failed", + detail: "GitHub returned an unreadable create response.", + cause: decoded.failure, + }), + ); + }), + ), + + comment: (input) => + verifiedIssue(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: Effect.fn("GitHubIssueTracker.setReaction")(function* (input) { + let subjectId: string; + if (input.subjectId === undefined) { + const target = yield* verifiedIssue(input, "setReaction"); + subjectId = target.id; + } else { + const { owner, name, number } = yield* issue(input, "setReaction"); + // Node ids are global: prove the comment belongs to this issue before writing. + const result = yield* client + .IssueCommentScope({ owner, name, number, commentId: input.subjectId }) + .pipe(graphql(input.host, "setReaction")); + const expected = result.repository?.issue?.id; + if ( + expected === undefined || + result.node?.__typename !== "IssueComment" || + result.node.issue.id !== expected + ) { + return yield* invalid( + "setReaction", + "The comment does not belong to the selected issue.", + ); + } + subjectId = input.subjectId; + } + const variables = { subjectId, content: gitHubReactionContent(input.content) }; + yield* ( + input.reacted + ? client.AddReaction(variables).pipe(Effect.asVoid) + : client.RemoveReaction(variables).pipe(Effect.asVoid) + ).pipe(graphql(input.host, "setReaction")); + }), + + listTemplates: (input) => + repository(input.scope, "listTemplates").pipe( + Effect.flatMap(({ owner, name }) => + client.IssueTemplates({ owner, name }).pipe(graphql(input.host, "listTemplates")), + ), + Effect.flatMap(({ repository }) => + repository === null + ? Effect.fail(notFound("listTemplates")) + : readIssueTemplates(repository), + ), + ), + + listCandidates: (input) => + repository(input.scope, "listCandidates").pipe( + Effect.flatMap(({ owner, name }) => + client.IssueCandidates({ owner, name }).pipe(graphql(input.host, "listCandidates")), + ), + Effect.flatMap(({ repository }) => + repository === null + ? Effect.fail(notFound("listCandidates")) + : Effect.succeed(toIssueCandidates(repository)), + ), + ), + } satisfies IssueTrackerApi; +}); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index 2fb2dfb2899c7..05844f6608fe6 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -17,6 +17,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubHttpClient from "./GitHubHttpClient.ts"; +import * as GitHubIssueTracker from "./GitHubIssueTracker.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; // `GitHubRest.layerWithDependencies` carries the quota reserve every GitHub reader shares, so it @@ -104,6 +105,7 @@ const makeInstance = Effect.gen(function* () { sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, pullRequests: GitHubPullRequestProvider.make, + issues: GitHubIssueTracker.make, fetchMedia: GitHubMediaFetch.make.pipe( Effect.provideService(HttpClient.HttpClient, mediaHttpClient), ), 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 0000000000000..3ad2ba2d22363 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -0,0 +1,342 @@ +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +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/source-control-core/server/IssueTracker"; + +import type * as Issues from "./issues.graphql.ts"; +import { toReactions } from "./gitHubPullRequestJson.ts"; + +type DetailIssue = NonNullable["issue"]>; +type Actor = { + readonly login: string; + readonly name?: string | null; + readonly avatarUrl: string | null; +}; +type Label = NonNullable["nodes"]>[number]>; +type TimelineItem = NonNullable[number]>; +type Comments = Omit; +type Reference = NonNullable< + NonNullable["nodes"]>[number] +>; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Actor | null | undefined): IssueActor | null { + const login = trimmed(raw?.login); + return login === null + ? null + : { + login, + name: trimmed(raw != null && "name" in raw ? raw.name : null), + avatarUrl: trimmed(raw?.avatarUrl), + }; +} + +function toActors(raw: ReadonlyArray | null | undefined): ReadonlyArray { + return (raw ?? []).flatMap((entry) => toActor(entry) ?? []); +} + +function toLabels(raw: ReadonlyArray