From 268826c2a4105305fa755150af2e744ffc40551c Mon Sep 17 00:00:00 2001 From: mtdewwolf Date: Sun, 23 Aug 2026 18:07:33 -0600 Subject: [PATCH 1/7] feat(clients): add GitHub issue browsing - Add issue listing and detail panels across server and web - Resolve the GitHub CLI reliably on Windows --- apps/server/src/auth/RpcAuthorization.test.ts | 7 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../githubIssue/GitHubIssueService.test.ts | 196 ++++++++ .../src/githubIssue/GitHubIssueService.ts | 240 ++++++++++ .../src/githubIssue/gitHubIssueJson.test.ts | 62 +++ .../server/src/githubIssue/gitHubIssueJson.ts | 109 +++++ apps/server/src/processRunner.test.ts | 11 +- apps/server/src/processRunner.ts | 4 + apps/server/src/server.ts | 7 + .../src/sourceControl/GitHubCli.test.ts | 65 +++ apps/server/src/sourceControl/GitHubCli.ts | 67 ++- apps/server/src/vcs/VcsProcess.test.ts | 30 +- apps/server/src/vcs/VcsProcess.ts | 4 +- apps/server/src/ws.ts | 12 + apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/components/ChatView.tsx | 26 +- apps/web/src/components/RightPanelTabs.tsx | 5 + .../githubIssue/GitHubIssueDetailPanel.tsx | 230 ++++++++++ .../src/components/sidebar/SidebarChrome.tsx | 19 +- apps/web/src/lib/openPullRequestLink.test.ts | 84 ++++ apps/web/src/lib/openPullRequestLink.ts | 74 +++ apps/web/src/rightPanelStore.test.ts | 16 + apps/web/src/rightPanelStore.ts | 50 ++- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.issues.tsx | 421 ++++++++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 11 +- apps/web/src/state/githubIssues.ts | 5 + docs/user/source-control.md | 12 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/state/githubIssues.ts | 22 + packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/githubIssue.test.ts | 39 ++ packages/contracts/src/githubIssue.ts | 120 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 32 ++ 38 files changed, 2003 insertions(+), 21 deletions(-) create mode 100644 apps/server/src/githubIssue/GitHubIssueService.test.ts create mode 100644 apps/server/src/githubIssue/GitHubIssueService.ts create mode 100644 apps/server/src/githubIssue/gitHubIssueJson.test.ts create mode 100644 apps/server/src/githubIssue/gitHubIssueJson.ts create mode 100644 apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx create mode 100644 apps/web/src/routes/_chat.issues.tsx create mode 100644 apps/web/src/state/githubIssues.ts create mode 100644 packages/client-runtime/src/state/githubIssues.ts create mode 100644 packages/contracts/src/githubIssue.test.ts create mode 100644 packages/contracts/src/githubIssue.ts 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 70227cdd4ebf..2958476cad5d 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 ee30d987591d..95717f78df17 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).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 45dc0ee9cfd5..a1be9a9fdbba 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -147,6 +147,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: 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..148e44e64585 --- /dev/null +++ b/apps/server/src/githubIssue/GitHubIssueService.test.ts @@ -0,0 +1,196 @@ +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; +}): OrchestrationProjectShell { + return { + id: input.id as ProjectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + repositoryIdentity: { + canonicalKey: `github.com/${input.repository}`, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://github.com/${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"); + }), +); diff --git a/apps/server/src/githubIssue/GitHubIssueService.ts b/apps/server/src/githubIssue/GitHubIssueService.ts new file mode 100644 index 000000000000..e28656d84633 --- /dev/null +++ b/apps/server/src/githubIssue/GitHubIssueService.ts @@ -0,0 +1,240 @@ +import type { + GitHubIssueDetail, + GitHubIssueListEntry, + GitHubIssueListInput, + GitHubIssueListResult, + GitHubIssueOperationError, + GitHubIssueRef, + GitHubIssueUnavailableError, + OrchestrationProjectShell, +} from "@t3tools/contracts"; +import { + GitHubIssueOperationError as GitHubIssueOperationErrorClass, + GitHubIssueUnavailableError as GitHubIssueUnavailableErrorClass, +} 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`; + +type GitHubIssueError = GitHubIssueUnavailableError | 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 fromCliError(operation: string) { + return (error: GitHubCli.GitHubCliError): GitHubIssueError => { + if (error._tag === "GitHubCliUnavailableError") { + return new GitHubIssueUnavailableErrorClass({ reason: "cli-missing", cause: error }); + } + if (error._tag === "GitHubCliAuthenticationError") { + return new GitHubIssueUnavailableErrorClass({ reason: "cli-unauthenticated", cause: error }); + } + return new GitHubIssueOperationErrorClass({ operation, detail: error.detail, cause: error }); + }; +} + +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")), + 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 }, + ); + + const unavailable = batches.find( + (batch) => "error" in batch && batch.error._tag === "GitHubIssueUnavailableError", + ); + if (unavailable && "error" in unavailable) return yield* unavailable.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, + message: `${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"))); + 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 3e41b4390f82..a6a0e18130b5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,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"; @@ -444,6 +445,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( @@ -464,6 +470,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..448aef15cbdd 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,66 @@ 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* ( + process: VcsProcess.VcsProcess["Service"], +) { + if ((yield* HostProcessPlatform) !== "win32") return "gh"; + + 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 +386,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(process); 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 11c659e28a70..1d47709fba1b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -114,6 +114,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"; @@ -476,6 +477,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; @@ -1826,6 +1828,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, @@ -2400,6 +2410,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", @@ -2430,6 +2441,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 46ed051154a6..509739c8cda5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -164,6 +164,10 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; +import { + GitHubIssueDetailPanel, + GitHubIssuesUnavailableState, +} from "./githubIssue/GitHubIssueDetailPanel"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { @@ -2092,8 +2096,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 versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -6489,7 +6494,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": + 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..c174fcafc40a --- /dev/null +++ b/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx @@ -0,0 +1,230 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, GitHubIssueDetail, GitHubIssueRef } from "@t3tools/contracts"; +import { + CircleDotIcon, + 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 { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Spinner } from "../ui/spinner"; +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 ( +
+ Loading issue... +
+ ); + } + if (error && detail === null) { + return ( + Try again} + /> + ); + } + if (detail === null) { + return ( + + ); + } + + return ( +
+
+ +
+

{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 GitHubIssuesUnavailableState({ + title, + description, +}: { + title: string; + description: string; +}) { + return ; +} + +function GitHubIssueEmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: ReactNode; +}) { + return ( + + + + + + {title} + {description} + {action ?
{action}
: null} +
+
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8fc6b835bf1a..683d43eb94c4 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"; @@ -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,9 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const pullRequestsSupported = environments.some( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, ); + const githubIssuesSupported = environments.some( + (environment) => environment.serverConfig?.environment.capabilities.githubIssues === true, + ); const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -176,6 +182,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 +226,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..4e8506512524 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,85 @@ 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://github.acme.test/platform/api/issues/7#issuecomment-1"), + ).toEqual({ + host: "github.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/123", + "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("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..8600a336642c 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -62,6 +62,12 @@ 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; @@ -135,6 +141,20 @@ 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(); + if (!isHostOf(host, "github.com", "github")) return null; + 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 +196,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() && + pullRequestHostOf(identity, "github") === 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 +252,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..8c5751c86ff7 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, @@ -541,6 +542,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..f9e976fe906c 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,6 +63,13 @@ 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"; @@ -86,7 +94,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 +102,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 +127,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -127,7 +139,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -212,6 +224,20 @@ 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", + ...target, + }; +} + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -299,6 +325,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 +428,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/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx new file mode 100644 index 000000000000..0f68547be34d --- /dev/null +++ b/apps/web/src/routes/_chat.issues.tsx @@ -0,0 +1,421 @@ +import type { GitHubIssueListEntry, GitHubIssueListState, ProjectId } from "@t3tools/contracts"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { + CircleDotIcon, + CircleSlash2Icon, + GithubIcon, + RefreshCwIcon, + SearchIcon, +} from "lucide-react"; +import { useCallback, useMemo } from "react"; +import type { ReactNode } from "react"; + +import { GitHubIssueDetailContent } from "../components/githubIssue/GitHubIssueDetailPanel"; +import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../components/WorkspaceBreadcrumb"; +import { Button } from "../components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../components/ui/empty"; +import { Input } from "../components/ui/input"; +import { SidebarInset } from "../components/ui/sidebar"; +import { Spinner } from "../components/ui/spinner"; +import { cn } from "../lib/utils"; +import { githubIssueEnvironment } from "../state/githubIssues"; +import { useProjects } from "../state/entities"; +import { usePrimaryEnvironment } from "../state/environments"; +import { useEnvironmentQuery } from "../state/query"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; + +export interface IssuesSearch { + readonly state: GitHubIssueListState; + readonly projectId?: ProjectId; + readonly selectedProjectId?: ProjectId; + readonly repository?: string; + readonly number?: number; + readonly q?: string; +} + +export const Route = createFileRoute("/_chat/issues")({ + validateSearch: (raw: Record): IssuesSearch => ({ + state: raw.state === "all" || raw.state === "closed" ? raw.state : "open", + ...(typeof raw.projectId === "string" && raw.projectId + ? { projectId: raw.projectId as ProjectId } + : {}), + ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId + ? { selectedProjectId: raw.selectedProjectId as ProjectId } + : {}), + ...(typeof raw.repository === "string" && raw.repository + ? { repository: raw.repository.slice(0, 200) } + : {}), + ...(typeof raw.number === "number" && Number.isSafeInteger(raw.number) && raw.number > 0 + ? { number: raw.number } + : {}), + ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}), + }), + component: GitHubIssuesRoute, +}); + +function GitHubIssuesRoute() { + const search = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + const capabilityKnown = primaryEnvironment !== null && primaryEnvironment.serverConfig !== null; + const supported = + primaryEnvironment?.serverConfig?.environment.capabilities.githubIssues === true; + const projects = useProjects(); + const githubProjects = useMemo( + () => + projects + .filter( + (project) => + project.environmentId === environmentId && + project.repositoryIdentity?.provider === "github", + ) + .toSorted((left, right) => left.title.localeCompare(right.title)), + [environmentId, projects], + ); + const scopedProjectId = githubProjects.some((project) => project.id === search.projectId) + ? search.projectId + : undefined; + const [sentQuery] = useDebouncedValue(search.q?.trim() ?? "", { wait: 250 }); + const listQuery = useEnvironmentQuery( + supported && environmentId + ? githubIssueEnvironment.list({ + environmentId, + input: { + state: search.state, + limit: 50, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(sentQuery ? { query: sentQuery } : {}), + }, + }) + : null, + ); + const selectedRef = + search.selectedProjectId && search.repository && search.number + ? { + projectId: search.selectedProjectId, + repository: search.repository, + number: search.number, + } + : null; + const detailQuery = useEnvironmentQuery( + supported && environmentId && selectedRef + ? githubIssueEnvironment.detail({ environmentId, input: selectedRef }) + : null, + ); + const selectIssue = useCallback( + (issue: GitHubIssueListEntry) => { + void navigate({ + search: (current) => ({ + ...current, + selectedProjectId: issue.projectId, + repository: issue.repository, + number: issue.number, + }), + }); + }, + [navigate], + ); + + const updateFilters = (patch: { + state?: GitHubIssueListState; + projectId?: ProjectId | undefined; + q?: string | undefined; + }) => { + void navigate({ + search: (current) => { + const { + repository: _repository, + number: _number, + selectedProjectId: _selectedProjectId, + projectId: currentProjectId, + q: currentQuery, + ...base + } = current; + const projectId = "projectId" in patch ? patch.projectId : currentProjectId; + const q = "q" in patch ? patch.q : currentQuery; + return { + ...base, + ...(patch.state ? { state: patch.state } : {}), + ...(projectId ? { projectId } : {}), + ...(q ? { q } : {}), + }; + }, + }); + }; + + const body = !capabilityKnown ? ( +
+ Connecting to the environment... +
+ ) : !supported ? ( + + ) : githubProjects.length === 0 ? ( + + ) : listQuery.isPending && listQuery.data === null ? ( +
+ Loading issues... +
+ ) : listQuery.error && listQuery.data === null ? ( + Try again} + /> + ) : listQuery.data?.entries.length === 0 && listQuery.data.errors.length > 0 ? ( + Try again} + /> + ) : listQuery.data?.entries.length === 0 ? ( + + ) : ( +
+ {listQuery.data?.entries.map((issue) => ( + + ))} +
+ ); + + return ( + +
+
+ + +

GitHub Issues

+
+
+
+ +
+ +
+
+
+ + + +
+
+ {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 ( + + ); +} + +function IssueEmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: ReactNode; +}) { + return ( + + + + + + {title} + {description} + {action ?
{action}
: null} +
+
+ ); +} 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/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/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 3a4324625a00..46b2f813505c 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -26,4 +26,14 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.pullRequests, ).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); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1777bcebc2f8..906809606ec2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -51,6 +51,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..fe683a54b5c1 --- /dev/null +++ b/packages/contracts/src/githubIssue.ts @@ -0,0 +1,120 @@ +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 GitHubIssueUnavailableError extends Schema.TaggedErrorClass()( + "GitHubIssueUnavailableError", + { + reason: Schema.Literals(["cli-missing", "cli-unauthenticated"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "cli-missing" + ? "GitHub CLI (`gh`) is required to browse issues. Install it from https://cli.github.com/ and reload." + : "GitHub CLI is not authenticated. Run `gh auth login` 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 45bf581de084..dbec5de92737 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -98,6 +98,14 @@ import { PullRequestUnavailableError, PullRequestUpdateInput, } from "./pullRequest.ts"; +import { + GitHubIssueDetail, + GitHubIssueListInput, + GitHubIssueListResult, + GitHubIssueOperationError, + GitHubIssueRef, + GitHubIssueUnavailableError, +} from "./githubIssue.ts"; import { RelayClientInstallFailedError, RelayClientInstallProgressEventSchema, @@ -305,6 +313,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", @@ -602,6 +614,24 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest error: PullRequestRpcError, }); +const GitHubIssueRpcError = Schema.Union([ + GitHubIssueUnavailableError, + 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, { @@ -1037,6 +1067,8 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsInvalidateRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, + WsGitHubIssuesListRpc, + WsGitHubIssuesDetailRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, From 6ceb44da46e79b00055795183aaaf6da4f65a787 Mon Sep 17 00:00:00 2001 From: mtdewwolf Date: Sun, 23 Aug 2026 20:06:41 -0600 Subject: [PATCH 2/7] fix(clients): polish GitHub issue browsing states and filters - Share the GitHub issue empty state across list and detail views - Use shared workspace controls and primary-environment capability gating - Distinguish missing and unauthenticated GitHub CLI errors --- .../src/githubIssue/GitHubIssueService.ts | 20 +- apps/server/src/sourceControl/GitHubCli.ts | 7 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 4 +- .../githubIssue/GitHubIssueDetailPanel.tsx | 28 +-- .../GitHubIssueEmptyState.test.tsx | 45 +++++ .../src/components/sidebar/SidebarChrome.tsx | 10 +- apps/web/src/routes/-chatIssuesChrome.test.ts | 28 +++ apps/web/src/routes/_chat.issues.tsx | 180 +++++++++--------- packages/contracts/src/githubIssue.ts | 22 ++- packages/contracts/src/rpc.ts | 6 +- 11 files changed, 224 insertions(+), 130 deletions(-) create mode 100644 apps/web/src/components/githubIssue/GitHubIssueEmptyState.test.tsx create mode 100644 apps/web/src/routes/-chatIssuesChrome.test.ts diff --git a/apps/server/src/githubIssue/GitHubIssueService.ts b/apps/server/src/githubIssue/GitHubIssueService.ts index e28656d84633..f07441a045ed 100644 --- a/apps/server/src/githubIssue/GitHubIssueService.ts +++ b/apps/server/src/githubIssue/GitHubIssueService.ts @@ -1,16 +1,18 @@ import type { + GitHubIssueCliMissingError, + GitHubIssueCliUnauthenticatedError, GitHubIssueDetail, GitHubIssueListEntry, GitHubIssueListInput, GitHubIssueListResult, GitHubIssueOperationError, GitHubIssueRef, - GitHubIssueUnavailableError, OrchestrationProjectShell, } from "@t3tools/contracts"; import { + GitHubIssueCliMissingError as GitHubIssueCliMissingErrorClass, + GitHubIssueCliUnauthenticatedError as GitHubIssueCliUnauthenticatedErrorClass, GitHubIssueOperationError as GitHubIssueOperationErrorClass, - GitHubIssueUnavailableError as GitHubIssueUnavailableErrorClass, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -25,7 +27,10 @@ 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`; -type GitHubIssueError = GitHubIssueUnavailableError | GitHubIssueOperationError; +/** Every project reads through the one `gh`, so a CLI failure ends the whole request. */ +type GitHubIssueCliError = GitHubIssueCliMissingError | GitHubIssueCliUnauthenticatedError; + +type GitHubIssueError = GitHubIssueCliError | GitHubIssueOperationError; interface GitHubProject { readonly project: OrchestrationProjectShell; @@ -65,10 +70,10 @@ function cliRepository(project: GitHubProject): string { function fromCliError(operation: string) { return (error: GitHubCli.GitHubCliError): GitHubIssueError => { if (error._tag === "GitHubCliUnavailableError") { - return new GitHubIssueUnavailableErrorClass({ reason: "cli-missing", cause: error }); + return new GitHubIssueCliMissingErrorClass({ cause: error }); } if (error._tag === "GitHubCliAuthenticationError") { - return new GitHubIssueUnavailableErrorClass({ reason: "cli-unauthenticated", cause: error }); + return new GitHubIssueCliUnauthenticatedErrorClass({ cause: error }); } return new GitHubIssueOperationErrorClass({ operation, detail: error.detail, cause: error }); }; @@ -156,7 +161,10 @@ export const make = Effect.gen(function* () { ); const unavailable = batches.find( - (batch) => "error" in batch && batch.error._tag === "GitHubIssueUnavailableError", + (batch) => + "error" in batch && + (batch.error._tag === "GitHubIssueCliMissingError" || + batch.error._tag === "GitHubIssueCliUnauthenticatedError"), ); if (unavailable && "error" in unavailable) return yield* unavailable.error; diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 448aef15cbdd..1be9d52ce628 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -22,11 +22,10 @@ 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* ( - process: VcsProcess.VcsProcess["Service"], -) { +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({ @@ -389,7 +388,7 @@ export const make = Effect.gen(function* () { // `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(process); + const executable = yield* resolveGitHubCliExecutable(); const execute: GitHubCli["Service"]["execute"] = (input) => process diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 509739c8cda5..ccc3961474bd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -166,7 +166,7 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { GitHubIssueDetailPanel, - GitHubIssuesUnavailableState, + GitHubIssueEmptyState, } from "./githubIssue/GitHubIssueDetailPanel"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; @@ -6497,7 +6497,7 @@ function ChatViewContent(props: ChatViewProps) { ) : activeRightPanelSurface?.kind === "github-issue" && !sourceControlCapabilityKnown ? ( ) : activeRightPanelSurface?.kind === "github-issue" && !supportsGitHubIssues ? ( - diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6c1aef93a1ed..b406bc34fa93 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -595,7 +595,9 @@ function SurfaceIcon({ return ; } case "github-issue": - return ; + // 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 index c174fcafc40a..be6bc646277c 100644 --- a/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx +++ b/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx @@ -17,7 +17,14 @@ import { useEnvironmentQuery } from "../../state/query"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { PullRequestMarkdown } from "../pullRequest/PullRequestMarkdown"; import { Button } from "../ui/button"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; import { Spinner } from "../ui/spinner"; import { toastManager } from "../ui/toast"; @@ -196,17 +203,12 @@ export function GitHubIssueDetailContent({ ); } -export function GitHubIssuesUnavailableState({ - title, - description, -}: { - title: string; - description: string; -}) { - return ; -} - -function GitHubIssueEmptyState({ +/** + * 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, @@ -223,8 +225,8 @@ function GitHubIssueEmptyState({ {title} {description} - {action ?
{action}
: null} + {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..957d59f19b21 --- /dev/null +++ b/apps/web/src/components/githubIssue/GitHubIssueEmptyState.test.tsx @@ -0,0 +1,45 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it } from "vite-plus/test"; + +import { EmptyContent } from "../ui/empty"; +import { GitHubIssueEmptyState } 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"); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 683d43eb94c4..fdec7e33cbe6 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -11,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, @@ -166,9 +166,11 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const pullRequestsSupported = environments.some( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, ); - const githubIssuesSupported = environments.some( - (environment) => environment.serverConfig?.environment.capabilities.githubIssues === 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); diff --git a/apps/web/src/routes/-chatIssuesChrome.test.ts b/apps/web/src/routes/-chatIssuesChrome.test.ts new file mode 100644 index 000000000000..dcc467a9c932 --- /dev/null +++ b/apps/web/src/routes/-chatIssuesChrome.test.ts @@ -0,0 +1,28 @@ +// @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 + + + + updateFilters({ q: event.target.value || undefined })} + onChange={(event) => updateFilters({ q: event.currentTarget.value || undefined })} /> - - - updateFilters({ state: event.target.value as GitHubIssueListState }) - } + onValueChange={(value: string | null) => { + const next = STATE_OPTIONS.find((option) => option.value === value); + if (next) updateFilters({ state: next.value }); + }} > - - - - - + + + {projectLabel} + + + All projects + {githubProjects.map((project) => ( + + {project.title} + + ))} + +
{listQuery.data && @@ -396,26 +421,3 @@ function IssueRow({ ); } - -function IssueEmptyState({ - title, - description, - action, -}: { - title: string; - description: string; - action?: ReactNode; -}) { - return ( - - - - - - {title} - {description} - {action ?
{action}
: null} -
-
- ); -} diff --git a/packages/contracts/src/githubIssue.ts b/packages/contracts/src/githubIssue.ts index fe683a54b5c1..eccfe4da7d41 100644 --- a/packages/contracts/src/githubIssue.ts +++ b/packages/contracts/src/githubIssue.ts @@ -92,17 +92,21 @@ export const GitHubIssueDetail = Schema.Struct({ }); export type GitHubIssueDetail = typeof GitHubIssueDetail.Type; -export class GitHubIssueUnavailableError extends Schema.TaggedErrorClass()( - "GitHubIssueUnavailableError", - { - reason: Schema.Literals(["cli-missing", "cli-unauthenticated"]), - cause: Schema.optional(Schema.Defect()), - }, +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() }, ) { override get message(): string { - return this.reason === "cli-missing" - ? "GitHub CLI (`gh`) is required to browse issues. Install it from https://cli.github.com/ and reload." - : "GitHub CLI is not authenticated. Run `gh auth login` and retry."; + return "GitHub CLI is not authenticated. Run `gh auth login` and retry."; } } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbec5de92737..238567fcc399 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -99,12 +99,13 @@ import { PullRequestUpdateInput, } from "./pullRequest.ts"; import { + GitHubIssueCliMissingError, + GitHubIssueCliUnauthenticatedError, GitHubIssueDetail, GitHubIssueListInput, GitHubIssueListResult, GitHubIssueOperationError, GitHubIssueRef, - GitHubIssueUnavailableError, } from "./githubIssue.ts"; import { RelayClientInstallFailedError, @@ -615,7 +616,8 @@ export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequest }); const GitHubIssueRpcError = Schema.Union([ - GitHubIssueUnavailableError, + GitHubIssueCliMissingError, + GitHubIssueCliUnauthenticatedError, GitHubIssueOperationError, EnvironmentAuthorizationError, ]); From d196efea706290326350aeee643a41ddd0c791de Mon Sep 17 00:00:00 2001 From: mtdewwolf Date: Sun, 23 Aug 2026 20:33:24 -0600 Subject: [PATCH 3/7] fix(clients): scope GitHub issue failures and filters correctly An unauthenticated GitHub Enterprise remote failed the whole issue list, discarding the repositories that had answered; only a missing `gh` ends the request now, and a locked-out host becomes a per-project error naming the hostname to sign in to. On the web side the detail header wrapped its actions into the title in the narrow right panel, the project filter read an empty sentinel, filter edits pushed a history entry per keystroke, and the "no projects" gate waited on every environment while the page reads only the primary. Model: Claude Opus 5 (1M context) via Claude Code Co-Authored-By: Claude Opus 5 (1M context) --- .../githubIssue/GitHubIssueService.test.ts | 95 ++++++++++++++++++- .../src/githubIssue/GitHubIssueService.ts | 27 ++++-- .../githubIssue/GitHubIssueDetailPanel.tsx | 57 ++++++----- apps/web/src/routes/_chat.issues.tsx | 23 +++-- apps/web/src/state/entities.ts | 14 ++- apps/web/src/state/shell.ts | 11 +++ 6 files changed, 186 insertions(+), 41 deletions(-) diff --git a/apps/server/src/githubIssue/GitHubIssueService.test.ts b/apps/server/src/githubIssue/GitHubIssueService.test.ts index 148e44e64585..519b03eb81d4 100644 --- a/apps/server/src/githubIssue/GitHubIssueService.test.ts +++ b/apps/server/src/githubIssue/GitHubIssueService.test.ts @@ -14,17 +14,19 @@ function project(input: { 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: `github.com/${input.repository}`, + canonicalKey: `${host}/${input.repository}`, locator: { source: "git-remote", remoteName: "origin", - remoteUrl: `https://github.com/${input.repository}.git`, + remoteUrl: `https://${host}/${input.repository}.git`, }, provider: input.provider ?? "github", displayName: input.repository, @@ -194,3 +196,92 @@ it.effect("loads issue detail with its discussion and workspace", () => 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("fails the whole read 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: "web", workspaceRoot: "/web", repository: "acme/web" })], + execute, + ); + + const error = yield* service.list({ state: "open" }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubIssueCliUnauthenticatedError"); + }), +); + +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 index f07441a045ed..09d61f1031cc 100644 --- a/apps/server/src/githubIssue/GitHubIssueService.ts +++ b/apps/server/src/githubIssue/GitHubIssueService.ts @@ -160,13 +160,22 @@ export const make = Effect.gen(function* () { { concurrency: PROJECT_CONCURRENCY }, ); - const unavailable = batches.find( - (batch) => - "error" in batch && - (batch.error._tag === "GitHubIssueCliMissingError" || - batch.error._tag === "GitHubIssueCliUnauthenticatedError"), + // 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 (unavailable && "error" in unavailable) return yield* unavailable.error; + if (cliMissing && "error" in cliMissing) return yield* cliMissing.error; + + // Authentication is per host: an unauthenticated Enterprise remote must not discard the + // issues another repository answered with. Only a workspace that is entirely locked out + // gets the global "run gh auth login" error, which is the only case where it is the answer. + const unauthenticated = batches.filter( + (batch) => "error" in batch && batch.error._tag === "GitHubIssueCliUnauthenticatedError", + ); + if (unauthenticated.length > 0 && unauthenticated.length === batches.length) { + const [first] = unauthenticated; + if (first && "error" in first) return yield* first.error; + } const entries: GitHubIssueListEntry[] = []; const errors: GitHubIssueListResult["errors"][number][] = []; @@ -176,7 +185,11 @@ export const make = Effect.gen(function* () { errors.push({ projectId: batch.project.project.id, projectTitle: batch.project.project.title, - message: `${batch.project.repository} could not be read.`, + // 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 \`gh auth login --hostname ${batch.project.host}\` and retry.` + : `${batch.project.repository} could not be read.`, }); continue; } diff --git a/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx b/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx index be6bc646277c..da454520a4d6 100644 --- a/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx +++ b/apps/web/src/components/githubIssue/GitHubIssueDetailPanel.tsx @@ -122,32 +122,39 @@ export function GitHubIssueDetailContent({ return (
-
- -
-

{detail.title}

-

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

+ {/* 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)} +

+
+
+
+ +
- -
diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx index a7e7c43704d8..d77f0b3f92df 100644 --- a/apps/web/src/routes/_chat.issues.tsx +++ b/apps/web/src/routes/_chat.issues.tsx @@ -24,13 +24,16 @@ import { Spinner } from "../components/ui/spinner"; import { isElectron } from "../env"; import { cn } from "../lib/utils"; import { githubIssueEnvironment } from "../state/githubIssues"; -import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; +import { useEnvironmentShellBootstrapped, useProjects } from "../state/entities"; import { usePrimaryEnvironment } from "../state/environments"; import { useEnvironmentQuery } from "../state/query"; import { formatRelativeTimeLabel } from "../timestampFormat"; -/** "Every project" wears the one value no project id can be. */ -const ALL_PROJECTS_VALUE = ""; +/** + * "Every project" wears a value no project id can be, matching the pull request filters. A + * project id is a UUID, so this can never collide with one. + */ +const ALL_PROJECTS_VALUE = "all"; const STATE_OPTIONS = [ { value: "open", label: "Open" }, @@ -76,8 +79,10 @@ function GitHubIssuesRoute() { const supported = primaryEnvironment?.serverConfig?.environment.capabilities.githubIssues === true; const projects = useProjects(); - // An unread shell has no projects yet, which is not the same answer as having none. - const projectsKnown = useAllEnvironmentShellsBootstrapped(); + // An unread shell has no projects yet, which is not the same answer as having none. Scoped to + // the primary environment because that is the only one this page reads: the all-environments + // gate would stay shut while some other server is still connecting. + const projectsKnown = useEnvironmentShellBootstrapped(environmentId); const githubProjects = useMemo( () => projects @@ -146,6 +151,9 @@ function GitHubIssuesRoute() { q?: string | undefined; }) => { void navigate({ + // Filters are transient edits, not places: without this every keystroke of a search would + // push a history entry and Back would walk the query one character at a time. + replace: true, search: (current) => { const { repository: _repository, @@ -282,7 +290,10 @@ function GitHubIssuesRoute() {