From 7014eda9a303e5b9984c41ad53c34bf22ae0b5fe Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:42:03 -0700 Subject: [PATCH 01/14] feat(issues): add issue tracking core, contracts, and GitHub tracker Issue tracking gets its own extension point beside source control so non-git trackers slot in without posing as source-control hosts. This layer adds the `@t3tools/issue-tracker-core` port and driver shape, the `issues.*` contracts and RPCs, an `IssueService` that resolves a project's tracker from its git remote and treats issues outside the project's own scope as read-only, and a GitHub tracker over the existing `GitHubApi`. Web UI, MCP tools, thread links, and the other trackers follow in later layers. Co-Authored-By: Claude Fable 5.1 --- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.test.ts | 21 + apps/server/src/auth/RpcAuthorization.ts | 5 + apps/server/src/issue/IssueService.test.ts | 268 +++++++ apps/server/src/issue/IssueService.ts | 563 ++++++++++++++ apps/server/src/issue/IssueTrackerRegistry.ts | 40 + apps/server/src/issue/builtInIssueTrackers.ts | 20 + .../src/observability/RpcInstrumentation.ts | 10 + apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 14 + .../contracts/src/clientRpcPermissions.ts | 5 + packages/contracts/src/index.ts | 1 + packages/contracts/src/issue.ts | 477 ++++++++++++ packages/contracts/src/rpc.ts | 109 +++ packages/issue-tracker-core/package.json | 25 + .../src/server/IssueTracker.ts | 190 +++++ .../issue-tracker-core/src/server/driver.ts | 26 + packages/issue-tracker-core/tsconfig.json | 7 + packages/source-control-github/package.json | 13 + .../src/server/GitHubIssueTracker.test.ts | 188 +++++ .../src/server/GitHubIssueTracker.ts | 504 ++++++++++++ .../src/server/gitHubIssueJson.ts | 736 ++++++++++++++++++ .../src/server/gitHubPullRequestJson.ts | 6 +- .../src/server/issueTrackerDriver.ts | 15 + pnpm-lock.yaml | 19 + 25 files changed, 3265 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/issue/IssueService.test.ts create mode 100644 apps/server/src/issue/IssueService.ts create mode 100644 apps/server/src/issue/IssueTrackerRegistry.ts create mode 100644 apps/server/src/issue/builtInIssueTrackers.ts create mode 100644 packages/contracts/src/issue.ts create mode 100644 packages/issue-tracker-core/package.json create mode 100644 packages/issue-tracker-core/src/server/IssueTracker.ts create mode 100644 packages/issue-tracker-core/src/server/driver.ts create mode 100644 packages/issue-tracker-core/tsconfig.json create mode 100644 packages/source-control-github/src/server/GitHubIssueTracker.test.ts create mode 100644 packages/source-control-github/src/server/GitHubIssueTracker.ts create mode 100644 packages/source-control-github/src/server/gitHubIssueJson.ts create mode 100644 packages/source-control-github/src/server/issueTrackerDriver.ts diff --git a/apps/server/package.json b/apps/server/package.json index 715fb3097986..82d344b10498 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,6 +37,7 @@ "@muse-code/sdk": "1.4.6", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index ce90ad31fea8..a88c4e66c874 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -127,6 +127,27 @@ describe("RPC authorization scopes", () => { ); }); + it("reads issues under the orchestration read scope and writes them like pull requests", () => { + for (const method of [ + WS_METHODS.issuesList, + WS_METHODS.issuesDetail, + WS_METHODS.issuesComments, + WS_METHODS.issuesTemplates, + WS_METHODS.issuesCandidates, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + for (const method of [ + WS_METHODS.issuesCreate, + WS_METHODS.issuesComment, + WS_METHODS.issuesUpdate, + WS_METHODS.issuesSetState, + WS_METHODS.issuesReact, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); + } + }); + it("requires source control writes to start, retry, or cancel project clones", () => { for (const method of [ WS_METHODS.projectCloneStart, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..8a619f461676 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -129,6 +129,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.issuesList]: AuthOrchestrationReadScope, + [WS_METHODS.issuesDetail]: AuthOrchestrationReadScope, + [WS_METHODS.issuesComments]: AuthOrchestrationReadScope, + [WS_METHODS.issuesTemplates]: AuthOrchestrationReadScope, + [WS_METHODS.issuesCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts new file mode 100644 index 000000000000..e52f3d142b38 --- /dev/null +++ b/apps/server/src/issue/IssueService.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { + IssueTrackerKind, + type OrchestrationProjectShell, + type ProjectId, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, + type TrackerIssueDetail, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as IssueService from "./IssueService.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const GITHUB = IssueTrackerKind.make("github"); + +function project(input: { + readonly id: string; + readonly repository?: string; + readonly provider?: string; + readonly host?: string; +}): OrchestrationProjectShell { + const host = input.host ?? "github.com"; + return { + id: input.id as ProjectId, + title: input.id, + workspaceRoot: `/w/${input.id}`, + ...(input.repository + ? { + repositoryIdentity: { + canonicalKey: `${host}/${input.repository}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: input.provider ?? "github", + displayName: input.repository, + }, + } + : {}), + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }; +} + +function issue(key: string, updatedAt = "2026-07-02T00:00:00Z"): TrackerIssueDetail { + return { + key, + displayKey: `#${key}`, + title: `Issue ${key}`, + url: `https://github.com/acme/web/issues/${key}`, + author: null, + state: { category: "open", name: "open" }, + createdAt: "2026-07-01T00:00:00Z", + updatedAt, + closedAt: null, + assignees: [], + labels: [], + commentCount: 0, + body: "", + viewer: "julius", + viewerPermissions: { setState: true, comment: true, edit: true, labels: true, assignees: true }, + comments: [], + commentsCursor: null, + events: [], + linkedPullRequests: [], + }; +} + +function fakeTracker(overrides: Partial = {}): IssueTrackerApi { + return { + kind: GITHUB, + capabilities: { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: false, + templates: false, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + closeReasons: ["completed"], + }, + getViewer: () => Effect.succeed("julius"), + listIssues: () => Effect.succeed({ items: [], nextCursor: null }), + getIssue: (input) => Effect.succeed(issue(input.key)), + getComments: () => Effect.succeed({ comments: [], nextCursor: null }), + getViewerPermissions: () => Effect.die("unused"), + comment: () => Effect.void, + update: () => Effect.void, + setState: () => Effect.void, + ...overrides, + }; +} + +function makeService(input: { + readonly projects: ReadonlyArray; + readonly trackers: ReadonlyArray; +}) { + return IssueService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + IssueTrackerRegistry.IssueTrackerRegistry, + IssueTrackerRegistry.fromTrackers(input.trackers), + ), + Layer.mock(ProjectService.ProjectService)({ + listShells: () => Effect.succeed(input.projects), + getShell: (projectId) => + Effect.succeed(Option.fromNullishOr(input.projects.find((p) => p.id === projectId))), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + }), + ), + ), + ); +} + +describe("IssueService", () => { + it.effect("reads another repository on the same host but refuses to write to it", () => + Effect.gen(function* () { + const writes: string[] = []; + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [ + fakeTracker({ + comment: (input) => + Effect.sync(() => { + writes.push(`${input.scope}#${input.key}`); + }), + }), + ], + }); + const own = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" }; + const external = { ...own, scope: "other/backend", key: "7" }; + + expect((yield* service.detail(own)).writable).toBe(true); + // The external issue is served by the project's checkout and credentials, read-only. + const detail = yield* service.detail(external); + expect(detail).toMatchObject({ writable: false, scope: "other/backend", key: "7" }); + + yield* service.comment({ ...own, body: "ok" }); + const refused = yield* Effect.flip(service.comment({ ...external, body: "no" })); + expect(refused).toMatchObject({ _tag: "IssueOperationError", reason: "read-only" }); + expect(writes).toEqual(["acme/web#1"]); + + // Another host is not reachable through this project's credentials at all. + const elsewhere = yield* Effect.flip(service.detail({ ...own, host: "ghe.example.test" })); + expect(elsewhere).toMatchObject({ + _tag: "IssueUnavailableError", + reason: "tracker-unsupported", + }); + }), + ); + + it.effect("refuses what the tracker did not declare before calling it", () => + Effect.gen(function* () { + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [ + fakeTracker({ + setState: () => + Effect.sync(() => { + calls += 1; + }), + setReaction: () => + Effect.sync(() => { + calls += 1; + }), + }), + ], + }); + const ref = { projectId: "web" as ProjectId, tracker: GITHUB, scope: "acme/web", key: "1" }; + + yield* service.setState({ ...ref, state: "closed", closeReason: "completed" }); + const unknownState = yield* Effect.flip(service.setState({ ...ref, state: "In Review" })); + expect(unknownState._tag).toBe("IssueOperationError"); + const unknownReason = yield* Effect.flip( + service.setState({ ...ref, state: "closed", closeReason: "not-planned" }), + ); + expect(unknownReason._tag).toBe("IssueOperationError"); + const reaction = yield* Effect.flip( + service.react({ ...ref, content: "heart", reacted: true }), + ); + expect(reaction._tag).toBe("IssueOperationError"); + expect(calls).toBe(1); + }), + ); + + it.effect("lists each repository once and reports unusable trackers per host", () => + Effect.gen(function* () { + const listed: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "web", repository: "acme/web" }), + project({ id: "web-worktree", repository: "acme/web" }), + project({ id: "api", repository: "acme/api" }), + project({ id: "gitlab", repository: "acme/gl", provider: "gitlab", host: "gitlab.com" }), + project({ id: "plain" }), + ], + trackers: [ + fakeTracker({ + listIssues: (input) => + Effect.gen(function* () { + listed.push(input.scope); + if (input.scope === "acme/api") + return yield* new IssueTrackerError({ + tracker: GITHUB, + operation: "listIssues", + reason: "failed", + detail: "boom", + }); + return { + items: [issue("2", "2026-07-05T00:00:00Z"), issue("1")], + nextCursor: "next", + }; + }), + }), + ], + }); + const result = yield* service.list({ state: "open" }); + expect(listed.sort()).toEqual(["acme/api", "acme/web"]); + expect(result.entries.map((entry) => [entry.projectId, entry.key])).toEqual([ + ["web", "2"], + ["web", "1"], + ]); + expect(result.nextCursors).toEqual({ "github.com acme/web": "next" }); + expect(result.errors).toEqual([{ projectId: "api", projectTitle: "api", message: "boom" }]); + expect(result.viewers).toEqual({ "github.com": "julius" }); + expect(result.trackers).toEqual([ + expect.objectContaining({ host: "github.com", projectCount: 3, configured: true }), + expect.objectContaining({ + host: "gitlab.com", + tracker: "gitlab", + projectCount: 1, + configured: false, + capabilities: null, + }), + ]); + + // A continuation only reads the scopes it names. + listed.length = 0; + const next = yield* service.list({ + state: "open", + cursors: { "github.com acme/web": "next" }, + }); + expect(listed).toEqual(["acme/web"]); + expect(next.errors).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts new file mode 100644 index 000000000000..ec2638510f2b --- /dev/null +++ b/apps/server/src/issue/IssueService.ts @@ -0,0 +1,563 @@ +/** + * Issues across the workspace's projects, read through whichever tracker each project resolves + * to. The service owns resolution and permission; trackers own their APIs. + * + * Resolution: a git-host project resolves to the tracker of the same kind as its remote, with the + * remote's host and `owner/repo` as the scope. A standalone tracker (Linear, Jira) will resolve + * from a per-project binding in settings through the same `resolveProjectTracker` hook; only the + * git-host path exists today. + * + * An issue whose scope is not any project's own can still be read through a project on the same + * tracker and host — a frontend project reading a backend issue, or an external URL — but is + * read-only: every write checks `writable` first. + * + * @module issue/IssueService + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { + IssueOperationError, + IssueTrackerKind, + IssueUnavailableError, + pullRequestHostOf, + SourceControlProviderKind, + type IssueCandidates, + type IssueCommentInput, + type IssueCommentsPage, + type IssueCommentsPageInput, + type IssueCreateInput, + type IssueCreateResult, + type IssueDetail, + type IssueError, + type IssueListEntry, + type IssueListInput, + type IssueListProjectError, + type IssueListResult, + type IssueReactionInput, + type IssueRef, + type IssueScopeRef, + type IssueSetStateInput, + type IssueTemplateList, + type IssueTrackerSummary, + type IssueUpdateInput, + type OrchestrationProjectShell, +} from "@t3tools/contracts"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as IssueTrackerRegistry from "./IssueTrackerRegistry.ts"; + +const DEFAULT_LIST_LIMIT = 50; +const SCOPE_CONCURRENCY = 4; + +/** One project's resolved tracker: which driver, where it is served from, and its container. */ +export interface ProjectTrackerBinding { + readonly tracker: IssueTrackerKind; + readonly host: string; + readonly scope: string; +} + +/** A project the registry has a tracker for, ready to serve reads and writes. */ +interface SupportedProject extends ProjectTrackerBinding { + readonly project: OrchestrationProjectShell; + readonly api: IssueTrackerApi; +} + +/** + * The git-host path of resolution: the tracker is the host's own, named like the remote's + * provider kind. Null for a project with no remote or a kind no tracker claims. + */ +export function resolveGitHostBinding( + project: OrchestrationProjectShell, +): ProjectTrackerBinding | null { + const identity = project.repositoryIdentity; + const scope = sourceControlRepositorySelector(identity); + const kind = identity?.provider; + if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; + return { + tracker: IssueTrackerKind.make(kind), + host: pullRequestHostOf(identity, SourceControlProviderKind.make(kind)), + scope, + }; +} + +function listCursorKey(host: string, scope: string): string { + return `${host} ${scope}`; +} + +function isTrackerUnusable(error: IssueTrackerError): boolean { + return error.reason === "missing-tool" || error.reason === "unauthenticated"; +} + +function toIssueError(operation: string): (error: IssueTrackerError) => IssueError { + return (error) => + isTrackerUnusable(error) + ? new IssueUnavailableError({ + reason: error.reason === "missing-tool" ? "tool-missing" : "unauthenticated", + tracker: error.tracker, + cause: error, + }) + : new IssueOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" || error.reason === "rate-limited" + ? { reason: error.reason } + : {}), + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + cause: error, + }); +} + +const readOnly = (operation: string) => + new IssueOperationError({ + operation, + reason: "read-only", + detail: + "This issue belongs to a repository outside the selected project and cannot be changed from here.", + }); + +const unsupported = (operation: string, what: string) => + new IssueOperationError({ operation, detail: `This tracker does not support ${what}.` }); + +export class IssueService extends Context.Service< + IssueService, + { + readonly list: (input: IssueListInput) => Effect.Effect; + readonly detail: (input: IssueRef) => Effect.Effect; + readonly comments: ( + input: IssueCommentsPageInput, + ) => Effect.Effect; + readonly templates: (input: IssueScopeRef) => Effect.Effect; + readonly candidates: (input: IssueScopeRef) => Effect.Effect; + readonly create: (input: IssueCreateInput) => Effect.Effect; + readonly comment: (input: IssueCommentInput) => Effect.Effect; + readonly update: (input: IssueUpdateInput) => Effect.Effect; + readonly setState: (input: IssueSetStateInput) => Effect.Effect; + readonly react: (input: IssueReactionInput) => Effect.Effect; + } +>()("t3/issue/IssueService") {} + +export const make = Effect.gen(function* () { + const registry = yield* IssueTrackerRegistry.IssueTrackerRegistry; + const projects = yield* ProjectService.ProjectService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + + /** Where a project's issues live. The one place a standalone tracker's binding will plug in. */ + const resolveProjectTracker = ( + project: OrchestrationProjectShell, + ): Effect.Effect => + (project.repositoryIdentity != null + ? Effect.succeed(project.repositoryIdentity) + : repositoryIdentities.resolve(project.workspaceRoot) + ).pipe( + Effect.map((repositoryIdentity) => resolveGitHostBinding({ ...project, repositoryIdentity })), + ); + + const listProjects = (filter: { + readonly projectId?: IssueListInput["projectId"]; + readonly host?: string | undefined; + }) => + (filter.projectId === undefined + ? projects.listShells() + : projects.getShell(filter.projectId).pipe(Effect.map(Option.toArray)) + ).pipe( + Effect.mapError( + (cause) => + new IssueOperationError({ + operation: "listProjects", + detail: "The project list could not be read.", + cause, + }), + ), + Effect.flatMap((shells) => + Effect.forEach( + shells, + (project) => + resolveProjectTracker(project).pipe( + Effect.map((binding) => (binding === null ? [] : [{ project, ...binding }])), + ), + { concurrency: SCOPE_CONCURRENCY }, + ), + ), + Effect.map((bound) => { + const host = filter.host?.trim().toLowerCase(); + const supported: SupportedProject[] = []; + const unimplemented = new Map< + string, + { tracker: IssueTrackerKind; projectCount: number } + >(); + for (const candidate of bound.flat()) { + if (host !== undefined && candidate.host !== host) continue; + const api = registry.get(candidate.tracker); + if (api === null) { + const counted = unimplemented.get(candidate.host); + if (counted === undefined) + unimplemented.set(candidate.host, { tracker: candidate.tracker, projectCount: 1 }); + else counted.projectCount += 1; + continue; + } + supported.push({ ...candidate, api }); + } + return { supported, unimplemented }; + }), + ); + + /** + * The project whose checkout and credentials serve a reference, and whether the reference is + * the project's own scope. A reference naming another scope on the same tracker and host is + * served by the project but marked read-only. + */ + const requireProject = ( + ref: IssueScopeRef, + ): Effect.Effect => + listProjects({ projectId: ref.projectId }).pipe( + Effect.flatMap(({ supported }) => { + const own = supported[0]; + if (own === undefined || own.tracker !== ref.tracker) { + return Effect.fail(new IssueUnavailableError({ reason: "tracker-unsupported" })); + } + const host = ref.host?.trim().toLowerCase() ?? own.host; + if (host !== own.host) { + return Effect.fail(new IssueUnavailableError({ reason: "tracker-unsupported" })); + } + const scope = ref.scope.trim(); + const writable = scope.toLowerCase() === own.scope.toLowerCase(); + return Effect.succeed({ ...own, scope, writable }); + }), + ); + + const requireWritable = (ref: IssueScopeRef, operation: string) => + requireProject(ref).pipe( + Effect.flatMap((project) => + project.writable ? Effect.succeed(project) : Effect.fail(readOnly(operation)), + ), + ); + + const list: IssueService["Service"]["list"] = (input) => + Effect.gen(function* () { + const { supported, unimplemented } = yield* listProjects(input); + const limit = input.limit ?? DEFAULT_LIST_LIMIT; + // Worktrees of one repository are separate projects; read each scope once. + const scopes = new Map(); + for (const project of supported) { + const key = listCursorKey(project.host, project.scope); + if (!scopes.has(key)) scopes.set(key, project); + } + const continuing = input.cursors; + const viewers = new Map(); + const trackers = new Map(); + const entries: IssueListEntry[] = []; + const errors: IssueListProjectError[] = []; + const nextCursors: Record = {}; + + const viewerFor = (project: SupportedProject) => + Effect.suspend(() => { + const known = viewers.get(project.host); + return known === undefined + ? project.api + .getViewer({ cwd: project.project.workspaceRoot, host: project.host }) + .pipe(Effect.tap((viewer) => Effect.sync(() => viewers.set(project.host, viewer)))) + : Effect.succeed(known); + }); + + // Every project counts, including worktrees of a scope read once below. + for (const project of supported) { + const existing = trackers.get(project.host); + trackers.set( + project.host, + existing === undefined + ? { + tracker: project.tracker, + host: project.host, + projectCount: 1, + capabilities: project.api.capabilities, + configured: true, + detail: null, + } + : { ...existing, projectCount: existing.projectCount + 1 }, + ); + } + const recordFailure = (project: SupportedProject, failure: IssueTrackerError) => { + const existing = trackers.get(project.host); + if (existing !== undefined && isTrackerUnusable(failure)) + trackers.set(project.host, { ...existing, configured: false, detail: failure.detail }); + }; + + yield* Effect.forEach( + scopes, + ([key, project]) => + Effect.gen(function* () { + // A continuation names the scopes it wants; one not named has nothing to add. + if (continuing !== undefined && continuing[key] === undefined) return; + const cursor = continuing?.[key]; + const page = yield* viewerFor(project).pipe( + Effect.flatMap((viewer) => + project.api.listIssues({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + state: input.state, + involvement: input.involvement ?? "all", + viewer, + limit, + query: input.query, + cursor, + }), + ), + Effect.result, + ); + if (page._tag === "Failure") { + recordFailure(project, page.failure); + errors.push({ + projectId: project.project.id, + projectTitle: project.project.title, + message: page.failure.detail, + }); + return; + } + for (const item of page.success.items) { + entries.push({ + ...item, + tracker: project.tracker, + host: project.host, + scope: project.scope, + projectId: project.project.id, + projectTitle: project.project.title, + }); + } + if (page.success.nextCursor !== null) nextCursors[key] = page.success.nextCursor; + }), + { concurrency: SCOPE_CONCURRENCY, discard: true }, + ); + + for (const [host, { tracker, projectCount }] of unimplemented) { + trackers.set(host, { + tracker, + host, + projectCount, + capabilities: null, + configured: false, + detail: "Issues cannot be browsed for this host yet.", + }); + } + entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return { + viewers: Object.fromEntries(viewers), + trackers: [...trackers.values()], + entries, + errors, + nextCursors, + }; + }); + + const detail: IssueService["Service"]["detail"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getIssue({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + }) + .pipe( + Effect.mapError(toIssueError("detail")), + Effect.map((issue): IssueDetail => ({ + ...issue, + tracker: project.tracker, + host: project.host, + scope: project.scope, + projectId: project.project.id, + projectTitle: project.project.title, + writable: project.writable, + capabilities: project.api.capabilities, + })), + ), + ), + ); + + const comments: IssueService["Service"]["comments"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api + .getComments({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + cursor: input.cursor, + }) + .pipe(Effect.mapError(toIssueError("comments"))), + ), + ); + + const templates: IssueService["Service"]["templates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.listTemplates === undefined || !project.api.capabilities.templates + ? Effect.fail(unsupported("templates", "issue templates")) + : project.api + .listTemplates({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + }) + .pipe(Effect.mapError(toIssueError("templates"))), + ), + ); + + const candidates: IssueService["Service"]["candidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.listCandidates === undefined || + !(project.api.capabilities.labels || project.api.capabilities.assignees) + ? Effect.fail(unsupported("candidates", "labels or assignees")) + : project.api + .listCandidates({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + }) + .pipe(Effect.mapError(toIssueError("candidates"))), + ), + ); + + const create: IssueService["Service"]["create"] = (input) => + requireWritable(input, "create").pipe( + Effect.flatMap((project) => + project.api.create === undefined || !project.api.capabilities.create + ? Effect.fail(unsupported("create", "filing issues")) + : project.api + .create({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }) + .pipe(Effect.mapError(toIssueError("create"))), + ), + ); + + const comment: IssueService["Service"]["comment"] = (input) => + requireWritable(input, "comment").pipe( + Effect.flatMap((project) => { + if (input.body.trim().length === 0) { + return Effect.fail( + new IssueOperationError({ operation: "comment", detail: "A comment needs some text." }), + ); + } + return project.api.comment === undefined || !project.api.capabilities.comment + ? Effect.fail(unsupported("comment", "comments")) + : project.api + .comment({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + body: input.body, + }) + .pipe(Effect.mapError(toIssueError("comment"))); + }), + ); + + const update: IssueService["Service"]["update"] = (input) => + requireWritable(input, "update").pipe( + Effect.flatMap((project) => { + const { capabilities } = project.api; + const wantsWords = input.title !== undefined || input.body !== undefined; + if (!wantsWords && input.labels === undefined && input.assignees === undefined) { + return Effect.fail( + new IssueOperationError({ operation: "update", detail: "Nothing to change." }), + ); + } + if (project.api.update === undefined) return Effect.fail(unsupported("update", "edits")); + if (wantsWords && !capabilities.edit) + return Effect.fail(unsupported("update", "editing the title or body")); + if (input.labels !== undefined && !capabilities.labels) + return Effect.fail(unsupported("update", "labels")); + if (input.assignees !== undefined && !capabilities.assignees) + return Effect.fail(unsupported("update", "assignees")); + return project.api + .update({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }) + .pipe(Effect.mapError(toIssueError("update"))); + }), + ); + + const setState: IssueService["Service"]["setState"] = (input) => + requireWritable(input, "setState").pipe( + Effect.flatMap((project) => { + const { capabilities } = project.api; + const target = capabilities.states.find((state) => state.name === input.state); + if (project.api.setState === undefined || target === undefined) + return Effect.fail(unsupported("setState", `the state "${input.state}"`)); + if ( + input.closeReason !== undefined && + (target.category !== "closed" || !capabilities.closeReasons.includes(input.closeReason)) + ) + return Effect.fail(unsupported("setState", `the close reason "${input.closeReason}"`)); + return project.api + .setState({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + state: input.state, + closeReason: input.closeReason, + }) + .pipe(Effect.mapError(toIssueError("setState"))); + }), + ); + + const react: IssueService["Service"]["react"] = (input) => + requireWritable(input, "react").pipe( + Effect.flatMap((project) => + project.api.setReaction === undefined || !project.api.capabilities.reactions + ? Effect.fail(unsupported("react", "reactions")) + : project.api + .setReaction({ + cwd: project.project.workspaceRoot, + host: project.host, + scope: project.scope, + key: input.key, + subjectId: input.subjectId, + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(toIssueError("react"))), + ), + ); + + return IssueService.of({ + list, + detail, + comments, + templates, + candidates, + create, + comment, + update, + setState, + react, + }); +}); + +export const layer = Layer.effect(IssueService, make); diff --git a/apps/server/src/issue/IssueTrackerRegistry.ts b/apps/server/src/issue/IssueTrackerRegistry.ts new file mode 100644 index 000000000000..1647a9ea1ffa --- /dev/null +++ b/apps/server/src/issue/IssueTrackerRegistry.ts @@ -0,0 +1,40 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { IssueTrackerKind } from "@t3tools/contracts"; +import type { IssueTrackerApi } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import * as BuiltInIssueTrackers from "./builtInIssueTrackers.ts"; + +export class IssueTrackerRegistry extends Context.Service< + IssueTrackerRegistry, + { + /** Null for a kind with no implementation, which the service reports as unsupported. */ + readonly get: (kind: IssueTrackerKind) => IssueTrackerApi | null; + readonly kinds: ReadonlyArray; + } +>()("t3/issue/IssueTrackerRegistry") {} + +/** Exported for tests, which stand a registry up from trackers they supply themselves. */ +export function fromTrackers( + trackers: ReadonlyArray, +): IssueTrackerRegistry["Service"] { + const byKind = new Map(trackers.map((tracker) => [tracker.kind, tracker])); + return { + get: (kind) => byKind.get(kind) ?? null, + kinds: trackers.map((tracker) => tracker.kind), + }; +} + +export const make = Effect.gen(function* () { + const trackers = yield* Effect.forEach( + BuiltInIssueTrackers.BUILT_IN_ISSUE_TRACKERS, + (driver) => driver.make, + ); + return fromTrackers(trackers); +}); + +export const layer = Layer.effect(IssueTrackerRegistry, make).pipe( + Layer.provide(BuiltInDrivers.layer), +); diff --git a/apps/server/src/issue/builtInIssueTrackers.ts b/apps/server/src/issue/builtInIssueTrackers.ts new file mode 100644 index 000000000000..6fccbcfd57e9 --- /dev/null +++ b/apps/server/src/issue/builtInIssueTrackers.ts @@ -0,0 +1,20 @@ +/** + * The issue tracker drivers this build ships with. Git hosts contribute theirs from their source + * control package; a standalone tracker (Linear, Jira) adds its own package and a row here. A + * kind with no driver shows up as unsupported rather than missing. + * + * The services these drivers need are the same ones `sourceControl/builtInDrivers.ts` provides, + * so the server reuses that layer rather than building a second `GitHubApi`. + * + * @module issue/builtInIssueTrackers + */ +import * as GitHubIssueTrackerDriver from "@t3tools/source-control-github/server/issueTrackerDriver"; +import type { IssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +const drivers = [GitHubIssueTrackerDriver.driver]; + +export type BuiltInIssueTrackersEnv = + (typeof drivers)[number] extends IssueTrackerDriver ? R : never; + +export const BUILT_IN_ISSUE_TRACKERS: ReadonlyArray> = + drivers; diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..4f6921c380e9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -128,6 +128,16 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", [WS_METHODS.pullRequestsLabelCandidates]: "pull-requests", [WS_METHODS.pullRequestsSetLabels]: "pull-requests", + [WS_METHODS.issuesList]: "issues", + [WS_METHODS.issuesDetail]: "issues", + [WS_METHODS.issuesComments]: "issues", + [WS_METHODS.issuesTemplates]: "issues", + [WS_METHODS.issuesCandidates]: "issues", + [WS_METHODS.issuesCreate]: "issues", + [WS_METHODS.issuesComment]: "issues", + [WS_METHODS.issuesUpdate]: "issues", + [WS_METHODS.issuesSetState]: "issues", + [WS_METHODS.issuesReact]: "issues", [WS_METHODS.sourceControlLookupRepository]: "source-control", [WS_METHODS.sourceControlCloneRepository]: "source-control", [WS_METHODS.sourceControlPublishRepository]: "source-control", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3c1539a70985..fb5cac8eeb96 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,6 +37,8 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as IssueService from "./issue/IssueService.ts"; +import * as IssueTrackerRegistry from "./issue/IssueTrackerRegistry.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; @@ -347,6 +349,8 @@ const layerPullRequestService = PullRequestService.layer.pipe( Layer.provide(SourceControlRateLimit.layer), ); +const layerIssueService = IssueService.layer.pipe(Layer.provide(IssueTrackerRegistry.layer)); + const layerGitManager = GitManager.layer.pipe( // Per-project git settings resolve the acting thread's project. Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), @@ -732,6 +736,7 @@ const layerMakeRoutes = Layer.mergeAll( // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Provided // after the guide service so it reaches that too. Layer.provide(layerPullRequestService), + Layer.provide(layerIssueService), // The stream route and the WebSocket RPCs share one browser. Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), // Server browser tabs and HTML render previews install and run the same headless browser. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 96994ee37c13..7feab0cab72a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -228,6 +228,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as IssueService from "./issue/IssueService.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; @@ -1242,6 +1243,7 @@ const layerWsRpc = ( const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -2575,6 +2577,16 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.labelCandidates(input)), [WS_METHODS.pullRequestsSetLabels]: (input) => withPullRequestViewer(input, pullRequests.setLabels(input)), + [WS_METHODS.issuesList]: (input) => issues.list(input), + [WS_METHODS.issuesDetail]: (input) => issues.detail(input), + [WS_METHODS.issuesComments]: (input) => issues.comments(input), + [WS_METHODS.issuesTemplates]: (input) => issues.templates(input), + [WS_METHODS.issuesCandidates]: (input) => issues.candidates(input), + [WS_METHODS.issuesCreate]: (input) => issues.create(input), + [WS_METHODS.issuesComment]: (input) => issues.comment(input), + [WS_METHODS.issuesUpdate]: (input) => issues.update(input), + [WS_METHODS.issuesSetState]: (input) => issues.setState(input), + [WS_METHODS.issuesReact]: (input) => issues.react(input), [WS_METHODS.sourceControlLookupRepository]: (input) => sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => @@ -3167,6 +3179,7 @@ export const layer = Layer.unwrap( const serverBrowser = yield* ServerBrowser.ServerBrowser; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( @@ -3234,6 +3247,7 @@ export const layer = Layer.unwrap( // One server-lifetime service means clients share the same PR caches, and a WS // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), + Layer.provide(Layer.succeed(IssueService.IssueService, issues)), Layer.provide(OtlpEndpointCheck.layer), // Shared like the pull request service, so concurrent generations single-flight. Layer.provide( diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index e85e5df7769c..a07798316fdd 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -25,6 +25,11 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsSetFilesViewed]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, + [WS_METHODS.issuesCreate]: AuthSourceControlWriteScope, + [WS_METHODS.issuesComment]: AuthSourceControlWriteScope, + [WS_METHODS.issuesUpdate]: AuthSourceControlWriteScope, + [WS_METHODS.issuesSetState]: AuthSourceControlWriteScope, + [WS_METHODS.issuesReact]: AuthSourceControlWriteScope, // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 600d503ade39..8e788c7af95c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -34,6 +34,7 @@ export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; export * from "./pullRequestReviewGuide.ts"; +export * from "./issue.ts"; export * from "./orchestrationDispatch.ts"; export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.ts"; diff --git a/packages/contracts/src/issue.ts b/packages/contracts/src/issue.ts new file mode 100644 index 000000000000..9a8db9532eed --- /dev/null +++ b/packages/contracts/src/issue.ts @@ -0,0 +1,477 @@ +import * as Schema from "effect/Schema"; +import * as HttpServerRespondable from "effect/http/HttpServerRespondable"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ProjectId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { PullRequestActor, PullRequestLabel, PullRequestState } from "./pullRequest.ts"; + +/** + * Which tracker an issue lives in. Git hosts reuse their `SourceControlProviderKind` spelling + * (`github`, `gitlab`); standalone trackers get their own (`linear`, `jira`). Branded apart from + * the source control kind because the two sets are not the same: Bitbucket is a git host with no + * tracker, and Linear is a tracker with no git host. + */ +export const IssueTrackerKind = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][a-z0-9-]*$/), +).pipe(Schema.brand("IssueTrackerKind")); +export type IssueTrackerKind = typeof IssueTrackerKind.Type; + +export const IssueActor = PullRequestActor; +export type IssueActor = typeof IssueActor.Type; + +export const IssueLabel = PullRequestLabel; +export type IssueLabel = typeof IssueLabel.Type; + +/** + * Where an issue stands, in two parts: the category every tracker has, and the name this tracker + * gives it. GitHub only has the two; Linear and Jira have a workflow of their own ("In Review", + * "Backlog"), which `name` carries verbatim so the page can show the tracker's word while every + * filter and badge keys off `category`. + */ +export const IssueStateCategory = Schema.Literals(["open", "closed"]); +export type IssueStateCategory = typeof IssueStateCategory.Type; + +export const IssueState = Schema.Struct({ + category: IssueStateCategory, + name: TrimmedNonEmptyString, + /** Why it was closed, where the tracker records one (GitHub: `completed` / `not-planned`). */ + closeReason: Schema.optional(TrimmedNonEmptyString), +}); +export type IssueState = typeof IssueState.Type; + +/** What a listing asks for: either category, or both. */ +export const IssueListState = Schema.Literals(["all", "open", "closed"]); +export type IssueListState = typeof IssueListState.Type; + +export const IssueInvolvement = Schema.Literals(["all", "assigned", "authored", "mentioned"]); +export type IssueInvolvement = typeof IssueInvolvement.Type; + +export const IssueReactionContent = Schema.Literals([ + "thumbs-up", + "thumbs-down", + "laugh", + "hooray", + "confused", + "heart", + "rocket", + "eyes", +]); +export type IssueReactionContent = typeof IssueReactionContent.Type; + +export const IssueReaction = Schema.Struct({ + content: IssueReactionContent, + count: PositiveInt, + /** Who reacted, as far as the tracker named them; the viewer is left out and shown as "You". */ + actors: Schema.Array(TrimmedNonEmptyString), + viewerHasReacted: Schema.Boolean, +}); +export type IssueReaction = typeof IssueReaction.Type; + +/** + * Addresses one issue. `tracker` picks the driver, `scope` is the tracker's own container — the + * `owner/repo` path on a git host, a team or project key on Linear and Jira — and `key` is the + * issue's native id within it: `"123"` on GitHub, `"ENG-123"` on Linear. `host` is where the + * tracker is served from, for a kind that can be self-hosted (GitHub Enterprise, GitLab, Forgejo); + * absent means the kind's public host. + * + * `projectId` names the project whose checkout and credentials serve the read. An issue outside + * every project's own scope can still be read through any project on the same tracker and host, + * but the service refuses to write to it. + */ +export const IssueRef = Schema.Struct({ + projectId: ProjectId, + tracker: IssueTrackerKind, + host: Schema.optional(TrimmedNonEmptyString), + scope: TrimmedNonEmptyString, + key: TrimmedNonEmptyString, +}); +export type IssueRef = typeof IssueRef.Type; + +/** A scope with no issue in it, for reads about the container itself: templates, candidates. */ +export const IssueScopeRef = Schema.Struct({ + projectId: ProjectId, + tracker: IssueTrackerKind, + host: Schema.optional(TrimmedNonEmptyString), + scope: TrimmedNonEmptyString, +}); +export type IssueScopeRef = typeof IssueScopeRef.Type; + +/** One row of a listing. Carries everything a list needs and nothing a detail needs. */ +export const IssueSummary = Schema.Struct({ + tracker: IssueTrackerKind, + host: TrimmedNonEmptyString, + scope: TrimmedNonEmptyString, + key: TrimmedNonEmptyString, + /** How the tracker writes the key for people: `#123` on a git host, `ENG-123` on Linear. */ + displayKey: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.NullOr(IssueActor), + state: IssueState, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + closedAt: Schema.NullOr(IsoDateTime), + assignees: Schema.Array(IssueActor), + labels: Schema.Array(IssueLabel), + commentCount: NonNegativeInt, +}); +export type IssueSummary = typeof IssueSummary.Type; + +export const IssueListEntry = Schema.Struct({ + ...IssueSummary.fields, + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, +}); +export type IssueListEntry = typeof IssueListEntry.Type; + +export const IssueComment = Schema.Struct({ + id: TrimmedNonEmptyString, + author: Schema.NullOr(IssueActor), + body: Schema.String, + createdAt: IsoDateTime, + url: Schema.NullOr(Schema.String), + /** Absent from a tracker with no reactions, which differs from none on this comment. */ + reactions: Schema.optional(Schema.Array(IssueReaction)), +}); +export type IssueComment = typeof IssueComment.Type; + +/** + * Something that happened to an issue that nobody wrote words for: it was closed, labelled, + * assigned, renamed. Kinds are the common vocabulary; a tracker drops what it cannot map rather + * than guessing. + */ +export const IssueEventKind = Schema.Literals([ + "closed", + "reopened", + "labeled", + "unlabeled", + "assigned", + "unassigned", + "renamed", + "referenced", + "state-changed", +]); +export type IssueEventKind = typeof IssueEventKind.Type; + +export const IssueEvent = Schema.Struct({ + id: TrimmedNonEmptyString, + kind: IssueEventKind, + actor: Schema.NullOr(IssueActor), + createdAt: IsoDateTime, + /** What the event was about, where it has a subject: a label name, a login, a new title. */ + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueEvent = typeof IssueEvent.Type; + +/** A change request the tracker says closes or mentions this issue. */ +export const IssueLinkedPullRequest = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: PullRequestState, + isDraft: Schema.Boolean, + /** Merging it closes this issue, rather than only citing it. */ + closesIssue: Schema.Boolean, +}); +export type IssueLinkedPullRequest = typeof IssueLinkedPullRequest.Type; + +/** + * What a tracker can do at all, so a surface never offers an action that would fail. The + * service refuses a call outside these before it reaches the driver. + */ +export const IssueCapabilities = Schema.Struct({ + create: Schema.Boolean, + /** Title and body can be rewritten after filing. */ + edit: Schema.Boolean, + comment: Schema.Boolean, + labels: Schema.Boolean, + assignees: Schema.Boolean, + /** Reactions can be read and written on the issue and its comments. */ + reactions: Schema.Boolean, + /** The scope offers starting points for a new issue. */ + templates: Schema.Boolean, + /** The tracker reports linked change requests of its own accord. */ + linkedPullRequests: Schema.Boolean, + /** State changes arrive as events beside the comments, not only as comments. */ + events: Schema.Boolean, + /** The tracker narrows a listing by free text itself. */ + search: Schema.Boolean, + /** + * The states this tracker can be asked to move an issue into, by name. A git host offers + * `open` and `closed`; a workflow tracker offers its own columns. Empty means state cannot be + * changed from here. + */ + states: Schema.Array(IssueState), + /** Reasons a close can carry. Empty means a close takes none. */ + closeReasons: Schema.Array(TrimmedNonEmptyString), +}); +export type IssueCapabilities = typeof IssueCapabilities.Type; + +/** + * What the signed-in account may do with this issue, which `capabilities` says nothing about. + * A permission the tracker reports nothing about is granted, so a refusal comes from the + * tracker, which says why, rather than from a missing control. + */ +export const IssueViewerPermissions = Schema.Struct({ + setState: Schema.Boolean, + comment: Schema.Boolean, + edit: Schema.Boolean, + labels: Schema.Boolean, + assignees: Schema.Boolean, +}); +export type IssueViewerPermissions = typeof IssueViewerPermissions.Type; + +export const IssueDetail = Schema.Struct({ + ...IssueSummary.fields, + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + /** False for an issue outside every project's scope, which is read-only through this server. */ + writable: Schema.Boolean, + capabilities: IssueCapabilities, + viewerPermissions: IssueViewerPermissions, + /** The signed-in account, so a comment edit is offered only on the viewer's own remarks. */ + viewer: Schema.NullOr(TrimmedNonEmptyString), + body: Schema.String, + reactions: Schema.optional(Schema.Array(IssueReaction)), + comments: Schema.Array(IssueComment), + /** Where older comments carry on; null once the conversation is whole. */ + commentsCursor: Schema.NullOr(TrimmedNonEmptyString), + events: Schema.Array(IssueEvent), + linkedPullRequests: Schema.Array(IssueLinkedPullRequest), +}); +export type IssueDetail = typeof IssueDetail.Type; + +/** Older comments, one page past a cursor the detail or a previous page handed over. */ +export const IssueCommentsPageInput = Schema.Struct({ + ...IssueRef.fields, + cursor: TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)), +}); +export type IssueCommentsPageInput = typeof IssueCommentsPageInput.Type; + +export const IssueCommentsPage = Schema.Struct({ + comments: Schema.Array(IssueComment), + nextCursor: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueCommentsPage = typeof IssueCommentsPage.Type; + +/** Where each scope a listing already reached carries on, keyed `" "`. */ +export const IssueListCursors = Schema.Record( + TrimmedNonEmptyString, + TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)), +); +export type IssueListCursors = typeof IssueListCursors.Type; + +export const IssueListInput = Schema.Struct({ + state: IssueListState, + involvement: Schema.optional(IssueInvolvement), + projectId: Schema.optional(ProjectId), + /** Narrows to one host, named as the host itself. Absent means every host the workspace has. */ + host: Schema.optional(TrimmedNonEmptyString), + /** Rows per scope, which with a continuation is rows per slice. */ + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 }))), + /** Carry on from an earlier answer: only the scopes named here are read, one slice each. */ + cursors: Schema.optional(IssueListCursors), + /** Free text the trackers themselves are asked to match. */ + query: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), +}); +export type IssueListInput = typeof IssueListInput.Type; + +/** One tracker account the workspace has projects on, and whether it can be read right now. */ +export const IssueTrackerSummary = Schema.Struct({ + tracker: IssueTrackerKind, + host: TrimmedNonEmptyString, + projectCount: PositiveInt, + capabilities: Schema.NullOr(IssueCapabilities), + /** False when the tracker's tool or credentials are missing, with `detail` saying which. */ + configured: Schema.Boolean, + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueTrackerSummary = typeof IssueTrackerSummary.Type; + +export const IssueListProjectError = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type IssueListProjectError = typeof IssueListProjectError.Type; + +export const IssueListResult = Schema.Struct({ + /** The signed-in account per host, which is what involvement compares against. */ + viewers: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), + trackers: Schema.Array(IssueTrackerSummary), + /** By update, newest first, across every scope this answer covers. */ + entries: Schema.Array(IssueListEntry), + errors: Schema.Array(IssueListProjectError), + /** Where each scope carries on, sent straight back as `cursors`. Absent once a scope is done. */ + nextCursors: IssueListCursors, +}); +export type IssueListResult = typeof IssueListResult.Type; + +const IssueTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(1024)); +// Not trimmed: a body is markdown, where leading spaces open a code block. The bound keeps +// oversized payloads off the wire; the service rejects a comment that is only whitespace. +const IssueBody = Schema.String.check(Schema.isMaxLength(65_536)); +const CommentBody = Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)); + +export const IssueCreateInput = Schema.Struct({ + ...IssueScopeRef.fields, + title: IssueTitle, + /** May be empty: an issue with a title and nothing else is a legitimate one. */ + body: IssueBody, + labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(50)), + assignees: Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(25)), +}); +export type IssueCreateInput = typeof IssueCreateInput.Type; + +export const IssueCreateResult = Schema.Struct({ + key: TrimmedNonEmptyString, + displayKey: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, +}); +export type IssueCreateResult = typeof IssueCreateResult.Type; + +export const IssueCommentInput = Schema.Struct({ + ...IssueRef.fields, + body: CommentBody, +}); +export type IssueCommentInput = typeof IssueCommentInput.Type; + +/** + * Rewrites the issue's own fields. Every field is optional and only the present ones are + * written; `labels` and `assignees` are the whole set, which is how every tracker writes them. + * The service refuses a request with nothing present. + */ +export const IssueUpdateInput = Schema.Struct({ + ...IssueRef.fields, + title: Schema.optional(IssueTitle), + body: Schema.optional(IssueBody), + labels: Schema.optional(Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(50))), + assignees: Schema.optional(Schema.Array(TrimmedNonEmptyString).check(Schema.isMaxLength(25))), +}); +export type IssueUpdateInput = typeof IssueUpdateInput.Type; + +/** Moves the issue to one of the states the tracker declared in `capabilities.states`. */ +export const IssueSetStateInput = Schema.Struct({ + ...IssueRef.fields, + state: TrimmedNonEmptyString, + /** Only meaningful for a closing state on a tracker that declared `closeReasons`. */ + closeReason: Schema.optional(TrimmedNonEmptyString), +}); +export type IssueSetStateInput = typeof IssueSetStateInput.Type; + +export const IssueReactionInput = Schema.Struct({ + ...IssueRef.fields, + /** Absent reacts to the issue itself; present names a comment id as the detail carried it. */ + subjectId: Schema.optional(TrimmedNonEmptyString), + content: IssueReactionContent, + reacted: Schema.Boolean, +}); +export type IssueReactionInput = typeof IssueReactionInput.Type; + +/** + * One starting point a scope offers for a new issue. Only the body-shaped kind travels here: + * GitHub's typed issue forms are flattened to a body by the driver, so a composer on any + * tracker is one text box with a draft in it. + */ +export const IssueTemplate = Schema.Struct({ + key: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + about: Schema.String, + title: Schema.String, + body: Schema.String, + labels: Schema.Array(TrimmedNonEmptyString), + assignees: Schema.Array(TrimmedNonEmptyString), +}); +export type IssueTemplate = typeof IssueTemplate.Type; + +export const IssueTemplateList = Schema.Struct({ + templates: Schema.Array(IssueTemplate), + /** False where the scope's config forbids an issue filed from nothing. */ + blankIssuesEnabled: Schema.Boolean, +}); +export type IssueTemplateList = typeof IssueTemplateList.Type; + +export const IssueLabelCandidate = Schema.Struct({ + ...IssueLabel.fields, + description: Schema.NullOr(Schema.String), +}); +export type IssueLabelCandidate = typeof IssueLabelCandidate.Type; + +export const IssueAssigneeCandidate = Schema.Struct({ + ...IssueActor.fields, + /** How the tracker addresses this person on a write, which is not always the login. */ + id: TrimmedNonEmptyString, +}); +export type IssueAssigneeCandidate = typeof IssueAssigneeCandidate.Type; + +/** The labels and people a scope offers, read when a picker opens rather than with every issue. */ +export const IssueCandidates = Schema.Struct({ + labels: Schema.Array(IssueLabelCandidate), + assignees: Schema.Array(IssueAssigneeCandidate), + /** The tracker has more than were read, so somebody missing is not necessarily ineligible. */ + truncated: Schema.Boolean, +}); +export type IssueCandidates = typeof IssueCandidates.Type; + +export const IssueUnavailableReason = Schema.Literals([ + "tool-missing", + "unauthenticated", + "tracker-unsupported", +]); +export type IssueUnavailableReason = typeof IssueUnavailableReason.Type; + +/** The tracker cannot be read at all for this project, as opposed to one request failing. */ +export class IssueUnavailableError extends Schema.TaggedError()( + "IssueUnavailableError", + { + reason: IssueUnavailableReason, + tracker: Schema.optional(IssueTrackerKind), + cause: Schema.optional(Schema.Defect()), + }, + { httpApiStatus: 503 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(IssueUnavailableError)(this, { status: 503 }); + } + + override get message(): string { + switch (this.reason) { + case "tool-missing": + return "The tool this tracker is read through is not installed or set up."; + case "unauthenticated": + return "This tracker has no working credentials."; + case "tracker-unsupported": + return "Issues cannot be browsed for this project yet."; + } + } +} + +export class IssueOperationError extends Schema.TaggedError()( + "IssueOperationError", + { + operation: Schema.String, + detail: TrimmedNonEmptyString, + reason: Schema.optional(Schema.Literals(["not-found", "read-only", "rate-limited"])), + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, + { httpApiStatus: 502 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(IssueOperationError)(this, { status: 502 }); + } + + override get message(): string { + return `Issue operation ${this.operation} failed: ${this.detail}`; + } +} + +export type IssueError = IssueUnavailableError | IssueOperationError; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 61304c52f860..2ccd7a6437b5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -161,6 +161,25 @@ import { ProviderUploadFeedbackResult, } from "./provider.ts"; import { ProviderInstanceId, ProviderInstanceMutation } from "./providerInstance.ts"; +import { + IssueCandidates, + IssueCommentInput, + IssueCommentsPage, + IssueCommentsPageInput, + IssueCreateInput, + IssueCreateResult, + IssueDetail, + IssueListInput, + IssueListResult, + IssueOperationError, + IssueReactionInput, + IssueRef, + IssueScopeRef, + IssueSetStateInput, + IssueTemplateList, + IssueUnavailableError, + IssueUpdateInput, +} from "./issue.ts"; import { PullRequestReviewGuideResult } from "./pullRequestReviewGuide.ts"; import { PullRequestActionInput, @@ -561,6 +580,18 @@ export const WS_METHODS = { pullRequestsLabelCandidates: "pullRequests.labelCandidates", pullRequestsSetLabels: "pullRequests.setLabels", + // Issue tracking methods + issuesList: "issues.list", + issuesDetail: "issues.detail", + issuesComments: "issues.comments", + issuesTemplates: "issues.templates", + issuesCandidates: "issues.candidates", + issuesCreate: "issues.create", + issuesComment: "issues.comment", + issuesUpdate: "issues.update", + issuesSetState: "issues.setState", + issuesReact: "issues.react", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -1160,6 +1191,74 @@ const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { error: PullRequestRpcError, }); +const IssueRpcError = Schema.Union([ + IssueUnavailableError, + IssueOperationError, + EnvironmentAuthorizationError, +]); + +const WsIssuesListRpc = Rpc.make(WS_METHODS.issuesList, { + payload: IssueListInput, + success: IssueListResult, + error: IssueRpcError, +}); + +const WsIssuesDetailRpc = Rpc.make(WS_METHODS.issuesDetail, { + payload: IssueRef, + success: IssueDetail, + error: IssueRpcError, +}); + +/** Older comments, one page at a time, past the cursor the detail handed over. */ +const WsIssuesCommentsRpc = Rpc.make(WS_METHODS.issuesComments, { + payload: IssueCommentsPageInput, + success: IssueCommentsPage, + error: IssueRpcError, +}); + +const WsIssuesTemplatesRpc = Rpc.make(WS_METHODS.issuesTemplates, { + payload: IssueScopeRef, + success: IssueTemplateList, + error: IssueRpcError, +}); + +/** Read when a label or assignee picker opens, not with every issue. */ +const WsIssuesCandidatesRpc = Rpc.make(WS_METHODS.issuesCandidates, { + payload: IssueScopeRef, + success: IssueCandidates, + error: IssueRpcError, +}); + +const WsIssuesCreateRpc = Rpc.make(WS_METHODS.issuesCreate, { + payload: IssueCreateInput, + success: IssueCreateResult, + error: IssueRpcError, +}); + +const WsIssuesCommentRpc = Rpc.make(WS_METHODS.issuesComment, { + payload: IssueCommentInput, + success: Schema.Void, + error: IssueRpcError, +}); + +const WsIssuesUpdateRpc = Rpc.make(WS_METHODS.issuesUpdate, { + payload: IssueUpdateInput, + success: Schema.Void, + error: IssueRpcError, +}); + +const WsIssuesSetStateRpc = Rpc.make(WS_METHODS.issuesSetState, { + payload: IssueSetStateInput, + success: Schema.Void, + error: IssueRpcError, +}); + +const WsIssuesReactRpc = Rpc.make(WS_METHODS.issuesReact, { + payload: IssueReactionInput, + success: Schema.Void, + error: IssueRpcError, +}); + const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLookupRepository, { payload: SourceControlRepositoryLookupInput, success: SourceControlRepositoryInfo, @@ -1973,6 +2072,16 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsRequestReviewersRpc, WsPullRequestsLabelCandidatesRpc, WsPullRequestsSetLabelsRpc, + WsIssuesListRpc, + WsIssuesDetailRpc, + WsIssuesCommentsRpc, + WsIssuesTemplatesRpc, + WsIssuesCandidatesRpc, + WsIssuesCreateRpc, + WsIssuesCommentRpc, + WsIssuesUpdateRpc, + WsIssuesSetStateRpc, + WsIssuesReactRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, diff --git a/packages/issue-tracker-core/package.json b/packages/issue-tracker-core/package.json new file mode 100644 index 000000000000..334d65ac3aaa --- /dev/null +++ b/packages/issue-tracker-core/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/issue-tracker-core", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/IssueTracker": { + "types": "./src/server/IssueTracker.ts", + "import": "./src/server/IssueTracker.ts" + } + }, + "scripts": { + "typecheck": "tsc-rs --noEmit" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/issue-tracker-core/src/server/IssueTracker.ts b/packages/issue-tracker-core/src/server/IssueTracker.ts new file mode 100644 index 000000000000..d376a01c45dd --- /dev/null +++ b/packages/issue-tracker-core/src/server/IssueTracker.ts @@ -0,0 +1,190 @@ +/** + * The port every issue tracker implements. Implementations own their own tool and JSON shapes + * and hand back the neutral contract types; anything a tracker cannot do is declared in + * `capabilities` rather than failing at call time, and the service refuses a call outside them + * before it gets here. + * + * @module issue-tracker-core/server/IssueTracker + */ +import * as Schema from "effect/Schema"; +import type * as Effect from "effect/Effect"; +import type { + IssueCandidates, + IssueCapabilities, + IssueComment, + IssueCommentsPage, + IssueCreateResult, + IssueEvent, + IssueInvolvement, + IssueLinkedPullRequest, + IssueListState, + IssueReaction, + IssueReactionContent, + IssueSummary, + IssueTemplateList, + IssueTrackerKind, + IssueViewerPermissions, +} from "@t3tools/contracts"; +import { IssueTrackerKind as IssueTrackerKindSchema } from "@t3tools/contracts"; + +/** + * The one failure shape every tracker reports, so the service can decide what a failure means + * without knowing which CLI or API produced it. A missing or unauthenticated tool disables the + * tracker for the whole workspace; everything else is specific to the request. + */ +export class IssueTrackerError extends Schema.TaggedError()( + "IssueTrackerError", + { + tracker: IssueTrackerKindSchema, + operation: Schema.String, + reason: Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", + ]), + detail: Schema.String, + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `${this.tracker} failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * Where a tracker's issues are addressed from. `cwd` is a checkout whose credentials serve the + * read; `host` is where the tracker is served from (`github.com`, a GHE install, `linear.app`); + * `scope` is the tracker's own container — a repository path, a team or project key. + */ +export interface IssueScopeTarget { + readonly cwd: string; + readonly host: string; + readonly scope: string; +} + +export interface IssueTarget extends IssueScopeTarget { + /** The issue's native id within the scope: `"123"` on GitHub, `"ENG-123"` on Linear. */ + readonly key: string; +} + +/** An issue as the tracker sees it, before the service attaches project context. */ +export type TrackerIssueSummary = Omit; + +export interface TrackerIssuePage { + readonly items: ReadonlyArray; + /** Where the next slice starts, or null once the tracker has nothing more. Opaque. */ + readonly nextCursor: string | null; +} + +export interface TrackerIssueDetail extends TrackerIssueSummary { + readonly body: string; + readonly viewer: string | null; + readonly viewerPermissions: IssueViewerPermissions; + readonly reactions?: ReadonlyArray; + readonly comments: ReadonlyArray; + /** Where older comments carry on; null once the conversation is whole. */ + readonly commentsCursor: string | null; + readonly events: ReadonlyArray; + readonly linkedPullRequests: ReadonlyArray; +} + +export interface IssueTrackerApi { + readonly kind: IssueTrackerKind; + readonly capabilities: IssueCapabilities; + + /** The signed-in account, which is what involvement filtering compares against. */ + readonly getViewer: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect; + + readonly listIssues: ( + input: IssueScopeTarget & { + readonly state: IssueListState; + readonly involvement: IssueInvolvement; + readonly viewer: string; + readonly limit: number; + /** Free text as the tracker understands it; ignored by a tracker without `search`. */ + readonly query?: string | undefined; + /** A cursor this tracker handed back from an earlier page. Absent asks for the first. */ + readonly cursor?: string | undefined; + }, + ) => Effect.Effect; + + /** The issue with its first page of conversation, in one read where the tracker allows. */ + readonly getIssue: (input: IssueTarget) => Effect.Effect; + + /** One page of older comments past a cursor `getIssue` or a previous page handed over. */ + readonly getComments: ( + input: IssueTarget & { readonly cursor: string }, + ) => Effect.Effect; + + /** + * The same permissions `getIssue` carries, asked freshly before a write so access taken away + * since the page loaded is what decides. A tracker with nothing to say makes no request. + */ + readonly getViewerPermissions: ( + input: IssueTarget, + ) => Effect.Effect; + + /** Only called when `capabilities.create` is true. */ + readonly create?: ( + input: IssueScopeTarget & { + readonly title: string; + readonly body: string; + readonly labels: ReadonlyArray; + readonly assignees: ReadonlyArray; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.comment` is true. */ + readonly comment?: ( + input: IssueTarget & { readonly body: string }, + ) => Effect.Effect; + + /** + * Rewrites the present fields. Only called with a field the tracker declared it can write + * (`edit` for title and body, `labels`, `assignees`), and never with all of them absent. + */ + readonly update?: ( + input: IssueTarget & { + readonly title?: string | undefined; + readonly body?: string | undefined; + readonly labels?: ReadonlyArray | undefined; + readonly assignees?: ReadonlyArray | undefined; + }, + ) => Effect.Effect; + + /** Only called with a state name from `capabilities.states`. */ + readonly setState?: ( + input: IssueTarget & { + readonly state: string; + readonly closeReason?: string | undefined; + }, + ) => Effect.Effect; + + /** + * Adds a reaction or takes it back. `subjectId` is a comment id as the detail carried it; + * absent means the issue itself. Only called when `capabilities.reactions` is true. + */ + readonly setReaction?: ( + input: IssueTarget & { + readonly subjectId?: string | undefined; + readonly content: IssueReactionContent; + readonly reacted: boolean; + }, + ) => Effect.Effect; + + /** Only called when `capabilities.templates` is true. */ + readonly listTemplates?: ( + input: IssueScopeTarget, + ) => Effect.Effect; + + /** Only called when `capabilities.labels` or `capabilities.assignees` is true. */ + readonly listCandidates?: ( + input: IssueScopeTarget, + ) => Effect.Effect; +} diff --git a/packages/issue-tracker-core/src/server/driver.ts b/packages/issue-tracker-core/src/server/driver.ts new file mode 100644 index 000000000000..6579b8092a07 --- /dev/null +++ b/packages/issue-tracker-core/src/server/driver.ts @@ -0,0 +1,26 @@ +/** + * IssueTrackerDriver — what an issue tracker package hands the server. + * + * One driver per tracker kind. The server lists its drivers once and the registry iterates that + * list, so adding a tracker means writing its package and adding its driver to the list. Git + * hosts ship their driver inside their source control package; a standalone tracker (Linear, + * Jira) ships its own. + * + * `make` yields what it needs from the environment, and its `R` channel is the full list the + * server must provide. + * + * @module issue-tracker-core/server/driver + */ +import type { IssueTrackerKind } from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; + +import type { IssueTrackerApi } from "./IssueTracker.ts"; + +export interface IssueTrackerDriver { + readonly kind: IssueTrackerKind; + readonly make: Effect.Effect; +} + +/** Declares a driver with its `R` inferred from `make`. */ +export const defineIssueTrackerDriver = (driver: IssueTrackerDriver): IssueTrackerDriver => + driver; diff --git a/packages/issue-tracker-core/tsconfig.json b/packages/issue-tracker-core/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/issue-tracker-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json index d7be6f20ce15..4bdbd28fb892 100644 --- a/packages/source-control-github/package.json +++ b/packages/source-control-github/package.json @@ -31,6 +31,14 @@ "types": "./src/server/GitHubCredentials.ts", "import": "./src/server/GitHubCredentials.ts" }, + "./server/GitHubIssueTracker": { + "types": "./src/server/GitHubIssueTracker.ts", + "import": "./src/server/GitHubIssueTracker.ts" + }, + "./server/gitHubIssueJson": { + "types": "./src/server/gitHubIssueJson.ts", + "import": "./src/server/gitHubIssueJson.ts" + }, "./server/githubGraphQl": { "types": "./src/server/githubGraphQl.ts", "import": "./src/server/githubGraphQl.ts" @@ -70,6 +78,10 @@ "./server/githubStackRebase": { "types": "./src/server/githubStackRebase.ts", "import": "./src/server/githubStackRebase.ts" + }, + "./server/issueTrackerDriver": { + "types": "./src/server/issueTrackerDriver.ts", + "import": "./src/server/issueTrackerDriver.ts" } }, "scripts": { @@ -78,6 +90,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@t3tools/issue-tracker-core": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts new file mode 100644 index 000000000000..963caba36623 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubIssueTracker from "./GitHubIssueTracker.ts"; + +/** A detail answer as GitHub's GraphQL API shapes it, with every section populated once. */ +const DETAIL_RESPONSE = JSON.stringify({ + data: { + viewer: { login: "julius" }, + repository: { + viewerPermission: "TRIAGE", + issue: { + number: 42, + title: "Composer loses focus", + url: "https://github.com/acme/web/issues/42", + author: { login: "octocat", avatarUrl: "https://avatars/octocat", name: "Octo Cat" }, + state: "CLOSED", + stateReason: "NOT_PLANNED", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z", + closedAt: "2026-07-03T00:00:00Z", + comments: { + totalCount: 3, + pageInfo: { hasPreviousPage: true, startCursor: "Y3Vyc29y" }, + nodes: [ + { + id: "IC_1", + author: { login: "julius", avatarUrl: null }, + body: "Repro attached.", + createdAt: "2026-07-02T00:00:00Z", + url: "https://github.com/acme/web/issues/42#issuecomment-1", + reactionGroups: [ + { + content: "THUMBS_UP", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "theo" }] }, + }, + { + content: "EYES", + viewerHasReacted: false, + reactors: { totalCount: 0, nodes: [] }, + }, + ], + }, + ], + }, + assignees: { nodes: [{ login: "theo", name: null, avatarUrl: null }] }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }, null] }, + body: "Typing in the composer drops focus after a paste.", + viewerCanUpdate: false, + reactionGroups: [], + closedByPullRequestsReferences: { + nodes: [ + { + number: 50, + title: "fix(web): keep composer focus", + url: "https://github.com/acme/web/pull/50", + state: "MERGED", + isDraft: false, + repository: { nameWithOwner: "acme/web" }, + }, + ], + }, + timelineItems: { + nodes: [ + { + __typename: "LabeledEvent", + id: "LE_1", + createdAt: "2026-07-01T01:00:00Z", + actor: { login: "theo" }, + label: { name: "bug" }, + }, + { + __typename: "CrossReferencedEvent", + id: "CRE_1", + createdAt: "2026-07-02T01:00:00Z", + actor: { login: "julius" }, + source: { + __typename: "PullRequest", + number: 50, + title: "fix(web): keep composer focus", + url: "https://github.com/acme/web/pull/50", + state: "MERGED", + isDraft: false, + repository: { nameWithOwner: "acme/web" }, + }, + }, + { __typename: "MentionedEvent", id: "ME_1", createdAt: "2026-07-02T02:00:00Z" }, + { + __typename: "ClosedEvent", + id: "CE_1", + createdAt: "2026-07-03T00:00:00Z", + actor: null, + }, + ], + }, + }, + }, + }, +}); + +function tracker(graphql: (query: string, variables: unknown) => string) { + return GitHubIssueTracker.make.pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + graphql: ({ query, variables }) => Effect.succeed(graphql(query, variables)), + }), + ), + ); +} + +describe("GitHubIssueTracker", () => { + it.effect("maps a detail read to the neutral issue shape", () => + Effect.gen(function* () { + let seen: unknown; + const api = yield* tracker((_query, variables) => { + seen = variables; + return DETAIL_RESPONSE; + }); + const issue = yield* api.getIssue({ + cwd: "/w", + host: "github.com", + scope: "acme/web", + key: "42", + }); + expect(seen).toEqual({ owner: "acme", name: "web", number: 42 }); + expect(issue).toMatchObject({ + key: "42", + displayKey: "#42", + title: "Composer loses focus", + state: { category: "closed", name: "closed", closeReason: "not-planned" }, + author: { login: "octocat", name: "Octo Cat", avatarUrl: "https://avatars/octocat" }, + assignees: [{ login: "theo" }], + labels: [{ name: "bug", color: "d73a4a" }], + commentCount: 3, + viewer: "julius", + // Triage may label and assign; `viewerCanUpdate: false` withholds state and edits. + viewerPermissions: { + setState: false, + edit: false, + labels: true, + assignees: true, + comment: true, + }, + commentsCursor: "Y3Vyc29y", + }); + // The viewer is left out of `actors` and shown as "You"; an empty group is dropped. + expect(issue.comments[0]?.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["theo"], viewerHasReacted: true }, + ]); + // The closing reference wins over the cross-reference sighting of the same pull request. + expect(issue.linkedPullRequests).toEqual([ + { + repository: "acme/web", + number: 50, + title: "fix(web): keep composer focus", + url: "https://github.com/acme/web/pull/50", + state: "merged", + isDraft: false, + closesIssue: true, + }, + ]); + // An event kind this page has no vocabulary for is dropped rather than guessed at. + expect(issue.events.map((event) => [event.kind, event.detail])).toEqual([ + ["labeled", "bug"], + ["referenced", "acme/web#50"], + ["closed", null], + ]); + }), + ); + + it.effect("refuses a key that names no GitHub issue number without a request", () => + Effect.gen(function* () { + let requests = 0; + const api = yield* tracker(() => { + requests += 1; + return DETAIL_RESPONSE; + }); + const error = yield* Effect.flip( + api.getIssue({ cwd: "/w", host: "github.com", scope: "acme/web", key: "ENG-42" }), + ); + expect(error.reason).toBe("not-found"); + expect(requests).toBe(0); + }), + ); +}); diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts new file mode 100644 index 000000000000..1f18d4ecc200 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -0,0 +1,504 @@ +/** + * GitHub's issue tracker, over the same `GitHubApi` the pull request reads use: one credential, + * one quota reserve, one rate-limit pause per host. + * + * Reads go through GraphQL, which is the only API that answers the viewer's standing, the + * timeline and reactions. Writes to the issue's own fields go through REST `PATCH`, which takes + * the whole label and assignee sets in one call, the way the port asks for them. + * + * @module source-control-github/server/GitHubIssueTracker + */ +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { + IssueTrackerKind, + type IssueCapabilities, + type IssueInvolvement, +} from "@t3tools/contracts"; +import { + IssueTrackerError, + type IssueTrackerApi, +} from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import * as GitHubApi from "./GitHubApi.ts"; +import { + ADD_REACTION_GRAPHQL_MUTATION, + gitHubReactionContent, + REMOVE_REACTION_GRAPHQL_MUTATION, +} from "./gitHubPullRequestJson.ts"; +import { + decodeCreatedIssueJson, + decodeIssueCandidatesJson, + decodeIssueCommentScopeJson, + decodeIssueCommentsJson, + decodeIssueDetailJson, + decodeIssueNodeIdJson, + decodeIssueSearchJson, + decodeIssueTemplatesJson, + decodeIssueViewerPermissionsJson, + decodeViewerLoginJson, + ISSUE_CANDIDATES_GRAPHQL_QUERY, + ISSUE_COMMENT_SCOPE_GRAPHQL_QUERY, + ISSUE_COMMENTS_GRAPHQL_QUERY, + ISSUE_DETAIL_GRAPHQL_QUERY, + ISSUE_NODE_ID_GRAPHQL_QUERY, + ISSUE_TEMPLATES_GRAPHQL_QUERY, + ISSUE_VIEWER_PERMISSIONS_GRAPHQL_QUERY, + issueSearchGraphQlQuery, + SEARCH_MAX_ROWS, +} from "./gitHubIssueJson.ts"; + +const KIND = IssueTrackerKind.make("github"); + +export const CAPABILITIES: IssueCapabilities = { + create: true, + edit: true, + comment: true, + labels: true, + assignees: true, + reactions: true, + templates: true, + linkedPullRequests: true, + events: true, + search: true, + states: [ + { category: "open", name: "open" }, + { category: "closed", name: "closed" }, + ], + closeReasons: ["completed", "not-planned"], +}; + +/** Where a repository configures the rest of its issue chooser, as GitHub spells the path. */ +const TEMPLATE_CONFIG_PATH = ".github/ISSUE_TEMPLATE/config.yml"; + +/** What a repository selector may hold before it goes into a search query as itself. */ +const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** The GraphQL and REST APIs take owner and name apart, so `owner/repo` is split here. */ +function parseRepository(scope: string): { readonly owner: string; readonly name: string } { + const parts = scope.trim().split("/").filter(Boolean); + return { owner: parts.at(-2) ?? "", name: parts.at(-1) ?? "" }; +} + +/** An issue key is its number; anything else names no GitHub issue. */ +function parseNumber(key: string): number | null { + return /^[1-9]\d*$/.test(key) ? Number(key) : null; +} + +/** + * The reader's words as one literal phrase of a search. Outside quotes GitHub reads `is:closed` + * as a qualifier, so text typed into a box could widen the listing it narrows; inside them it is + * only text. The two characters that could end the phrase early are escaped. + */ +function searchPhrase(query: string): string { + return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +const INVOLVEMENT_QUALIFIER: Record = { + all: null, + assigned: "assignee", + authored: "author", + mentioned: "mentions", +}; + +function searchQuery(input: { + readonly scope: string; + readonly state: "all" | "open" | "closed"; + readonly involvement: IssueInvolvement; + readonly viewer: string; + readonly query?: string | undefined; +}): string { + const text = input.query?.trim() ?? ""; + const qualifier = INVOLVEMENT_QUALIFIER[input.involvement]; + return [ + // The search index holds pull requests as issues; `is:issue` keeps them out. + "is:issue", + `repo:${input.scope.trim()}`, + ...(input.state === "all" ? [] : [`is:${input.state}`]), + ...(qualifier === null ? [] : [`${qualifier}:${input.viewer}`]), + "sort:updated-desc", + ...(text.length === 0 ? [] : [searchPhrase(text)]), + ].join(" "); +} + +/** The REST close reasons, which the capability names in the contract's own spelling. */ +const CLOSE_REASON: Record = { + completed: "completed", + "not-planned": "not_planned", +}; + +/** The tags that mean GitHub is out of reach for this account rather than one request failing. */ +function reasonFor(error: GitHubApi.GitHubApiError): IssueTrackerError["reason"] { + switch (error._tag) { + case "GitHubCliMissingError": + return "missing-tool"; + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + case "GitHubApiAuthenticationError": + return "unauthenticated"; + case "GitHubApiRateLimitError": + case "SourceControlRateLimitPausedError": + return "rate-limited"; + case "GitHubApiNotFoundError": + return "not-found"; + default: + return "failed"; + } +} + +export const make: Effect.Effect = Effect.gen( + function* () { + const api = yield* GitHubApi.GitHubApi; + + const fail = (operation: string) => (error: GitHubApi.GitHubApiError) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: reasonFor(error), + detail: error.message, + ...("retryAt" in error && error.retryAt !== undefined ? { retryAt: error.retryAt } : {}), + cause: error, + }); + + const unreadable = (operation: string, cause: unknown) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "failed", + detail: `GitHub returned an unreadable ${operation} response.`, + cause, + }); + + const notFound = (operation: string) => + new IssueTrackerError({ + tracker: KIND, + operation, + reason: "not-found", + detail: "GitHub could not find the requested issue.", + }); + + /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ + const graphqlRead = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables?: Readonly>; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .graphql({ + host: input.host, + operation: input.operation, + query: input.query, + ...(input.variables === undefined ? {} : { variables: input.variables }), + }) + .pipe( + Effect.mapError(fail(input.operation)), + Effect.flatMap((raw) => { + const decoded = input.decode(raw.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(unreadable(input.operation, decoded.failure)); + }), + ); + + const mutate = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables: Readonly>; + }) => api.graphql(input).pipe(Effect.asVoid, Effect.mapError(fail(input.operation))); + + /** The issue as REST addresses it, or a not-found for a key that names no number. */ + const restIssue = ( + input: { readonly scope: string; readonly key: string }, + operation: string, + ): Effect.Effect< + { + readonly owner: string; + readonly name: string; + readonly number: number; + readonly path: string; + }, + IssueTrackerError + > => { + const number = parseNumber(input.key); + const { owner, name } = parseRepository(input.scope); + return number === null + ? Effect.fail(notFound(operation)) + : Effect.succeed({ owner, name, number, path: `repos/${owner}/${name}/issues/${number}` }); + }; + + /** Every write to an issue's own fields is one `PATCH`; only its body differs. */ + const patchIssue = ( + input: { readonly host: string; readonly scope: string; readonly key: string }, + operation: string, + body: Record, + ) => + restIssue(input, operation).pipe( + Effect.flatMap(({ path }) => + api + .rest({ host: input.host, operation, method: "PATCH", path, body }) + .pipe(Effect.asVoid, Effect.mapError(fail(operation))), + ), + ); + + /** + * A client-given comment id is trusted to be whatever node it names, which can hang off any + * issue on the host; the mutation would write wherever it belongs unless this agrees first. + */ + const commentBelongsToIssue = (input: { + readonly host: string; + readonly scope: string; + readonly key: string; + readonly commentId: string; + readonly operation: string; + }) => + restIssue(input, input.operation).pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: input.operation, + query: ISSUE_COMMENT_SCOPE_GRAPHQL_QUERY, + variables: { owner, name, number, commentId: input.commentId }, + decode: decodeIssueCommentScopeJson, + }), + ), + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.commentId) + : Effect.fail( + new IssueTrackerError({ + tracker: KIND, + operation: input.operation, + reason: "failed", + detail: "The comment does not belong to the selected issue.", + }), + ), + ), + ); + + return { + kind: KIND, + capabilities: CAPABILITIES, + + getViewer: (input) => + graphqlRead({ + host: input.host, + operation: "getViewer", + query: "query { viewer { login } }", + decode: decodeViewerLoginJson, + }), + + listIssues: (input) => { + if (!SEARCH_REPOSITORY.test(input.scope.trim())) { + // A name holding a space could end the `repo:` qualifier and start one of its own, so + // an unaddressable one refuses the read rather than being escaped into something GitHub + // might still read. + return Effect.fail( + new IssueTrackerError({ + tracker: KIND, + operation: "listIssues", + reason: "failed", + detail: "A repository was named that GitHub cannot address.", + }), + ); + } + return graphqlRead({ + host: input.host, + operation: "listIssues", + query: issueSearchGraphQlQuery(Math.min(input.limit, SEARCH_MAX_ROWS)), + variables: + input.cursor === undefined + ? { q: searchQuery(input) } + : { q: searchQuery(input), cursor: input.cursor }, + decode: decodeIssueSearchJson, + }); + }, + + getIssue: (input) => + restIssue(input, "getIssue").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "getIssue", + query: ISSUE_DETAIL_GRAPHQL_QUERY, + variables: { owner, name, number }, + decode: decodeIssueDetailJson, + }), + ), + Effect.filterOrFail( + (detail) => detail !== null, + () => notFound("getIssue"), + ), + ), + + getComments: (input) => + restIssue(input, "getComments").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "getComments", + query: ISSUE_COMMENTS_GRAPHQL_QUERY, + variables: { owner, name, number, cursor: input.cursor }, + decode: decodeIssueCommentsJson, + }), + ), + ), + + getViewerPermissions: (input) => + restIssue(input, "getViewerPermissions").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "getViewerPermissions", + query: ISSUE_VIEWER_PERMISSIONS_GRAPHQL_QUERY, + variables: { owner, name, number }, + decode: decodeIssueViewerPermissionsJson, + }), + ), + ), + + create: (input) => { + const { owner, name } = parseRepository(input.scope); + return api + .rest({ + host: input.host, + operation: "create", + method: "POST", + path: `repos/${owner}/${name}/issues`, + body: { + title: input.title, + body: input.body, + labels: input.labels, + assignees: input.assignees, + }, + }) + .pipe( + Effect.mapError(fail("create")), + Effect.flatMap((response) => { + const decoded = decodeCreatedIssueJson(response.body); + return Result.isSuccess(decoded) + ? Effect.succeed({ + key: String(decoded.success.number), + displayKey: `#${decoded.success.number}`, + url: decoded.success.url, + }) + : Effect.fail(unreadable("create", decoded.failure)); + }), + ); + }, + + comment: (input) => + restIssue(input, "comment").pipe( + Effect.flatMap(({ path }) => + api + .rest({ + host: input.host, + operation: "comment", + method: "POST", + path: `${path}/comments`, + body: { body: input.body }, + }) + .pipe(Effect.asVoid, Effect.mapError(fail("comment"))), + ), + ), + + update: (input) => + patchIssue(input, "update", { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.labels === undefined ? {} : { labels: input.labels }), + ...(input.assignees === undefined ? {} : { assignees: input.assignees }), + }), + + setState: (input) => + patchIssue(input, "setState", { + state: input.state, + ...(input.state === "closed" && input.closeReason !== undefined + ? { state_reason: CLOSE_REASON[input.closeReason] } + : {}), + }), + + setReaction: (input) => + (input.subjectId === undefined + ? restIssue(input, "setReaction").pipe( + Effect.flatMap(({ owner, name, number }) => + graphqlRead({ + host: input.host, + operation: "setReaction", + query: ISSUE_NODE_ID_GRAPHQL_QUERY, + variables: { owner, name, number }, + decode: decodeIssueNodeIdJson, + }), + ), + ) + : commentBelongsToIssue({ + ...input, + commentId: input.subjectId, + operation: "setReaction", + }) + ).pipe( + Effect.flatMap((subjectId) => + mutate({ + host: input.host, + operation: "setReaction", + query: input.reacted + ? ADD_REACTION_GRAPHQL_MUTATION + : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId, content: gitHubReactionContent(input.content) }, + }), + ), + ), + + listTemplates: (input) => { + const { owner, name } = parseRepository(input.scope); + return Effect.all( + [ + graphqlRead({ + host: input.host, + operation: "listTemplates", + query: ISSUE_TEMPLATES_GRAPHQL_QUERY, + variables: { owner, name }, + decode: decodeIssueTemplatesJson, + }), + // Only an explicit `blank_issues_enabled: false` takes the blank issue away; a missing + // config, or one this account may not read, leaves it where GitHub puts it. + api + .rest({ + host: input.host, + operation: "listTemplateConfig", + accept: "application/vnd.github.raw", + path: `repos/${owner}/${name}/contents/${TEMPLATE_CONFIG_PATH}`, + }) + .pipe( + Effect.map( + (response) => !/^\s*blank_issues_enabled\s*:\s*false\s*$/m.test(response.body), + ), + Effect.catch((error) => + error._tag === "GitHubApiRateLimitError" || + error._tag === "SourceControlRateLimitPausedError" + ? Effect.fail(fail("listTemplateConfig")(error)) + : Effect.succeed(true), + ), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([templates, blankIssuesEnabled]) => ({ templates, blankIssuesEnabled })), + ); + }, + + listCandidates: (input) => { + const { owner, name } = parseRepository(input.scope); + return graphqlRead({ + host: input.host, + operation: "listCandidates", + query: ISSUE_CANDIDATES_GRAPHQL_QUERY, + variables: { owner, name }, + decode: decodeIssueCandidatesJson, + }); + }, + }; + }, +); diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts new file mode 100644 index 000000000000..3504da2db960 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -0,0 +1,736 @@ +/** + * GraphQL documents and decoders for GitHub issues. Enum-ish fields are decoded as plain strings + * and normalized here, so a schema addition GitHub ships never fails a whole payload. + * + * @module source-control-github/server/gitHubIssueJson + */ +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + IssueActor, + IssueComment, + IssueEvent, + IssueEventKind, + IssueLabel, + IssueLinkedPullRequest, + IssueState, + IssueTemplate, + IssueViewerPermissions, + PullRequestState, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import type { TrackerIssueSummary } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import { + REACTION_GROUPS_FIELDS, + RawReactionGroupsSchema, + toReactions, +} from "./gitHubPullRequestJson.ts"; + +/** GitHub's ceiling on a connection page, which is what every bounded read below asks for. */ +const PAGE_SIZE = 100; +/** GitHub refuses a search page larger than this with EXCESSIVE_PAGINATION. */ +export const SEARCH_MAX_ROWS = PAGE_SIZE; + +const RawActorSchema = Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabelSchema = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPageInfoSchema = Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + hasPreviousPage: Schema.optional(Schema.Boolean), + startCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const nodes = (schema: S) => + Schema.optional( + Schema.NullOr( + Schema.Struct({ nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(schema)))) }), + ), + ); + +/** One issue row as both the search and the detail read answer with it. */ +const RawIssueSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), + /** The empty string on an open issue, so it is normalized rather than mapped. */ + stateReason: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + updatedAt: Schema.String, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + comments: Schema.optional( + Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), + ), + assignees: nodes(RawActorSchema), + labels: nodes(RawLabelSchema), +}); + +const ISSUE_ROW_FIELDS = `number title url state stateReason createdAt updatedAt closedAt + author { login avatarUrl ... on User { name } } + comments { totalCount } + assignees(first: 20) { nodes { login name avatarUrl } } + labels(first: 20) { nodes { name color } }`; + +/** + * One repository's issues as a search, which is the only read that takes free text and an + * updated-desc order with a cursor. `is:issue` keeps pull requests out of the shared index. + */ +export function issueSearchGraphQlQuery(rows: number): string { + return `query($q: String!, $cursor: String) { + search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), SEARCH_MAX_ROWS)}, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { ... on Issue { ${ISSUE_ROW_FIELDS} } } + } +}`; +} + +const RawSearchSchema = Schema.Struct({ + data: Schema.Struct({ + search: Schema.Struct({ + pageInfo: Schema.optional(Schema.NullOr(RawPageInfoSchema)), + // Row by row, so a node that is not an issue is skipped rather than blanking the page. + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + }), +}); + +const RawReferenceSchema = Schema.Struct({ + __typename: Schema.optional(Schema.NullOr(Schema.String)), + number: Schema.optional(Schema.NullOr(Schema.Int)), + title: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + repository: Schema.optional( + Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +/** Every timeline union member as one flat shape; `__typename` decides which fields were meant. */ +const RawTimelineItemSchema = Schema.Struct({ + __typename: Schema.String, + id: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.NullOr(Schema.String)), + actor: Schema.optional(Schema.NullOr(RawActorSchema)), + label: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + assignee: Schema.optional(Schema.NullOr(RawActorSchema)), + currentTitle: Schema.optional(Schema.NullOr(Schema.String)), + source: Schema.optional(Schema.NullOr(RawReferenceSchema)), + subject: Schema.optional(Schema.NullOr(RawReferenceSchema)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.String, + author: Schema.optional(Schema.NullOr(RawActorSchema)), + body: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, +}); + +const RawCommentsSchema = Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawCommentSchema)))), +}); + +const RawViewerFieldsSchema = Schema.Struct({ + viewerCanUpdate: Schema.optional(Schema.Boolean), +}); + +const COMMENT_FIELDS = `id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS}`; + +/** + * The whole issue in one read: its row, body, the newest page of comments, the newest hundred + * state changes, the change requests that close or mention it, and where the viewer stands. + * Comments are read newest-last so the first page is the recent end; older pages follow + * `pageInfo.startCursor` backwards. + */ +export const ISSUE_DETAIL_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + viewer { login } + repository(owner: $owner, name: $name) { + viewerPermission + issue(number: $number) { + ${ISSUE_ROW_FIELDS} + body + viewerCanUpdate + ${REACTION_GROUPS_FIELDS} + comments(last: ${PAGE_SIZE}) { + totalCount + pageInfo { hasPreviousPage startCursor } + nodes { ${COMMENT_FIELDS} } + } + closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { + nodes { number title url state isDraft repository { nameWithOwner } } + } + timelineItems(last: ${PAGE_SIZE}, itemTypes: [CLOSED_EVENT, REOPENED_EVENT, LABELED_EVENT, UNLABELED_EVENT, ASSIGNED_EVENT, UNASSIGNED_EVENT, RENAMED_TITLE_EVENT, CROSS_REFERENCED_EVENT]) { + nodes { + __typename + ... on ClosedEvent { id createdAt actor { login avatarUrl } } + ... on ReopenedEvent { id createdAt actor { login avatarUrl } } + ... on LabeledEvent { id createdAt actor { login avatarUrl } label { name } } + ... on UnlabeledEvent { id createdAt actor { login avatarUrl } label { name } } + ... on AssignedEvent { id createdAt actor { login avatarUrl } assignee { ... on User { login name } ... on Bot { login } } } + ... on UnassignedEvent { id createdAt actor { login avatarUrl } assignee { ... on User { login name } ... on Bot { login } } } + ... on RenamedTitleEvent { id createdAt actor { login avatarUrl } currentTitle } + ... on CrossReferencedEvent { + id createdAt actor { login avatarUrl } + source { __typename ... on PullRequest { number title url state isDraft repository { nameWithOwner } } ... on Issue { number repository { nameWithOwner } } } + } + } + } + } + } +}`; + +const RawDetailSchema = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + repository: Schema.Struct({ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + /** Null for a number that names no issue the viewer can see. */ + issue: Schema.NullOr( + Schema.Struct({ + ...RawIssueSchema.fields, + ...RawViewerFieldsSchema.fields, + body: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, + /** Overrides the row's count-only connection with the page the detail reads. */ + comments: Schema.optional(Schema.NullOr(RawCommentsSchema)), + closedByPullRequestsReferences: nodes(RawReferenceSchema), + timelineItems: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + ), + ), + }), + ), + }), + }), +}); + +/** The rest of a long conversation, read backwards from a cursor the detail handed over. */ +export const ISSUE_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String!) { + viewer { login } + repository(owner: $owner, name: $name) { + issue(number: $number) { + comments(last: ${PAGE_SIZE}, before: $cursor) { + pageInfo { hasPreviousPage startCursor } + nodes { ${COMMENT_FIELDS} } + } + } + } +}`; + +const RawCommentPageSchema = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), + repository: Schema.Struct({ + issue: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawCommentsSchema) })), + }), + }), +}); + +/** + * The viewer's standing on its own, for deciding a write by what GitHub says now rather than by + * what the page was told when it loaded. + */ +export const ISSUE_VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + viewerPermission + issue(number: $number) { viewerCanUpdate } + } +}`; + +const RawViewerPermissionsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + issue: Schema.NullOr(RawViewerFieldsSchema), + }), + }), +}); + +export const ISSUE_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { issue(number: $number) { id } } +}`; + +const RawIssueNodeIdSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ issue: Schema.Struct({ id: Schema.String }) }), + }), +}); + +/** Whether a comment id, which is global, hangs off the issue the request names. */ +export const ISSUE_COMMENT_SCOPE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $commentId: ID!) { + repository(owner: $owner, name: $name) { issue(number: $number) { id } } + node(id: $commentId) { ... on IssueComment { issue { id } } } +}`; + +const RawCommentScopeSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ issue: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + node: Schema.NullOr( + Schema.Struct({ issue: Schema.optional(Schema.Struct({ id: Schema.String })) }), + ), + }), +}); + +/** + * The labels a repository has and the people it may assign, in one read. `assignableUsers` is + * the list GitHub's own picker is built from, where `collaborators` is refused to anyone without + * push access. + */ +export const ISSUE_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + labels(first: ${PAGE_SIZE}) { pageInfo { hasNextPage } nodes { name color description } } + assignableUsers(first: ${PAGE_SIZE}) { pageInfo { hasNextPage } nodes { login name avatarUrl } } + } +}`; + +const RawCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + labels: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))), + }), + assignableUsers: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawActorSchema)))), + }), + }), + }), +}); + +/** + * The templates a repository offers, from the host rather than the checkout: a working tree sits + * on whatever branch its reader left it on, and the templates are the default branch's. + */ +export const ISSUE_TEMPLATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issueTemplates { + filename name about title body + assignees(first: 25) { nodes { login } } + labels(first: 50) { nodes { name } } + } + } +}`; + +const RawTemplateSchema = Schema.Struct({ + filename: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + about: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.NullOr(Schema.String)), + assignees: nodes(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + labels: nodes(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), +}); + +const RawTemplatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + issueTemplates: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), + }), + }), +}); + +const RawCreatedIssueSchema = Schema.Struct({ number: Schema.Int, html_url: Schema.String }); + +export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } +}`; + +const decodeSearch = decodeJsonResult(RawSearchSchema); +const decodeIssueRow = Schema.decodeUnknownExit(RawIssueSchema); +const decodeDetail = decodeJsonResult(RawDetailSchema); +const decodeCommentPage = decodeJsonResult(RawCommentPageSchema); +const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); +const decodeNodeId = decodeJsonResult(RawIssueNodeIdSchema); +const decodeCommentScope = decodeJsonResult(RawCommentScopeSchema); +const decodeCandidates = decodeJsonResult(RawCandidatesSchema); +const decodeTemplates = decodeJsonResult(RawTemplatesSchema); +const decodeTemplate = Schema.decodeUnknownExit(RawTemplateSchema); +const decodeTimelineItem = Schema.decodeUnknownExit(RawTimelineItemSchema); +const decodeCreatedIssue = decodeJsonResult(RawCreatedIssueSchema); +const decodeViewerLogin = decodeJsonResult( + Schema.Struct({ data: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) }) }), +); + +type DecodeFailure = + ReturnType extends Result.Result ? E : never; + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: typeof RawActorSchema.Type | null | undefined): IssueActor | null { + const login = trimmed(raw?.login); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) }; +} + +function toActors( + raw: ReadonlyArray | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((entry) => toActor(entry) ?? []); +} + +function toLabels( + raw: ReadonlyArray | null | undefined, +): ReadonlyArray { + return (raw ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null ? [] : [{ name, color: trimmed(label?.color) }]; + }); +} + +/** GitHub spells a close reason `COMPLETED` / `NOT_PLANNED`; an open issue has `""` or `REOPENED`. */ +export function toIssueState( + state: string | null | undefined, + stateReason: string | null | undefined, +): IssueState { + if (state?.trim().toUpperCase() !== "CLOSED") return { category: "open", name: "open" }; + const reason = stateReason?.trim().toUpperCase(); + return { + category: "closed", + name: "closed", + ...(reason === "COMPLETED" + ? { closeReason: "completed" } + : reason === "NOT_PLANNED" + ? { closeReason: "not-planned" } + : {}), + }; +} + +function toSummary(raw: typeof RawIssueSchema.Type): TrackerIssueSummary { + return { + key: String(raw.number), + displayKey: `#${raw.number}`, + title: raw.title, + url: raw.url, + author: toActor(raw.author), + state: toIssueState(raw.state, raw.stateReason), + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + closedAt: trimmed(raw.closedAt), + assignees: toActors(raw.assignees?.nodes), + labels: toLabels(raw.labels?.nodes), + commentCount: Math.max(0, raw.comments?.totalCount ?? 0), + }; +} + +export interface GitHubIssueSearchPage { + readonly items: ReadonlyArray; + readonly nextCursor: string | null; +} + +export function decodeIssueSearchJson( + raw: string, +): Result.Result { + const decoded = decodeSearch(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const search = decoded.success.data.search; + const items = (search.nodes ?? []).flatMap((entry) => { + const row = decodeIssueRow(entry); + return Exit.isSuccess(row) ? [toSummary(row.value)] : []; + }); + return Result.succeed({ + items, + nextCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, + }); +} + +function toPullRequestState(value: string | null | undefined): PullRequestState { + switch (value?.trim().toUpperCase()) { + case "MERGED": + return "merged"; + case "CLOSED": + return "closed"; + default: + return "open"; + } +} + +function toLinkedPullRequest( + raw: typeof RawReferenceSchema.Type | null | undefined, + closesIssue: boolean, +): IssueLinkedPullRequest | null { + if (raw == null) return null; + const typename = trimmed(raw.__typename); + if (typename !== null && typename !== "PullRequest") return null; + const repository = trimmed(raw.repository?.nameWithOwner); + const title = trimmed(raw.title); + const url = trimmed(raw.url); + const number = raw.number ?? 0; + if (repository === null || title === null || url === null || number <= 0) return null; + return { + repository, + number, + title, + url, + state: toPullRequestState(raw.state), + isDraft: raw.isDraft ?? false, + closesIssue, + }; +} + +/** The change GitHub recorded, or null for a kind this page has no vocabulary for. */ +function toEventFields( + raw: typeof RawTimelineItemSchema.Type, +): { readonly kind: IssueEventKind; readonly detail: string | null } | null { + switch (raw.__typename) { + case "ClosedEvent": + return { kind: "closed", detail: null }; + case "ReopenedEvent": + return { kind: "reopened", detail: null }; + case "LabeledEvent": + return { kind: "labeled", detail: trimmed(raw.label?.name) }; + case "UnlabeledEvent": + return { kind: "unlabeled", detail: trimmed(raw.label?.name) }; + case "AssignedEvent": + return { kind: "assigned", detail: trimmed(raw.assignee?.login) }; + case "UnassignedEvent": + return { kind: "unassigned", detail: trimmed(raw.assignee?.login) }; + case "RenamedTitleEvent": + return { kind: "renamed", detail: trimmed(raw.currentTitle) }; + case "CrossReferencedEvent": { + const repository = trimmed(raw.source?.repository?.nameWithOwner); + const number = raw.source?.number ?? 0; + return { + kind: "referenced", + detail: repository === null || number <= 0 ? null : `${repository}#${number}`, + }; + } + default: + return null; + } +} + +function toComments( + raw: typeof RawCommentsSchema.Type | null | undefined, + viewer: string | null, +): ReadonlyArray { + return (raw?.nodes ?? []).flatMap((comment) => { + const id = trimmed(comment?.id); + if (comment == null || id === null) return []; + return [ + { + id, + author: toActor(comment.author), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), + }, + ]; + }); +} + +/** Where a newest-last connection carries on into older rows; null once it is whole. */ +function previousCursorOf(pageInfo: typeof RawPageInfoSchema.Type | undefined): string | null { + return pageInfo?.hasPreviousPage === true ? trimmed(pageInfo.startCursor) : null; +} + +/** + * What the signed-in account may do. Closing and editing go by `viewerCanUpdate`, which GitHub + * grants the author as well as anyone with write; labelling and assigning need the triage role + * or better. Commenting is not gated: seeing the repository is enough to say something in it. + * An install that reports nothing grants rather than withholds, so the host's own refusal says why. + */ +export function toViewerPermissions(raw: { + readonly viewerPermission?: string | null | undefined; + readonly issue: typeof RawViewerFieldsSchema.Type | null; +}): IssueViewerPermissions { + const canUpdate = raw.issue?.viewerCanUpdate !== false; + const canTriage = ["ADMIN", "MAINTAIN", "WRITE", "TRIAGE"].includes( + raw.viewerPermission?.trim().toUpperCase() ?? "", + ); + return { + setState: canUpdate, + comment: true, + edit: canUpdate, + labels: canTriage, + assignees: canTriage, + }; +} + +export interface GitHubIssueDetail extends TrackerIssueSummary { + readonly body: string; + readonly viewer: string | null; + readonly viewerPermissions: IssueViewerPermissions; + readonly reactions: ReturnType; + readonly comments: ReadonlyArray; + readonly commentsCursor: string | null; + readonly events: ReadonlyArray; + readonly linkedPullRequests: ReadonlyArray; +} + +/** Null for a number that names no issue the viewer can see. */ +export function decodeIssueDetailJson( + raw: string, +): Result.Result { + const decoded = decodeDetail(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const { viewer: rawViewer, repository } = decoded.success.data; + const issue = repository.issue; + if (issue === null) return Result.succeed(null); + const viewer = trimmed(rawViewer?.login); + + // Closing references lead; a cross-reference seen twice is filed under the stronger relation. + const links = new Map(); + for (const node of issue.closedByPullRequestsReferences?.nodes ?? []) { + const link = toLinkedPullRequest(node, true); + if (link !== null) links.set(`${link.repository.toLowerCase()}#${link.number}`, link); + } + const events: IssueEvent[] = []; + for (const entry of issue.timelineItems?.nodes ?? []) { + const item = decodeTimelineItem(entry); + if (!Exit.isSuccess(item)) continue; + if (item.value.__typename === "CrossReferencedEvent") { + const link = toLinkedPullRequest(item.value.source, false); + const linkKey = link === null ? null : `${link.repository.toLowerCase()}#${link.number}`; + if (link !== null && linkKey !== null && !links.has(linkKey)) links.set(linkKey, link); + } + const fields = toEventFields(item.value); + const id = trimmed(item.value.id); + const createdAt = trimmed(item.value.createdAt); + if (fields === null || id === null || createdAt === null) continue; + events.push({ id, ...fields, actor: toActor(item.value.actor), createdAt }); + } + return Result.succeed({ + ...toSummary(issue), + body: issue.body ?? "", + viewer, + viewerPermissions: toViewerPermissions({ + viewerPermission: repository.viewerPermission, + issue, + }), + reactions: toReactions(issue.reactionGroups, viewer), + comments: toComments(issue.comments, viewer), + commentsCursor: previousCursorOf(issue.comments?.pageInfo), + events, + linkedPullRequests: [...links.values()], + }); +} + +export function decodeIssueCommentsJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly nextCursor: string | null }, + DecodeFailure +> { + const decoded = decodeCommentPage(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const comments = decoded.success.data.repository.issue?.comments; + const viewer = trimmed(decoded.success.data.viewer?.login); + return Result.succeed({ + comments: toComments(comments, viewer), + nextCursor: previousCursorOf(comments?.pageInfo), + }); +} + +export function decodeIssueViewerPermissionsJson( + raw: string, +): Result.Result { + return Result.map(decodeViewerPermissions(raw), ({ data }) => + toViewerPermissions(data.repository), + ); +} + +export function decodeIssueNodeIdJson(raw: string): Result.Result { + return Result.map(decodeNodeId(raw), ({ data }) => data.repository.issue.id); +} + +export function decodeIssueCommentScopeJson(raw: string): Result.Result { + return Result.map(decodeCommentScope(raw), ({ data }) => { + const expected = data.repository?.issue?.id ?? null; + return expected !== null && expected === (data.node?.issue?.id ?? null); + }); +} + +export function decodeViewerLoginJson(raw: string): Result.Result { + return Result.map(decodeViewerLogin(raw), ({ data }) => data.viewer.login); +} + +export function decodeIssueCandidatesJson(raw: string): Result.Result< + { + readonly labels: ReadonlyArray; + readonly assignees: ReadonlyArray; + readonly truncated: boolean; + }, + DecodeFailure +> { + return Result.map(decodeCandidates(raw), ({ data }) => { + const { labels, assignableUsers } = data.repository; + return { + labels: (labels.nodes ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null + ? [] + : [{ name, color: trimmed(label?.color), description: trimmed(label?.description) }]; + }), + // GitHub addresses an assignee by the login it shows, so the id is the handle itself. + assignees: toActors(assignableUsers.nodes).map((actor) => ({ ...actor, id: actor.login })), + truncated: + labels.pageInfo?.hasNextPage === true || assignableUsers.pageInfo?.hasNextPage === true, + }; + }); +} + +/** A template that cannot be read is skipped: one malformed form must not take the others. */ +export function decodeIssueTemplatesJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodeTemplates(raw), ({ data }) => + (data.repository.issueTemplates ?? []).flatMap((entry) => { + const template = decodeTemplate(entry); + if (Exit.isFailure(template)) return []; + const key = trimmed(template.value.filename); + if (key === null) return []; + return [ + { + key, + name: trimmed(template.value.name) ?? key, + about: template.value.about ?? "", + title: template.value.title ?? "", + body: template.value.body ?? "", + labels: (template.value.labels?.nodes ?? []).flatMap((node) => trimmed(node?.name) ?? []), + assignees: (template.value.assignees?.nodes ?? []).flatMap( + (node) => trimmed(node?.login) ?? [], + ), + }, + ]; + }), + ); +} + +export function decodeCreatedIssueJson( + raw: string, +): Result.Result<{ readonly number: number; readonly url: string }, DecodeFailure> { + return Result.map(decodeCreatedIssue(raw), (issue) => ({ + number: issue.number, + url: issue.html_url, + })); +} diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index e3768b04d7bc..61e7ea3ba522 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -287,7 +287,7 @@ const REACTORS_PER_GROUP = 10; * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: * a hover says who reacted, and a hundred and forty names is a count, not a sentence. */ -const REACTION_GROUPS_FIELDS = `reactionGroups { +export const REACTION_GROUPS_FIELDS = `reactionGroups { content viewerHasReacted reactors(first: ${REACTORS_PER_GROUP}) { @@ -328,7 +328,7 @@ export function gitHubReactionContent(content: PullRequestReactionContent): stri return GITHUB_REACTION_BY_CONTENT[content]; } -const RawReactionGroupsSchema = Schema.optional( +export const RawReactionGroupsSchema = Schema.optional( Schema.NullOr( Schema.Array( Schema.Struct({ @@ -363,7 +363,7 @@ type RawReactionGroups = typeof RawReactionGroupsSchema.Type; * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it * in would name them twice — but `count` still counts them along with everyone else. */ -function toReactions( +export function toReactions( groups: RawReactionGroups, viewer: string | null, ): ReadonlyArray { diff --git a/packages/source-control-github/src/server/issueTrackerDriver.ts b/packages/source-control-github/src/server/issueTrackerDriver.ts new file mode 100644 index 000000000000..b986988e899e --- /dev/null +++ b/packages/source-control-github/src/server/issueTrackerDriver.ts @@ -0,0 +1,15 @@ +/** + * The GitHub issue tracker driver, registered beside the source control driver so the server's + * issue registry can list it without knowing the package. + * + * @module source-control-github/server/issueTrackerDriver + */ +import { IssueTrackerKind } from "@t3tools/contracts"; +import { defineIssueTrackerDriver } from "@t3tools/issue-tracker-core/server/driver"; + +import * as GitHubIssueTracker from "./GitHubIssueTracker.ts"; + +export const driver = defineIssueTrackerDriver({ + kind: IssueTrackerKind.make("github"), + make: GitHubIssueTracker.make, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acce8eb89284..63d6b1fcfbac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -630,6 +630,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../../packages/issue-tracker-core '@t3tools/provider-acp': specifier: workspace:* version: link:../../packages/provider-acp @@ -1182,6 +1185,19 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/issue-tracker-core: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + packages/provider-acp: dependencies: '@t3tools/contracts': @@ -1712,6 +1728,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + '@t3tools/issue-tracker-core': + specifier: workspace:* + version: link:../issue-tracker-core '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core From d344e4bbd73db365852bbb65df1dd2939bd448fa Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:50:46 -0700 Subject: [PATCH 02/14] chore(issues): refresh the lockfile for Effect 4.0.4 Co-Authored-By: Claude Opus 5.5 --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63d6b1fcfbac..57482137904a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1191,8 +1191,8 @@ importers: specifier: workspace:* version: link:../contracts effect: - specifier: 4.0.2 - version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + specifier: 4.0.4 + version: 4.0.4 devDependencies: '@types/node': specifier: 24.12.4 From cf13507f0510a83f21d411010bd9ee2b630027f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:03:40 -0700 Subject: [PATCH 03/14] fix(server): give issue RPCs their own handler layer With Effect 4.0.4 and the ten issue RPCs, typing `toLayer` over the whole WebSocket RPC group hits TypeScript's instantiation depth limit (TS2589, only reported when a single checker sees ws.ts after the orchestration runtime). The checker then gives up and types the handler layer's requirements as `any`, which spreads through the route and server layers into binCli.ts and the CLI tests. The issue RPCs now form `WsIssuesRpcGroup`, implemented by their own `toLayer`, and `WsRpcGroup` merges it with the rest. The wire surface, authorization, and instrumentation are unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/ws.ts | 54 ++++++++++++++++++++++------------- packages/contracts/src/rpc.ts | 34 +++++++++++++++------- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7feab0cab72a..dad035b50515 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -100,6 +100,8 @@ import { type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, + WsCoreRpcGroup, + WsIssuesRpcGroup, WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -550,6 +552,8 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Middleware added later wraps middleware added earlier, so instrumentation wraps authorization. const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); +const ServerWsCoreRpcGroup = WsCoreRpcGroup.middleware(RpcInstrumentation); +const ServerWsIssuesRpcGroup = WsIssuesRpcGroup.middleware(RpcInstrumentation); // When a resuming client's cursor is more than this many events behind the // current head, skip the per-event catch-up replay and send a fresh shell // snapshot instead. Replaying each intervening event costs a shell refetch; @@ -1198,14 +1202,32 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); -const layerWsRpc = ( +const layerWsIssuesRpc = ServerWsIssuesRpcGroup.toLayer( + Effect.gen(function* () { + const issues = yield* IssueService.IssueService; + return ServerWsIssuesRpcGroup.of({ + [WS_METHODS.issuesList]: (input) => issues.list(input), + [WS_METHODS.issuesDetail]: (input) => issues.detail(input), + [WS_METHODS.issuesComments]: (input) => issues.comments(input), + [WS_METHODS.issuesTemplates]: (input) => issues.templates(input), + [WS_METHODS.issuesCandidates]: (input) => issues.candidates(input), + [WS_METHODS.issuesCreate]: (input) => issues.create(input), + [WS_METHODS.issuesComment]: (input) => issues.comment(input), + [WS_METHODS.issuesUpdate]: (input) => issues.update(input), + [WS_METHODS.issuesSetState]: (input) => issues.setState(input), + [WS_METHODS.issuesReact]: (input) => issues.react(input), + }); + }), +); + +const layerWsCoreRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], serverBrowser: ServerBrowser.ServerBrowser["Service"], ) => - ServerWsRpcGroup.toLayer( + ServerWsCoreRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const sql = yield* SqlClient.SqlClient; @@ -1243,7 +1265,6 @@ const layerWsRpc = ( const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; - const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -1834,7 +1855,7 @@ const layerWsRpc = ( return result; }); - const handlers = ServerWsRpcGroup.of({ + const handlers = ServerWsCoreRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -2577,16 +2598,6 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.labelCandidates(input)), [WS_METHODS.pullRequestsSetLabels]: (input) => withPullRequestViewer(input, pullRequests.setLabels(input)), - [WS_METHODS.issuesList]: (input) => issues.list(input), - [WS_METHODS.issuesDetail]: (input) => issues.detail(input), - [WS_METHODS.issuesComments]: (input) => issues.comments(input), - [WS_METHODS.issuesTemplates]: (input) => issues.templates(input), - [WS_METHODS.issuesCandidates]: (input) => issues.candidates(input), - [WS_METHODS.issuesCreate]: (input) => issues.create(input), - [WS_METHODS.issuesComment]: (input) => issues.comment(input), - [WS_METHODS.issuesUpdate]: (input) => issues.update(input), - [WS_METHODS.issuesSetState]: (input) => issues.setState(input), - [WS_METHODS.issuesReact]: (input) => issues.react(input), [WS_METHODS.sourceControlLookupRepository]: (input) => sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => @@ -3229,12 +3240,15 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - layerWsRpc( - session, - clientOrigin, - clientAnalyticsProps, - previewAutomationBroker, - serverBrowser, + Layer.merge( + layerWsCoreRpc( + session, + clientOrigin, + clientAnalyticsProps, + previewAutomationBroker, + serverBrowser, + ), + layerWsIssuesRpc, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 2ccd7a6437b5..d9f0aaefd94e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1974,7 +1974,27 @@ export class RpcScopeAuthorization extends RpcMiddleware.Service Date: Wed, 14 Oct 2026 01:16:25 -0700 Subject: [PATCH 04/14] chore(issues): drop unused exports and register new packages in CI lists Co-Authored-By: Claude Opus 5.5 --- apps/server/src/issue/IssueService.ts | 2 +- apps/server/src/issue/IssueTrackerRegistry.ts | 2 +- package.json | 2 +- .../src/server/GitHubIssueTracker.ts | 2 +- .../source-control-github/src/server/gitHubIssueJson.ts | 8 ++------ scripts/release-smoke.ts | 1 + 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index ec2638510f2b..ad438b713817 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -74,7 +74,7 @@ interface SupportedProject extends ProjectTrackerBinding { * The git-host path of resolution: the tracker is the host's own, named like the remote's * provider kind. Null for a project with no remote or a kind no tracker claims. */ -export function resolveGitHostBinding( +function resolveGitHostBinding( project: OrchestrationProjectShell, ): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; diff --git a/apps/server/src/issue/IssueTrackerRegistry.ts b/apps/server/src/issue/IssueTrackerRegistry.ts index 1647a9ea1ffa..7e0664527b4b 100644 --- a/apps/server/src/issue/IssueTrackerRegistry.ts +++ b/apps/server/src/issue/IssueTrackerRegistry.ts @@ -27,7 +27,7 @@ export function fromTrackers( }; } -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const trackers = yield* Effect.forEach( BuiltInIssueTrackers.BUILT_IN_ISSUE_TRACKERS, (driver) => driver.make, diff --git a/package.json b/package.json index af62f0e08a73..b726e47eb847 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/issue-tracker-core --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts index 1f18d4ecc200..c75f58c54982 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -50,7 +50,7 @@ import { const KIND = IssueTrackerKind.make("github"); -export const CAPABILITIES: IssueCapabilities = { +const CAPABILITIES: IssueCapabilities = { create: true, edit: true, comment: true, diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts index 3504da2db960..109be5cfb670 100644 --- a/packages/source-control-github/src/server/gitHubIssueJson.ts +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -356,10 +356,6 @@ const RawTemplatesSchema = Schema.Struct({ const RawCreatedIssueSchema = Schema.Struct({ number: Schema.Int, html_url: Schema.String }); -export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { - updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } -}`; - const decodeSearch = decodeJsonResult(RawSearchSchema); const decodeIssueRow = Schema.decodeUnknownExit(RawIssueSchema); const decodeDetail = decodeJsonResult(RawDetailSchema); @@ -407,7 +403,7 @@ function toLabels( } /** GitHub spells a close reason `COMPLETED` / `NOT_PLANNED`; an open issue has `""` or `REOPENED`. */ -export function toIssueState( +function toIssueState( state: string | null | undefined, stateReason: string | null | undefined, ): IssueState { @@ -559,7 +555,7 @@ function previousCursorOf(pageInfo: typeof RawPageInfoSchema.Type | undefined): * or better. Commenting is not gated: seeing the repository is enough to say something in it. * An install that reports nothing grants rather than withholds, so the host's own refusal says why. */ -export function toViewerPermissions(raw: { +function toViewerPermissions(raw: { readonly viewerPermission?: string | null | undefined; readonly issue: typeof RawViewerFieldsSchema.Type | null; }): IssueViewerPermissions { diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index cceda3374220..e8507af86228 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,7 @@ const workspaceFiles = [ "packages/provider-grok/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/issue-tracker-core/package.json", "packages/source-control-azure-devops/package.json", "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", From c30673523d5993b2b1fa1674a86a30fc5532207c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:43:06 -0700 Subject: [PATCH 05/14] fix(issues): match GitHub search words apart, not as one phrase Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitHubIssueTracker.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts index c75f58c54982..c6e4e132a332 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -86,12 +86,15 @@ function parseNumber(key: string): number | null { } /** - * The reader's words as one literal phrase of a search. Outside quotes GitHub reads `is:closed` - * as a qualifier, so text typed into a box could widen the listing it narrows; inside them it is - * only text. The two characters that could end the phrase early are escaped. + * The reader's words as quoted search terms. Outside quotes GitHub reads `is:closed` as a + * qualifier, so text typed into a box could widen the listing it narrows; inside them it is only + * text. Each word is quoted on its own so the words still match apart, not only as a phrase. */ -function searchPhrase(query: string): string { - return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +function searchTerms(query: string): ReadonlyArray { + return query + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word) => `"${word.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`); } const INVOLVEMENT_QUALIFIER: Record = { @@ -108,7 +111,6 @@ function searchQuery(input: { readonly viewer: string; readonly query?: string | undefined; }): string { - const text = input.query?.trim() ?? ""; const qualifier = INVOLVEMENT_QUALIFIER[input.involvement]; return [ // The search index holds pull requests as issues; `is:issue` keeps them out. @@ -117,7 +119,7 @@ function searchQuery(input: { ...(input.state === "all" ? [] : [`is:${input.state}`]), ...(qualifier === null ? [] : [`${qualifier}:${input.viewer}`]), "sort:updated-desc", - ...(text.length === 0 ? [] : [searchPhrase(text)]), + ...searchTerms(input.query ?? ""), ].join(" "); } From 0e1f41c3572f39e86661bce7df8d3ce9271fe878 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:47:11 -0700 Subject: [PATCH 06/14] style(issues): format the issue service Co-Authored-By: Claude Opus 5.5 --- apps/server/src/issue/IssueService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index ad438b713817..a3c168497039 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -74,9 +74,7 @@ interface SupportedProject extends ProjectTrackerBinding { * The git-host path of resolution: the tracker is the host's own, named like the remote's * provider kind. Null for a project with no remote or a kind no tracker claims. */ -function resolveGitHostBinding( - project: OrchestrationProjectShell, -): ProjectTrackerBinding | null { +function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrackerBinding | null { const identity = project.repositoryIdentity; const scope = sourceControlRepositorySelector(identity); const kind = identity?.provider; From b72ded81a733bae28ee94f33d7e464183119ac38 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:02:17 -0700 Subject: [PATCH 07/14] fix(issues): select GitHub issue comments once in the detail query Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitHubIssueTracker.test.ts | 6 +++++- .../source-control-github/src/server/gitHubIssueJson.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index 963caba36623..9dcd6e6184bc 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -115,8 +115,10 @@ describe("GitHubIssueTracker", () => { it.effect("maps a detail read to the neutral issue shape", () => Effect.gen(function* () { let seen: unknown; - const api = yield* tracker((_query, variables) => { + let sentQuery = ""; + const api = yield* tracker((query, variables) => { seen = variables; + sentQuery = query; return DETAIL_RESPONSE; }); const issue = yield* api.getIssue({ @@ -126,6 +128,8 @@ describe("GitHubIssueTracker", () => { key: "42", }); expect(seen).toEqual({ owner: "acme", name: "web", number: 42 }); + // GitHub rejects a query that selects one field twice with different arguments. + expect(sentQuery.match(/\bcomments\b/g)).toHaveLength(1); expect(issue).toMatchObject({ key: "42", displayKey: "#42", diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts index 109be5cfb670..99ad56776443 100644 --- a/packages/source-control-github/src/server/gitHubIssueJson.ts +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -78,12 +78,15 @@ const RawIssueSchema = Schema.Struct({ labels: nodes(RawLabelSchema), }); -const ISSUE_ROW_FIELDS = `number title url state stateReason createdAt updatedAt closedAt +/** A row without its comment count, which the detail reads from its own comment page. */ +const ISSUE_ROW_BASE_FIELDS = `number title url state stateReason createdAt updatedAt closedAt author { login avatarUrl ... on User { name } } - comments { totalCount } assignees(first: 20) { nodes { login name avatarUrl } } labels(first: 20) { nodes { name color } }`; +const ISSUE_ROW_FIELDS = `${ISSUE_ROW_BASE_FIELDS} + comments { totalCount }`; + /** * One repository's issues as a search, which is the only read that takes free text and an * updated-desc order with a cursor. `is:issue` keeps pull requests out of the shared index. @@ -166,7 +169,7 @@ export const ISSUE_DETAIL_GRAPHQL_QUERY = `query($owner: String!, $name: String! repository(owner: $owner, name: $name) { viewerPermission issue(number: $number) { - ${ISSUE_ROW_FIELDS} + ${ISSUE_ROW_BASE_FIELDS} body viewerCanUpdate ${REACTION_GROUPS_FIELDS} From 1a4e4c1079cac99cfa83be5c3cb823341e0b04cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 06:26:27 +0000 Subject: [PATCH 08/14] test(issues): focus coverage on observable behavior --- apps/server/src/auth/RpcAuthorization.test.ts | 74 +++++++++++++------ apps/server/src/issue/IssueService.test.ts | 30 ++++++-- .../src/server/GitHubIssueTracker.test.ts | 27 +------ 3 files changed, 80 insertions(+), 51 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index a88c4e66c874..be716e5b734e 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -6,6 +6,8 @@ import { AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, ProviderInstanceId, + ProjectId, + IssueTrackerKind, ThreadId, AuthOrchestrationOperateScope, ORCHESTRATION_V2_WS_METHODS, @@ -127,27 +129,6 @@ describe("RPC authorization scopes", () => { ); }); - it("reads issues under the orchestration read scope and writes them like pull requests", () => { - for (const method of [ - WS_METHODS.issuesList, - WS_METHODS.issuesDetail, - WS_METHODS.issuesComments, - WS_METHODS.issuesTemplates, - WS_METHODS.issuesCandidates, - ]) { - expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); - } - for (const method of [ - WS_METHODS.issuesCreate, - WS_METHODS.issuesComment, - WS_METHODS.issuesUpdate, - WS_METHODS.issuesSetState, - WS_METHODS.issuesReact, - ]) { - expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); - } - }); - it("requires source control writes to start, retry, or cancel project clones", () => { for (const method of [ WS_METHODS.projectCloneStart, @@ -283,6 +264,57 @@ describe("RPC scope middleware", () => { ); }); +it.effect("allows issue reads but requires a source-control grant before commenting", () => + Effect.gen(function* () { + const tested = [WS_METHODS.issuesList, WS_METHODS.issuesComment] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + const comments: string[] = []; + const handlers = Layer.mergeAll( + group.toLayerHandler(WS_METHODS.issuesList, () => + Effect.succeed({ entries: [], trackers: [], errors: [], viewers: {}, nextCursors: {} }), + ), + group.toLayerHandler(WS_METHODS.issuesComment, ({ body }) => + Effect.sync(() => { + comments.push(body); + }), + ), + ); + const reader = yield* RpcTest.makeClient(group).pipe( + Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthOrchestrationReadScope]))), + ); + const comment = { + projectId: ProjectId.make("web"), + tracker: IssueTrackerKind.make("github"), + scope: "acme/web", + key: "42", + body: "Repro attached.", + }; + + expect((yield* reader[WS_METHODS.issuesList]({ state: "open" })).entries).toEqual([]); + expect(yield* reader[WS_METHODS.issuesComment](comment).pipe(Effect.flip)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthSourceControlWriteScope, + }); + expect(comments).toEqual([]); + + const writer = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.merge( + handlers, + RpcAuthorization.layer([AuthOrchestrationReadScope, AuthSourceControlWriteScope]), + ), + ), + ); + yield* writer[WS_METHODS.issuesComment](comment); + expect(comments).toEqual(["Repro attached."]); + }).pipe(Effect.scoped), +); + describe("settings mutation authorization", () => { const group = WsRpcGroup.omit( ...[...WsRpcGroup.requests.keys()].filter( diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index e52f3d142b38..55a2e7209a01 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -156,8 +156,11 @@ describe("IssueService", () => { expect(detail).toMatchObject({ writable: false, scope: "other/backend", key: "7" }); yield* service.comment({ ...own, body: "ok" }); - const refused = yield* Effect.flip(service.comment({ ...external, body: "no" })); - expect(refused).toMatchObject({ _tag: "IssueOperationError", reason: "read-only" }); + 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. @@ -204,7 +207,7 @@ describe("IssueService", () => { }), ); - it.effect("lists each repository once and reports unusable trackers per host", () => + it.effect("deduplicates and pages repositories despite partial failures", () => Effect.gen(function* () { const listed: string[] = []; const service = yield* makeService({ @@ -212,7 +215,13 @@ describe("IssueService", () => { project({ id: "web", repository: "acme/web" }), project({ id: "web-worktree", repository: "acme/web" }), project({ id: "api", repository: "acme/api" }), - project({ id: "gitlab", repository: "acme/gl", provider: "gitlab", host: "gitlab.com" }), + project({ id: "docs", repository: "acme/docs" }), + project({ + id: "gitlab", + repository: "acme/gl", + provider: "gitlab", + host: "gitlab.com", + }), project({ id: "plain" }), ], trackers: [ @@ -227,8 +236,12 @@ describe("IssueService", () => { reason: "failed", detail: "boom", }); + if (input.scope === "acme/docs") + return { items: [issue("3", "2026-07-04T00:00:00Z")], nextCursor: null }; + if (input.cursor === "next") + return { items: [issue("4", "2026-07-01T00:00:00Z")], nextCursor: null }; return { - items: [issue("2", "2026-07-05T00:00:00Z"), issue("1")], + items: [issue("1"), issue("2", "2026-07-05T00:00:00Z")], nextCursor: "next", }; }), @@ -236,16 +249,17 @@ describe("IssueService", () => { ], }); const result = yield* service.list({ state: "open" }); - expect(listed.sort()).toEqual(["acme/api", "acme/web"]); + expect(listed.sort()).toEqual(["acme/api", "acme/docs", "acme/web"]); expect(result.entries.map((entry) => [entry.projectId, entry.key])).toEqual([ ["web", "2"], + ["docs", "3"], ["web", "1"], ]); expect(result.nextCursors).toEqual({ "github.com acme/web": "next" }); expect(result.errors).toEqual([{ projectId: "api", projectTitle: "api", message: "boom" }]); expect(result.viewers).toEqual({ "github.com": "julius" }); expect(result.trackers).toEqual([ - expect.objectContaining({ host: "github.com", projectCount: 3, configured: true }), + expect.objectContaining({ host: "github.com", projectCount: 4, configured: true }), expect.objectContaining({ host: "gitlab.com", tracker: "gitlab", @@ -262,6 +276,8 @@ describe("IssueService", () => { cursors: { "github.com acme/web": "next" }, }); expect(listed).toEqual(["acme/web"]); + expect(next.entries.map((entry) => [entry.projectId, entry.key])).toEqual([["web", "4"]]); + expect(next.nextCursors).toEqual({}); expect(next.errors).toEqual([]); }), ); diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index 9dcd6e6184bc..a8ca65f5f6b0 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -5,7 +5,6 @@ import * as Layer from "effect/Layer"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubIssueTracker from "./GitHubIssueTracker.ts"; -/** A detail answer as GitHub's GraphQL API shapes it, with every section populated once. */ const DETAIL_RESPONSE = JSON.stringify({ data: { viewer: { login: "julius" }, @@ -101,11 +100,11 @@ const DETAIL_RESPONSE = JSON.stringify({ }, }); -function tracker(graphql: (query: string, variables: unknown) => string) { +function tracker(graphql: (variables: unknown) => string) { return GitHubIssueTracker.make.pipe( Effect.provide( Layer.mock(GitHubApi.GitHubApi)({ - graphql: ({ query, variables }) => Effect.succeed(graphql(query, variables)), + graphql: ({ variables }) => Effect.succeed(graphql(variables)), }), ), ); @@ -115,10 +114,8 @@ describe("GitHubIssueTracker", () => { it.effect("maps a detail read to the neutral issue shape", () => Effect.gen(function* () { let seen: unknown; - let sentQuery = ""; - const api = yield* tracker((query, variables) => { + const api = yield* tracker((variables) => { seen = variables; - sentQuery = query; return DETAIL_RESPONSE; }); const issue = yield* api.getIssue({ @@ -128,12 +125,11 @@ describe("GitHubIssueTracker", () => { key: "42", }); expect(seen).toEqual({ owner: "acme", name: "web", number: 42 }); - // GitHub rejects a query that selects one field twice with different arguments. - expect(sentQuery.match(/\bcomments\b/g)).toHaveLength(1); expect(issue).toMatchObject({ key: "42", displayKey: "#42", title: "Composer loses focus", + body: "Typing in the composer drops focus after a paste.", state: { category: "closed", name: "closed", closeReason: "not-planned" }, author: { login: "octocat", name: "Octo Cat", avatarUrl: "https://avatars/octocat" }, assignees: [{ login: "theo" }], @@ -174,19 +170,4 @@ describe("GitHubIssueTracker", () => { ]); }), ); - - it.effect("refuses a key that names no GitHub issue number without a request", () => - Effect.gen(function* () { - let requests = 0; - const api = yield* tracker(() => { - requests += 1; - return DETAIL_RESPONSE; - }); - const error = yield* Effect.flip( - api.getIssue({ cwd: "/w", host: "github.com", scope: "acme/web", key: "ENG-42" }), - ); - expect(error.reason).toBe("not-found"); - expect(requests).toBe(0); - }), - ); }); From f0b32e92e6abdb116a52075a2fd85b36165f6a32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 06:56:50 +0000 Subject: [PATCH 09/14] fix(issues): support YAML forms and interactive writes --- apps/server/src/issue/IssueService.test.ts | 63 +++++++- apps/server/src/issue/IssueService.ts | 18 ++- .../src/server/IssueTracker.ts | 8 - packages/source-control-github/package.json | 3 +- .../src/server/GitHubIssueTracker.test.ts | 141 ++++++++++++++-- .../src/server/GitHubIssueTracker.ts | 52 +----- .../src/server/gitHubIssueTemplates.ts | 153 ++++++++++++++++++ .../src/server/issues.graphql | 22 +-- .../src/server/issues.graphql.ts | 95 +++++------ pnpm-lock.yaml | 3 + 10 files changed, 426 insertions(+), 132 deletions(-) create mode 100644 packages/source-control-github/src/server/gitHubIssueTemplates.ts diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 4e09892df4ef..20c9753d5b6e 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -2,8 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; +import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota"; +import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; +import * as GitHubIssueTracker from "@t3tools/source-control-github/server/GitHubIssueTracker"; import { IssueTrackerKind, + SourceControlProviderKind, type OrchestrationProjectShell, type ProjectId, } from "@t3tools/contracts"; @@ -100,7 +108,6 @@ function fakeTracker(overrides: Partial = {}): IssueTrackerApi listIssues: () => Effect.succeed({ items: [], nextCursor: null }), getIssue: (input) => Effect.succeed(issue(input.key)), getComments: () => Effect.succeed({ comments: [], nextCursor: null }), - getViewerPermissions: () => Effect.die("unused"), comment: () => Effect.void, update: () => Effect.void, setState: () => Effect.void, @@ -178,6 +185,60 @@ describe("IssueService", () => { }), ); + it.effect("posts a comment while background requests to GitHub are paused", () => { + const requests: string[] = []; + return Effect.gen(function* () { + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const key = { provider: SourceControlProviderKind.make("github"), host: "github.com" }; + yield* limits + .recordRateLimit({ ...key, lease: 0 }) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, "test")); + const tracker = yield* GitHubIssueTracker.make; + expect( + yield* Effect.flip(tracker.getViewer({ cwd: "/w/web", host: key.host })), + ).toMatchObject({ reason: "rate-limited" }); + const service = yield* makeService({ + projects: [project({ id: "web", repository: "acme/web" })], + trackers: [tracker], + }); + yield* service.comment({ + projectId: "web" as ProjectId, + tracker: GITHUB, + scope: "acme/web", + key: "42", + body: "Repro attached.", + }); + expect(requests).toEqual(["POST https://api.github.com/repos/acme/web/issues/42/comments"]); + }).pipe( + Effect.provide( + GitHubRest.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + SourceControlRateLimit.layer, + GitHubQuota.layer, + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("test-token"), + source: "gh" as const, + fingerprint: "test", + }), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(`${request.method} ${request.url}`); + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}"))); + }), + ), + ), + ), + ), + ), + ); + }); + it.effect("refuses what the tracker did not declare before calling it", () => Effect.gen(function* () { let calls = 0; diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index a4795f0c83d1..b031cf782fa0 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -31,6 +31,7 @@ import { type IssueUpdateInput, type OrchestrationProjectShell, } from "@t3tools/contracts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { IssueTrackerError, @@ -44,6 +45,11 @@ 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)); + /** One project's resolved tracker: which driver, where it is served from, and its container. */ interface ProjectTrackerBinding { readonly tracker: IssueTrackerKind; @@ -536,12 +542,12 @@ const make = Effect.gen(function* () { detail, comments, templates, - candidates, - create, - comment, - update, - setState, - react, + candidates: interactive(candidates), + create: interactive(create), + comment: interactive(comment), + update: interactive(update), + setState: interactive(setState), + react: interactive(react), }); }); diff --git a/packages/issue-tracker-core/src/server/IssueTracker.ts b/packages/issue-tracker-core/src/server/IssueTracker.ts index d376a01c45dd..4913d5e6e73e 100644 --- a/packages/issue-tracker-core/src/server/IssueTracker.ts +++ b/packages/issue-tracker-core/src/server/IssueTracker.ts @@ -122,14 +122,6 @@ export interface IssueTrackerApi { input: IssueTarget & { readonly cursor: string }, ) => Effect.Effect; - /** - * The same permissions `getIssue` carries, asked freshly before a write so access taken away - * since the page loaded is what decides. A tracker with nothing to say makes no request. - */ - readonly getViewerPermissions: ( - input: IssueTarget, - ) => Effect.Effect; - /** Only called when `capabilities.create` is true. */ readonly create?: ( input: IssueScopeTarget & { diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json index c2b595a86793..d213974f40b6 100644 --- a/packages/source-control-github/package.json +++ b/packages/source-control-github/package.json @@ -91,7 +91,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/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index ab687ef9e0f5..b8e0594ad36f 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -133,7 +133,7 @@ const decodeVariables = Schema.decodeUnknownSync( ), ); -function tracker(onRequest: (variables: unknown) => void) { +function tracker(onRequest: (variables: unknown) => void, templateRepository?: unknown) { return GitHubIssueTracker.make.pipe( Effect.provide( GitHubRest.layer.pipe( @@ -158,24 +158,26 @@ function tracker(onRequest: (variables: unknown) => void) { const { variables } = decodeVariables(new TextDecoder().decode(request.body.body)); onRequest(variables); const body = - "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: [], + 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; + }) + : DETAIL_RESPONSE; return Effect.succeed( HttpClientResponse.fromWeb( request, @@ -194,6 +196,113 @@ function tracker(onRequest: (variables: unknown) => void) { } describe("GitHubIssueTracker", () => { + 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 }, + ], + }, + }; + const api = yield* tracker(() => {}, repository); + const input = { cwd: "/w", host: "github.com", scope: "acme/web" }; + const result = yield* api.listTemplates!(input); + 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[] = []; diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts index c75e1b0c83aa..ff564090fab7 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/issue-tracker-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"; @@ -23,8 +24,6 @@ import { toIssueComments, toIssueDetail, toIssueSearch, - toIssueTemplates, - toViewerPermissions, } from "./gitHubIssueJson.ts"; const KIND = IssueTrackerKind.make("github"); @@ -47,7 +46,6 @@ const CAPABILITIES: IssueCapabilities = { closeReasons: ["completed", "not-planned"], }; -const TEMPLATE_CONFIG_PATH = ".github/ISSUE_TEMPLATE/config.yml"; const REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9_.-]+$/; function searchTerms(query: string): ReadonlyArray { @@ -275,20 +273,6 @@ export const make = Effect.gen(function* () { ), ), - getViewerPermissions: (input) => - issue(input, "getViewerPermissions").pipe( - Effect.flatMap(({ owner, name, number }) => - client - .IssueViewerPermissions({ owner, name, number }) - .pipe(graphql(input.host, "getViewerPermissions")), - ), - Effect.flatMap(({ repository }) => - repository?.issue == null - ? Effect.fail(notFound("getViewerPermissions")) - : Effect.succeed(toViewerPermissions(repository)), - ), - ), - create: (input) => repository(input.scope, "create").pipe( Effect.flatMap(({ owner, name }) => @@ -398,35 +382,13 @@ export const make = Effect.gen(function* () { listTemplates: (input) => repository(input.scope, "listTemplates").pipe( Effect.flatMap(({ owner, name }) => - Effect.all( - [ - client.IssueTemplates({ owner, name }).pipe( - graphql(input.host, "listTemplates"), - Effect.flatMap(({ repository }) => - repository === null - ? Effect.fail(notFound("listTemplates")) - : Effect.succeed(toIssueTemplates(repository)), - ), - ), - api - .rest({ - host: input.host, - operation: "listTemplateConfig", - accept: "application/vnd.github.raw", - path: `repos/${owner}/${name}/contents/${TEMPLATE_CONFIG_PATH}`, - }) - .pipe( - Effect.map( - (response) => !/^\s*blank_issues_enabled\s*:\s*false\s*$/m.test(response.body), - ), - Effect.catchTags({ GitHubApiNotFoundError: () => Effect.succeed(true) }), - Effect.mapError(fail("listTemplateConfig")), - ), - ], - { concurrency: 2 }, - ), + client.IssueTemplates({ owner, name }).pipe(graphql(input.host, "listTemplates")), + ), + Effect.flatMap(({ repository }) => + repository === null + ? Effect.fail(notFound("listTemplates")) + : readIssueTemplates(repository), ), - Effect.map(([templates, blankIssuesEnabled]) => ({ templates, blankIssuesEnabled })), ), listCandidates: (input) => diff --git a/packages/source-control-github/src/server/gitHubIssueTemplates.ts b/packages/source-control-github/src/server/gitHubIssueTemplates.ts new file mode 100644 index 000000000000..27f270d54b69 --- /dev/null +++ b/packages/source-control-github/src/server/gitHubIssueTemplates.ts @@ -0,0 +1,153 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { parse as parseYaml } from "yaml"; +import { IssueTrackerKind, type IssueTemplate } from "@t3tools/contracts"; +import { IssueTrackerError } from "@t3tools/issue-tracker-core/server/IssueTracker"; + +import type * as Issues from "./issues.graphql.ts"; +import { toIssueTemplates } from "./gitHubIssueJson.ts"; + +const Names = Schema.Union([Schema.String, Schema.Array(Schema.String)]); +const FormOption = Schema.Union([ + Schema.String, + Schema.Struct({ label: Schema.String, required: Schema.optional(Schema.Boolean) }), +]); +const FormField = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + attributes: Schema.optional( + Schema.Struct({ + label: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + value: Schema.optional(Schema.String), + placeholder: Schema.optional(Schema.String), + render: Schema.optional(Schema.String), + multiple: Schema.optional(Schema.Boolean), + default: Schema.optional(Schema.Union([Schema.Int, Schema.Array(Schema.Int)])), + options: Schema.optional(Schema.Array(FormOption)), + }), + ), + validations: Schema.optional( + Schema.Struct({ + required: Schema.optional(Schema.Boolean), + accept: Schema.optional(Schema.String), + }), + ), +}); +const decodeForm = Schema.decodeUnknownSync( + Schema.Struct({ + name: Schema.String, + description: Schema.String, + title: Schema.optional(Schema.String), + labels: Schema.optional(Names), + assignees: Schema.optional(Names), + body: Schema.Array(FormField), + }), +); +const decodeConfig = Schema.decodeUnknownSync( + Schema.Struct({ blank_issues_enabled: Schema.optional(Schema.Boolean) }), +); + +function names(value: string | ReadonlyArray | undefined): ReadonlyArray { + return (typeof value === "string" ? value.split(",") : (value ?? [])) + .map((name) => name.trim()) + .filter((name) => name.length > 0); +} + +function fieldMarkdown(field: typeof FormField.Type): string { + const attributes = field.attributes; + if (field.type === "markdown") return attributes?.value ?? ""; + + const sections = [`### ${attributes?.label ?? field.id ?? field.type}`]; + if (attributes?.description) sections.push(attributes.description); + if (field.validations?.required) sections.push("Required."); + if (attributes?.placeholder) + sections.push(`Example:\n> ${attributes.placeholder.trim().replaceAll("\n", "\n> ")}`); + if (attributes?.render) sections.push(`Format: ${attributes.render}`); + if (field.validations?.accept) sections.push(`Accepted files: ${field.validations.accept}`); + + const options = attributes?.options ?? []; + if (field.type === "checkboxes") { + sections.push( + options + .map((option) => { + const label = typeof option === "string" ? option : option.label; + const required = typeof option !== "string" && option.required; + return `- [ ] ${label}${required ? " (required)" : ""}`; + }) + .join("\n"), + ); + } else if (field.type === "dropdown") { + const defaults = attributes?.default; + const defaultIndices = typeof defaults === "number" ? [defaults] : (defaults ?? []); + sections.push(attributes?.multiple ? "Choose one or more:" : "Choose one:"); + sections.push( + options + .map((option, index) => { + const label = typeof option === "string" ? option : option.label; + return `- ${label}${defaultIndices.includes(index) ? " (default)" : ""}`; + }) + .join("\n"), + ); + } + if (attributes?.value) sections.push(attributes.value); + return sections.filter(Boolean).join("\n\n"); +} + +export const readIssueTemplates = Effect.fnUntraced(function* ( + repository: NonNullable, +) { + const templates = new Map( + toIssueTemplates(repository).map((template) => [ + template.key.slice(template.key.lastIndexOf("/") + 1), + template, + ]), + ); + let blankIssuesEnabled = true; + const directory = repository.templateDirectory; + for (const entry of directory?.__typename === "Tree" ? (directory.entries ?? []) : []) { + if (!/\.ya?ml$/i.test(entry.name)) continue; + const blob = entry.object; + if (blob?.__typename !== "Blob" || blob.isTruncated || blob.text === null) { + return yield* new IssueTrackerError({ + tracker: IssueTrackerKind.make("github"), + operation: "listTemplates", + reason: "failed", + detail: "GitHub returned incomplete issue template contents.", + }); + } + const text = blob.text; + const parsed = yield* Effect.try({ + try: () => { + const document: unknown = parseYaml(text); + if (/^config\.ya?ml$/i.test(entry.name)) { + return { config: decodeConfig(document ?? {}) }; + } + return { form: decodeForm(document) }; + }, + catch: (cause) => + new IssueTrackerError({ + tracker: IssueTrackerKind.make("github"), + operation: "listTemplates", + reason: "failed", + detail: "The repository has an unreadable issue template or configuration.", + cause, + }), + }); + if (parsed.config !== undefined) { + blankIssuesEnabled = parsed.config.blank_issues_enabled ?? true; + } else if (parsed.form !== undefined) { + const form = parsed.form; + templates.set(entry.name, { + key: entry.name, + name: form.name, + about: form.description, + title: form.title ?? "", + labels: names(form.labels), + assignees: names(form.assignees), + body: form.body.map(fieldMarkdown).filter(Boolean).join("\n\n"), + }); + } + } + return { templates: [...templates.values()], blankIssuesEnabled }; +}); diff --git a/packages/source-control-github/src/server/issues.graphql b/packages/source-control-github/src/server/issues.graphql index 4e343dc04aee..d68597d3de77 100644 --- a/packages/source-control-github/src/server/issues.graphql +++ b/packages/source-control-github/src/server/issues.graphql @@ -264,15 +264,6 @@ query IssueComments($owner: String!, $name: String!, $number: Int!, $cursor: Str } } -query IssueViewerPermissions($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - viewerPermission - issue(number: $number) { - viewerCanUpdate - } - } -} - query IssueNodeId($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { @@ -323,6 +314,19 @@ query IssueCandidates($owner: String!, $name: String!) { query IssueTemplates($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { + templateDirectory: object(expression: "HEAD:.github/ISSUE_TEMPLATE") { + ... on Tree { + entries { + name + object { + ... on Blob { + text + isTruncated + } + } + } + } + } issueTemplates { filename name diff --git a/packages/source-control-github/src/server/issues.graphql.ts b/packages/source-control-github/src/server/issues.graphql.ts index b52119fa1c3c..8d942e5e50b1 100644 --- a/packages/source-control-github/src/server/issues.graphql.ts +++ b/packages/source-control-github/src/server/issues.graphql.ts @@ -660,50 +660,6 @@ export declare namespace IssueComments { export type Result = typeof IssueComments.result.Type } -/** An Issue is a place to discuss ideas, enhancements, tasks, and bugs for a project. */ -export const IssueViewerPermissionsRepositoryIssueIssue = Schema.Struct({ - /** Check if the current viewer can update this object. */ - viewerCanUpdate: Schema.Boolean -}) -export interface IssueViewerPermissionsRepositoryIssueIssue extends Schema.Struct.Type {} - -/** A repository contains the content for a project. */ -export const IssueViewerPermissionsRepositoryRepository = Schema.Struct({ - /** The users permission level on the repository. Will return null if authenticated as an GitHub App. */ - viewerPermission: Schema.NullOr(GraphQL.enumLiterals(Shared.RepositoryPermission.literals)), - /** Returns a single issue from the current repository by number. */ - issue: Schema.NullOr(IssueViewerPermissionsRepositoryIssueIssue) -}) -export interface IssueViewerPermissionsRepositoryRepository extends Schema.Struct.Type {} - -/** `query IssueViewerPermissions` */ -export const IssueViewerPermissions = GraphQL.query("IssueViewerPermissions", { - document: { - variables: [ - { name: "owner", body: "String!" }, - { name: "name", body: "String!" }, - { name: "number", body: "Int!" } - ], - selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){viewerPermission issue(number:$", "number", "){viewerCanUpdate}}"] } - ], - fragments: [] - }, - variables: { - owner: Schema.String, - name: Schema.String, - number: Shared.Int - }, - result: Schema.Struct({ - /** Lookup a given repository by the owner and repository name. */ - repository: Schema.NullOr(IssueViewerPermissionsRepositoryRepository) - }) -}) -export declare namespace IssueViewerPermissions { - export type Variables = typeof IssueViewerPermissions.variables.Type - export type Result = typeof IssueViewerPermissions.result.Type -} - /** An Issue is a place to discuss ideas, enhancements, tasks, and bugs for a project. */ export const IssueNodeIdRepositoryIssueIssue = Schema.Struct({ /** The Node ID of the Issue object */ @@ -867,6 +823,48 @@ export declare namespace IssueCandidates { export type Result = typeof IssueCandidates.result.Type } +/** Represents a Git blob. */ +export const Blob = Schema.Struct({ + __typename: Schema.Literal("Blob"), + /** UTF8 text data or null if the Blob is binary */ + text: Schema.NullOr(Schema.String), + /** Indicates whether the contents is truncated */ + isTruncated: Schema.Boolean +}) +export interface Blob extends Schema.Struct.Type {} + +/** Represents a Git object. */ +export const ObjectGitObjectOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["Blob"]) +}) +export interface ObjectGitObjectOther extends Schema.Struct.Type {} + +/** Represents a Git tree entry. */ +export const TreeEntry = Schema.Struct({ + /** Entry file name. */ + name: Schema.String, + /** Entry file object. */ + object: Schema.NullOr(Schema.Union([ + Blob, + ObjectGitObjectOther + ])) +}) +export interface TreeEntry extends Schema.Struct.Type {} + +/** Represents a Git tree. */ +export const Tree = Schema.Struct({ + __typename: Schema.Literal("Tree"), + /** A list of tree entries. */ + entries: Schema.NullOr(Schema.Array(TreeEntry)) +}) +export interface Tree extends Schema.Struct.Type {} + +/** Represents a Git object. */ +export const TemplateDirectoryGitObjectOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["Tree"]) +}) +export interface TemplateDirectoryGitObjectOther extends Schema.Struct.Type {} + /** A list of users. */ export const IssueTemplatesAssigneesUserConnection = Schema.Struct({ /** A list of nodes. */ @@ -902,6 +900,11 @@ export interface IssueTemplate extends Schema.Struct.Type Date: Mon, 19 Oct 2026 07:04:37 +0000 Subject: [PATCH 10/14] fix(issues): keep valid templates when a form is malformed --- .../src/server/GitHubIssueTracker.test.ts | 4 ++++ .../src/server/gitHubIssueTemplates.ts | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index b8e0594ad36f..d04964106bab 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -250,6 +250,10 @@ body: entries: [ { name: "config.yml", object: config }, { name: "bug_report.yml", object: form }, + { + name: "invalid.yml", + object: { ...form, text: "name: Missing required metadata\nbody: []" }, + }, ], }, }; diff --git a/packages/source-control-github/src/server/gitHubIssueTemplates.ts b/packages/source-control-github/src/server/gitHubIssueTemplates.ts index 27f270d54b69..b7729ab78cdc 100644 --- a/packages/source-control-github/src/server/gitHubIssueTemplates.ts +++ b/packages/source-control-github/src/server/gitHubIssueTemplates.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { parse as parseYaml } from "yaml"; import { IssueTrackerKind, type IssueTemplate } from "@t3tools/contracts"; @@ -117,10 +118,11 @@ export const readIssueTemplates = Effect.fnUntraced(function* ( }); } const text = blob.text; + const isConfig = /^config\.ya?ml$/i.test(entry.name); const parsed = yield* Effect.try({ try: () => { const document: unknown = parseYaml(text); - if (/^config\.ya?ml$/i.test(entry.name)) { + if (isConfig) { return { config: decodeConfig(document ?? {}) }; } return { form: decodeForm(document) }; @@ -133,11 +135,15 @@ export const readIssueTemplates = Effect.fnUntraced(function* ( detail: "The repository has an unreadable issue template or configuration.", cause, }), - }); - if (parsed.config !== undefined) { - blankIssuesEnabled = parsed.config.blank_issues_enabled ?? true; - } else if (parsed.form !== undefined) { - const form = parsed.form; + }).pipe(Effect.result); + if (Result.isFailure(parsed)) { + if (isConfig) return yield* parsed.failure; + continue; + } + if (parsed.success.config !== undefined) { + blankIssuesEnabled = parsed.success.config.blank_issues_enabled ?? true; + } else if (parsed.success.form !== undefined) { + const form = parsed.success.form; templates.set(entry.name, { key: entry.name, name: form.name, From 53554e6e59e47ea05c2195de777cd02fc0856ad5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:05:21 +0000 Subject: [PATCH 11/14] fix(issues): preserve continuation cursors for retry --- apps/server/src/issue/IssueService.test.ts | 19 +++++++++++++++++-- apps/server/src/issue/IssueService.ts | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 20c9753d5b6e..70ee85cc02e5 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -277,6 +277,7 @@ describe("IssueService", () => { 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" }), @@ -305,6 +306,15 @@ describe("IssueService", () => { }); 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 { @@ -338,11 +348,16 @@ describe("IssueService", () => { // A continuation only reads the scopes it names. listed.length = 0; - const next = yield* service.list({ + const failed = yield* service.list({ state: "open", - cursors: { "github.com acme/web": "next" }, + 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 index b031cf782fa0..a6e6ee3c036f 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -305,6 +305,7 @@ const make = Effect.gen(function* () { Effect.result, ); if (page._tag === "Failure") { + if (cursor !== undefined) nextCursors[key] = cursor; recordFailure(project, page.failure); errors.push({ projectId: project.project.id, From d95de6570fb1dd4c3c4c8161f82ae6b74725f70b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:33:13 +0000 Subject: [PATCH 12/14] fix(issues): verify GitHub issue targets before writing --- apps/server/src/issue/IssueService.test.ts | 15 +- .../src/server/GitHubIssueTracker.test.ts | 228 ++++++++++++++---- .../src/server/GitHubIssueTracker.ts | 39 ++- .../src/server/gitHubIssueJson.ts | 2 +- 4 files changed, 220 insertions(+), 64 deletions(-) diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 70ee85cc02e5..89462a4b3fba 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -208,7 +208,10 @@ describe("IssueService", () => { key: "42", body: "Repro attached.", }); - expect(requests).toEqual(["POST https://api.github.com/repos/acme/web/issues/42/comments"]); + expect(requests).toEqual([ + "POST https://api.github.com/graphql", + "POST https://api.github.com/repos/acme/web/issues/42/comments", + ]); }).pipe( Effect.provide( GitHubRest.layer.pipe( @@ -229,7 +232,15 @@ describe("IssueService", () => { HttpClient.HttpClient, HttpClient.make((request) => { requests.push(`${request.method} ${request.url}`); - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}"))); + const body = request.url.endsWith("/graphql") + ? JSON.stringify({ data: { repository: { issue: { id: "I_42" } } } }) + : "{}"; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body, { headers: { "content-type": "application/json" } }), + ), + ); }), ), ), diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index d04964106bab..783d8eb45877 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -128,74 +128,200 @@ const DETAIL_RESPONSE = JSON.stringify({ const decodeVariables = Schema.decodeUnknownSync( Schema.fromJsonString( Schema.Struct({ + query: Schema.String, variables: Schema.Record(Schema.String, Schema.Unknown), }), ), ); +function transport(httpClient: HttpClient.HttpClient) { + return GitHubRest.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + GitHubQuota.layer, + SourceControlRateLimit.layer, + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("test-token"), + source: "gh" as const, + fingerprint: "test", + }), + }), + Layer.succeed(HttpClient.HttpClient, httpClient), + ), + ), + ); +} + function tracker(onRequest: (variables: unknown) => void, templateRepository?: unknown) { return GitHubIssueTracker.make.pipe( Effect.provide( - GitHubRest.layer.pipe( - Layer.provideMerge( - Layer.mergeAll( - GitHubQuota.layer, - SourceControlRateLimit.layer, - Layer.mock(GitHubCredentials.GitHubCredentials)({ - get: (host) => - Effect.succeed({ - host, - token: Redacted.make("test-token"), - source: "gh" as const, - fingerprint: "test", - }), - }), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - 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: [], - }, - }, - }, + 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" }], }, - }) - : DETAIL_RESPONSE; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(body, { - headers: { "content-type": "application/json" }, - }), - ), - ); + assignees: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + }, + }, + }, + }) + : DETAIL_RESPONSE; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body, { + headers: { "content-type": "application/json" }, }), ), - ), - ), + ); + }), ), ), ); } +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 = { diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.ts b/packages/source-control-github/src/server/GitHubIssueTracker.ts index ff564090fab7..258fcedc115c 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.ts @@ -105,6 +105,14 @@ function reasonFor( } } +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), @@ -123,7 +131,7 @@ export const make = Effect.gen(function* () { reason: reasonFor(error), detail: GraphQLClientError.isGraphQLClientError(error) ? `GitHub returned an unreadable ${operation} response.` - : error.message, + : FAILURE_DETAIL[reasonFor(error)], ...("retryAt" in error && error.retryAt !== undefined ? { retryAt: error.retryAt } : {}), cause: error, }); @@ -161,12 +169,27 @@ export const make = Effect.gen(function* () { ) => effect.pipe(GitHubGraphQlClient.call({ host, operation }), Effect.mapError(fail(operation))); + const verifiedIssue = Effect.fnUntraced(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, ) => - issue(input, operation).pipe( + verifiedIssue(input, operation).pipe( Effect.flatMap(({ path }) => api .rest({ host: input.host, operation, method: "PATCH", path, body }) @@ -312,7 +335,7 @@ export const make = Effect.gen(function* () { ), comment: (input) => - issue(input, "comment").pipe( + verifiedIssue(input, "comment").pipe( Effect.flatMap(({ path }) => api .rest({ @@ -344,16 +367,12 @@ export const make = Effect.gen(function* () { setReaction: (input) => Effect.gen(function* () { - const { owner, name, number } = yield* issue(input, "setReaction"); let subjectId: string; if (input.subjectId === undefined) { - const result = yield* client - .IssueNodeId({ owner, name, number }) - .pipe(graphql(input.host, "setReaction")); - const id = result.repository?.issue?.id; - if (id === undefined) return yield* notFound("setReaction"); - subjectId = id; + 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 }) diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts index c3abbb1a146e..55a56ee255d8 100644 --- a/packages/source-control-github/src/server/gitHubIssueJson.ts +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -194,7 +194,7 @@ function previousCursorOf( return pageInfo?.hasPreviousPage === true ? pageInfo.startCursor : null; } -export function toViewerPermissions(raw: { +function toViewerPermissions(raw: { readonly viewerPermission?: string | null | undefined; readonly issue: { readonly viewerCanUpdate: boolean } | null; }): IssueViewerPermissions { From b8fb68b1e1116df2d3e335bb9001202a1dcc7825 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:41:40 +0000 Subject: [PATCH 13/14] fix(server): deduplicate issue lists by canonical repository --- apps/server/src/issue/IssueService.test.ts | 15 ++++++++++----- apps/server/src/issue/IssueService.ts | 10 ++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index 89462a4b3fba..91aff9826f9c 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -21,6 +21,8 @@ import { type TrackerIssueDetail, } from "@t3tools/issue-tracker-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"; @@ -42,7 +44,7 @@ function project(input: { ...(input.repository ? { repositoryIdentity: { - canonicalKey: `${host}/${input.repository}`, + canonicalKey: normalizeGitRemoteUrl(`https://${host}/${input.repository}.git`), locator: { source: "git-remote" as const, remoteName: "origin", @@ -291,7 +293,7 @@ describe("IssueService", () => { let failContinuation = true; const service = yield* makeService({ projects: [ - project({ id: "web", repository: "acme/web" }), + 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" }), @@ -337,13 +339,16 @@ describe("IssueService", () => { ], }); const result = yield* service.list({ state: "open" }); - expect(listed.sort()).toEqual(["acme/api", "acme/docs", "acme/web"]); + 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.nextCursors).toEqual({ "github.com acme/web": "next" }); + 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: "boom" }]); expect(result.viewers).toEqual({ "github.com": "julius" }); expect(result.trackers).toEqual([ @@ -363,7 +368,7 @@ describe("IssueService", () => { state: "open", cursors: result.nextCursors, }); - expect(listed).toEqual(["acme/web"]); + expect(listed).toEqual(["Acme/Web"]); expect(failed.nextCursors).toEqual(result.nextCursors); expect(failed.errors).toEqual([ { projectId: "web", projectTitle: "web", message: "try again" }, diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index a6e6ee3c036f..f90635b53101 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -52,6 +52,7 @@ const interactive = /** 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; @@ -73,6 +74,7 @@ function resolveGitHostBinding(project: OrchestrationProjectShell): ProjectTrack const kind = identity?.provider; if (!identity || kind === undefined || kind === "unknown" || scope === null) return null; return { + canonicalKey: identity.canonicalKey, tracker: IssueTrackerKind.make(kind), host: pullRequestHostOf(identity, SourceControlProviderKind.make(kind)), scope, @@ -237,8 +239,7 @@ const make = Effect.gen(function* () { // Worktrees of one repository are separate projects; read each scope once. const scopes = new Map(); for (const project of supported) { - const key = listCursorKey(project.host, project.scope); - if (!scopes.has(key)) scopes.set(key, project); + if (!scopes.has(project.canonicalKey)) scopes.set(project.canonicalKey, project); } const continuing = input.cursors; const viewers = new Map(); @@ -282,9 +283,10 @@ const make = Effect.gen(function* () { }; yield* Effect.forEach( - scopes, - ([key, project]) => + 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]; From b4f989d02b6417d89ea047ba38290ae6a9054772 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:49:52 +0000 Subject: [PATCH 14/14] fix(issues): preserve duplicate reasons and skip blank form names --- .../src/server/GitHubIssueTracker.test.ts | 16 ++++++++++++++-- .../src/server/gitHubIssueJson.ts | 6 ++++-- .../src/server/gitHubIssueTemplates.ts | 4 ++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts index 783d8eb45877..70b0133374df 100644 --- a/packages/source-control-github/src/server/GitHubIssueTracker.test.ts +++ b/packages/source-control-github/src/server/GitHubIssueTracker.test.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/http"; +import { IssueTemplateList } from "@t3tools/contracts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; @@ -28,7 +29,7 @@ const DETAIL_RESPONSE = JSON.stringify({ name: "Octo Cat", }, state: "CLOSED", - stateReason: "NOT_PLANNED", + stateReason: "DUPLICATE", createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z", closedAt: "2026-07-03T00:00:00Z", @@ -199,6 +200,8 @@ function tracker(onRequest: (variables: unknown) => void, templateRepository?: u ); } +const encodeTemplateList = Schema.encodeSync(IssueTemplateList); + const decodeBody = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); @@ -376,6 +379,14 @@ body: 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: []" }, @@ -386,6 +397,7 @@ body: 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({ @@ -460,7 +472,7 @@ Redact secrets before uploading.`); displayKey: "#42", title: "Composer loses focus", body: "Typing in the composer drops focus after a paste.", - state: { category: "closed", name: "closed", closeReason: "not-planned" }, + state: { category: "closed", name: "closed", closeReason: "duplicate" }, author: { login: "octocat", name: "Octo Cat", diff --git a/packages/source-control-github/src/server/gitHubIssueJson.ts b/packages/source-control-github/src/server/gitHubIssueJson.ts index 55a56ee255d8..7f6ac8d2a5c3 100644 --- a/packages/source-control-github/src/server/gitHubIssueJson.ts +++ b/packages/source-control-github/src/server/gitHubIssueJson.ts @@ -58,7 +58,7 @@ function toLabels(raw: ReadonlyArray