diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..3714ba5f52ec 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -54,6 +54,13 @@ describe("RPC authorization scopes", () => { ); }); + it("reads GitHub issues without granting mutation access", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.githubIssuesList)).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForRpcMethod(WS_METHODS.githubIssuesDetail)).toBe( + AuthOrchestrationReadScope, + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..30ab0158ef5c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -73,6 +73,8 @@ export const RPC_REQUIRED_SCOPES = { // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.githubIssuesList]: AuthOrchestrationReadScope, + [WS_METHODS.githubIssuesDetail]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index b9a50ca8335e..3e13534cab58 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.pullRequests).toBe(true); + expect(second.capabilities.githubIssues).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index e55639ce659c..2bd213486886 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -148,6 +148,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, attachmentUploads: true, pullRequests: true, + githubIssues: true, threadSettlement: true, threadSnooze: true, threadPinning: true, diff --git a/apps/server/src/githubIssue/GitHubIssueService.test.ts b/apps/server/src/githubIssue/GitHubIssueService.test.ts new file mode 100644 index 000000000000..5295a8717508 --- /dev/null +++ b/apps/server/src/githubIssue/GitHubIssueService.test.ts @@ -0,0 +1,297 @@ +import { assert, it, vi } from "@effect/vitest"; +import type { OrchestrationProjectShell, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubIssueService from "./GitHubIssueService.ts"; + +function project(input: { + id: string; + title: string; + workspaceRoot: string; + repository: string; + provider?: string; + host?: string; +}): OrchestrationProjectShell { + const host = input.host ?? "github.com"; + return { + id: input.id as ProjectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + repositoryIdentity: { + canonicalKey: `${host}/${input.repository}`, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://${host}/${input.repository}.git`, + }, + provider: input.provider ?? "github", + displayName: input.repository, + }, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-20T00:00:00Z", + }; +} + +function output(stdout: string) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +function issue(number: number, updatedAt = "2026-08-21T00:00:00Z") { + return { + number, + title: `Issue ${number}`, + url: `https://github.com/acme/web/issues/${number}`, + author: { login: "octocat", name: null }, + assignees: [], + labels: [], + state: "OPEN", + createdAt: "2026-08-20T00:00:00Z", + updatedAt, + }; +} + +function makeService( + projects: ReadonlyArray, + execute: GitHubCli.GitHubCli["Service"]["execute"], +) { + return GitHubIssueService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubCli.GitHubCli)({ execute }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects, + threads: [], + updatedAt: "2026-08-21T00:00:00Z", + }), + }), + ), + ), + ); +} + +it.effect("lists GitHub issues for local projects and forwards filters", () => + Effect.gen(function* () { + const execute = vi.fn(() => + Effect.succeed(output(JSON.stringify([issue(2), issue(1)]))), + ); + const service = yield* makeService( + [ + project({ id: "p1", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + project({ + id: "p2", + title: "gitlab", + workspaceRoot: "/other", + repository: "acme/other", + provider: "gitlab", + }), + ], + execute, + ); + + const result = yield* service.list({ state: "open", query: "websocket", limit: 1 }); + + assert.strictEqual(execute.mock.calls.length, 1); + assert.deepStrictEqual(execute.mock.calls[0]?.[0].args, [ + "issue", + "list", + "--repo", + "acme/web", + "--state", + "open", + "--limit", + "2", + "--json", + "number,title,url,author,assignees,labels,state,createdAt,updatedAt", + "--search", + "websocket", + ]); + assert.strictEqual(result.entries[0]?.number, 2); + assert.strictEqual(result.entries[0]?.projectId, "p1"); + assert.strictEqual(result.truncated, true); + }), +); + +it.effect("applies the result limit across repositories after sorting", () => + Effect.gen(function* () { + const execute = vi.fn((input) => + Effect.succeed( + output( + JSON.stringify( + input.cwd === "/web" + ? [issue(1, "2026-08-21T01:00:00Z")] + : [issue(2, "2026-08-21T02:00:00Z")], + ), + ), + ), + ); + const service = yield* makeService( + [ + project({ id: "p1", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + project({ id: "p2", title: "api", workspaceRoot: "/api", repository: "acme/api" }), + ], + execute, + ); + + const result = yield* service.list({ state: "all", limit: 1 }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [2], + ); + assert.strictEqual(result.truncated, true); + }), +); + +it.effect("loads issue detail with its discussion and workspace", () => + Effect.gen(function* () { + const execute = vi.fn(() => + Effect.succeed( + output( + JSON.stringify({ + ...issue(42), + body: "Visible issue body", + closedAt: null, + comments: [ + { + id: "comment-1", + author: { login: "reviewer", name: null }, + body: "Please fix this.", + createdAt: "2026-08-21T01:00:00Z", + url: "https://github.com/acme/web/issues/42#issuecomment-1", + }, + ], + }), + ), + ), + ); + const service = yield* makeService( + [project({ id: "p1", title: "web", workspaceRoot: "/web", repository: "acme/web" })], + execute, + ); + + const detail = yield* service.detail({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 42, + }); + + assert.strictEqual(detail.workspaceRoot, "/web"); + assert.strictEqual(detail.body, "Visible issue body"); + assert.strictEqual(detail.commentCount, 1); + assert.strictEqual(detail.comments[0]?.author?.login, "reviewer"); + }), +); + +it.effect("keeps issues from signed-in hosts when one remote is unauthenticated", () => + Effect.gen(function* () { + const execute = vi.fn((input) => + input.cwd === "/enterprise" + ? Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: input.cwd, + cause: new Error("gh auth login"), + }), + ) + : Effect.succeed(output(JSON.stringify([issue(7)]))), + ); + const service = yield* makeService( + [ + project({ id: "p1", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + project({ + id: "p2", + title: "internal", + workspaceRoot: "/enterprise", + repository: "acme/internal", + host: "ghe.acme.dev", + }), + ], + execute, + ); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [7], + ); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.errors[0]?.projectId, "p2"); + // The host is named because it is the one the reader has to sign in to. + assert.include(result.errors[0]?.message ?? "", "ghe.acme.dev"); + }), +); + +it.effect("returns host-scoped errors when every host is unauthenticated", () => + Effect.gen(function* () { + const execute = vi.fn((input) => + Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: input.cwd, + cause: new Error("gh auth login"), + }), + ), + ); + const service = yield* makeService( + [ + project({ + id: "p1", + title: "internal", + workspaceRoot: "/enterprise", + repository: "acme/internal", + host: "ghe.acme.dev", + }), + ], + execute, + ); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(result.entries, []); + assert.strictEqual(result.errors.length, 1); + assert.include(result.errors[0]?.message ?? "", "gh auth login --hostname ghe.acme.dev"); + }), +); + +it.effect("fails the whole read when the GitHub CLI is missing", () => + Effect.gen(function* () { + const execute = vi.fn((input) => + input.cwd === "/web" + ? Effect.fail( + new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: input.cwd, + cause: new Error("spawn gh ENOENT"), + }), + ) + : Effect.succeed(output(JSON.stringify([issue(9)]))), + ); + const service = yield* makeService( + [ + project({ id: "p1", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + project({ id: "p2", title: "api", workspaceRoot: "/api", repository: "acme/api" }), + ], + execute, + ); + + const error = yield* service.list({ state: "open" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubIssueCliMissingError"); + }), +); diff --git a/apps/server/src/githubIssue/GitHubIssueService.ts b/apps/server/src/githubIssue/GitHubIssueService.ts new file mode 100644 index 000000000000..c10ffa45de43 --- /dev/null +++ b/apps/server/src/githubIssue/GitHubIssueService.ts @@ -0,0 +1,259 @@ +import type { + GitHubIssueCliMissingError, + GitHubIssueCliUnauthenticatedError, + GitHubIssueDetail, + GitHubIssueListEntry, + GitHubIssueListInput, + GitHubIssueListResult, + GitHubIssueOperationError, + GitHubIssueRef, + OrchestrationProjectShell, +} from "@t3tools/contracts"; +import { + GitHubIssueCliMissingError as GitHubIssueCliMissingErrorClass, + GitHubIssueCliUnauthenticatedError as GitHubIssueCliUnauthenticatedErrorClass, + GitHubIssueOperationError as GitHubIssueOperationErrorClass, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { decodeGitHubIssueDetail, decodeGitHubIssueList } from "./gitHubIssueJson.ts"; + +const DEFAULT_LIMIT = 50; +const PROJECT_CONCURRENCY = 8; +const ISSUE_LIST_FIELDS = "number,title,url,author,assignees,labels,state,createdAt,updatedAt"; +const ISSUE_DETAIL_FIELDS = `${ISSUE_LIST_FIELDS},body,comments,closedAt`; + +/** Every project reads through the one `gh`; a missing CLI ends the whole request. */ +type GitHubIssueCliError = GitHubIssueCliMissingError | GitHubIssueCliUnauthenticatedError; + +type GitHubIssueError = GitHubIssueCliError | GitHubIssueOperationError; + +interface GitHubProject { + readonly project: OrchestrationProjectShell; + readonly repository: string; + readonly host: string; +} + +export class GitHubIssueService extends Context.Service< + GitHubIssueService, + { + readonly list: ( + input: GitHubIssueListInput, + ) => Effect.Effect; + readonly detail: (input: GitHubIssueRef) => Effect.Effect; + } +>()("t3/githubIssue/GitHubIssueService") {} + +function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { + const identity = project.repositoryIdentity; + if (!identity) return null; + if (identity.displayName) return identity.displayName; + return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; +} + +function repositoryHostOf(project: OrchestrationProjectShell): string { + return ( + project.repositoryIdentity?.canonicalKey?.split("/")[0]?.trim().toLowerCase() || "github.com" + ); +} + +function cliRepository(project: GitHubProject): string { + return project.host === "github.com" + ? project.repository + : `${project.host}/${project.repository}`; +} + +function authCommandForHost(host: string): string { + return host === "github.com" ? "gh auth login" : `gh auth login --hostname ${host}`; +} + +function fromCliError(operation: string, host: string) { + return (error: GitHubCli.GitHubCliError): GitHubIssueError => { + if (error._tag === "GitHubCliUnavailableError") { + return new GitHubIssueCliMissingErrorClass({ cause: error }); + } + if (error._tag === "GitHubCliAuthenticationError") { + return new GitHubIssueCliUnauthenticatedErrorClass({ host, cause: error }); + } + return new GitHubIssueOperationErrorClass({ operation, detail: error.detail, cause: error }); + }; +} + +/** + * How one repository's own failure reads beside the ones that answered. An unauthenticated host + * is named with the command that fixes it, since a workspace spanning github.com and an + * Enterprise install is signed in to each separately. + */ +function decodeError(operation: string, cause: unknown): GitHubIssueOperationError { + return new GitHubIssueOperationErrorClass({ + operation, + detail: "GitHub CLI returned unreadable issue data.", + cause, + }); +} + +export const make = Effect.gen(function* () { + const cli = yield* GitHubCli.GitHubCli; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + + const workspaceProjects = Effect.fn("GitHubIssueService.workspaceProjects")(function* ( + projectId?: GitHubIssueListInput["projectId"], + ) { + const snapshot = yield* projections.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new GitHubIssueOperationErrorClass({ + operation: "listProjects", + detail: "The project list could not be read.", + cause, + }), + ), + ); + const seen = new Set(); + const projects: GitHubProject[] = []; + for (const project of snapshot.projects) { + if (projectId !== undefined && project.id !== projectId) continue; + if (project.repositoryIdentity?.provider !== "github") continue; + const repository = repositoryIdentityOf(project); + if (repository === null) continue; + const host = repositoryHostOf(project); + const key = `${host}/${repository}`.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + projects.push({ project, repository, host }); + } + return projects; + }); + + const list: GitHubIssueService["Service"]["list"] = Effect.fn("GitHubIssueService.list")( + function* (input) { + const projects = yield* workspaceProjects(input.projectId); + const limit = input.limit ?? DEFAULT_LIMIT; + const batches = yield* Effect.forEach( + projects, + (project) => + cli + .execute({ + cwd: project.project.workspaceRoot, + args: [ + "issue", + "list", + "--repo", + cliRepository(project), + "--state", + input.state, + "--limit", + String(limit + 1), + "--json", + ISSUE_LIST_FIELDS, + ...(input.query === undefined ? [] : ["--search", input.query]), + ], + }) + .pipe( + Effect.mapError(fromCliError("list", project.host)), + Effect.flatMap((output) => + decodeGitHubIssueList(output.stdout).pipe( + Effect.mapError((cause) => decodeError("list", cause)), + ), + ), + Effect.map((issues) => ({ project, issues })), + Effect.match({ + onFailure: (error) => ({ project, error }), + onSuccess: (value) => value, + }), + ), + { concurrency: PROJECT_CONCURRENCY }, + ); + + // A missing `gh` is the one failure no repository can survive, so it ends the request. + const cliMissing = batches.find( + (batch) => "error" in batch && batch.error._tag === "GitHubIssueCliMissingError", + ); + if (cliMissing && "error" in cliMissing) return yield* cliMissing.error; + + const entries: GitHubIssueListEntry[] = []; + const errors: GitHubIssueListResult["errors"][number][] = []; + let truncated = false; + for (const batch of batches) { + if ("error" in batch) { + errors.push({ + projectId: batch.project.project.id, + projectTitle: batch.project.project.title, + // The failing host is named here because it is the one the reader has to sign in to. + message: + batch.error._tag === "GitHubIssueCliUnauthenticatedError" + ? `${batch.project.repository} needs GitHub CLI authentication. Run \`${authCommandForHost(batch.project.host)}\` and retry.` + : `${batch.project.repository} could not be read.`, + }); + continue; + } + truncated ||= batch.issues.length > limit; + for (const issue of batch.issues.slice(0, limit)) { + entries.push({ + ...issue, + projectId: batch.project.project.id, + projectTitle: batch.project.project.title, + repository: batch.project.repository, + }); + } + } + const sortedEntries = entries.toSorted((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + ); + truncated ||= sortedEntries.length > limit; + return { + entries: sortedEntries.slice(0, limit), + errors, + truncated, + }; + }, + ); + + const detail: GitHubIssueService["Service"]["detail"] = Effect.fn("GitHubIssueService.detail")( + function* (input) { + const projects = yield* workspaceProjects(input.projectId); + const project = projects.find( + (candidate) => candidate.repository.toLowerCase() === input.repository.toLowerCase(), + ); + if (project === undefined) { + return yield* new GitHubIssueOperationErrorClass({ + operation: "detail", + detail: "This issue does not belong to the selected project.", + }); + } + const output = yield* cli + .execute({ + cwd: project.project.workspaceRoot, + args: [ + "issue", + "view", + String(input.number), + "--repo", + cliRepository(project), + "--json", + ISSUE_DETAIL_FIELDS, + ], + }) + .pipe(Effect.mapError(fromCliError("detail", project.host))); + const issue = yield* decodeGitHubIssueDetail(output.stdout).pipe( + Effect.mapError((cause) => decodeError("detail", cause)), + ); + return { + ...issue, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + commentCount: issue.comments.length, + }; + }, + ); + + return GitHubIssueService.of({ list, detail }); +}); + +export const layer = Layer.effect(GitHubIssueService, make); diff --git a/apps/server/src/githubIssue/gitHubIssueJson.test.ts b/apps/server/src/githubIssue/gitHubIssueJson.test.ts new file mode 100644 index 000000000000..2833b657ceeb --- /dev/null +++ b/apps/server/src/githubIssue/gitHubIssueJson.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { decodeGitHubIssueDetail, decodeGitHubIssueList } from "./gitHubIssueJson.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const rawIssue = { + number: 42, + title: "Support GitHub issues", + url: "https://github.com/t3tools/t3code/issues/42", + author: { login: "octocat", name: "Octo Cat" }, + assignees: [{ login: "maintainer", name: null }], + labels: [{ name: "feature", color: "1d76db" }], + state: "OPEN", + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-21T00:00:00Z", +}; + +describe("GitHub issue JSON", () => { + it.effect("normalizes list actors, labels, and state", () => + Effect.gen(function* () { + const [issue] = yield* decodeGitHubIssueList(encodeJson([rawIssue])); + + expect(issue).toMatchObject({ + number: 42, + state: "open", + author: { login: "octocat", avatarUrl: null }, + assignees: [{ login: "maintainer", avatarUrl: null }], + labels: [{ name: "feature", color: "1d76db" }], + }); + }), + ); + + it.effect("normalizes the body and issue discussion", () => + Effect.gen(function* () { + const issue = yield* decodeGitHubIssueDetail( + encodeJson({ + ...rawIssue, + body: "Please make issues visible.", + closedAt: null, + comments: [ + { + id: "comment-1", + author: { login: "reviewer", name: null }, + body: "This should open an agent thread.", + createdAt: "2026-08-21T01:00:00Z", + }, + ], + }), + ); + + expect(issue.body).toBe("Please make issues visible."); + expect(issue.comments[0]).toMatchObject({ + id: "comment-1", + updatedAt: "2026-08-21T01:00:00Z", + url: rawIssue.url, + }); + }), + ); +}); diff --git a/apps/server/src/githubIssue/gitHubIssueJson.ts b/apps/server/src/githubIssue/gitHubIssueJson.ts new file mode 100644 index 000000000000..706878d7bcef --- /dev/null +++ b/apps/server/src/githubIssue/gitHubIssueJson.ts @@ -0,0 +1,109 @@ +import type { + GitHubIssueActor, + GitHubIssueComment, + GitHubIssueLabel, + GitHubIssueState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const RawActor = Schema.Struct({ + login: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawLabel = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawComment = Schema.Struct({ + id: Schema.String, + author: Schema.NullOr(RawActor), + body: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), +}); + +const RawIssue = Schema.Struct({ + number: Schema.Number, + title: Schema.String, + url: Schema.String, + author: Schema.NullOr(RawActor), + assignees: Schema.Array(RawActor), + labels: Schema.Array(RawLabel), + state: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + body: Schema.optional(Schema.String), + comments: Schema.optional(Schema.Array(RawComment)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const decodeIssueList = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(RawIssue))); +const decodeIssueDetail = Schema.decodeEffect(Schema.fromJsonString(RawIssue)); + +type RawActor = typeof RawActor.Type; +type RawLabel = typeof RawLabel.Type; +type RawComment = typeof RawComment.Type; +export type RawGitHubIssue = typeof RawIssue.Type; + +function actor(raw: RawActor): GitHubIssueActor { + return { + login: raw.login, + name: raw.name ?? null, + avatarUrl: raw.avatarUrl ?? null, + }; +} + +function label(raw: RawLabel): GitHubIssueLabel { + return { name: raw.name, color: raw.color ?? null }; +} + +function state(raw: string): GitHubIssueState { + return raw.toLowerCase() === "closed" ? "closed" : "open"; +} + +export function normalizeGitHubIssue(raw: RawGitHubIssue) { + return { + number: raw.number, + title: raw.title, + url: raw.url, + author: raw.author === null ? null : actor(raw.author), + assignees: raw.assignees.map(actor), + labels: raw.labels.map(label), + state: state(raw.state), + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + }; +} + +function comment(raw: RawComment, issueUrl: string): GitHubIssueComment { + return { + id: raw.id, + author: raw.author === null ? null : actor(raw.author), + body: raw.body, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt ?? raw.createdAt, + url: raw.url ?? issueUrl, + }; +} + +export const decodeGitHubIssueList = Effect.fn("decodeGitHubIssueList")(function* (raw: string) { + const decoded = yield* decodeIssueList(raw); + return decoded.map(normalizeGitHubIssue); +}); + +export const decodeGitHubIssueDetail = Effect.fn("decodeGitHubIssueDetail")(function* ( + raw: string, +) { + const decoded = yield* decodeIssueDetail(raw); + return { + ...normalizeGitHubIssue(decoded), + body: decoded.body ?? "", + comments: (decoded.comments ?? []).map((entry) => comment(entry, decoded.url)), + closedAt: decoded.closedAt ?? null, + }; +}); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..5a541892bcd8 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -19,6 +19,7 @@ type ChildProcessCommand = { readonly args: ReadonlyArray; readonly options: { readonly shell?: boolean | string; + readonly stdin?: "pipe" | "ignore"; }; }; @@ -86,6 +87,7 @@ describe("runProcess", () => { Effect.sync(() => { expect(command.command).toBe("fake"); expect(command.args).toEqual(["stdout-bytes", "32"]); + expect(command.options.stdin).toBe("ignore"); return makeHandle({ stdout: "x".repeat(32) }); }), ); @@ -293,8 +295,9 @@ describe("runProcess", () => { Effect.gen(function* () { const stdinWritten = yield* Deferred.make(); const decoder = new TextDecoder(); - const spawner = makeSpawner(() => - Effect.succeed( + const spawner = makeSpawner((command) => { + expect(command.options.stdin).toBe("pipe"); + return Effect.succeed( makeHandle({ stdout: "stdin payload", stdin: Sink.forEach((chunk: Uint8Array) => { @@ -305,8 +308,8 @@ describe("runProcess", () => { }), exitCode: Deferred.await(stdinWritten).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), }), - ), - ); + ); + }); const result = yield* runWith(spawner)({ command: "fake", diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 16b5625d4690..68ae705ab7ca 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -306,6 +306,10 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), + // On Windows, leaving an unused stdin pipe open can emit ECONNRESET after + // short-lived CLI processes exit. With no stream consumer attached that + // becomes an uncaught Socket error and takes down the server. + stdin: input.stdin === undefined ? "ignore" : "pipe", ...(input.env !== undefined ? { env: input.env, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0a31bf376dae..d8af1504091a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as GitHubIssueService from "./githubIssue/GitHubIssueService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -445,6 +446,11 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(VcsProcess.layer), ); +const GitHubIssueServiceLive = GitHubIssueService.layer.pipe( + Layer.provide(GitHubCli.layer), + Layer.provide(VcsProcess.layer), +); + export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( @@ -466,6 +472,7 @@ export const makeRoutesLayer = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), + Layer.provide(GitHubIssueServiceLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c1..eb1fbe03c4eb 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import { HostProcessPlatform, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -24,6 +25,17 @@ const layer = GitHubCli.layer.pipe( run: mockRun, }), ), + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +const windowsLayer = GitHubCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: mockRun, + }), + ), + Layer.provide(Layer.succeed(HostProcessPlatform, "win32")), + Layer.provide(Layer.succeed(HostProcessWorkingDirectory, "C:\\t3")), ); afterEach(() => { @@ -31,6 +43,59 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { + it.effect("skips unrelated gh shims when resolving GitHub CLI on Windows", () => { + mockRun + .mockReturnValueOnce( + Effect.succeed( + processOutput("C:\\npm\\gh.exe\r\nC:\\Program Files\\GitHub CLI\\gh.exe\r\n"), + ), + ) + .mockReturnValueOnce(Effect.succeed(processOutput("npm gh helper\n"))) + .mockReturnValueOnce(Effect.succeed(processOutput("gh version 2.96.0 (test)\n"))) + .mockReturnValueOnce(Effect.succeed(processOutput("main\n"))); + + return Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + const branch = yield* gh.getDefaultBranch({ cwd: "C:\\repo" }); + + assert.equal(branch, "main"); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.resolveExecutable", + command: "where.exe", + args: ["gh.exe"], + cwd: "C:\\t3", + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.resolveExecutable", + command: "C:\\npm\\gh.exe", + args: ["--version"], + cwd: "C:\\t3", + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }); + expect(mockRun).toHaveBeenNthCalledWith(3, { + operation: "GitHubCli.resolveExecutable", + command: "C:\\Program Files\\GitHub CLI\\gh.exe", + args: ["--version"], + cwd: "C:\\t3", + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }); + expect(mockRun).toHaveBeenNthCalledWith(4, { + operation: "GitHubCli.execute", + command: "C:\\Program Files\\GitHub CLI\\gh.exe", + args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + cwd: "C:\\repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(windowsLayer)); + }); + it("does not classify a missing cwd as an unavailable gh executable", () => { const context = { command: "gh", cwd: "/repo" } as const; const missingCwd = new VcsProcessSpawnError({ diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20e..1be9d52ce628 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -10,6 +10,7 @@ import { type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; +import { HostProcessPlatform, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { @@ -18,6 +19,65 @@ import { } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +const EXECUTABLE_PROBE_TIMEOUT_MS = 5_000; +const EXECUTABLE_PROBE_MAX_OUTPUT_BYTES = 64 * 1024; + +const resolveGitHubCliExecutable = Effect.fn("GitHubCli.resolveExecutable")(function* () { + if ((yield* HostProcessPlatform) !== "win32") return "gh"; + + const process = yield* VcsProcess.VcsProcess; + const cwd = yield* HostProcessWorkingDirectory; + const candidates = yield* process + .run({ + operation: "GitHubCli.resolveExecutable", + command: "where.exe", + args: ["gh.exe"], + cwd, + allowNonZeroExit: true, + timeoutMs: EXECUTABLE_PROBE_TIMEOUT_MS, + maxOutputBytes: EXECUTABLE_PROBE_MAX_OUTPUT_BYTES, + }) + .pipe( + Effect.match({ + onFailure: () => [] as ReadonlyArray, + onSuccess: (output) => + output.exitCode === 0 + ? Array.from( + new Set( + output.stdout + .split(/\r?\n/g) + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0), + ), + ) + : [], + }), + ); + + for (const candidate of candidates) { + const isGitHubCli = yield* process + .run({ + operation: "GitHubCli.resolveExecutable", + command: candidate, + args: ["--version"], + cwd, + allowNonZeroExit: true, + timeoutMs: EXECUTABLE_PROBE_TIMEOUT_MS, + maxOutputBytes: EXECUTABLE_PROBE_MAX_OUTPUT_BYTES, + }) + .pipe( + Effect.match({ + onFailure: () => false, + onSuccess: (output) => output.exitCode === 0 && /^gh version \d+/m.test(output.stdout), + }), + ); + if (isGitHubCli) return candidate; + } + + // Preserve the normal unavailable/command-error path when no genuine CLI + // can be verified. The execute call below will carry the useful failure. + return "gh.exe"; +}); const gitHubCliFailureFields = { command: Schema.Literal("gh"), @@ -325,12 +385,16 @@ function deriveRepositoryCloneUrlsFromCreateOutput( export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; + // `gh` is also the name of an unrelated npm package that installs both .cmd + // and .exe shims. Verify Windows PATH candidates once so all GitHub features + // use the genuine CLI, even when an npm bin directory appears first. + const executable = yield* resolveGitHubCliExecutable(); const execute: GitHubCli["Service"]["execute"] = (input) => process .run({ operation: "GitHubCli.execute", - command: "gh", + command: executable, args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cdce2..f58771d206c2 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { TestClock } from "effect/testing"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, @@ -34,13 +35,14 @@ const baseInput = { const captureProcessResult = ( result: Effect.Effect, + input: VcsProcess.VcsProcessInput = baseInput, ) => VcsProcess.make.pipe( Effect.provideService( ProcessRunner.ProcessRunner, ProcessRunner.ProcessRunner.of({ run: () => result }), ), - Effect.flatMap((service) => service.run(baseInput)), + Effect.flatMap((service) => service.run(input)), Effect.flip, ); @@ -183,6 +185,32 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("classifies GitHub failures when the CLI uses an absolute Windows path", () => + Effect.gen(function* () { + const error = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr: "pull request not found", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + { + ...baseInput, + command: "C:\\Program Files\\GitHub CLI\\gh.exe", + }, + ); + + expect(error).toMatchObject({ + _tag: "VcsProcessExitError", + failureKind: "not-found", + }); + }), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa13604..781d3fbc46a8 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -55,6 +55,8 @@ const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); + const executable = command.split(/[\\/]/).at(-1)?.toLowerCase(); + const isGitHubCli = executable === "gh" || executable === "gh.exe"; if ( normalized.includes("authentication failed") || @@ -80,7 +82,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai } if ( - (command === "gh" && + (isGitHubCli && (normalized.includes("could not resolve to a pullrequest") || normalized.includes("repository.pullrequest") || normalized.includes("no pull requests found for branch") || diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55b0be07c667..719ce3c23e44 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -118,6 +118,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 GitHubIssueService from "./githubIssue/GitHubIssueService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -480,6 +481,7 @@ const makeWsRpcLayer = ( const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const pullRequests = yield* PullRequestService.PullRequestService; + const githubIssues = yield* GitHubIssueService.GitHubIssueService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1832,6 +1834,14 @@ const makeWsRpcLayer = ( pullRequests.requestReviewers(input), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.githubIssuesList]: (input) => + observeRpcEffect(WS_METHODS.githubIssuesList, githubIssues.list(input), { + "rpc.aggregate": "github-issues", + }), + [WS_METHODS.githubIssuesDetail]: (input) => + observeRpcEffect(WS_METHODS.githubIssuesDetail, githubIssues.detail(input), { + "rpc.aggregate": "github-issues", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -2416,6 +2426,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const githubIssues = yield* GitHubIssueService.GitHubIssueService; return HttpRouter.add( "GET", "/ws", @@ -2446,6 +2457,7 @@ export const websocketRpcRouteLayer = 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(GitHubIssueService.GitHubIssueService, githubIssues)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13024a7516ff..f8a8ab7b069a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1737,7 +1737,7 @@ function ChatMarkdown({ handleMarkdownFragmentClick(event, href); return; } - // A link to a change request in a workspace project opens beside the + // A link to a pull request or issue in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and // the panel it opens offers the browser as one of its actions. Anything else is // an ordinary link and keeps the `_blank` the shell already handles. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..740cf702143d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -164,6 +164,11 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; +import { + GitHubIssueDetailPanel, + GitHubIssueEmptyState, +} from "./githubIssue/GitHubIssueDetailPanel"; +import { GitHubIssueDetailGhost } from "./githubIssue/GitHubIssueGhosts"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { @@ -2098,8 +2103,9 @@ function ChatViewContent(props: ChatViewProps) { const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); - const pullRequestsCapabilityKnown = serverConfig !== null; + const sourceControlCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; + const supportsGitHubIssues = serverConfig?.environment.capabilities.githubIssues === true; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = @@ -6523,7 +6529,24 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> - ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + ) : activeRightPanelSurface?.kind === "github-issue" && !sourceControlCapabilityKnown ? ( + + ) : activeRightPanelSurface?.kind === "github-issue" && !supportsGitHubIssues ? ( + + ) : activeRightPanelSurface?.kind === "github-issue" ? ( + + ) : activeRightPanelSurface?.kind === "pull-request" && !sourceControlCapabilityKnown ? ( ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( ; } + case "github-issue": + // The tab carries no issue state, and a closed issue reads as muted everywhere else it is + // drawn. Stays neutral until a tab status feeds this the way pull requests do. + return ; case "agents": return ; } diff --git a/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx b/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx new file mode 100644 index 000000000000..e9e8d5a0d3e5 --- /dev/null +++ b/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx @@ -0,0 +1,248 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, GitHubIssueDetail, GitHubIssueRef } from "@t3tools/contracts"; +import { + CircleDotIcon, + CircleSlash2Icon, + ExternalLinkIcon, + GithubIcon, + MessageSquareIcon, + WrenchIcon, +} from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { useComposerDraftStore } from "../../composerDraftStore"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { cn } from "../../lib/utils"; +import { githubIssueEnvironment } from "../../state/githubIssues"; +import { useEnvironmentQuery } from "../../state/query"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { PullRequestMarkdown } from "../pullRequest/PullRequestMarkdown"; +import { GitHubIssueDetailGhost } from "./GitHubIssueGhosts"; +import { Button } from "../ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; +import { toastManager } from "../ui/toast"; + +export function GitHubIssueDetailPanel({ + environmentId, + reference, +}: { + environmentId: EnvironmentId; + reference: GitHubIssueRef; +}) { + const query = useEnvironmentQuery( + githubIssueEnvironment.detail({ environmentId, input: reference }), + ); + return ( + + ); +} + +export function GitHubIssueDetailContent({ + environmentId, + detail, + error, + loading, + onRetry, +}: { + environmentId: EnvironmentId | null; + detail: GitHubIssueDetail | null; + error: string | null; + loading: boolean; + onRetry: () => void; +}) { + const newThread = useNewThreadHandler(); + const [preparing, setPreparing] = useState(false); + + const fixInThread = async () => { + if (!detail || !environmentId || preparing) return; + setPreparing(true); + const opened = await newThread(scopeProjectRef(environmentId, detail.projectId)).catch( + () => null, + ); + if (opened === null) { + setPreparing(false); + toastManager.add({ + type: "error", + title: "Could not open a thread", + description: "Try again from the project.", + }); + return; + } + const prompt = [ + `Fix GitHub issue #${detail.number} in ${detail.repository}: ${detail.title}`, + detail.url, + "", + "Read the issue and its discussion, reproduce the problem, implement the smallest complete fix, and run focused verification.", + ].join("\n"); + useComposerDraftStore.getState().setPrompt(opened.draftId, prompt); + setPreparing(false); + toastManager.add({ + type: "success", + title: "Issue ready in a thread", + description: "The task is in the composer — read it over, then send.", + }); + }; + + if (loading && detail === null) { + return ; + } + if (error && detail === null) { + return ( + Try again} + /> + ); + } + if (detail === null) { + return ( + + ); + } + + return ( +
+ {/* Both buttons are shrink-0, and the right panel leaves roughly 290px of content below + 760px, so on one row they would squeeze the title into a ribbon. The action group drops + to its own line instead, the way the pull request panel gives its title a full row. */} +
+
+ +
+

{detail.title}

+

+ {detail.repository} #{detail.number} · opened by {detail.author?.login ?? "unknown"} ·{" "} + {formatRelativeTimeLabel(detail.createdAt)} +

+
+
+
+ + +
+
+ +
+ {detail.labels.map((label) => ( + + {label.name} + + ))} + {detail.assignees.map((assignee) => ( + + assigned to {assignee.login} + + ))} +
+ +
+ {detail.body ? ( + + ) : ( +

No description provided.

+ )} +
+ +
+

+ Discussion ({detail.commentCount}) +

+
+ {detail.comments.map((comment) => ( +
+

+ {comment.author?.login ?? "unknown"} commented{" "} + {formatRelativeTimeLabel(comment.createdAt)} +

+ +
+ ))} + {detail.comments.length === 0 ? ( +

No comments yet.

+ ) : null} +
+
+
+ ); +} + +export function GitHubIssueStateIcon({ + state, + className, +}: { + state: GitHubIssueDetail["state"]; + className?: string; +}) { + const Icon = state === "open" ? CircleDotIcon : CircleSlash2Icon; + return ; +} + +/** + * Every empty answer this feature can give — the route's list and both detail surfaces — so the + * two never drift apart. `Empty` owns the spacing between its slots, and `EmptyContent` is the + * slot an action belongs in. + */ +export function GitHubIssueEmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: ReactNode; +}) { + return ( + + + + + + {title} + {description} + + {action ? {action} : null} + + ); +} diff --git a/apps/web/src/components/githubIssue/GitHubIssueEmptyState.test.tsx b/apps/web/src/components/githubIssue/GitHubIssueEmptyState.test.tsx new file mode 100644 index 000000000000..678843db71b4 --- /dev/null +++ b/apps/web/src/components/githubIssue/GitHubIssueEmptyState.test.tsx @@ -0,0 +1,53 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { CircleDotIcon, CircleSlash2Icon } from "lucide-react"; +import { describe, expect, it } from "vite-plus/test"; + +import { EmptyContent } from "../ui/empty"; +import { GitHubIssueEmptyState, GitHubIssueStateIcon } from "./GitHubIssueDetailPanel"; + +function elementsOf(node: ReactNode): ReactElement[] { + if (Array.isArray(node)) return node.flatMap(elementsOf); + if (!isValidElement(node)) return []; + const element = node as ReactElement<{ children?: ReactNode }>; + return [element, ...elementsOf(element.props.children)]; +} + +function textOf(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(textOf).join(" "); + if (!isValidElement(node)) return ""; + return textOf((node as ReactElement<{ children?: ReactNode }>).props.children); +} + +describe("GitHubIssueEmptyState", () => { + it("says what happened without offering a futile retry", () => { + const state = GitHubIssueEmptyState({ + title: "GitHub issues unavailable", + description: "Update this environment's T3 Code server to browse GitHub issues.", + }); + + expect(textOf(state)).toContain("GitHub issues unavailable"); + expect(textOf(state)).toContain("Update this environment's T3 Code server"); + expect(elementsOf(state).some((element) => element.type === EmptyContent)).toBe(false); + }); + + // Empty owns the spacing between its slots, so an action carried on a call-site margin would + // override that layout and drift from every other empty state in the app. + it("hands a retry to the slot Empty lays out actions in", () => { + const state = GitHubIssueEmptyState({ + title: "Could not load issues", + description: "GitHub did not answer.", + action: , + }); + + expect(elementsOf(state).some((element) => element.type === EmptyContent)).toBe(true); + expect(textOf(state)).toContain("Try again"); + }); +}); + +describe("GitHubIssueStateIcon", () => { + it("uses the closed-state icon for closed issues", () => { + expect(GitHubIssueStateIcon({ state: "closed" }).type).toBe(CircleSlash2Icon); + expect(GitHubIssueStateIcon({ state: "open" }).type).toBe(CircleDotIcon); + }); +}); diff --git a/apps/web/src/components/githubIssue/GitHubIssueGhosts.test.tsx b/apps/web/src/components/githubIssue/GitHubIssueGhosts.test.tsx new file mode 100644 index 000000000000..6635908eb6a6 --- /dev/null +++ b/apps/web/src/components/githubIssue/GitHubIssueGhosts.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { GitHubIssueDetailGhost } from "./GitHubIssueGhosts"; + +describe("GitHubIssueDetailGhost", () => { + // The issue surface used to borrow the pull request ghost, which announced the wrong thing. + it("announces the surface it stands for", () => { + const props = GitHubIssueDetailGhost().props as { + role?: string; + "aria-label"?: string; + }; + + expect(props.role).toBe("status"); + expect(props["aria-label"]).toBe("Loading issue"); + }); +}); diff --git a/apps/web/src/components/githubIssue/GitHubIssueGhosts.tsx b/apps/web/src/components/githubIssue/GitHubIssueGhosts.tsx new file mode 100644 index 000000000000..8c5f2462cbaa --- /dev/null +++ b/apps/web/src/components/githubIssue/GitHubIssueGhosts.tsx @@ -0,0 +1,60 @@ +/** + * The issue detail panel opening. Bars sit in the geometry `GitHubIssueDetailContent` fills — + * state glyph beside a title and its meta line, the action pair, label chips, the body card, then + * the discussion — so the panel does not rearrange under the reader when the answer lands. + * + * Shares `GhostBar` and the single `animate-ghost-pulse` layer with the pull request ghosts rather + * than the app's shimmer skeleton, for the reasons written up there. + */ +import { GhostBar } from "../pullRequest/PullRequestGhosts"; + +export function GitHubIssueDetailGhost() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+
+ + + +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..f0c354d6e60b 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -11,7 +11,7 @@ */ import { cn } from "~/lib/utils"; -function GhostBar({ className }: { className?: string | undefined }) { +export function GhostBar({ className }: { className?: string | undefined }) { return
; } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8fc6b835bf1a..fdec7e33cbe6 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,6 +1,7 @@ import { ArrowLeftIcon, ChartNoAxesColumnIcon, + CircleDotIcon, GitPullRequestIcon, SettingsIcon, } from "lucide-react"; @@ -10,7 +11,7 @@ import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-ro import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { useEnvironments } from "../../state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, @@ -155,7 +156,9 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ? "usage" : location.pathname === "/pull-requests" ? "pull-requests" - : null, + : location.pathname === "/issues" + ? "github-issues" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -163,6 +166,11 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const pullRequestsSupported = environments.some( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, ); + // Issues read the primary server alone, so a link gated on any other one would open a page + // that can only say "unavailable". + const primaryEnvironment = usePrimaryEnvironment(); + const githubIssuesSupported = + primaryEnvironment?.serverConfig?.environment.capabilities.githubIssues === true; const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -176,6 +184,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { closeMobileSidebar(); void navigate({ to: "/settings" }); }, [closeMobileSidebar, navigate]); + const handleIssuesClick = useCallback(() => { + closeMobileSidebar(); + void navigate({ to: "/issues", search: { state: "open" } }); + }, [closeMobileSidebar, navigate]); const handleUsageClick = useCallback(() => { if (isMobile) { @@ -216,6 +228,13 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { onClick={handlePullRequestsClick} /> ) : null} + {githubIssuesSupported ? ( + } + label="GitHub Issues" + onClick={handleIssuesClick} + /> + ) : null} } label="Usage" diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index edba97fa7d3d..954087168fdf 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, + findProjectForGitHubIssue, openPullRequestLink, parseChangeRequestUrl, + parseGitHubIssueUrl, PullRequestLinkOpenError, shouldOpenPullRequestExternally, } from "./openPullRequestLink"; @@ -228,3 +230,103 @@ describe("findProjectForChangeRequest", () => { ).toBeUndefined(); }); }); + +describe("parseGitHubIssueUrl", () => { + it("reads public and Enterprise GitHub issue URLs", () => { + expect(parseGitHubIssueUrl("https://github.com/T3Tools/T3Code/issues/123")).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + }); + expect( + parseGitHubIssueUrl("https://code.acme.test/platform/api/issues/7#issuecomment-1"), + ).toEqual({ + host: "code.acme.test", + repository: "platform/api", + number: 7, + }); + }); + + it("leaves pull requests and unrelated links alone", () => { + for (const link of [ + "https://github.com/t3tools/t3code/pull/123", + "https://github.com/t3tools/t3code/issues/new", + "https://example.test/t3tools/t3code/issues/not-a-number", + "not a url", + ]) { + expect(parseGitHubIssueUrl(link), link).toBeNull(); + } + }); +}); + +describe("findProjectForGitHubIssue", () => { + const project = (identity: Record) => + ({ id: "p1", repositoryIdentity: identity }) as never; + + it("matches the GitHub project by repository and host", () => { + const projects = [ + project({ + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + owner: "pingdotgg", + name: "t3code", + }), + ]; + expect( + findProjectForGitHubIssue(projects, { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7966, + }), + ).toBe(projects[0]); + }); + + it("treats a GitHub project without a canonical key as public GitHub", () => { + const projects = [ + project({ + provider: "github", + displayName: "pingdotgg/t3code", + owner: "pingdotgg", + name: "t3code", + }), + ]; + expect( + findProjectForGitHubIssue(projects, { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7966, + }), + ).toBe(projects[0]); + }); + + it("does not claim another host or a non-GitHub project", () => { + const projects = [ + project({ + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + owner: "pingdotgg", + name: "t3code", + }), + project({ + canonicalKey: "gitlab.com/pingdotgg/t3code", + provider: "gitlab", + owner: "pingdotgg", + name: "t3code", + }), + ]; + expect( + findProjectForGitHubIssue(projects, { + host: "github.acme.test", + repository: "pingdotgg/t3code", + number: 1, + }), + ).toBeUndefined(); + expect( + findProjectForGitHubIssue([projects[1]!], { + host: "gitlab.com", + repository: "pingdotgg/t3code", + number: 1, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index c8ec1b7a628c..3276dd8cc1d5 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -62,12 +62,25 @@ export interface ChangeRequestLink { readonly number: number; } +export interface GitHubIssueLink { + readonly host: string; + readonly repository: string; + readonly number: number; +} + /** The host itself, one of its subdomains, or an install named after the provider. */ function isHostOf(hostname: string, apex: string, label?: string): boolean { if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; return label !== undefined && hostname.startsWith(`${label}.`); } +function githubIssueHostOf( + identity: { readonly canonicalKey?: string | undefined } | null | undefined, +): string { + const host = identity?.canonicalKey?.split("/")[0]?.trim(); + return host === undefined || host.length === 0 ? "github.com" : host.toLowerCase(); +} + /** * The repository and number behind a change request URL on a host the page can read, or null for * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an @@ -135,6 +148,21 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null { return url.toString(); } +export function parseGitHubIssueUrl(targetUrl: string): GitHubIssueLink | null { + let url: URL; + try { + url = new URL(targetUrl); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + // GitHub Enterprise hosts are arbitrary. The workspace project match in the click handler is + // the safety gate that keeps an ordinary link from being claimed by the issue surface. + const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); +} + function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); @@ -176,6 +204,24 @@ export function findProjectForChangeRequest( }); } +export function findProjectForGitHubIssue( + projects: ReadonlyArray, + link: GitHubIssueLink, +): EnvironmentProject | undefined { + return projects.find((project) => { + const identity = project.repositoryIdentity; + if (!identity || identity.provider !== "github") return false; + const repository = + identity.displayName ?? + (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); + return ( + repository !== null && + repository.toLowerCase() === link.repository.toLowerCase() && + githubIssueHostOf(identity) === link.host.toLowerCase() + ); + }); +} + /** * Opens a change request link on the page, and says whether it did. Anything else — another * organisation's repository, a host nothing here is checked out from, a link that merely looks @@ -214,6 +260,42 @@ export function useOpenChangeRequestLink( (event, targetUrl, targetThreadRef) => { if (shouldOpenPullRequestExternally(event)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; + const parsedIssue = parseGitHubIssueUrl(targetUrl); + if (parsedIssue !== null) { + const environmentId = resolvedThreadRef?.environmentId ?? primaryEnvironmentId; + if (environmentId === null) return false; + // Beside a thread the panel reads on that thread's environment, so a project from another + // one could not be read there whatever its remote says: two environments can hold the same + // repository, and handing the panel the wrong one's id opens a surface that never loads. + const projects = allProjects.filter((project) => project.environmentId === environmentId); + const capabilities = serverConfigs.get(environmentId)?.environment.capabilities; + const issueProject = + capabilities?.githubIssues === true + ? findProjectForGitHubIssue(projects, parsedIssue) + : undefined; + if (issueProject === undefined) return false; + event.preventDefault(); + event.stopPropagation(); + const repository = issueProject.repositoryIdentity?.displayName ?? parsedIssue.repository; + if (resolvedThreadRef) { + useRightPanelStore.getState().openGitHubIssue(resolvedThreadRef, { + projectId: issueProject.id, + repository, + number: parsedIssue.number, + }); + return true; + } + void navigate({ + to: "/issues", + search: { + state: "all", + repository, + number: parsedIssue.number, + selectedProjectId: issueProject.id, + }, + }); + return true; + } const parsed = parseChangeRequestUrl(targetUrl); if (parsed === null) return false; const reads = (environmentId: string) => diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index b6997554efbc..bb2faa2d7409 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { + githubIssueSurface, migratePersistedRightPanelState, pullRequestSurfaceId, selectActiveRightPanel, @@ -148,6 +149,49 @@ describe("rightPanelStore", () => { }); }); + it("normalizes persisted GitHub issue surfaces to their reference-keyed tab", () => { + const id = githubIssueSurface({ + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }).id; + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "github-issue", + surfaces: [ + { + id: "github-issue", + kind: "github-issue", + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: id, + surfaces: [ + { + id, + kind: "github-issue", + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }, + ], + }, + }, + }); + }); + it("drops the pull-request list's shared panel so a restart opens the page fresh", () => { const id = pullRequestSurfaceId({ projectId: "project-a", @@ -541,6 +585,21 @@ describe("rightPanelStore", () => { }); }); + it("tracks one surface per GitHub issue", () => { + const first = { projectId: "project-a", repository: "pingdotgg/t3code", number: 7966 }; + const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 7967 }; + useRightPanelStore.getState().openGitHubIssue(refA, first); + useRightPanelStore.getState().openGitHubIssue(refA, second); + useRightPanelStore.getState().openGitHubIssue(refA, first); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual([ + githubIssueSurface(first).id, + githubIssueSurface(second).id, + ]); + expect(state.activeSurfaceId).toBe(githubIssueSurface(first).id); + }); + it("tracks one surface per terminal session", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openTerminal(refA, "term-2"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d272..795566a7b22a 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -21,6 +21,7 @@ export const RIGHT_PANEL_KINDS = [ "preview", "terminal", "pull-request", + "github-issue", "agents", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -62,13 +63,21 @@ export type RightPanelSurface = repository: string; number: number; } + | { + id: `github-issue:${string}`; + kind: "github-issue"; + projectId: string; + repository: string; + number: number; + } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. -const RIGHT_PANEL_STORAGE_VERSION = 11; +// v12 adds GitHub issue surfaces. +const RIGHT_PANEL_STORAGE_VERSION = 12; /** * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session @@ -86,7 +95,7 @@ interface RightPanelStoreState { byThreadKey: Record; open: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; @@ -94,6 +103,10 @@ interface RightPanelStoreState { ref: ScopedThreadRef, target: { environmentId?: string; projectId: string; repository: string; number: number }, ) => void; + openGitHubIssue: ( + ref: ScopedThreadRef, + target: { projectId: string; repository: string; number: number }, + ) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -115,7 +128,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -127,7 +140,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -212,6 +225,22 @@ export function updatePullRequestTabStatus; + +export function githubIssueSurface(target: { + projectId: string; + repository: string; + number: number; +}): GitHubIssueSurface { + return { + id: `github-issue:${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`, + kind: "github-issue", + projectId: target.projectId, + repository: target.repository, + number: target.number, + }; +} + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -299,6 +328,18 @@ export function migratePersistedRightPanelState(persistedState: unknown): { }), ]; } + if (surface.kind === "github-issue") { + if ( + typeof surface.projectId !== "string" || + typeof surface.repository !== "string" || + typeof surface.number !== "number" || + !Number.isSafeInteger(surface.number) || + surface.number < 1 + ) { + return []; + } + return [githubIssueSurface(surface)]; + } if (surface.kind !== "terminal") return [surface]; if ( !("resourceId" in surface) || @@ -390,6 +431,12 @@ export const useRightPanelStore = create()( return upsertSurface(current, pullRequestSurface(target)); }), })), + openGitHubIssue: (ref, target) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, githubIssueSurface(target)), + ), + })), openFile: (ref, relativePath, line) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..6393602630b3 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as SettingsAppearanceRouteImport } from './routes/settings.appear import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' +import { Route as ChatIssuesRouteImport } from './routes/_chat.issues' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -119,6 +120,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ path: '/pull-requests', getParentRoute: () => ChatRoute, } as any) +const ChatIssuesRoute = ChatIssuesRouteImport.update({ + id: '/issues', + path: '/issues', + getParentRoute: () => ChatRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/issues': typeof ChatIssuesRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -157,6 +164,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/issues': typeof ChatIssuesRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -180,6 +188,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/_chat/issues': typeof ChatIssuesRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/connect_/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -204,6 +213,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/issues' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -224,6 +234,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/issues' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -246,6 +257,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/_chat/issues' | '/_chat/pull-requests' | '/connect_/callback' | '/projects/$projectKey' @@ -401,6 +413,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPullRequestsRouteImport parentRoute: typeof ChatRoute } + '/_chat/issues': { + id: '/_chat/issues' + path: '/issues' + fullPath: '/issues' + preLoaderRoute: typeof ChatIssuesRouteImport + parentRoute: typeof ChatRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -419,6 +438,7 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatIssuesRoute: typeof ChatIssuesRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute @@ -426,6 +446,7 @@ interface ChatRouteChildren { } const ChatRouteChildren: ChatRouteChildren = { + ChatIssuesRoute: ChatIssuesRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, diff --git a/apps/web/src/routes/-chatIssuesChrome.test.ts b/apps/web/src/routes/-chatIssuesChrome.test.ts new file mode 100644 index 000000000000..457c9ef680e2 --- /dev/null +++ b/apps/web/src/routes/-chatIssuesChrome.test.ts @@ -0,0 +1,39 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The issues page is assembled from shared chrome and control primitives, and every hand-rolled +// stand-in for one fails quietly: an undefined class name is a no-op rather than a layout, and a +// native { + const next = STATE_OPTIONS.find((option) => option.value === value); + if (next) updateFilters({ state: next.value }); + }} + > + + {stateLabel} + + + {STATE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+
+ {listQuery.data && + listQuery.data.entries.length > 0 && + listQuery.data.errors.length > 0 ? ( +
+ {listQuery.data.errors.length} GitHub-backed project + {listQuery.data.errors.length === 1 ? " was" : "s were"} unavailable. +
+ ) : null} + {listQuery.data?.truncated ? ( +
+ Showing the newest 50 issues. Narrow the list with search or filters. +
+ ) : null} + {body} +
+ + +
+ +
+ + + + {selectedRef ? ( +
+
+ +
+ +
+ ) : null} + + ); +} + +function IssueRow({ + issue, + selected, + showProject, + onSelect, +}: { + issue: GitHubIssueListEntry; + selected: boolean; + showProject: boolean; + onSelect: (issue: GitHubIssueListEntry) => void; +}) { + const StateIcon = issue.state === "open" ? CircleDotIcon : CircleSlash2Icon; + return ( + + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f8894ea57775..dd33553629b8 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -569,6 +569,11 @@ function PullRequestsRouteView() { const pageSize = page.key === filterKey ? page.size : PAGE_SIZE; const sentCursors = page.key === filterKey ? page.cursors : null; const sentRegrown = page.key === filterKey ? page.regrown : []; + // With the default page and no search, listQuery and baselineQuery point at + // the same cached query. Refresh it once: a second refresh cancels the first + // request and can leave a false failure banner after the replacement wins. + const listUsesBaselineQuery = + sentQuery.length === 0 && sentCursors === null && pageSize === PAGE_SIZE; // Typing a search, or clearing one, starts the list again at its first page. Without this the // paging state from before the search is still filed under these filters and comes back with @@ -719,7 +724,7 @@ function PullRequestsRouteView() { setInvalidating(false); } refreshList(); - baselineQuery.refresh(); + if (!listUsesBaselineQuery) baselineQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); statsQuery.refresh(); @@ -865,9 +870,7 @@ function PullRequestsRouteView() { // a host with no cursor to continue from — where "more" means asking for a longer page — would // have its extra rows thrown away for the ninety-nine the baseline keeps answering with. const answered = - (sentQuery.length === 0 && sentCursors === null && pageSize === PAGE_SIZE - ? baselineQuery.data - : listQuery.data) ?? + (listUsesBaselineQuery ? baselineQuery.data : listQuery.data) ?? (loaded?.scope === scopeKey && loaded.query === sentQuery ? loaded.data : null); // Clearing a search returns to a list that has already been read, so it comes back at once // rather than after another round trip: the search was the temporary state, not the list. diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 7bca31182379..65dd27420bd2 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -23,7 +23,7 @@ import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom } from "./server"; -import { allEnvironmentShellsBootstrappedAtom } from "./shell"; +import { allEnvironmentShellsBootstrappedAtom, environmentShellBootstrappedAtom } from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); @@ -32,6 +32,10 @@ const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); +const EMPTY_SHELL_BOOTSTRAPPED_ATOM = Atom.make(false).pipe( + Atom.withLabel("web-environment-shell-bootstrapped:empty"), +); + const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( Atom.withLabel("web-project:empty"), ); @@ -124,6 +128,14 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } +export function useEnvironmentShellBootstrapped(environmentId: EnvironmentId | null): boolean { + return useAtomValue( + environmentId === null + ? EMPTY_SHELL_BOOTSTRAPPED_ATOM + : environmentShellBootstrappedAtom(environmentId), + ); +} + export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/state/githubIssues.ts b/apps/web/src/state/githubIssues.ts new file mode 100644 index 000000000000..610bdcca35ef --- /dev/null +++ b/apps/web/src/state/githubIssues.ts @@ -0,0 +1,5 @@ +import { createGitHubIssueEnvironmentAtoms } from "@t3tools/client-runtime/state/github-issues"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const githubIssueEnvironment = createGitHubIssueEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index dfb104e5c996..ec3a4aa9c60e 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -8,6 +8,7 @@ import { createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -22,6 +23,16 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ shellStateValueAtom: environmentShell.stateValueAtom, }); +/** + * Whether one environment's shell snapshot has landed. A page reading a single server wants this + * rather than the all-environments gate below, which a second server still connecting holds shut. + */ +export const environmentShellBootstrappedAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot), + ).pipe(Atom.withLabel(`web-environment-shell-bootstrapped:${environmentId}`)), +); + export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); if (Option.isNone(catalog)) { diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe736..b25b37a2b8a3 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,12 +1,12 @@ # Source Control Integrations -T3 Code connects to your Git hosting provider so you can create pull requests, review code, and manage repositories without leaving the app. +T3 Code connects to your Git hosting provider so you can create pull requests, review code, browse GitHub issues, and manage repositories without leaving the app. ## Supported Providers T3 Code works with the platforms your team already uses: -- **GitHub** – Pull requests, repository creation, and clone integration +- **GitHub** – Pull requests, issues, repository creation, and clone integration - **GitLab** – Merge requests, repository publishing, and hosted clones - **Bitbucket** – Pull request workflows (via API token authentication) - **Azure DevOps** – Pull request support for Microsoft-hosted repositories @@ -53,6 +53,12 @@ T3 Code works with the platforms your team already uses: - Works on GitHub, GitLab, and Bitbucket. Azure DevOps takes a new title and description; its comments stay read-only here, as they already were +### Turn GitHub Issues Into Agent Tasks + +- Open **GitHub Issues** from the sidebar to browse issues across your GitHub-backed projects +- Filter by project or state, search GitHub, and open an issue to read its description and discussion +- Choose **Fix in a thread** to open the issue's project and place a focused task in the composer for an agent + ### Know Your Setup at a Glance The **Source Control settings** page shows you exactly what's connected: @@ -77,7 +83,7 @@ Run a quick **Rescan** after setting up a new machine or changing credentials. ``` 3. Open **Settings → Source Control** in T3 Code and verify GitHub shows as authenticated -You can now clone, publish, and create pull requests. +You can now clone, publish, create pull requests, and hand GitHub issues to an agent. ### For GitLab diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abed33998966..0a42539cb69d 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -71,6 +71,10 @@ "types": "./src/state/git.ts", "default": "./src/state/git.ts" }, + "./state/github-issues": { + "types": "./src/state/githubIssues.ts", + "default": "./src/state/githubIssues.ts" + }, "./state/models": { "types": "./src/state/models.ts", "default": "./src/state/models.ts" diff --git a/packages/client-runtime/src/state/githubIssues.ts b/packages/client-runtime/src/state/githubIssues.ts new file mode 100644 index 000000000000..9f1cf0a24720 --- /dev/null +++ b/packages/client-runtime/src/state/githubIssues.ts @@ -0,0 +1,22 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import type { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function createGitHubIssueEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:github-issues:list", + tag: WS_METHODS.githubIssuesList, + staleTimeMs: 30_000, + }), + detail: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:github-issues:detail", + tag: WS_METHODS.githubIssuesDetail, + staleTimeMs: 15_000, + }), + }; +} diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 455cc58f47d1..c404958bb650 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -27,6 +27,16 @@ describe("ExecutionEnvironmentDescriptor", () => { ).toBe(true); }); + it("negotiates GitHub issue support independently", () => { + expect(decodeDescriptor(descriptor).capabilities.githubIssues).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, githubIssues: true }, + }).capabilities.githubIssues, + ).toBe(true); + }); + it("treats a missing attachment upload capability as unsupported", () => { expect(decodeDescriptor(descriptor).capabilities.attachmentUploads).toBeUndefined(); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1468fe9ef3d3..d3a7de889d30 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -53,6 +53,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on servers from before the pull-request workspace shipped, so clients must not probe them. */ pullRequests: Schema.optionalKey(Schema.Boolean), + /** Server exposes GitHub issue list and detail APIs. */ + githubIssues: Schema.optionalKey(Schema.Boolean), /** Server understands thread.settle / thread.unsettle commands. Absent on pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ diff --git a/packages/contracts/src/githubIssue.test.ts b/packages/contracts/src/githubIssue.test.ts new file mode 100644 index 000000000000..e796de8c0ee1 --- /dev/null +++ b/packages/contracts/src/githubIssue.test.ts @@ -0,0 +1,39 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { GitHubIssueDetail, GitHubIssueListInput } from "./githubIssue.ts"; + +describe("GitHub issue contracts", () => { + it("bounds and trims host search text", () => { + const decode = Schema.decodeUnknownSync(GitHubIssueListInput); + expect(decode({ state: "open", query: " websocket " }).query).toBe("websocket"); + expect(() => decode({ state: "open", query: "x".repeat(201) })).toThrow(); + }); + + it("round-trips issue detail through the RPC JSON codec", () => { + const codec = Schema.toCodecJson(GitHubIssueDetail); + const detail: GitHubIssueDetail = { + projectId: "project-1" as GitHubIssueDetail["projectId"], + projectTitle: "t3code", + workspaceRoot: "/repo", + repository: "t3tools/t3code", + number: 42, + title: "Support GitHub issues", + url: "https://github.com/t3tools/t3code/issues/42", + author: { login: "octocat", name: "Octo Cat", avatarUrl: null }, + assignees: [], + labels: [{ name: "feature", color: "1d76db" }], + state: "open", + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-21T00:00:00Z", + body: "Issue body", + comments: [], + commentCount: 0, + closedAt: null, + }; + + expect(Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(detail))).toStrictEqual( + detail, + ); + }); +}); diff --git a/packages/contracts/src/githubIssue.ts b/packages/contracts/src/githubIssue.ts new file mode 100644 index 000000000000..b09db47dd839 --- /dev/null +++ b/packages/contracts/src/githubIssue.ts @@ -0,0 +1,132 @@ +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ProjectId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const GitHubIssueState = Schema.Literals(["open", "closed"]); +export type GitHubIssueState = typeof GitHubIssueState.Type; + +export const GitHubIssueListState = Schema.Literals(["all", "open", "closed"]); +export type GitHubIssueListState = typeof GitHubIssueListState.Type; + +export const GitHubIssueActor = Schema.Struct({ + login: TrimmedNonEmptyString, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), +}); +export type GitHubIssueActor = typeof GitHubIssueActor.Type; + +export const GitHubIssueLabel = Schema.Struct({ + name: TrimmedNonEmptyString, + color: Schema.NullOr(Schema.String), +}); +export type GitHubIssueLabel = typeof GitHubIssueLabel.Type; + +export const GitHubIssueListEntry = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.NullOr(GitHubIssueActor), + assignees: Schema.Array(GitHubIssueActor), + labels: Schema.Array(GitHubIssueLabel), + state: GitHubIssueState, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type GitHubIssueListEntry = typeof GitHubIssueListEntry.Type; + +export const GitHubIssueListInput = Schema.Struct({ + state: GitHubIssueListState, + projectId: Schema.optional(ProjectId), + query: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), +}); +export type GitHubIssueListInput = typeof GitHubIssueListInput.Type; + +export const GitHubIssueListProjectError = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type GitHubIssueListProjectError = typeof GitHubIssueListProjectError.Type; + +export const GitHubIssueListResult = Schema.Struct({ + entries: Schema.Array(GitHubIssueListEntry), + errors: Schema.Array(GitHubIssueListProjectError), + truncated: Schema.Boolean, +}); +export type GitHubIssueListResult = typeof GitHubIssueListResult.Type; + +export const GitHubIssueRef = Schema.Struct({ + projectId: ProjectId, + repository: TrimmedNonEmptyString, + number: PositiveInt, +}); +export type GitHubIssueRef = typeof GitHubIssueRef.Type; + +export const GitHubIssueComment = Schema.Struct({ + id: TrimmedNonEmptyString, + author: Schema.NullOr(GitHubIssueActor), + body: Schema.String, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + url: TrimmedNonEmptyString, +}); +export type GitHubIssueComment = typeof GitHubIssueComment.Type; + +export const GitHubIssueDetail = Schema.Struct({ + ...GitHubIssueListEntry.fields, + workspaceRoot: TrimmedNonEmptyString, + body: Schema.String, + comments: Schema.Array(GitHubIssueComment), + commentCount: NonNegativeInt, + closedAt: Schema.NullOr(IsoDateTime), +}); +export type GitHubIssueDetail = typeof GitHubIssueDetail.Type; + +export class GitHubIssueCliMissingError extends Schema.TaggedErrorClass()( + "GitHubIssueCliMissingError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "GitHub CLI (`gh`) is required to browse issues. Install it from https://cli.github.com/ and reload."; + } +} + +export class GitHubIssueCliUnauthenticatedError extends Schema.TaggedErrorClass()( + "GitHubIssueCliUnauthenticatedError", + { + cause: Schema.Defect(), + // Optional keeps the error decodable while an older server is still in the wild. + host: Schema.optional(TrimmedNonEmptyString), + }, +) { + override get message(): string { + const loginCommand = + this.host === undefined || this.host === "github.com" + ? "gh auth login" + : `gh auth login --hostname ${this.host}`; + return `GitHub CLI is not authenticated. Run \`${loginCommand}\` and retry.`; + } +} + +export class GitHubIssueOperationError extends Schema.TaggedErrorClass()( + "GitHubIssueOperationError", + { + operation: Schema.String, + detail: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `GitHub issue operation ${this.operation} failed: ${this.detail}`; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..1d63e04ecd09 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,6 +19,7 @@ export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./pullRequest.ts"; +export * from "./githubIssue.ts"; export * from "./orchestration.ts"; export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..6f9cf51458bb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -106,6 +106,15 @@ import { PullRequestUnavailableError, PullRequestUpdateInput, } from "./pullRequest.ts"; +import { + GitHubIssueCliMissingError, + GitHubIssueCliUnauthenticatedError, + GitHubIssueDetail, + GitHubIssueListInput, + GitHubIssueListResult, + GitHubIssueOperationError, + GitHubIssueRef, +} from "./githubIssue.ts"; import { RelayClientInstallFailedError, RelayClientInstallProgressEventSchema, @@ -315,6 +324,10 @@ export const WS_METHODS = { pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", + // GitHub issue methods + githubIssuesList: "githubIssues.list", + githubIssuesDetail: "githubIssues.detail", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -612,6 +625,25 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest error: PullRequestRpcError, }); +const GitHubIssueRpcError = Schema.Union([ + GitHubIssueCliMissingError, + GitHubIssueCliUnauthenticatedError, + GitHubIssueOperationError, + EnvironmentAuthorizationError, +]); + +export const WsGitHubIssuesListRpc = Rpc.make(WS_METHODS.githubIssuesList, { + payload: GitHubIssueListInput, + success: GitHubIssueListResult, + error: GitHubIssueRpcError, +}); + +export const WsGitHubIssuesDetailRpc = Rpc.make(WS_METHODS.githubIssuesDetail, { + payload: GitHubIssueRef, + success: GitHubIssueDetail, + error: GitHubIssueRpcError, +}); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -1058,6 +1090,8 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsInvalidateRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, + WsGitHubIssuesListRpc, + WsGitHubIssuesDetailRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc,