From ec444edbb9b32c2fff93c9755808fffa8351cf77 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Sun, 11 Oct 2026 12:02:07 +0000 Subject: [PATCH] feat(issues): browse a project's GitHub issues beside pull requests Adds an Issues page (sidebar, command palette) that lists a project's issues with search, state, assignee and label filters, and opens one to read its description, labels, assignees, state and comments, with Open in GitHub for everything else (#11067). In a thread, the panel's surface menu gains Issues (I): the thread project's issues beside the conversation, each selected issue opening as its own tab. GitHub issue links in chat and pull request descriptions open there too, including `#123` references that turn out to be issues rather than pull requests; outside a thread they open the Issues page. Issue trackers sit behind their own provider interface (apps/server/src/issue/IssueProvider.ts) rather than the source control driver, so a tracker that is not a git host can be added beside GitHub. GitHub reads through the existing GitHub API client, sharing its credentials, quota reserve and rate-limit pause with pull requests. Listings are cursor-paginated per page, cached on the server for 30s (identical reads coalesce, failures are not kept) and per page on the client. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 3 + .../src/environment/ServerEnvironment.ts | 1 + .../src/issue/GitHubIssueProvider.test.ts | 250 ++++++++++++ apps/server/src/issue/GitHubIssueProvider.ts | 355 ++++++++++++++++++ apps/server/src/issue/IssueProvider.ts | 71 ++++ .../server/src/issue/IssueProviderRegistry.ts | 36 ++ apps/server/src/issue/IssueService.test.ts | 156 ++++++++ apps/server/src/issue/IssueService.ts | 182 +++++++++ .../src/observability/RpcInstrumentation.ts | 3 + apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 8 + apps/web/src/closedViewStore.ts | 10 + apps/web/src/components/ChatMarkdown.tsx | 27 +- apps/web/src/components/ChatView.tsx | 27 ++ apps/web/src/components/CommandPalette.tsx | 18 + .../RightPanelTabs.keyboard.test.tsx | 2 + .../src/components/RightPanelTabs.test.tsx | 2 + apps/web/src/components/RightPanelTabs.tsx | 32 ++ .../web/src/components/issue/IssueBrowser.tsx | 298 +++++++++++++++ .../src/components/issue/IssueDetailPanel.tsx | 215 +++++++++++ apps/web/src/components/issue/IssueList.tsx | 202 ++++++++++ .../components/issue/ThreadIssuesPanel.tsx | 49 +++ .../issue/issueProjects.logic.test.ts | 80 ++++ .../components/issue/issueProjects.logic.ts | 56 +++ .../src/components/issue/issueTrackers.tsx | 40 ++ .../src/components/sidebar/SidebarChrome.tsx | 16 +- .../src/components/sidebar/mainAppLocation.ts | 5 +- apps/web/src/lib/openIssueLink.test.ts | 49 +++ apps/web/src/lib/openIssueLink.ts | 110 ++++++ apps/web/src/reopenClosedView.ts | 3 + apps/web/src/rightPanelStore.test.ts | 49 +++ apps/web/src/rightPanelStore.ts | 71 +++- apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/_chat.issues.tsx | 229 +++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 2 + apps/web/src/state/environments.ts | 4 + apps/web/src/state/issues.test.ts | 48 +++ apps/web/src/state/issues.ts | 121 ++++++ docs/README.md | 1 + docs/user/issues.md | 22 ++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/issues.ts | 33 ++ .../client-runtime/src/state/presentation.ts | 8 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/issue.ts | 155 ++++++++ packages/contracts/src/rpc.ts | 37 ++ 47 files changed, 3107 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/issue/GitHubIssueProvider.test.ts create mode 100644 apps/server/src/issue/GitHubIssueProvider.ts create mode 100644 apps/server/src/issue/IssueProvider.ts create mode 100644 apps/server/src/issue/IssueProviderRegistry.ts create mode 100644 apps/server/src/issue/IssueService.test.ts create mode 100644 apps/server/src/issue/IssueService.ts create mode 100644 apps/web/src/components/issue/IssueBrowser.tsx create mode 100644 apps/web/src/components/issue/IssueDetailPanel.tsx create mode 100644 apps/web/src/components/issue/IssueList.tsx create mode 100644 apps/web/src/components/issue/ThreadIssuesPanel.tsx create mode 100644 apps/web/src/components/issue/issueProjects.logic.test.ts create mode 100644 apps/web/src/components/issue/issueProjects.logic.ts create mode 100644 apps/web/src/components/issue/issueTrackers.tsx create mode 100644 apps/web/src/lib/openIssueLink.test.ts create mode 100644 apps/web/src/lib/openIssueLink.ts create mode 100644 apps/web/src/routes/_chat.issues.tsx create mode 100644 apps/web/src/state/issues.test.ts create mode 100644 apps/web/src/state/issues.ts create mode 100644 docs/user/issues.md create mode 100644 packages/client-runtime/src/state/issues.ts create mode 100644 packages/contracts/src/issue.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..ea5b37333a87 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -121,6 +121,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.issuesSources]: AuthOrchestrationReadScope, + [WS_METHODS.issuesList]: AuthOrchestrationReadScope, + [WS_METHODS.issuesDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a55b1098d5f0..3d442dc47263 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { questionAttachments: true, fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, + issues: true, pullRequestChecks: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, diff --git a/apps/server/src/issue/GitHubIssueProvider.test.ts b/apps/server/src/issue/GitHubIssueProvider.test.ts new file mode 100644 index 000000000000..68ad0a4472bf --- /dev/null +++ b/apps/server/src/issue/GitHubIssueProvider.test.ts @@ -0,0 +1,250 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ProjectId } from "@t3tools/contracts"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; + +import { + decodeIssueDetailJson, + decodeIssueSearchJson, + issueSearchQuery, + make, +} from "./GitHubIssueProvider.ts"; +import type { IssueProject } from "./IssueProvider.ts"; + +const gitHubProject = (provider = "github"): IssueProject => ({ + id: "project-1" as ProjectId, + workspaceRoot: "/repo", + repositoryIdentity: { + canonicalKey: "github.com/acme/web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/acme/web", + }, + provider, + displayName: "acme/web", + }, +}); + +const issueNode = (number: number, overrides: Record = {}) => ({ + __typename: "Issue", + number, + title: `Issue ${number}`, + url: `https://github.com/acme/web/issues/${number}`, + state: "OPEN", + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-02T10:00:00Z", + author: { __typename: "User", login: "octo", name: "Octo Cat", avatarUrl: "https://a/octo" }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + assignees: { nodes: [{ __typename: "Bot", login: "helper[bot]", avatarUrl: null }] }, + comments: { totalCount: 3 }, + ...overrides, +}); + +const provideApi = (graphql: GitHubApi.GitHubApi["Service"]["graphql"]) => + Effect.provide(Layer.mock(GitHubApi.GitHubApi)({ graphql })); + +describe("issueSearchQuery", () => { + it("scopes the search to the repository's issues and maps every filter", () => { + assert.strictEqual( + issueSearchQuery({ + repository: "acme/web", + state: "open", + assignee: "me", + label: "good first issue", + query: "login crash", + }), + 'repo:acme/web is:issue is:open assignee:@me label:"good first issue" "login" "crash" sort:updated-desc', + ); + assert.strictEqual( + issueSearchQuery({ + repository: "acme/web", + state: "all", + assignee: "none", + label: undefined, + query: undefined, + }), + "repo:acme/web is:issue no:assignee sort:updated-desc", + ); + }); + + it("keeps typed qualifiers from reading beyond the project", () => { + const query = issueSearchQuery({ + repository: "acme/web", + state: "open", + assignee: "anyone", + label: 'x" repo:other/secret', + query: 'repo:other/secret is:pr "quoted', + }); + assert.include(query, '"repo:other/secret" "is:pr" "quoted"'); + assert.include(query, 'label:"x repo:other/secret"'); + // Outside the quoted terms, the project's own repository is the only one named. + assert.deepStrictEqual(query.replace(/"[^"]*"/g, '""').match(/repo:\S+/g), ["repo:acme/web"]); + }); +}); + +describe("decodeIssueSearchJson", () => { + it("reads issues, skips anything else the search returned, and carries the cursor", () => { + const decoded = decodeIssueSearchJson( + JSON.stringify({ + data: { + search: { + pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjUw" }, + nodes: [issueNode(7), {}, null], + }, + }, + }), + ); + assert.isTrue(Result.isSuccess(decoded)); + if (!Result.isSuccess(decoded)) return; + assert.strictEqual(decoded.success.nextCursor, "Y3Vyc29yOjUw"); + assert.deepStrictEqual(decoded.success.items, [ + { + id: "7", + reference: "#7", + title: "Issue 7", + url: "https://github.com/acme/web/issues/7", + state: "open", + author: { login: "octo", name: "Octo Cat", avatarUrl: "https://a/octo" }, + labels: [{ name: "bug", color: "d73a4a" }], + assignees: [{ login: "helper[bot]", name: null, avatarUrl: null, isBot: true }], + commentCount: 3, + createdAt: "2026-10-01T10:00:00Z", + updatedAt: "2026-10-02T10:00:00Z", + }, + ]); + }); + + it("has no next cursor on the last page", () => { + const decoded = decodeIssueSearchJson( + JSON.stringify({ + data: { search: { pageInfo: { hasNextPage: false, endCursor: "end" }, nodes: [] } }, + }), + ); + assert.deepStrictEqual(Result.getOrThrow(decoded), { items: [], nextCursor: null }); + }); +}); + +describe("decodeIssueDetailJson", () => { + it("reads the body, close reason and comments", () => { + const decoded = decodeIssueDetailJson( + JSON.stringify({ + data: { + repository: { + issue: { + ...issueNode(9, { state: "CLOSED" }), + body: "Steps to reproduce", + closedAt: "2026-10-03T10:00:00Z", + stateReason: "NOT_PLANNED", + comments: { + totalCount: 120, + nodes: [ + { + id: "IC_1", + url: "https://github.com/acme/web/issues/9#issuecomment-1", + body: "Same here", + createdAt: "2026-10-02T11:00:00Z", + author: null, + }, + ], + }, + }, + }, + }, + }), + ); + const issue = Result.getOrThrow(decoded); + assert.strictEqual(issue?.state, "closed"); + assert.strictEqual(issue?.stateReason, "not-planned"); + assert.strictEqual(issue?.commentCount, 120); + assert.deepStrictEqual(issue?.comments, [ + { + id: "IC_1", + author: null, + body: "Same here", + createdAt: "2026-10-02T11:00:00Z", + url: "https://github.com/acme/web/issues/9#issuecomment-1", + }, + ]); + }); + + it("is null when GitHub has no such issue", () => { + const decoded = decodeIssueDetailJson( + JSON.stringify({ data: { repository: { issue: null } } }), + ); + assert.isNull(Result.getOrThrow(decoded)); + }); +}); + +describe("GitHubIssueProvider", () => { + it.effect("lists through one search against the project's host", () => + Effect.gen(function* () { + const requests: Array = []; + const provider = yield* make.pipe( + provideApi((input) => { + requests.push(input); + return Effect.succeed( + JSON.stringify({ + data: { search: { pageInfo: { hasNextPage: false }, nodes: [issueNode(1)] } }, + }), + ); + }), + ); + const page = yield* provider.listIssues(gitHubProject(), { + state: "closed", + assignee: "anyone", + label: undefined, + query: undefined, + limit: 500, + cursor: "abc", + }); + assert.deepStrictEqual( + page.items.map((item) => item.id), + ["1"], + ); + assert.strictEqual(requests.length, 1); + assert.strictEqual(requests[0]?.host, "github.com"); + assert.deepStrictEqual(requests[0]?.variables, { + q: "repo:acme/web is:issue is:closed sort:updated-desc", + first: 100, + after: "abc", + }); + }), + ); + + it.effect("refuses a project that is not on GitHub without asking GitHub", () => + Effect.gen(function* () { + const provider = yield* make.pipe(provideApi(() => Effect.die("unexpected request"))); + const error = yield* Effect.flip(provider.sourceName(gitHubProject("gitlab"))); + assert.strictEqual(error.reason, "unsupported"); + }), + ); + + it.effect("reports a rate limit as such, and an unknown issue as not found", () => + Effect.gen(function* () { + const limited = yield* make.pipe( + provideApi((input) => + Effect.fail( + new GitHubApi.GitHubApiRateLimitError({ host: input.host, operation: input.operation }), + ), + ), + ); + const rateLimited = yield* Effect.flip(limited.getIssue(gitHubProject(), "4")); + assert.strictEqual(rateLimited.reason, "rate-limited"); + + const missing = yield* make.pipe( + provideApi(() => Effect.succeed(JSON.stringify({ data: { repository: { issue: null } } }))), + ); + assert.strictEqual( + (yield* Effect.flip(missing.getIssue(gitHubProject(), "4"))).reason, + "not-found", + ); + assert.strictEqual( + (yield* Effect.flip(missing.getIssue(gitHubProject(), "ENG-4"))).reason, + "not-found", + ); + }), + ); +}); diff --git a/apps/server/src/issue/GitHubIssueProvider.ts b/apps/server/src/issue/GitHubIssueProvider.ts new file mode 100644 index 000000000000..47279326a6dc --- /dev/null +++ b/apps/server/src/issue/GitHubIssueProvider.ts @@ -0,0 +1,355 @@ +/** + * GitHub issues, read through the same GitHub API client, credentials, quota reserve and rate-limit + * pause as pull requests. A project's issues are its own repository's: a listing is one + * repository-scoped search, a detail one `repository.issue` read with its first comments. + */ +import { + pullRequestHostOf, + SourceControlProviderKind, + type IssueAssigneeFilter, + type IssueComment, + type IssueDetail, + type IssueListEntry, + type IssueListState, + type IssueStateReason, + type PullRequestActor, + type PullRequestLabel, +} from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; +import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { IssueProviderError, type IssueProject, type IssueProviderApi } from "./IssueProvider.ts"; + +/** GitHub serves at most a hundred rows per GraphQL page. */ +const MAX_PAGE_SIZE = 100; +/** Comments a detail reads; past this the page points at GitHub for the rest. */ +const DETAIL_COMMENT_LIMIT = 100; + +/** A repository goes into the search query as itself, so it must be nothing but `owner/name`. */ +const REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +const ASSIGNEE_QUALIFIERS: Record = { + anyone: null, + me: "assignee:@me", + none: "no:assignee", +}; + +/** A value written into the search as one quoted term, so it cannot start a qualifier of its own. */ +function quoted(value: string): string { + return `"${value.replaceAll("\\", "").replaceAll('"', "")}"`; +} + +/** + * The listing as one GitHub search. Every word the reader typed is a quoted term: `repo:` or + * `is:pr` would otherwise read beyond the project's own issues. + */ +export function issueSearchQuery(input: { + readonly repository: string; + readonly state: IssueListState; + readonly assignee: IssueAssigneeFilter; + readonly label: string | undefined; + readonly query: string | undefined; +}): string { + const assignee = ASSIGNEE_QUALIFIERS[input.assignee]; + const words = (input.query ?? "").split(/\s+/).filter((word) => word.length > 0); + return [ + `repo:${input.repository}`, + "is:issue", + ...(input.state === "all" ? [] : [`is:${input.state}`]), + ...(assignee === null ? [] : [assignee]), + ...(input.label === undefined ? [] : [`label:${quoted(input.label)}`]), + ...words.map(quoted), + "sort:updated-desc", + ].join(" "); +} + +const ACTOR_FIELDS = "__typename login avatarUrl ... on User { name }"; + +const ISSUE_FIELDS = ` + number title url state createdAt updatedAt + author { ${ACTOR_FIELDS} } + labels(first: 20) { nodes { name color } } + assignees(first: 10) { nodes { ${ACTOR_FIELDS} } }`; + +const ISSUE_SEARCH_GRAPHQL_QUERY = ` +query IssueSearch($q: String!, $first: Int!, $after: String) { + search(query: $q, type: ISSUE, first: $first, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { __typename ... on Issue { ${ISSUE_FIELDS} comments { totalCount } } } + } +}`; + +/** + * `stateReason` is asked for on github.com only: an older Enterprise server may not know it, and + * an unknown field fails the whole read. + */ +const issueDetailGraphQlQuery = (withStateReason: boolean) => ` +query IssueDetail($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + ${ISSUE_FIELDS} + body closedAt${withStateReason ? " stateReason" : ""} + comments(first: ${DETAIL_COMMENT_LIMIT}) { + totalCount + nodes { id url body createdAt author { ${ACTOR_FIELDS} } } + } + } + } +}`; + +const RawActor = Schema.Struct({ + __typename: Schema.optional(Schema.String), + login: Schema.optional(Schema.NullOr(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 RawIssue = Schema.Struct({ + number: Schema.Number, + title: Schema.String, + url: Schema.String, + state: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + author: Schema.optional(Schema.NullOr(RawActor)), + labels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabel)) })), + ), + assignees: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawActor)) })), + ), + comments: Schema.Struct({ totalCount: Schema.Number }), +}); + +const RawSearchResponse = Schema.Struct({ + data: Schema.Struct({ + search: Schema.Struct({ + pageInfo: Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + // A search for issues still answers with `{}` for anything that is not one. + nodes: Schema.Array(Schema.NullOr(Schema.Unknown)), + }), + }), +}); + +const RawComment = Schema.Struct({ + id: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.String, + createdAt: Schema.String, + author: Schema.optional(Schema.NullOr(RawActor)), +}); + +const RawDetailResponse = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + issue: Schema.NullOr( + Schema.Struct({ + ...RawIssue.fields, + body: Schema.String, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + stateReason: Schema.optional(Schema.NullOr(Schema.String)), + comments: Schema.Struct({ + totalCount: Schema.Number, + nodes: Schema.Array(Schema.NullOr(RawComment)), + }), + }), + ), + }), + ), + }), +}); + +const decodeSearchResponse = decodeJsonResult(RawSearchResponse); +const decodeRawIssue = Schema.decodeUnknownOption(RawIssue); +const decodeDetailResponse = decodeJsonResult(RawDetailResponse); + +function toActor(raw: typeof RawActor.Type | null | undefined): PullRequestActor | null { + const login = raw?.login?.trim(); + if (login === undefined || login.length === 0) return null; + return { + login, + name: raw?.name?.trim() || null, + avatarUrl: raw?.avatarUrl?.trim() || null, + ...(raw?.__typename === "Bot" ? { isBot: true } : {}), + }; +} + +function toEntry(raw: typeof RawIssue.Type): IssueListEntry { + return { + id: String(raw.number), + reference: `#${raw.number}`, + title: raw.title, + url: raw.url, + state: raw.state === "CLOSED" ? "closed" : "open", + author: toActor(raw.author), + labels: (raw.labels?.nodes ?? []).flatMap((label): PullRequestLabel[] => { + const name = label?.name.trim(); + return name ? [{ name, color: label?.color?.trim() || null }] : []; + }), + assignees: (raw.assignees?.nodes ?? []).flatMap((actor) => toActor(actor) ?? []), + commentCount: raw.comments.totalCount, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + }; +} + +const STATE_REASONS: Partial> = { + COMPLETED: "completed", + NOT_PLANNED: "not-planned", + DUPLICATE: "duplicate", +}; + +export function decodeIssueSearchJson( + raw: string, +): Result.Result<{ items: ReadonlyArray; nextCursor: string | null }, unknown> { + return Result.map(decodeSearchResponse(raw), ({ data: { search } }) => ({ + items: search.nodes.flatMap((node) => { + const issue = decodeRawIssue(node); + return issue._tag === "Some" ? [toEntry(issue.value)] : []; + }), + nextCursor: search.pageInfo.hasNextPage ? (search.pageInfo.endCursor ?? null) : null, + })); +} + +/** Null where GitHub answered that the repository or issue does not exist. */ +export function decodeIssueDetailJson(raw: string): Result.Result { + return Result.map(decodeDetailResponse(raw), ({ data }) => { + const issue = data.repository?.issue; + if (issue == null) return null; + return { + ...toEntry(issue), + body: issue.body, + closedAt: issue.closedAt ?? null, + stateReason: STATE_REASONS[issue.stateReason ?? ""] ?? null, + comments: issue.comments.nodes.flatMap((comment): IssueComment[] => + comment === null + ? [] + : [ + { + id: comment.id, + author: toActor(comment.author), + body: comment.body, + createdAt: comment.createdAt, + url: comment.url ?? null, + }, + ], + ), + }; + }); +} + +export const make = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + + const fail = (operation: string, reason: IssueProviderError["reason"], detail: string) => + new IssueProviderError({ tracker: "github", operation, reason, detail }); + + /** The project's own GitHub repository; anything else has no GitHub issues to read. */ + const repositoryOf = ( + project: IssueProject, + operation: string, + ): Effect.Effect<{ readonly repository: string; readonly host: string }, IssueProviderError> => { + const identity = project.repositoryIdentity; + const repository = sourceControlRepositorySelector(identity)?.trim() ?? null; + if (identity?.provider !== "github" || repository === null || !REPOSITORY.test(repository)) { + return Effect.fail( + fail(operation, "unsupported", "This project is not a GitHub repository."), + ); + } + return Effect.succeed({ + repository, + host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")), + }); + }; + + const read = (input: { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables: Readonly>; + readonly decode: (raw: string) => Result.Result; + }) => + api + .graphql({ + host: input.host, + operation: input.operation, + query: input.query, + variables: input.variables, + }) + .pipe( + Effect.mapError( + (error) => + new IssueProviderError({ + tracker: "github", + operation: input.operation, + reason: gitHubProviderFailure(error).reason, + detail: error.message, + cause: error, + }), + ), + Effect.flatMap((raw) => { + const decoded = input.decode(raw.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + fail(input.operation, "failed", "GitHub answered in a shape T3 Code cannot read."), + ); + }), + ); + + return { + kind: "github", + sourceName: (project) => + repositoryOf(project, "sourceName").pipe(Effect.map(({ repository }) => repository)), + listIssues: (project, input) => + repositoryOf(project, "listIssues").pipe( + Effect.flatMap(({ repository, host }) => + read({ + host, + operation: "listIssues", + query: ISSUE_SEARCH_GRAPHQL_QUERY, + variables: { + q: issueSearchQuery({ ...input, repository }), + first: Math.min(Math.max(input.limit, 1), MAX_PAGE_SIZE), + after: input.cursor ?? null, + }, + decode: decodeIssueSearchJson, + }), + ), + ), + getIssue: (project, id) => { + const number = /^[1-9][0-9]{0,9}$/.test(id) ? Number(id) : null; + if (number === null) return Effect.fail(fail("getIssue", "not-found", `No issue #${id}.`)); + return repositoryOf(project, "getIssue").pipe( + Effect.flatMap(({ repository, host }) => { + const [owner, name] = repository.split("/"); + return read({ + host, + operation: "getIssue", + query: issueDetailGraphQlQuery(host === "github.com"), + variables: { owner, name, number }, + decode: decodeIssueDetailJson, + }); + }), + Effect.filterOrFail( + (issue) => issue !== null, + () => fail("getIssue", "not-found", `Issue #${number} was not found.`), + ), + ); + }, + } satisfies IssueProviderApi; +}); diff --git a/apps/server/src/issue/IssueProvider.ts b/apps/server/src/issue/IssueProvider.ts new file mode 100644 index 000000000000..f8d0f524d98b --- /dev/null +++ b/apps/server/src/issue/IssueProvider.ts @@ -0,0 +1,71 @@ +/** + * IssueProvider — what an issue tracker implements so the Issues page can read it. + * + * Trackers are not source control hosts: GitHub reads a project's issues from its repository, + * while another tracker may keep them somewhere the repository says nothing about. So each call + * hands the provider the whole project and the provider decides where its issues live, failing + * with `unsupported` or `not-configured` where it cannot say. + * + * Read-only. Everything a provider returns is already in the contract's tracker-neutral shape. + */ +import type { + IssueAssigneeFilter, + IssueDetail, + IssueErrorReason, + IssueListEntry, + IssueListState, + IssueTrackerKind, + ProjectId, + RepositoryIdentity, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import type * as Effect from "effect/Effect"; + +export class IssueProviderError extends Data.TaggedError("IssueProviderError")<{ + readonly tracker: IssueTrackerKind; + readonly operation: string; + readonly reason: IssueErrorReason; + readonly detail: string; + readonly cause?: unknown; +}> { + override get message(): string { + return `${this.tracker} failed in ${this.operation}: ${this.detail}`; + } +} + +/** The project as a tracker sees it. */ +export interface IssueProject { + readonly id: ProjectId; + readonly workspaceRoot: string; + readonly repositoryIdentity: RepositoryIdentity | null; +} + +export interface ProviderIssueQuery { + readonly state: IssueListState; + readonly assignee: IssueAssigneeFilter; + readonly label: string | undefined; + readonly query: string | undefined; + readonly limit: number; + readonly cursor: string | undefined; +} + +export interface ProviderIssuePage { + readonly items: ReadonlyArray; + /** Opaque to everyone but the provider that issued it; null on the last page. */ + readonly nextCursor: string | null; +} + +export interface IssueProviderApi { + readonly kind: IssueTrackerKind; + /** Where this project's issues live on the tracker, named for the page. */ + readonly sourceName: (project: IssueProject) => Effect.Effect; + readonly listIssues: ( + project: IssueProject, + query: ProviderIssueQuery, + ) => Effect.Effect; + /** `id` is an entry's own `id`, as a listing handed it over. */ + readonly getIssue: ( + project: IssueProject, + id: string, + ) => Effect.Effect; +} diff --git a/apps/server/src/issue/IssueProviderRegistry.ts b/apps/server/src/issue/IssueProviderRegistry.ts new file mode 100644 index 000000000000..4a9d738ca905 --- /dev/null +++ b/apps/server/src/issue/IssueProviderRegistry.ts @@ -0,0 +1,36 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { IssueTrackerKind } from "@t3tools/contracts"; + +import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import * as GitHubIssueProvider from "./GitHubIssueProvider.ts"; +import type { IssueProviderApi } from "./IssueProvider.ts"; + +export class IssueProviderRegistry extends Context.Service< + IssueProviderRegistry, + { + readonly get: (kind: IssueTrackerKind) => IssueProviderApi | null; + /** Every tracker this build can read, in the order the page offers them. */ + readonly all: ReadonlyArray; + } +>()("t3/issue/IssueProviderRegistry") {} + +/** Exported for tests, which stand a registry up from providers they supply themselves. */ +export function fromProviders( + providers: ReadonlyArray, +): IssueProviderRegistry["Service"] { + const byKind = new Map(providers.map((provider) => [provider.kind, provider])); + return { get: (kind) => byKind.get(kind) ?? null, all: providers }; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + return fromProviders([yield* GitHubIssueProvider.make]); +}); + +export const layer = Layer.effect(IssueProviderRegistry, make).pipe( + // The same GitHub client pull requests read through, so issues share its credentials, quota + // reserve and rate-limit pause rather than spending a second budget against the same account. + Layer.provide(BuiltInDrivers.layer), +); diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts new file mode 100644 index 000000000000..6fbc0565195f --- /dev/null +++ b/apps/server/src/issue/IssueService.test.ts @@ -0,0 +1,156 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { IssueListEntry, OrchestrationProjectShell, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { IssueProviderError, type IssueProviderApi } from "./IssueProvider.ts"; +import * as IssueProviderRegistry from "./IssueProviderRegistry.ts"; +import * as IssueService from "./IssueService.ts"; + +const project = { + id: "project-1" as ProjectId, + title: "web", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", +} as unknown as OrchestrationProjectShell; + +const entry: IssueListEntry = { + id: "1", + reference: "#1", + title: "First", + url: "https://github.com/acme/web/issues/1", + state: "open", + author: null, + labels: [], + assignees: [], + commentCount: 0, + createdAt: "2026-10-01T00:00:00Z", + updatedAt: "2026-10-01T00:00:00Z", +}; + +function makeService(provider: IssueProviderApi) { + return IssueService.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + IssueProviderRegistry.IssueProviderRegistry, + IssueProviderRegistry.fromProviders([provider]), + ), + Layer.mock(ProjectService.ProjectService)({ + getShell: (projectId) => + Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + }), + ), + ), + ); +} + +function fakeProvider(overrides: Partial = {}): IssueProviderApi { + return { + kind: "github", + sourceName: () => Effect.succeed("acme/web"), + listIssues: () => Effect.succeed({ items: [entry], nextCursor: null }), + getIssue: () => Effect.die("unused"), + ...overrides, + }; +} + +describe("IssueService", () => { + it.effect("reports each tracker's availability for a project", () => + Effect.gen(function* () { + const available = yield* makeService(fakeProvider()); + assert.deepStrictEqual(yield* available.sources({ projectId: project.id }), { + sources: [ + { tracker: "github", available: true, name: "acme/web", reason: null, detail: null }, + ], + }); + + const unsupported = yield* makeService( + fakeProvider({ + sourceName: () => + Effect.fail( + new IssueProviderError({ + tracker: "github", + operation: "sourceName", + reason: "unsupported", + detail: "This project is not a GitHub repository.", + }), + ), + }), + ); + const { sources } = yield* unsupported.sources({ projectId: project.id }); + assert.strictEqual(sources[0]?.available, false); + assert.strictEqual(sources[0]?.reason, "unsupported"); + }), + ); + + it.effect("serves an identical page from its cache", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeService( + fakeProvider({ + listIssues: () => + Effect.sync(() => { + reads++; + return { items: [entry], nextCursor: null }; + }), + }), + ); + const input = { projectId: project.id, tracker: "github", state: "open" } as const; + yield* service.list(input); + const again = yield* service.list({ ...input }); + assert.strictEqual(reads, 1); + assert.deepStrictEqual(again, { entries: [entry], nextCursor: null }); + yield* service.list({ ...input, cursor: "next" }); + assert.strictEqual(reads, 2); + }), + ); + + it.effect("does not keep a failure, so a retry reaches the tracker", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeService( + fakeProvider({ + listIssues: () => + Effect.suspend(() => + ++reads === 1 + ? Effect.fail( + new IssueProviderError({ + tracker: "github", + operation: "listIssues", + reason: "unauthenticated", + detail: "Sign in to GitHub.", + }), + ) + : Effect.succeed({ items: [entry], nextCursor: null }), + ), + }), + ); + const input = { projectId: project.id, tracker: "github", state: "open" } as const; + const error = yield* Effect.flip(service.list(input)); + assert.strictEqual(error.reason, "unauthenticated"); + assert.strictEqual(error.message, "Sign in to GitHub."); + yield* service.list(input); + assert.strictEqual(reads, 2); + }), + ); + + it.effect("fails a read for a project that no longer exists", () => + Effect.gen(function* () { + const service = yield* makeService(fakeProvider()); + const error = yield* Effect.flip( + service.detail({ projectId: "gone" as ProjectId, tracker: "github", id: "1" }), + ); + assert.strictEqual(error.reason, "not-found"); + }), + ); +}); diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts new file mode 100644 index 000000000000..a241475c019b --- /dev/null +++ b/apps/server/src/issue/IssueService.ts @@ -0,0 +1,182 @@ +import { + IssueError, + type IssueDetail, + type IssueListInput, + type IssueListResult, + type IssueRef, + type IssueSource, + type IssueSourceInput, + type IssueSourcesInput, + type IssueSourcesResult, + type ProjectId, +} from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import type { IssueProject, IssueProviderApi, IssueProviderError } from "./IssueProvider.ts"; +import * as IssueProviderRegistry from "./IssueProviderRegistry.ts"; + +/** Rows per page when the client does not ask for a size. */ +const DEFAULT_PAGE_SIZE = 50; +/** + * How long a page or detail is served from memory. Long enough that reopening the page or flipping + * between issues costs nothing; short enough that a new comment shows on the next refresh. + */ +const READ_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 256; + +/** Reads a project's issues from whichever trackers it has. The server owns every tracker call. */ +export class IssueService extends Context.Service< + IssueService, + { + readonly sources: (input: IssueSourcesInput) => Effect.Effect; + readonly list: (input: IssueListInput) => Effect.Effect; + readonly detail: (input: IssueRef) => Effect.Effect; + } +>()("t3/issue/IssueService") {} + +const toIssueError = (error: IssueProviderError) => + new IssueError({ + reason: error.reason, + detail: error.detail.trim() || "The issue read failed.", + tracker: error.tracker, + cause: error, + }); + +export const make = Effect.gen(function* () { + const registry = yield* IssueProviderRegistry.IssueProviderRegistry; + const projects = yield* ProjectService.ProjectService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + + const resolveProject = Effect.fn("IssueService.resolveProject")(function* (projectId: ProjectId) { + const shell = yield* projects + .getShell(projectId) + .pipe( + Effect.mapError( + (cause) => + new IssueError({ reason: "failed", detail: "The project could not be read.", cause }), + ), + ); + if (Option.isNone(shell)) { + return yield* new IssueError({ + reason: "not-found", + detail: "The project no longer exists.", + }); + } + const project = shell.value; + return { + id: project.id, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: + project.repositoryIdentity ?? (yield* repositoryIdentities.resolve(project.workspaceRoot)), + } satisfies IssueProject; + }); + + const resolve = (input: IssueSourceInput) => + Effect.gen(function* () { + const provider: IssueProviderApi | null = registry.get(input.tracker); + if (provider === null) { + return yield* new IssueError({ + reason: "unsupported", + detail: "This server cannot read that issue tracker.", + tracker: input.tracker, + }); + } + return { provider, project: yield* resolveProject(input.projectId) }; + }); + + const listUncached = Effect.fn("IssueService.list")(function* (input: IssueListInput) { + const { provider, project } = yield* resolve(input); + const page = yield* provider + .listIssues(project, { + state: input.state, + assignee: input.assignee ?? "anyone", + label: input.label, + query: input.query, + limit: input.limit ?? DEFAULT_PAGE_SIZE, + cursor: input.cursor, + }) + .pipe(Effect.mapError(toIssueError)); + return { entries: page.items, nextCursor: page.nextCursor } satisfies IssueListResult; + }); + + const detailUncached = Effect.fn("IssueService.detail")(function* (input: IssueRef) { + const { provider, project } = yield* resolve(input); + return yield* provider.getIssue(project, input.id).pipe(Effect.mapError(toIssueError)); + }); + + // Keys are the serialized input, so identical reads from several clients coalesce into one + // tracker request. Failures are not kept: a retry after signing in must reach the tracker. + const timeToLive = (exit: Exit.Exit) => + Exit.isSuccess(exit) ? READ_TTL : Duration.zero; + const listCache = yield* Cache.makeWith( + (key: string) => listUncached(JSON.parse(key) as IssueListInput), + { capacity: CACHE_CAPACITY, timeToLive }, + ); + const detailCache = yield* Cache.makeWith( + (key: string) => detailUncached(JSON.parse(key) as IssueRef), + { capacity: CACHE_CAPACITY, timeToLive }, + ); + + /** Every tracker, each saying whether it can serve this project and, if not, why. */ + const sources = Effect.fn("IssueService.sources")(function* (input: IssueSourcesInput) { + const project = yield* resolveProject(input.projectId); + const sources = yield* Effect.forEach( + registry.all, + (provider) => + provider.sourceName(project).pipe( + Effect.match({ + onSuccess: (name): IssueSource => ({ + tracker: provider.kind, + available: true, + name, + reason: null, + detail: null, + }), + onFailure: (error): IssueSource => ({ + tracker: provider.kind, + available: false, + name: null, + reason: error.reason, + detail: error.detail.trim() || null, + }), + }), + ), + { concurrency: "unbounded" }, + ); + return { sources } satisfies IssueSourcesResult; + }); + + return IssueService.of({ + sources, + // Keys list every field in a fixed order, so the same read is one key however it was built. + list: (input) => + Cache.get( + listCache, + JSON.stringify({ + projectId: input.projectId, + tracker: input.tracker, + state: input.state, + assignee: input.assignee, + label: input.label, + query: input.query, + limit: input.limit, + cursor: input.cursor, + }), + ), + detail: (input) => + Cache.get( + detailCache, + JSON.stringify({ projectId: input.projectId, tracker: input.tracker, id: input.id }), + ), + }); +}); + +export const layer = Layer.effect(IssueService, make); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..da90c4176a1e 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -96,6 +96,9 @@ const RPC_AGGREGATES = { [WS_METHODS.secretsAnswerRequest]: "secrets", [WS_METHODS.cloudGetRelayClientStatus]: "cloud", [WS_METHODS.cloudInstallRelayClient]: "cloud", + [WS_METHODS.issuesSources]: "issues", + [WS_METHODS.issuesList]: "issues", + [WS_METHODS.issuesDetail]: "issues", [WS_METHODS.pullRequestsList]: "pull-requests", [WS_METHODS.pullRequestsListStats]: "pull-requests", [WS_METHODS.pullRequestsSummary]: "pull-requests", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3c1539a70985..a5e9a9c7880f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,6 +37,8 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; import * as PullRequestHttp from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import * as IssueProviderRegistry from "./issue/IssueProviderRegistry.ts"; +import * as IssueService from "./issue/IssueService.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; import * as PullRequestReviewGuides from "./persistence/PullRequestReviewGuides.ts"; @@ -347,6 +349,8 @@ const layerPullRequestService = PullRequestService.layer.pipe( Layer.provide(SourceControlRateLimit.layer), ); +const layerIssueService = IssueService.layer.pipe(Layer.provide(IssueProviderRegistry.layer)); + const layerGitManager = GitManager.layer.pipe( // Per-project git settings resolve the acting thread's project. Layer.provide(Layer.merge(ProjectionStoreV2.layer, ProjectStore.layer)), @@ -732,6 +736,7 @@ const layerMakeRoutes = Layer.mergeAll( // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Provided // after the guide service so it reaches that too. Layer.provide(layerPullRequestService), + Layer.provide(layerIssueService), // The stream route and the WebSocket RPCs share one browser. Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), // Server browser tabs and HTML render previews install and run the same headless browser. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375c2ac78aa1..c2c89a5532a7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -223,6 +223,7 @@ import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as IssueService from "./issue/IssueService.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; @@ -1227,6 +1228,7 @@ const layerWsRpc = ( const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const deviceService = yield* DeviceService.DeviceService; @@ -2474,6 +2476,9 @@ const layerWsRpc = ( Effect.forkScoped, ), ), + [WS_METHODS.issuesSources]: (input) => issues.sources(input), + [WS_METHODS.issuesList]: (input) => issues.list(input), + [WS_METHODS.issuesDetail]: (input) => issues.detail(input), [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), @@ -3152,6 +3157,7 @@ export const layer = Layer.unwrap( const serverBrowser = yield* ServerBrowser.ServerBrowser; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const issues = yield* IssueService.IssueService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( @@ -3219,6 +3225,8 @@ export const layer = Layer.unwrap( // One server-lifetime service means clients share the same PR caches, and a WS // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), + // Shared like the pull request service, so every client reads through one cache. + Layer.provide(Layer.succeed(IssueService.IssueService, issues)), Layer.provide(OtlpEndpointCheck.layer), // Shared like the pull request service, so concurrent generations single-flight. Layer.provide( diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d0..a26cacf8f1c8 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -2,6 +2,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ChatFileAttachment, INCOGNITO_BROWSER_PROFILE_ID, + IssueTrackerKind, PreviewSessionSnapshot, ScopedThreadRef, } from "@t3tools/contracts"; @@ -51,6 +52,7 @@ const isPersistentView = (entry: ClosedViewEntry) => const isThreadRef = Schema.is(ScopedThreadRef); const isSnapshot = Schema.is(PreviewSessionSnapshot); const isAttachment = Schema.is(ChatFileAttachment); +const isIssueTracker = Schema.is(IssueTrackerKind); const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { const view = entry as ClosedViewEntry | null; @@ -63,7 +65,15 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { case "diff": case "files": case "pull-requests": + case "issues": return surface.id === surface.kind; + case "issue": + return ( + typeof surface.projectId === "string" && + isIssueTracker(surface.tracker) && + typeof surface.issueId === "string" && + typeof surface.reference === "string" + ); case "preview": return surface.resourceId === null || typeof surface.resourceId === "string"; case "file": diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 34dc23858dcd..3b27b76d5157 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -200,6 +200,7 @@ import { resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; +import { useOpenIssueLink } from "~/lib/openIssueLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -2494,6 +2495,7 @@ function useChatMarkdownState({ }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); + const openIssueLink = useOpenIssueLink(threadRef); // Subscribed rather than read at click time: the anchor has to decide // synchronously whether to intercept its `_blank`, and a subscription is what // makes a persisted "app" apply once settings hydrate after launch. @@ -2738,6 +2740,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, onRunShellCommand, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, openExternalLinkInPreview, openMarkdownMedia, @@ -2771,6 +2774,7 @@ function useChatMarkdownState({ onUseArtifactTemplate, onRunShellCommand, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, openExternalLinkInPreview, openMarkdownMedia, @@ -2920,6 +2924,7 @@ const CHAT_MARKDOWN_COMPONENTS = { threadRef, openMarkdownMedia, openChangeRequestLink, + openIssueLink, openDeferredMarkdownLink, linkTargetPreference, openExternalLinkInPreview, @@ -3028,7 +3033,8 @@ const CHAT_MARKDOWN_COMPONENTS = { // the panel it opens offers the browser as one of its actions. if ( !href || - openChangeRequestLink(event, href, undefined, environmentId ?? undefined) + openChangeRequestLink(event, href, undefined, environmentId ?? undefined) || + openIssueLink(event, href, environmentId ?? undefined) ) { return; } @@ -3159,7 +3165,24 @@ const CHAT_MARKDOWN_COMPONENTS = { environmentId ?? undefined, ) } - onOpenFallback={openDeferredMarkdownLink} + // A `#123` that is no pull request is the issue with that number. + onOpenFallback={async (url) => { + if ( + openIssueLink( + { + metaKey: false, + ctrlKey: false, + preventDefault: () => undefined, + stopPropagation: () => undefined, + }, + url, + environmentId ?? undefined, + ) + ) { + return; + } + await openDeferredMarkdownLink(url); + }} /> ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b760810a3876..82a9193cd8ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -288,6 +288,8 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; +import { IssueDetailPanel } from "./issue/IssueDetailPanel"; +import { ThreadIssuesPanel } from "./issue/ThreadIssuesPanel"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; @@ -5471,6 +5473,14 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); }, [activeThreadRef, pullRequestsSurfaceAvailable]); + // Offered for any project on a server that reads issues; the panel itself explains a project + // with no tracker, which would otherwise cost every thread a read to find out. + const issuesSurfaceAvailable = + isServerThread && serverConfig?.environment.capabilities.issues === true && !!activeProject; + const addIssuesSurface = useCallback(() => { + if (!activeThreadRef || !issuesSurfaceAvailable) return; + useRightPanelStore.getState().open(activeThreadRef, "issues"); + }, [activeThreadRef, issuesSurfaceAvailable]); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); @@ -11175,6 +11185,19 @@ export default function ChatView(props: ChatViewProps) { /> ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "issues" && activeThreadRef ? ( + + ) : renderedRightPanelSurface?.kind === "issue" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( {rightPanelContent} @@ -12122,6 +12147,7 @@ export default function ChatView(props: ChatViewProps) { onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} + onAddIssues={addIssuesSurface} onAddDevice={addDeviceSurface} browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} @@ -12129,6 +12155,7 @@ export default function ChatView(props: ChatViewProps) { filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} + issuesAvailable={issuesSurfaceAvailable} deviceAvailable={activeThreadRef !== null} > {rightPanelContent} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7280f7457baa..f344eb47d6d6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -61,6 +61,7 @@ import { ChartNoAxesColumnIcon, CheckIcon, ChevronRightIcon, + CircleDotIcon, CornerLeftUpIcon, FileSearchIcon, FolderGit2Icon, @@ -2221,6 +2222,23 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + environments.some( + (environment) => environment.serverConfig?.environment.capabilities.issues === true, + ) + ) { + actionItems.push({ + kind: "action", + value: "action:issues", + searchTerms: ["issues", "issue", "bugs", "tickets", "github", "tracker"], + title: "Open issues", + icon: , + run: async () => { + await navigate({ to: "/issues" }); + }, + }); + } + actionItems.push({ kind: "action", value: "action:usage", diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..295460615ae3 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -83,6 +83,7 @@ async function renderPanel(overrides: Partial diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 823028eddff1..56fa430385f8 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -130,6 +130,7 @@ function renderTabs( onAddTerminal={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} + onAddIssues={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} onAddDevice={() => undefined} @@ -139,6 +140,7 @@ function renderTabs( filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} + issuesAvailable={false} deviceAvailable={false} >
content
diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34..621ad14564db 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -30,6 +30,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, + CircleDotIcon, FileDiff, Files, Globe2, @@ -142,6 +143,7 @@ interface RightPanelTabsProps { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; + onAddIssues: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; @@ -149,6 +151,7 @@ interface RightPanelTabsProps { filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; + issuesAvailable: boolean; deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; @@ -177,6 +180,7 @@ const SURFACE_DISABLED_REASONS = { diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", + issues: "This thread's project has no issue tracker T3 Code can read.", device: "Devices are only available from a thread.", } as const; @@ -200,6 +204,7 @@ const SURFACE_UNAVAILABLE_HINTS = { diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", + issues: "No issue tracker for this project.", device: "Available from a thread.", } as const; @@ -340,6 +345,7 @@ function RightPanelEmptyState(props: { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; + onAddIssues: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; @@ -347,6 +353,7 @@ function RightPanelEmptyState(props: { filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; + issuesAvailable: boolean; deviceAvailable: boolean; }) { // -1 means no highlight: it only appears on hover or arrow use. @@ -401,6 +408,14 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, }, + { + label: "Issues", + icon: CircleDotIcon, + shortcut: "I", + available: props.issuesAvailable, + disabledReason: SURFACE_UNAVAILABLE_HINTS.issues, + onClick: props.onAddIssues, + }, { label: "Device", description: "Watch an iOS Simulator or Android Emulator.", @@ -618,6 +633,10 @@ function surfaceTitle( return `#${surface.number}`; case "pull-requests": return "Pull requests"; + case "issue": + return surface.reference; + case "issues": + return "Issues"; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "preview": { @@ -701,6 +720,9 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "issue": + case "issues": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -983,6 +1005,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequests, onClick: props.onAddPullRequests, }, + { + label: "Issues", + icon: CircleDotIcon, + shortcut: "I", + available: props.issuesAvailable, + disabledReason: SURFACE_DISABLED_REASONS.issues, + onClick: props.onAddIssues, + }, { label: "Device", icon: Smartphone, @@ -1482,6 +1512,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} + onAddIssues={props.onAddIssues} onAddDevice={props.onAddDevice} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} @@ -1489,6 +1520,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} + issuesAvailable={props.issuesAvailable} deviceAvailable={props.deviceAvailable} /> ) : ( diff --git a/apps/web/src/components/issue/IssueBrowser.tsx b/apps/web/src/components/issue/IssueBrowser.tsx new file mode 100644 index 000000000000..02209110c25c --- /dev/null +++ b/apps/web/src/components/issue/IssueBrowser.tsx @@ -0,0 +1,298 @@ +import { + IssueTrackerKind, + type EnvironmentId, + type IssueAssigneeFilter, + type IssueListEntry, + type IssueListState, + type IssueSource, + type ProjectId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { useMemo, type ReactNode } from "react"; + +import { issueEnvironment, usePaginatedIssues } from "~/state/issues"; +import { useDebouncedValue } from "~/state/queries"; +import { useEnvironmentQuery } from "~/state/query"; + +import { PullRequestListGhost } from "../pullRequest/PullRequestGhosts"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { ScrollArea } from "../ui/scroll-area"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { + IssueFilterMenu, + IssueListRow, + IssueSearchInput, + type IssueFilterOption, +} from "./IssueList"; +import { ISSUE_TRACKERS } from "./issueTrackers"; + +/** What narrows a listing. The page keeps these in its URL, a thread's panel in its own state. */ +export interface IssueBrowserFilters { + readonly tracker?: IssueTrackerKind | undefined; + readonly state: IssueListState; + readonly assignee: IssueAssigneeFilter; + readonly label?: string | undefined; + readonly q: string; +} + +export const DEFAULT_ISSUE_BROWSER_FILTERS: IssueBrowserFilters = { + state: "open", + assignee: "anyone", + q: "", +}; + +/** A change to the filters; `undefined` clears `tracker` or `label`. */ +export type IssueBrowserFiltersPatch = Partial; + +export const isIssueTracker = Schema.is(IssueTrackerKind); + +const STATE_OPTIONS: ReadonlyArray> = [ + { value: "open", label: "Open" }, + { value: "closed", label: "Closed" }, + { value: "all", label: "All states" }, +]; + +const ASSIGNEE_OPTIONS: ReadonlyArray> = [ + { value: "anyone", label: "Anyone" }, + { value: "me", label: "Assigned to me" }, + { value: "none", label: "Unassigned" }, +]; + +/** The "any label" choice, as a value no label name can be. */ +const ANY_LABEL = ""; +const SEARCH_DEBOUNCE_MS = 250; + +/** What the browser says instead of a list when the project has no tracker it can read. */ +function IssueSourceUnavailable({ source }: { source: IssueSource | null }) { + const title = + source === null + ? "No issue tracker for this project" + : source.reason === "not-configured" + ? `${ISSUE_TRACKERS[source.tracker].label} is not set up for this project` + : `Could not read ${ISSUE_TRACKERS[source.tracker].label} issues`; + return ( + + + {title} + + {source?.detail ?? "Issues can be browsed for projects whose repository is on GitHub."} + + + + ); +} + +/** + * One project's issues: its trackers, the filters, and a paged list. Shared by the Issues page and + * a thread's Issues panel, which differ only in where the filters live and what selecting does. + */ +export function IssueBrowser({ + environmentId, + projectId, + filters, + onFiltersChange, + selectedIssueId, + onSelectIssue, + renderHeader, +}: { + environmentId: EnvironmentId; + projectId: ProjectId; + filters: IssueBrowserFilters; + onFiltersChange: (patch: IssueBrowserFiltersPatch) => void; + selectedIssueId: string | null; + onSelectIssue: (issue: IssueListEntry, tracker: IssueTrackerKind) => void; + /** Lays out the header around the browser's own controls: tracker switch and refresh. */ + renderHeader: (controls: ReactNode) => ReactNode; +}) { + const sourcesQuery = useEnvironmentQuery( + issueEnvironment.sources({ environmentId, input: { projectId } }), + ); + // A tracker that has nothing to do with this project is left out rather than explained. + const offered = useMemo( + () => (sourcesQuery.data?.sources ?? []).filter((source) => source.reason !== "unsupported"), + [sourcesQuery.data], + ); + const source = offered.find((candidate) => candidate.tracker === filters.tracker) ?? offered[0]; + + const query = useDebouncedValue(filters.q, SEARCH_DEBOUNCE_MS); + const listInput = source?.available + ? { + projectId, + tracker: source.tracker, + state: filters.state, + ...(filters.assignee === "anyone" ? {} : { assignee: filters.assignee }), + ...(filters.label ? { label: filters.label } : {}), + ...(query ? { query } : {}), + } + : null; + const issues = usePaginatedIssues(environmentId, listInput); + + // Labels to filter by, from the rows already loaded: no tracker lists a project's labels for + // free, and the selected one stays on offer even when no loaded row carries it. + const labelOptions = useMemo(() => { + const names = new Set( + issues.entries.flatMap((issue) => issue.labels.map((label) => label.name)), + ); + if (filters.label) names.add(filters.label); + return [ + { value: ANY_LABEL, label: "Any label" }, + ...[...names] + .toSorted((a, b) => a.localeCompare(b)) + .map((name) => ({ value: name, label: name })), + ]; + }, [issues.entries, filters.label]); + + const filtered = + filters.state !== "open" || filters.assignee !== "anyone" || filters.label !== undefined; + + let body: ReactNode; + if (sourcesQuery.error !== null && sourcesQuery.data === null) { + body = ( + + + Could not load issues + {sourcesQuery.error} + + + + ); + } else if (sourcesQuery.data === null || (source?.available && issues.isLoading)) { + body = ; + } else if (!source?.available) { + body = ; + } else if (issues.error !== null && issues.entries.length === 0) { + body = ( + + + Could not load issues + {issues.error} + + + + ); + } else if (issues.entries.length === 0) { + body = ( + + + + {query + ? `Nothing matches “${query.slice(0, 48)}”` + : filtered + ? "Nothing under these filters" + : "No open issues"} + + + {query || filtered + ? "Try fewer words, or widen the filters." + : `${source.name ?? "This project"} has no open issues.`} + + + + ); + } else { + const tracker = source.tracker; + body = ( +
+ {issues.entries.map((issue) => ( + onSelectIssue(issue, tracker)} + /> + ))} + {issues.hasMore || issues.error !== null ? ( +
+ {issues.error !== null ? ( +

{issues.error}

+ ) : null} + +
+ ) : null} +
+ ); + } + + const controls = ( + <> + {offered.length > 1 ? ( + { + const tracker = value[0]; + if (isIssueTracker(tracker)) onFiltersChange({ tracker, label: undefined }); + }} + > + {offered.map((candidate) => { + const presentation = ISSUE_TRACKERS[candidate.tracker]; + return ( + + + {presentation.label} + + ); + })} + + ) : null} + + + ); + + return ( + <> + {renderHeader(controls)} + {source?.available ? ( +
+ 0 && (filters.q !== query || issues.isRefreshing)} + onChange={(value) => onFiltersChange({ q: value })} + /> + onFiltersChange({ state })} + /> + onFiltersChange({ assignee })} + /> + onFiltersChange({ label: label || undefined })} + /> +
+ ) : null} + {body} + + ); +} diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx new file mode 100644 index 000000000000..bf21e131aeef --- /dev/null +++ b/apps/web/src/components/issue/IssueDetailPanel.tsx @@ -0,0 +1,215 @@ +import type { EnvironmentId, IssueComment, IssueRef } from "@t3tools/contracts"; +import { ExternalLinkIcon, XIcon } from "lucide-react"; + +import { useEnvironmentQuery } from "~/state/query"; +import { issueEnvironment } from "~/state/issues"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { PullRequestMarkdown } from "../pullRequest/PullRequestMarkdown"; +import { + PullRequestActorLabel, + PullRequestLabelChip, + PullRequestMetaLine, +} from "../pullRequest/pullRequestPresentation"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { ScrollArea } from "../ui/scroll-area"; +import { Spinner } from "../ui/spinner"; +import { IssueStateGlyph } from "./IssueList"; +import { ISSUE_TRACKERS } from "./issueTrackers"; + +function IssueFact({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function IssueCommentItem({ + comment, + cwd, + environmentId, +}: { + comment: IssueComment; + cwd: string; + environmentId: EnvironmentId; +}) { + return ( +
  • +
    + + + {formatRelativeTimeLabel(comment.createdAt)} + +
    +
    + {comment.body.trim().length > 0 ? ( + + ) : ( +

    No content.

    + )} +
    +
  • + ); +} + +/** + * One issue in full: what it says, who is on it, and its conversation, with the tracker one click + * away for everything this read-only view leaves out. + */ +export function IssueDetailPanel({ + environmentId, + reference, + cwd, + onClose, +}: { + environmentId: EnvironmentId; + reference: IssueRef; + /** The project's checkout, which relative links in the issue's markdown resolve against. */ + cwd: string; + /** Absent beside a thread, where the surface tab's own X owns closing. */ + onClose?: (() => void) | undefined; +}) { + const query = useEnvironmentQuery(issueEnvironment.detail({ environmentId, input: reference })); + const issue = query.data; + const tracker = ISSUE_TRACKERS[reference.tracker]; + const openLabel = `Open in ${tracker.label}`; + + return ( +
    +
    + + {issue?.reference ?? ""} + +
    + {issue ? ( + + ) : null} + + {onClose ? ( + + ) : null} +
    +
    + + {issue === null ? ( + query.error !== null ? ( + + + Could not load the issue + {query.error} + + + + ) : ( +
    + +
    + ) + ) : ( + +
    +
    +

    {issue.title}

    + + + + {issue.statusName ?? (issue.state === "open" ? "Open" : "Closed")} + + + + + opened {formatRelativeTimeLabel(issue.createdAt)} + + + {issue.closedAt ? ( + closed {formatRelativeTimeLabel(issue.closedAt)} + ) : null} + +
    + +
    + + {issue.assignees.length > 0 ? ( + issue.assignees.map((assignee) => ( + + )) + ) : ( + No one + )} + + + {issue.labels.length > 0 ? ( + issue.labels.map((label) => ( + + )) + ) : ( + None + )} + +
    + +
    + {issue.body.trim().length > 0 ? ( + + ) : ( +

    No description provided.

    + )} +
    + +
    +

    + {issue.commentCount === 1 ? "1 comment" : `${issue.commentCount} comments`} +

    + {issue.comments.length > 0 ? ( +
      + {issue.comments.map((comment) => ( + + ))} +
    + ) : null} + {issue.commentCount > issue.comments.length ? ( +
    + {issue.commentCount - issue.comments.length} more on {tracker.label} + + ) : null} +
    +
    + + )} +
    + ); +} diff --git a/apps/web/src/components/issue/IssueList.tsx b/apps/web/src/components/issue/IssueList.tsx new file mode 100644 index 000000000000..4a6680c8c74f --- /dev/null +++ b/apps/web/src/components/issue/IssueList.tsx @@ -0,0 +1,202 @@ +import type { IssueListEntry } from "@t3tools/contracts"; +import { ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "~/lib/utils"; + +import { + PULL_REQUEST_ROW_CLASS, + PULL_REQUEST_ROW_NUMBER_CLASS, + PullRequestRowLines, +} from "../pullRequest/PullRequestListRow"; +import { + PullRequestActorAvatar, + PullRequestLabelChip, + PullRequestMetaLine, +} from "../pullRequest/pullRequestPresentation"; +import { Button } from "../ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; +import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { issueStatePresentation } from "./issueTrackers"; + +export function IssueStateGlyph({ + issue, + className, +}: { + issue: Pick & { + readonly stateReason?: Parameters[0]["stateReason"]; + }; + className?: string; +}) { + const presentation = issueStatePresentation(issue); + const label = issue.statusName ?? presentation.label; + return ( + + {/* The row is a button, so the trigger stays a span rather than nesting a control. */} + }> + + + {label} + + ); +} + +/** Labels past this many are counted on the last chip rather than drawn. */ +const ROW_LABEL_LIMIT = 3; + +export function IssueListRow({ + issue, + selected, + onSelect, +}: { + issue: IssueListEntry; + selected: boolean; + onSelect: () => void; +}) { + const shown = issue.labels.slice(0, ROW_LABEL_LIMIT); + const hidden = issue.labels.length - shown.length; + return ( + + ); +} + +export interface IssueFilterOption { + readonly value: Value; + readonly label: string; +} + +/** One narrowing as a quiet menu button showing its current choice. */ +export function IssueFilterMenu({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + } + className="min-w-0" + > + {current.label} + + + + onChange(next as Value)}> + {options.map((option) => ( + + {option.label} + + ))} + + + + ); +} + +/** + * The search box. It reports every keystroke; the page debounces before asking the tracker, and + * shows `busy` while that answer is on its way. + */ +export function IssueSearchInput({ + value, + busy, + onChange, +}: { + value: string; + busy: boolean; + onChange: (value: string) => void; +}) { + const [draft, setDraft] = useState(null); + return ( + + + {busy ? : } + + setDraft(value)} + onBlur={() => setDraft(null)} + onChange={(event) => { + setDraft(event.currentTarget.value); + onChange(event.currentTarget.value); + }} + placeholder="Search issues" + aria-label="Search issues" + /> + + ); +} diff --git a/apps/web/src/components/issue/ThreadIssuesPanel.tsx b/apps/web/src/components/issue/ThreadIssuesPanel.tsx new file mode 100644 index 000000000000..1d5641738cdd --- /dev/null +++ b/apps/web/src/components/issue/ThreadIssuesPanel.tsx @@ -0,0 +1,49 @@ +import type { ProjectId, ScopedThreadRef } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useRightPanelStore } from "~/rightPanelStore"; + +import { + DEFAULT_ISSUE_BROWSER_FILTERS, + IssueBrowser, + type IssueBrowserFilters, +} from "./IssueBrowser"; + +/** + * The thread project's issues beside the thread. Selecting one opens it as its own tab, the way a + * pull request opens, so several can stay at hand while the list keeps its place. + */ +export function ThreadIssuesPanel({ + threadRef, + projectId, +}: { + threadRef: ScopedThreadRef; + projectId: ProjectId; +}) { + const [filters, setFilters] = useState(DEFAULT_ISSUE_BROWSER_FILTERS); + return ( +
    + setFilters((current) => ({ ...current, ...patch }))} + selectedIssueId={null} + onSelectIssue={(issue, tracker) => + useRightPanelStore.getState().openIssue(threadRef, { + projectId, + tracker, + issueId: issue.id, + reference: issue.reference, + }) + } + renderHeader={(controls) => ( +
    + Issues +
    {controls}
    +
    + )} + /> +
    + ); +} diff --git a/apps/web/src/components/issue/issueProjects.logic.test.ts b/apps/web/src/components/issue/issueProjects.logic.test.ts new file mode 100644 index 000000000000..699f7194981e --- /dev/null +++ b/apps/web/src/components/issue/issueProjects.logic.test.ts @@ -0,0 +1,80 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; +import { describe, expect, it } from "vite-plus/test"; + +import { issueProjectOptions, resolveIssueProject } from "./issueProjects.logic"; + +const local = "local" as EnvironmentId; +const remote = "remote" as EnvironmentId; + +function project( + id: string, + title: string, + environmentId: EnvironmentId, + canonicalKey?: string, +): EnvironmentProject { + return { + id: id as ProjectId, + title, + environmentId, + workspaceRoot: `/work/${id}`, + ...(canonicalKey + ? { + repositoryIdentity: { + canonicalKey, + locator: { source: "git-remote", remoteName: "origin", remoteUrl: canonicalKey }, + }, + } + : {}), + } as unknown as EnvironmentProject; +} + +describe("issueProjectOptions", () => { + it("offers one project per repository per environment, by title", () => { + const options = issueProjectOptions( + [ + project("web", "web", local, "github.com/acme/web"), + project("web-worktree", "web (fix)", local, "github.com/Acme/Web"), + project("web-remote", "web", remote, "github.com/acme/web"), + project("api", "api", local, "github.com/acme/api"), + project("scratch", "scratch", local), + ], + new Set([local, remote]), + ); + expect(options.map((option) => option.projectId)).toEqual([ + "api", + "scratch", + "web", + "web-remote", + ]); + }); + + it("leaves out environments that cannot serve issues", () => { + const options = issueProjectOptions( + [project("web", "web", local, "github.com/acme/web"), project("api", "api", remote)], + new Set([local]), + ); + expect(options.map((option) => option.projectId)).toEqual(["web"]); + }); +}); + +describe("resolveIssueProject", () => { + const options = issueProjectOptions( + [project("a", "a", local), project("b", "b", remote)], + new Set([local, remote]), + ); + + it("picks the project the URL names, on its environment", () => { + expect(resolveIssueProject(options, { projectId: "b", environmentId: remote })?.projectId).toBe( + "b", + ); + }); + + it("falls back to the first project for a stale or missing selection", () => { + expect(resolveIssueProject(options, { projectId: "b", environmentId: local })?.projectId).toBe( + "a", + ); + expect(resolveIssueProject(options, {})?.projectId).toBe("a"); + expect(resolveIssueProject([], {})).toBeNull(); + }); +}); diff --git a/apps/web/src/components/issue/issueProjects.logic.ts b/apps/web/src/components/issue/issueProjects.logic.ts new file mode 100644 index 000000000000..0c3b2150615c --- /dev/null +++ b/apps/web/src/components/issue/issueProjects.logic.ts @@ -0,0 +1,56 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/models"; + +export interface IssueProjectOption { + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +} + +/** + * The projects the Issues page can pick between: those on an environment that serves issues, one + * per repository, since a repository's worktrees are separate projects with the same issues. + */ +export function issueProjectOptions( + projects: ReadonlyArray, + supportedEnvironmentIds: ReadonlySet, +): ReadonlyArray { + const seen = new Set(); + const options: IssueProjectOption[] = []; + for (const project of projects) { + if (!supportedEnvironmentIds.has(project.environmentId)) continue; + const repository = project.repositoryIdentity?.canonicalKey.toLowerCase() ?? project.id; + const key = `${project.environmentId}\u0000${repository}`; + if (seen.has(key)) continue; + seen.add(key); + options.push({ + key, + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + }); + } + return options.toSorted((left, right) => left.title.localeCompare(right.title)); +} + +/** The project the URL names, else the first one, so the page always shows something. */ +export function resolveIssueProject( + options: ReadonlyArray, + selected: { + readonly environmentId?: string | undefined; + readonly projectId?: string | undefined; + }, +): IssueProjectOption | null { + return ( + options.find( + (option) => + option.projectId === selected.projectId && + (selected.environmentId === undefined || option.environmentId === selected.environmentId), + ) ?? + options[0] ?? + null + ); +} diff --git a/apps/web/src/components/issue/issueTrackers.tsx b/apps/web/src/components/issue/issueTrackers.tsx new file mode 100644 index 000000000000..411fab953bbf --- /dev/null +++ b/apps/web/src/components/issue/issueTrackers.tsx @@ -0,0 +1,40 @@ +import type { IssueStateReason, IssueTrackerKind } from "@t3tools/contracts"; +import { CircleCheckIcon, CircleDotIcon, CircleSlashIcon } from "lucide-react"; +import type { ElementType } from "react"; + +import { GitHubIcon } from "../Icons"; + +export interface IssueTrackerPresentation { + readonly label: string; + readonly Icon: ElementType<{ className?: string }>; +} + +export const ISSUE_TRACKERS: Record = { + github: { label: "GitHub", Icon: GitHubIcon }, +}; + +/** An issue's state as one glyph, in the tones pull requests use for open and merged. */ +export function issueStatePresentation(input: { + readonly state: "open" | "closed"; + readonly stateReason?: IssueStateReason | null | undefined; +}) { + if (input.state === "open") { + return { + label: "Open", + Icon: CircleDotIcon, + toneClassName: "text-emerald-600 dark:text-emerald-300/90", + }; + } + if (input.stateReason === "not-planned" || input.stateReason === "duplicate") { + return { + label: input.stateReason === "duplicate" ? "Closed as duplicate" : "Closed as not planned", + Icon: CircleSlashIcon, + toneClassName: "text-zinc-500 dark:text-zinc-400/80", + }; + } + return { + label: "Closed", + Icon: CircleCheckIcon, + toneClassName: "text-violet-600 dark:text-violet-300/90", + }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ae05529374c3..be55f348828c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,11 +1,11 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { ArrowLeftIcon, ChartNoAxesColumnIcon, CircleDotIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { usePullRequestsSupported } from "../../state/environments"; +import { useIssuesSupported, usePullRequestsSupported } from "../../state/environments"; import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, @@ -176,6 +176,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { select: (location) => isSidebarUtilityPage(location.pathname), }); const pullRequestsSupported = usePullRequestsSupported(); + const issuesSupported = useIssuesSupported(); const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -188,6 +189,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { search: readPullRequestListPreferences(), }); }, [closeMobileSidebar, navigate]); + const handleIssuesClick = useCallback(() => { + closeMobileSidebar(); + void navigate({ to: "/issues" }); + }, [closeMobileSidebar, navigate]); const handleSettingsClick = useCallback(() => { closeMobileSidebar(); void navigate({ to: "/settings" }); @@ -228,6 +233,13 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { onClick={handlePullRequestsClick} /> ) : null} + {issuesSupported ? ( + } + label="Issues" + onClick={handleIssuesClick} + /> + ) : null} } label="Usage" diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index fcbf2f489d33..e7b0313e1378 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -1,7 +1,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect } from "react"; -// Settings, Usage, and Pull Requests replace the sidebar utility row with a +// Settings, Usage, Pull Requests, and Issues replace the sidebar utility row with a // Back button. Everything else is the main app. Legacy `/projects/` links // redirect into settings, so they count too and are never remembered. export function isSidebarUtilityPage(pathname: string) { @@ -10,7 +10,8 @@ export function isSidebarUtilityPage(pathname: string) { pathname.startsWith("/settings/") || pathname.startsWith("/projects/") || pathname === "/usage" || - pathname === "/pull-requests" + pathname === "/pull-requests" || + pathname === "/issues" ); } diff --git a/apps/web/src/lib/openIssueLink.test.ts b/apps/web/src/lib/openIssueLink.test.ts new file mode 100644 index 000000000000..08977588bb1b --- /dev/null +++ b/apps/web/src/lib/openIssueLink.test.ts @@ -0,0 +1,49 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { describe, expect, it } from "vite-plus/test"; + +import { findProjectForIssue, parseGitHubIssueUrl } from "./openIssueLink"; + +describe("parseGitHubIssueUrl", () => { + it("reads the repository and number of an issue link", () => { + expect(parseGitHubIssueUrl("https://github.com/PingDotGG/T3Code/issues/42")).toEqual({ + tracker: "github", + repositoryKey: "github.com/pingdotgg/t3code", + issueId: "42", + reference: "#42", + }); + expect( + parseGitHubIssueUrl("https://ghe.acme.test/team/web/issues/7#issuecomment-1")?.repositoryKey, + ).toBe("ghe.acme.test/team/web"); + }); + + it("leaves pull requests and other pages to other handlers", () => { + expect(parseGitHubIssueUrl("https://github.com/acme/web/pull/42")).toBeNull(); + expect(parseGitHubIssueUrl("https://github.com/acme/web/issues")).toBeNull(); + expect(parseGitHubIssueUrl("https://github.com/acme/web/issues/42/files")).toBeNull(); + expect(parseGitHubIssueUrl("not a url")).toBeNull(); + expect(parseGitHubIssueUrl("javascript:alert(1)")).toBeNull(); + }); +}); + +describe("findProjectForIssue", () => { + const project = (id: string, canonicalKey?: string) => + ({ + id, + ...(canonicalKey ? { repositoryIdentity: { canonicalKey } } : {}), + }) as unknown as EnvironmentProject; + + it("matches the project checking out the issue's repository", () => { + const link = parseGitHubIssueUrl("https://github.com/acme/web/issues/1")!; + expect( + findProjectForIssue( + [ + project("api", "github.com/acme/api"), + project("web", "github.com/Acme/Web"), + project("x"), + ], + link, + )?.id, + ).toBe("web"); + expect(findProjectForIssue([project("api", "github.com/acme/api")], link)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/openIssueLink.ts b/apps/web/src/lib/openIssueLink.ts new file mode 100644 index 000000000000..12f7144c5f24 --- /dev/null +++ b/apps/web/src/lib/openIssueLink.ts @@ -0,0 +1,110 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, IssueTrackerKind, ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, type MouseEvent } from "react"; + +import { useRightPanelStore } from "../rightPanelStore"; +import { useProjects, useServerConfigs } from "../state/entities"; +import { shouldOpenPullRequestExternally } from "./openPullRequestLink"; + +/** An issue a link names, before it is matched to a project. */ +export interface IssueLink { + readonly tracker: IssueTrackerKind; + /** The repository the issue lives in, as `host/owner/name`, lowercased. */ + readonly repositoryKey: string; + /** The tracker's own key for the issue, as its listing would give it. */ + readonly issueId: string; + readonly reference: string; +} + +/** + * A GitHub issue URL, `https://///issues/`, with anything after the number + * (a comment anchor, a query) ignored. Null for anything else, a pull request included: GitHub + * serves `/issues/` for a pull request too, but redirects it, and a link that means a pull + * request says `/pull/`. + */ +export function parseGitHubIssueUrl(url: string): IssueLink | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + const match = /^\/([^/]+)\/([^/]+)\/issues\/([1-9][0-9]{0,9})\/?$/u.exec(parsed.pathname); + if (match === null) return null; + const [, owner, repo, number] = match; + return { + tracker: "github", + repositoryKey: `${parsed.host}/${owner}/${repo}`.toLowerCase(), + issueId: number!, + reference: `#${number}`, + }; +} + +/** The workspace project whose repository the issue lives in, if there is one. */ +export function findProjectForIssue( + projects: ReadonlyArray, + link: IssueLink, +): EnvironmentProject | undefined { + return projects.find( + (project) => project.repositoryIdentity?.canonicalKey.toLowerCase() === link.repositoryKey, + ); +} + +/** + * Opens an issue link in T3 Code, and says whether it did. Beside a thread the issue opens as a + * tab in the thread's panel, read through the thread's environment; anywhere else the Issues page + * opens on it. A link to a repository no project here checks out stays an ordinary link. + */ +export function useOpenIssueLink(threadRef?: ScopedThreadRef) { + const navigate = useNavigate(); + const allProjects = useProjects(); + const serverConfigs = useServerConfigs(); + return useCallback( + ( + event: Pick< + MouseEvent, + "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey" + >, + url: string, + environmentId?: EnvironmentId, + ): boolean => { + if (shouldOpenPullRequestExternally(event)) return false; + const link = parseGitHubIssueUrl(url); + if (link === null) return false; + const scope = threadRef?.environmentId ?? environmentId; + const project = findProjectForIssue( + allProjects.filter( + (candidate) => + (scope === undefined || candidate.environmentId === scope) && + serverConfigs.get(candidate.environmentId)?.environment.capabilities.issues === true, + ), + link, + ); + if (project === undefined) return false; + event.preventDefault(); + event.stopPropagation(); + if (threadRef) { + useRightPanelStore.getState().openIssue(threadRef, { + projectId: project.id, + tracker: link.tracker, + issueId: link.issueId, + reference: link.reference, + }); + } else { + void navigate({ + to: "/issues", + search: { + environmentId: project.environmentId, + projectId: project.id, + tracker: link.tracker, + issue: link.issueId, + }, + }); + } + return true; + }, + [allProjects, navigate, serverConfigs, threadRef], + ); +} diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts index 551119a178a7..d8926a63e195 100644 --- a/apps/web/src/reopenClosedView.ts +++ b/apps/web/src/reopenClosedView.ts @@ -143,6 +143,9 @@ export async function reopenClosedView( case "pull-request": panels.openPullRequest(ref, surface); break; + case "issue": + panels.openIssue(ref, surface); + break; default: panels.open(ref, surface.kind); } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 961a4a178dac..e2928bc211b8 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { useClosedViewStore } from "./closedViewStore"; import { + issueSurface, migratePersistedRightPanelState, pullRequestSurface, pullRequestSurfaceId, @@ -337,6 +338,54 @@ describe("rightPanelStore", () => { expect(store.getUserActionRevision(refA)).toBe(revision); }); + it("opens each issue as its own tab beside the issues list, once", () => { + const store = useRightPanelStore.getState(); + const target = { + projectId: "project-a", + tracker: "github", + issueId: "42", + reference: "#42", + } as const; + store.open(refA, "issues"); + store.openIssue(refA, target); + store.openIssue(refA, { ...target, issueId: "43", reference: "#43" }); + store.openIssue(refA, target); + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual([ + "issues", + issueSurface(target).id, + issueSurface({ ...target, issueId: "43", reference: "#43" }).id, + ]); + expect(state.activeSurfaceId).toBe(issueSurface(target).id); + expect(state.isOpen).toBe(true); + }); + + it("keeps saved issue tabs and drops malformed ones during migration", () => { + const valid = issueSurface({ + projectId: "project-a", + tracker: "github", + issueId: "42", + reference: "#42", + }); + const migrated = migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: valid.id, + surfaces: [ + { id: "issues", kind: "issues" }, + valid, + { ...valid, id: "issue:bad", tracker: "jira" }, + { id: "issue:missing", kind: "issue", projectId: "project-a" }, + ], + }, + }, + }); + expect( + selectThreadRightPanelState(migrated.byThreadKey, refA).surfaces.map((surface) => surface.id), + ).toEqual(["issues", valid.id]); + }); + it("restores a saved maximized panel during migration", () => { const migrated = migratePersistedRightPanelState({ byThreadKey: { diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index fa417786a019..5c486031e09c 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -12,7 +12,13 @@ import { scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { + EnvironmentId, + IssueTrackerKind, + ThreadId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -30,6 +36,8 @@ const RIGHT_PANEL_KINDS = [ "terminal", "pull-request", "pull-requests", + "issue", + "issues", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -85,7 +93,23 @@ export type RightPanelSurface = url?: string; } /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ - | { id: "pull-requests"; kind: "pull-requests" }; + | { id: "pull-requests"; kind: "pull-requests" } + | { + /** + * One issue beside a thread, read through the thread's own environment. The reference lives + * in the id so several issues can stay open as peer tabs. + */ + id: `issue:${string}`; + kind: "issue"; + projectId: string; + tracker: IssueTrackerKind; + /** The tracker's own key for the issue, as its listing gave it. */ + issueId: string; + /** How the tracker writes the issue's name, for the tab: `#123`, `ENG-123`. */ + reference: string; + } + /** The thread project's issues, one singleton tab beside any number of `issue` tabs. */ + | { id: "issues"; kind: "issues" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). @@ -93,7 +117,8 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the device surface. // v14 removes the agents surface; lineage lives in the thread title bar. -const RIGHT_PANEL_STORAGE_VERSION = 14; +// v15 adds the issue and issues surfaces. +const RIGHT_PANEL_STORAGE_VERSION = 15; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -146,7 +171,7 @@ interface RightPanelStoreState { ) => boolean; open: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; @@ -164,6 +189,7 @@ interface RightPanelStoreState { url?: string; }, ) => void; + openIssue: (ref: ScopedThreadRef, target: IssueSurfaceTarget) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -191,7 +217,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; setThreadPanelOpen: ( ref: ScopedThreadRef, @@ -214,9 +240,11 @@ const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { + case "issues": + return { id: "issues", kind }; case "diff": return { id: "diff", kind }; case "files": @@ -228,6 +256,22 @@ const singletonSurface = ( } }; +const isIssueTracker = Schema.is(IssueTrackerKind); + +export type IssueSurface = Extract; +export type IssueSurfaceTarget = Omit; + +export function issueSurface(target: IssueSurfaceTarget): IssueSurface { + return { + id: `issue:${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.tracker)}:${encodeURIComponent(target.issueId)}`, + kind: "issue", + projectId: target.projectId, + tracker: target.tracker, + issueId: target.issueId, + reference: target.reference, + }; +} + const browserSurface = (tabId: string | null): RightPanelSurface => tabId ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } @@ -504,6 +548,15 @@ export function migratePersistedRightPanelState(persistedState: unknown): { }), ]; } + if (surface.kind === "issue") { + const { projectId, tracker, issueId, reference } = surface; + return typeof projectId === "string" && + isIssueTracker(tracker) && + typeof issueId === "string" && + typeof reference === "string" + ? [issueSurface({ projectId, tracker, issueId, reference })] + : []; + } if (surface.kind !== "terminal") return [surface]; if ( !("resourceId" in surface) || @@ -706,6 +759,12 @@ export const useRightPanelStore = create()( : next; }), ), + openIssue: (ref, target) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => + upsertSurface(current, issueSurface(target)), + ), + ), openFile: (ref, requestedPath, line) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7c0b13ef7676..103594a8598b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -34,6 +34,7 @@ import { Route as SettingsAppearanceRouteImport } from './routes/settings.appear import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatNewRouteImport } from './routes/_chat.new' +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' @@ -162,6 +163,11 @@ const ChatNewRoute = ChatNewRouteImport.update({ path: '/new', 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', @@ -182,6 +188,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute + '/issues': typeof ChatIssuesRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -209,6 +216,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute + '/issues': typeof ChatIssuesRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -239,6 +247,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute + '/_chat/issues': typeof ChatIssuesRoute '/_chat/new': typeof ChatNewRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -270,6 +279,7 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/welcome' + | '/issues' | '/new' | '/pull-requests' | '/projects/$projectKey' @@ -297,6 +307,7 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/welcome' + | '/issues' | '/new' | '/pull-requests' | '/projects/$projectKey' @@ -326,6 +337,7 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/welcome' + | '/_chat/issues' | '/_chat/new' | '/_chat/pull-requests' | '/projects/$projectKey' @@ -536,6 +548,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatNewRouteImport 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' @@ -554,6 +573,7 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatIssuesRoute: typeof ChatIssuesRoute ChatNewRoute: typeof ChatNewRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute ChatIndexRoute: typeof ChatIndexRoute @@ -562,6 +582,7 @@ interface ChatRouteChildren { } const ChatRouteChildren: ChatRouteChildren = { + ChatIssuesRoute: ChatIssuesRoute, ChatNewRoute: ChatNewRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, ChatIndexRoute: ChatIndexRoute, diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx new file mode 100644 index 000000000000..08108f906b09 --- /dev/null +++ b/apps/web/src/routes/_chat.issues.tsx @@ -0,0 +1,229 @@ +import { + IssueAssigneeFilter, + IssueListState, + type EnvironmentId, + type IssueTrackerKind, + type ProjectId, +} from "@t3tools/contracts"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { useCallback, useMemo } from "react"; + +import { + IssueBrowser, + isIssueTracker, + type IssueBrowserFiltersPatch, +} from "../components/issue/IssueBrowser"; +import { IssueDetailPanel } from "../components/issue/IssueDetailPanel"; +import { IssueFilterMenu } from "../components/issue/IssueList"; +import { issueProjectOptions, resolveIssueProject } from "../components/issue/issueProjects.logic"; +import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset } from "../components/ui/sidebar"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../components/WorkspaceBreadcrumb"; +import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; +import { isElectron } from "../env"; +import { useEscapeToGoBack } from "../hooks/useNavigateBack"; +import { cn } from "../lib/utils"; +import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; +import { useEnvironments } from "../state/environments"; + +export interface IssuesSearch { + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + readonly tracker?: IssueTrackerKind; + readonly state?: IssueListState; + readonly assignee?: IssueAssigneeFilter; + readonly label?: string; + readonly q?: string; + /** The open issue, as its listing's `id`. */ + readonly issue?: string; +} + +/** A change to the URL; `undefined` clears a key. */ +type IssuesSearchPatch = { readonly [Key in keyof IssuesSearch]?: IssuesSearch[Key] | undefined }; + +const isListState = Schema.is(IssueListState); +const isAssignee = Schema.is(IssueAssigneeFilter); + +/** A bounded, non-empty string from the URL, or nothing. */ +const urlText = (value: unknown) => + typeof value === "string" && value.trim().length > 0 ? value.trim().slice(0, 200) : undefined; + +export const Route = createFileRoute("/_chat/issues")({ + validateSearch: (raw: Record): IssuesSearch => { + const environmentId = urlText(raw.environmentId); + const projectId = urlText(raw.projectId); + const label = urlText(raw.label); + const q = urlText(raw.q); + // A hand-typed `issue=123` arrives parsed as a number. + const issue = urlText(typeof raw.issue === "number" ? String(raw.issue) : raw.issue); + return { + ...(environmentId ? { environmentId: environmentId as EnvironmentId } : {}), + ...(projectId ? { projectId: projectId as ProjectId } : {}), + ...(isIssueTracker(raw.tracker) ? { tracker: raw.tracker } : {}), + ...(isListState(raw.state) && raw.state !== "open" ? { state: raw.state } : {}), + ...(isAssignee(raw.assignee) && raw.assignee !== "anyone" ? { assignee: raw.assignee } : {}), + ...(label ? { label } : {}), + ...(q ? { q } : {}), + ...(issue ? { issue } : {}), + }; + }, + component: IssuesRouteView, +}); + +function IssuesRouteView() { + useEscapeToGoBack(); + const search = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const updateSearch = useCallback( + (patch: IssuesSearchPatch) => + navigate({ + search: (previous) => { + const next: Record = { ...previous, ...patch }; + for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key]; + return next as IssuesSearch; + }, + replace: true, + }), + [navigate], + ); + // Defaults stay out of the URL, and a new search or tracker closes the issue it no longer lists. + const updateFilters = useCallback( + (patch: IssueBrowserFiltersPatch) => + void updateSearch({ + ...("tracker" in patch ? { tracker: patch.tracker, issue: undefined } : {}), + ...("state" in patch ? { state: patch.state === "open" ? undefined : patch.state } : {}), + ...("assignee" in patch + ? { assignee: patch.assignee === "anyone" ? undefined : patch.assignee } + : {}), + ...("label" in patch ? { label: patch.label } : {}), + ...("q" in patch ? { q: patch.q || undefined, issue: undefined } : {}), + }), + [updateSearch], + ); + + const { environments } = useEnvironments(); + const supportedEnvironmentIds = useMemo( + () => + new Set( + environments + .filter( + (environment) => environment.serverConfig?.environment.capabilities.issues === true, + ) + .map((environment) => environment.environmentId), + ), + [environments], + ); + const allProjects = useProjects(); + const projectsKnown = useAllEnvironmentShellsBootstrapped(); + const projectOptions = useMemo( + () => issueProjectOptions(allProjects, supportedEnvironmentIds), + [allProjects, supportedEnvironmentIds], + ); + const project = resolveIssueProject(projectOptions, search); + const selectedIssue = + search.issue !== undefined && project !== null + ? { projectId: project.projectId, tracker: search.tracker ?? "github", id: search.issue } + : null; + + const header = (controls: React.ReactNode) => ( + + + +

    Issues

    +
    + {projectOptions.length > 0 && project !== null ? ( + <> + + + ({ + value: option.key, + label: option.title, + }))} + onChange={(key) => { + const next = projectOptions.find((option) => option.key === key); + if (!next) return; + void updateSearch({ + environmentId: next.environmentId, + projectId: next.projectId, + issue: undefined, + label: undefined, + }); + }} + /> + + + ) : null} +
    +
    {controls}
    +
    + ); + + return ( + +
    +
    + {project === null ? ( + <> + {header(null)} + {projectsKnown ? ( + + + No projects to show issues for + + Add a project whose repository is on GitHub, and its issues appear here. + + + + ) : ( + + )} + + ) : ( + void updateSearch({ issue: issue.id, tracker })} + renderHeader={header} + /> + )} +
    + + {selectedIssue !== null && project !== null ? ( +
    + void updateSearch({ issue: undefined })} + /> +
    + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b3d1b140aece..fb3543b49496 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2291,6 +2291,7 @@ function PullRequestsRouteView() { onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} + onAddIssues={() => undefined} onAddDevice={() => undefined} browserAvailable={false} terminalAvailable={false} @@ -2298,6 +2299,7 @@ function PullRequestsRouteView() { filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} + issuesAvailable={false} deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index b7105c2adf9c..717509292b44 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -102,6 +102,10 @@ export function usePullRequestsSupported() { return useAtomValue(environmentSummaries.pullRequestsSupportedAtom); } +export function useIssuesSupported() { + return useAtomValue(environmentSummaries.issuesSupportedAtom); +} + export function useEnvironmentMachines() { return useAtomValue(environmentSummaries.machineByIdAtom); } diff --git a/apps/web/src/state/issues.test.ts b/apps/web/src/state/issues.test.ts new file mode 100644 index 000000000000..75284617ff51 --- /dev/null +++ b/apps/web/src/state/issues.test.ts @@ -0,0 +1,48 @@ +import type { IssueListEntry, IssueListResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeIssuePages } from "./issues"; + +const entry = (id: string, title = `Issue ${id}`): IssueListEntry => ({ + id, + reference: `#${id}`, + title, + url: `https://github.com/acme/web/issues/${id}`, + state: "open", + author: null, + labels: [], + assignees: [], + commentCount: 0, + createdAt: "2026-10-01T00:00:00Z", + updatedAt: "2026-10-01T00:00:00Z", +}); + +const page = (entries: IssueListEntry[], nextCursor: string | null): IssueListResult => ({ + entries, + nextCursor, +}); + +describe("mergeIssuePages", () => { + it("joins pages in order and carries on from the last page's cursor", () => { + const merged = mergeIssuePages([page([entry("3"), entry("2")], "a"), page([entry("1")], "b")]); + expect(merged.entries.map((issue) => issue.id)).toEqual(["3", "2", "1"]); + expect(merged.nextCursor).toBe("b"); + }); + + it("keeps the first sighting of an issue a later page hands over again", () => { + const merged = mergeIssuePages([ + page([entry("5", "first"), entry("4")], "a"), + page([entry("5", "again"), entry("3")], null), + ]); + expect(merged.entries.map((issue) => [issue.id, issue.title])).toEqual([ + ["5", "first"], + ["4", "Issue 4"], + ["3", "Issue 3"], + ]); + expect(merged.nextCursor).toBeNull(); + }); + + it("has nothing and no cursor before any page arrives", () => { + expect(mergeIssuePages([])).toEqual({ entries: [], nextCursor: null }); + }); +}); diff --git a/apps/web/src/state/issues.ts b/apps/web/src/state/issues.ts new file mode 100644 index 000000000000..d7dbf1dbb118 --- /dev/null +++ b/apps/web/src/state/issues.ts @@ -0,0 +1,121 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createIssueEnvironmentAtoms } from "@t3tools/client-runtime/state/issues"; +import type { + EnvironmentId, + IssueError, + IssueListEntry, + IssueListInput, + IssueListResult, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useCallback, useMemo, useState } from "react"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { formatEnvironmentQueryError } from "./query"; + +export const issueEnvironment = createIssueEnvironmentAtoms(connectionAtomRuntime); + +/** Rows per page. Small enough to arrive quickly, large enough that most lists are one page. */ +const ISSUE_PAGE_SIZE = 50; + +const FIRST_PAGE: ReadonlyArray = [undefined]; + +/** + * The pages loaded so far as one list. An issue updated while the reader pages moves to the top + * of a fresh read and can be handed over again by a later page, so the first sighting wins. + */ +export function mergeIssuePages(pages: ReadonlyArray): { + readonly entries: ReadonlyArray; + readonly nextCursor: string | null; +} { + const seen = new Set(); + const entries: IssueListEntry[] = []; + for (const page of pages) { + for (const entry of page.entries) { + if (seen.has(entry.id)) continue; + seen.add(entry.id); + entries.push(entry); + } + } + return { entries, nextCursor: pages.at(-1)?.nextCursor ?? null }; +} + +/** + * A project's issues on one tracker, a page at a time. Each page is its own cached query keyed by + * its cursor, so loading more reads one page and returning to a list redraws it from memory. + */ +export function usePaginatedIssues( + environmentId: EnvironmentId | null, + input: Omit | null, +) { + const targetKey = + environmentId !== null && input !== null ? JSON.stringify([environmentId, input]) : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + }>({ targetKey, cursors: FIRST_PAGE }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : FIRST_PAGE; + // Read back out of `targetKey` rather than closed over: `input` is a fresh object every render. + const pageAtoms = useMemo(() => { + if (targetKey === null) return []; + const [target, query] = JSON.parse(targetKey) as [EnvironmentId, IssueListInput]; + return cursors.map((cursor) => + issueEnvironment.list({ + environmentId: target, + input: { ...query, limit: ISSUE_PAGE_SIZE, ...(cursor === undefined ? {} : { cursor }) }, + }), + ); + }, [cursors, targetKey]); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`web:issue-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const merged = useMemo( + () => mergeIssuePages(results.flatMap((result) => Option.toArray(AsyncResult.value(result)))), + [results], + ); + const failed = results.find((result) => result._tag === "Failure"); + const failure = + failed?._tag === "Failure" + ? (Option.getOrNull(Cause.findErrorOption(failed.cause)) as IssueError | null) + : null; + + const refresh = useCallback(() => { + setPagination({ targetKey, cursors: FIRST_PAGE }); + const firstPage = pageAtoms[0]; + if (firstPage !== undefined) appAtomRegistry.refresh(firstPage); + }, [pageAtoms, targetKey]); + const nextCursor = merged.nextCursor; + const loadMore = useCallback(() => { + if (targetKey === null || nextCursor === null) return; + setPagination((current) => { + const loaded = current.targetKey === targetKey ? current.cursors : FIRST_PAGE; + return loaded.includes(nextCursor) + ? current + : { targetKey, cursors: [...loaded, nextCursor] }; + }); + }, [nextCursor, targetKey]); + + return { + entries: merged.entries, + /** Nothing has arrived yet for the first page. */ + isLoading: + pageAtoms.length > 0 && + failed === undefined && + results.every((result) => Option.isNone(AsyncResult.value(result))), + isRefreshing: results.some((result) => result.waiting), + isLoadingMore: results.length > 1 && (results.at(-1)?.waiting ?? false), + hasMore: nextCursor !== null, + error: failed?._tag === "Failure" ? formatEnvironmentQueryError(failed.cause) : null, + failure, + refresh, + loadMore, + }; +} diff --git a/docs/README.md b/docs/README.md index d7628960cb8b..f59baca83a1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Permission modes](./user/permission-modes.md) - [Terminal history](./user/terminal.md) - [Source control](./user/source-control.md) +- [Issues](./user/issues.md) - [Project settings](./user/project-settings.md) - [Appearance and themes](./user/appearance.md) - [Keyboard shortcuts](./user/keybindings.md) diff --git a/docs/user/issues.md b/docs/user/issues.md new file mode 100644 index 000000000000..6cd6b083a5aa --- /dev/null +++ b/docs/user/issues.md @@ -0,0 +1,22 @@ +# Issues + +Open **Issues** from the sidebar or the command palette to browse a project's issues without +leaving T3 Code. Pick the project at the top of the page. A project whose repository is on GitHub +shows that repository's issues, using the same GitHub account as pull requests (see +[Source control](./source-control.md#github)). + +Search matches the words you type, the way the tracker searches. Filter by state, by assignee (anyone, you, or +nobody), and by label. The label menu offers the labels on issues already loaded. Lists load 50 +issues at a time; **Load more issues** fetches the next page. + +Select an issue to read its description, labels, assignees, state, and comments. The view is +read-only: use **Open in GitHub** to comment, edit, or close the issue. An issue with more than +100 comments shows the first 100 and links to the rest. + +## Issues beside a thread + +In a thread, open the panel's surface menu and choose **Issues** (or press I in an empty panel) to +browse the thread project's issues without leaving the conversation. Each issue you select opens +as its own tab. Links to a project's GitHub issues in chat and in pull request descriptions open +there too, including `#123` references that turn out to be issues rather than pull requests. +Outside a thread, such a link opens the Issues page on that issue. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0366c65b1ed2..0b2f518697e9 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -199,6 +199,10 @@ "types": "./src/state/providerInstanceDisplay.ts", "default": "./src/state/providerInstanceDisplay.ts" }, + "./state/issues": { + "types": "./src/state/issues.ts", + "default": "./src/state/issues.ts" + }, "./state/pull-requests": { "types": "./src/state/pullRequests.ts", "default": "./src/state/pullRequests.ts" diff --git a/packages/client-runtime/src/state/issues.ts b/packages/client-runtime/src/state/issues.ts new file mode 100644 index 000000000000..0316a6b00f68 --- /dev/null +++ b/packages/client-runtime/src/state/issues.ts @@ -0,0 +1,33 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import type { Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +/** + * Issue reads for one environment. Each page of a listing is its own query, keyed by its cursor, + * so loading more never re-reads the pages already shown, and a page revisited within the stale + * time is served from memory. The server keeps its own short cache shared by every client. + */ +export function createIssueEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + /** Which trackers a project has. Rarely changes, so it is read once per visit. */ + sources: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:sources", + tag: WS_METHODS.issuesSources, + staleTimeMs: 5 * 60_000, + }), + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:list", + tag: WS_METHODS.issuesList, + staleTimeMs: 60_000, + }), + detail: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:detail", + tag: WS_METHODS.issuesDetail, + staleTimeMs: 60_000, + }), + }; +} diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index deca7ff162b3..4f9b296e0e4a 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -211,6 +211,13 @@ export function createEnvironmentSummaryAtoms(input: { .pullRequests === true, ), ); + const issuesSupportedAtom = Atom.make((get) => + get(environmentIdsAtom).some( + (environmentId) => + get(input.presentationAtom(environmentId))?.serverConfig?.environment.capabilities + .issues === true, + ), + ); return { environmentIdsAtom, connectedEnvironmentIdsAtom, @@ -218,5 +225,6 @@ export function createEnvironmentSummaryAtoms(input: { environmentsAtom, machineByIdAtom, pullRequestsSupportedAtom, + issuesSupportedAtom, }; } diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4763076639b..801576eddc60 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -111,6 +111,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 the issue list and detail reads. Absent on servers from before they shipped. */ + issues: Schema.optionalKey(Schema.Boolean), pullRequestChecks: Schema.optionalKey(Schema.Boolean), /** Server understands canonical inline context links plus their message context records. Absent on servers from before inline context shipped, which drop the records and forward diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 600d503ade39..40c4188f6eaa 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -33,6 +33,7 @@ export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./projectClone.ts"; export * from "./pullRequest.ts"; +export * from "./issue.ts"; export * from "./pullRequestReviewGuide.ts"; export * from "./orchestrationDispatch.ts"; export * from "./orchestrationProject.ts"; diff --git a/packages/contracts/src/issue.ts b/packages/contracts/src/issue.ts new file mode 100644 index 000000000000..4b026dccb35a --- /dev/null +++ b/packages/contracts/src/issue.ts @@ -0,0 +1,155 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PullRequestActor, PullRequestLabel } from "./pullRequest.ts"; + +/** + * The issue trackers this build can read. A tracker is not a source control host: a project's + * repository decides where its pull requests live, while its issues may live in a tracker of + * their own. + */ +export const IssueTrackerKind = Schema.Literals(["github"]); +export type IssueTrackerKind = typeof IssueTrackerKind.Type; + +/** Where an issue stands, folded to the two states every tracker has. */ +export const IssueState = Schema.Literals(["open", "closed"]); +export type IssueState = typeof IssueState.Type; + +/** What a listing asks for: one state, or both. */ +export const IssueListState = Schema.Literals(["open", "closed", "all"]); +export type IssueListState = typeof IssueListState.Type; + +/** Who an issue is assigned to, as a listing narrows it. */ +export const IssueAssigneeFilter = Schema.Literals(["anyone", "me", "none"]); +export type IssueAssigneeFilter = typeof IssueAssigneeFilter.Type; + +/** Why an issue was closed, where the tracker says. */ +export const IssueStateReason = Schema.Literals(["completed", "not-planned", "duplicate"]); +export type IssueStateReason = typeof IssueStateReason.Type; + +export const IssueListEntry = Schema.Struct({ + /** The tracker's own key for reading the issue again. Opaque to the client. */ + id: TrimmedNonEmptyString, + /** How the tracker writes the issue's name: `#123` on GitHub. */ + reference: TrimmedNonEmptyString, + title: Schema.String, + url: TrimmedNonEmptyString, + state: IssueState, + /** The tracker's own name for the state, where it has finer ones than open and closed. */ + statusName: Schema.optional(TrimmedNonEmptyString), + author: Schema.NullOr(PullRequestActor), + labels: Schema.Array(PullRequestLabel), + assignees: Schema.Array(PullRequestActor), + commentCount: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type IssueListEntry = typeof IssueListEntry.Type; + +const IssueQualifierValue = TrimmedNonEmptyString.check(Schema.isMaxLength(200)); + +/** Which tracker of a project to read. The tracker decides where the project's issues live. */ +export const IssueSourceInput = Schema.Struct({ + projectId: ProjectId, + tracker: IssueTrackerKind, +}); +export type IssueSourceInput = typeof IssueSourceInput.Type; + +/** One page of a project's issues on one tracker, newest update first. */ +export const IssueListInput = Schema.Struct({ + ...IssueSourceInput.fields, + state: IssueListState, + assignee: Schema.optional(IssueAssigneeFilter), + /** One label name, matched the way the tracker matches it. */ + label: Schema.optional(IssueQualifierValue), + /** Free text the tracker matches. Bounded because it travels into the tracker's own query. */ + query: Schema.optional(IssueQualifierValue), + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), + /** Opaque, handed back exactly as a previous page's `nextCursor` gave it. */ + cursor: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), +}); +export type IssueListInput = typeof IssueListInput.Type; + +export const IssueListResult = Schema.Struct({ + entries: Schema.Array(IssueListEntry), + /** Null once the tracker has nothing more to give. */ + nextCursor: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueListResult = typeof IssueListResult.Type; + +/** Addresses one issue, by the `id` its listing gave. */ +export const IssueRef = Schema.Struct({ + ...IssueSourceInput.fields, + id: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}); +export type IssueRef = typeof IssueRef.Type; + +export const IssueComment = Schema.Struct({ + id: TrimmedNonEmptyString, + author: Schema.NullOr(PullRequestActor), + body: Schema.String, + createdAt: IsoDateTime, + url: Schema.NullOr(Schema.String), +}); +export type IssueComment = typeof IssueComment.Type; + +export const IssueDetail = Schema.Struct({ + ...IssueListEntry.fields, + body: Schema.String, + stateReason: Schema.NullOr(IssueStateReason), + closedAt: Schema.NullOr(IsoDateTime), + /** + * The oldest comments, up to a bound. `commentCount` is the full count, so a page can say how + * many more the tracker holds. + */ + comments: Schema.Array(IssueComment), +}); +export type IssueDetail = typeof IssueDetail.Type; + +export const IssueErrorReason = Schema.Literals([ + /** The tracker has nothing to offer this project, such as GitHub for a GitLab checkout. */ + "unsupported", + /** The tracker could serve this project once it is told where the project's issues live. */ + "not-configured", + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", +]); +export type IssueErrorReason = typeof IssueErrorReason.Type; + +/** + * One tracker as a project sees it. `available` sources can be listed; the rest carry the reason, + * so the page can explain or offer the setup instead of failing a listing. + */ +export const IssueSource = Schema.Struct({ + tracker: IssueTrackerKind, + available: Schema.Boolean, + /** Where the issues are read from, as the page names it: `owner/repo` on GitHub. */ + name: Schema.NullOr(TrimmedNonEmptyString), + reason: Schema.NullOr(IssueErrorReason), + detail: Schema.NullOr(TrimmedNonEmptyString), +}); +export type IssueSource = typeof IssueSource.Type; + +export const IssueSourcesInput = Schema.Struct({ projectId: ProjectId }); +export type IssueSourcesInput = typeof IssueSourcesInput.Type; + +export const IssueSourcesResult = Schema.Struct({ sources: Schema.Array(IssueSource) }); +export type IssueSourcesResult = typeof IssueSourcesResult.Type; + +/** + * Every way an issue read fails. The first four mean the project's issues cannot be read on this + * tracker until something changes outside the page; the rest are specific to the request. + */ +export class IssueError extends Schema.TaggedError()("IssueError", { + reason: IssueErrorReason, + detail: TrimmedNonEmptyString, + tracker: Schema.optional(IssueTrackerKind), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return this.detail; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 61304c52f860..ea2d79e64372 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -162,6 +162,15 @@ import { } from "./provider.ts"; import { ProviderInstanceId, ProviderInstanceMutation } from "./providerInstance.ts"; import { PullRequestReviewGuideResult } from "./pullRequestReviewGuide.ts"; +import { + IssueDetail, + IssueError, + IssueListInput, + IssueListResult, + IssueRef, + IssueSourcesInput, + IssueSourcesResult, +} from "./issue.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -527,6 +536,11 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Issue methods + issuesSources: "issues.sources", + issuesList: "issues.list", + issuesDetail: "issues.detail", + // Pull request methods pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", @@ -956,6 +970,26 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); +const IssueRpcError = Schema.Union([IssueError, EnvironmentAuthorizationError]); + +const WsIssuesSourcesRpc = Rpc.make(WS_METHODS.issuesSources, { + payload: IssueSourcesInput, + success: IssueSourcesResult, + error: IssueRpcError, +}); + +const WsIssuesListRpc = Rpc.make(WS_METHODS.issuesList, { + payload: IssueListInput, + success: IssueListResult, + error: IssueRpcError, +}); + +const WsIssuesDetailRpc = Rpc.make(WS_METHODS.issuesDetail, { + payload: IssueRef, + success: IssueDetail, + error: IssueRpcError, +}); + const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, @@ -1941,6 +1975,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsIssuesSourcesRpc, + WsIssuesListRpc, + WsIssuesDetailRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc,