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