diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..77ee9342845f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -121,6 +121,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.issuesSources]: AuthOrchestrationReadScope, + [WS_METHODS.issuesSourceOptions]: 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..47a608180de4 --- /dev/null +++ b/apps/server/src/issue/IssueProvider.ts @@ -0,0 +1,79 @@ +/** + * 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, + IssueSourceOption, + 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; + /** + * The places this project's issues could live, for a tracker that has to be told which one, + * such as a Linear team. Absent for a tracker that works it out from the project itself. + */ + readonly sourceOptions?: ( + project: IssueProject, + ) => Effect.Effect, IssueProviderError>; + /** `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..369c67a30050 --- /dev/null +++ b/apps/server/src/issue/IssueProviderRegistry.ts @@ -0,0 +1,37 @@ +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"; +import * as LinearIssueProvider from "./LinearIssueProvider.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, yield* LinearIssueProvider.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..8e364aa49ecd --- /dev/null +++ b/apps/server/src/issue/IssueService.test.ts @@ -0,0 +1,203 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { + IssueListEntry, + OrchestrationProjectShell, + ProjectId, + ServerSettings, +} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as ServerSettingsService from "../serverSettings.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, + settingsChanges: Stream.Stream = Stream.empty, +) { + 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), + }), + Layer.mock(ServerSettingsService.ServerSettingsService)({ streamChanges: settingsChanges }), + ), + ), + ); +} + +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("drops cached reads when settings change, such as a project's team", () => + Effect.gen(function* () { + const changed = yield* Deferred.make(); + const handled = yield* Deferred.make(); + // Emits one change, then marks it handled when the service asks for the next one. + const changes = Stream.fromEffect( + Deferred.await(changed).pipe(Effect.as(DEFAULT_SERVER_SETTINGS)), + ).pipe( + Stream.concat( + Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe( + Stream.flatMap(() => Stream.never), + ), + ), + ); + let reads = 0; + const service = yield* makeService( + fakeProvider({ + listIssues: () => + Effect.sync(() => { + reads++; + return { items: [entry], nextCursor: null }; + }), + }), + changes, + ); + const input = { projectId: project.id, tracker: "github", state: "open" } as const; + yield* service.list(input); + yield* Deferred.succeed(changed, undefined); + yield* Deferred.await(handled); + yield* service.list(input); + 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..eee038456df6 --- /dev/null +++ b/apps/server/src/issue/IssueService.ts @@ -0,0 +1,206 @@ +import { + IssueError, + type IssueDetail, + type IssueListInput, + type IssueListResult, + type IssueRef, + type IssueSource, + type IssueSourceInput, + type IssueSourceOptionsResult, + 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 Stream from "effect/Stream"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as ServerSettings from "../serverSettings.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 sourceOptions: ( + input: IssueSourceInput, + ) => 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 serverSettings = yield* ServerSettings.ServerSettingsService; + + 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 }, + ); + + // Trackers read their configuration from settings (a token, a project's team), so a change there + // can move where a project's issues live. Settings change rarely; every cached read goes. + yield* Stream.runForEach(serverSettings.streamChanges, () => + Effect.andThen(Cache.invalidateAll(listCache), Cache.invalidateAll(detailCache)), + ).pipe(Effect.forkScoped); + + /** 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; + }); + + /** Not cached: it is read when a picker opens, and a new team should show at once. */ + const sourceOptions = Effect.fn("IssueService.sourceOptions")(function* ( + input: IssueSourceInput, + ) { + const { provider, project } = yield* resolve(input); + if (provider.sourceOptions === undefined) return { options: [] }; + const options = yield* provider.sourceOptions(project).pipe(Effect.mapError(toIssueError)); + return { options } satisfies IssueSourceOptionsResult; + }); + + return IssueService.of({ + sources, + sourceOptions, + // 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/issue/LinearIssueProvider.test.ts b/apps/server/src/issue/LinearIssueProvider.test.ts new file mode 100644 index 000000000000..e3965390333b --- /dev/null +++ b/apps/server/src/issue/LinearIssueProvider.test.ts @@ -0,0 +1,306 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ProjectId, ServerSettings } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import * as ServerSettingsService from "../serverSettings.ts"; +import type { IssueProject } from "./IssueProvider.ts"; +import { linearFailureReason, linearIssueFilter, make } from "./LinearIssueProvider.ts"; + +// Fixture values only: no real key or workspace is ever contacted. +const API_KEY = "fixture-not-a-real-key"; +const TEAM_ID = "team-eng"; + +const project: IssueProject = { + id: "project-1" as ProjectId, + workspaceRoot: "/repo", + repositoryIdentity: null, +}; + +const linearIssue = (identifier: string, overrides: Record = {}) => ({ + id: `uuid-${identifier}`, + identifier, + title: `Fix ${identifier}`, + url: `https://linear.app/acme/issue/${identifier}`, + createdAt: "2026-10-01T10:00:00.000Z", + updatedAt: "2026-10-02T10:00:00.000Z", + completedAt: null, + canceledAt: null, + state: { name: "In Progress", type: "started" }, + creator: { name: "Ada Lovelace", displayName: "ada", avatarUrl: "https://a/ada" }, + assignee: null, + labels: { nodes: [{ name: "Bug", color: "#eb5757" }] }, + ...overrides, +}); + +interface Recorded { + readonly authorization: string | undefined; + readonly body: { readonly query: string; readonly variables: Record }; +} + +function harness(input: { + readonly settings?: Partial; + readonly respond: (body: Recorded["body"]) => { status?: number; json: unknown }; +}) { + const requests: Recorded[] = []; + const httpClient = HttpClient.make((request) => { + const body = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as Recorded["body"]; + requests.push({ authorization: request.headers.authorization, body }); + const answer = input.respond(body); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(answer.json), { status: answer.status ?? 200 }), + ), + ); + }); + const provider = make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient, httpClient), + Layer.mock(ServerSettingsService.ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + linearApiKey: API_KEY, + linearTeamId: TEAM_ID, + ...input.settings, + }), + }), + ), + ), + ); + return { provider, requests }; +} + +describe("linearIssueFilter", () => { + it("narrows to the team and maps every filter onto Linear's own", () => { + assert.deepStrictEqual( + linearIssueFilter({ + teamId: TEAM_ID, + state: "open", + assignee: "me", + label: "Bug", + query: " crash ", + }), + { + team: { id: { eq: TEAM_ID } }, + state: { type: { nin: ["completed", "canceled", "duplicate"] } }, + assignee: { isMe: { eq: true } }, + labels: { some: { name: { eqIgnoreCase: "Bug" } } }, + or: [ + { title: { containsIgnoreCase: "crash" } }, + { description: { containsIgnoreCase: "crash" } }, + ], + }, + ); + assert.deepStrictEqual( + linearIssueFilter({ + teamId: TEAM_ID, + state: "all", + assignee: "none", + label: undefined, + query: undefined, + }), + { team: { id: { eq: TEAM_ID } }, assignee: { null: true } }, + ); + }); +}); + +describe("linearFailureReason", () => { + it("reads authentication and rate limits from status or Linear's codes", () => { + assert.strictEqual(linearFailureReason(400, ["AUTHENTICATION_ERROR"]), "unauthenticated"); + assert.strictEqual(linearFailureReason(401, []), "unauthenticated"); + assert.strictEqual(linearFailureReason(400, ["RATELIMITED"]), "rate-limited"); + assert.strictEqual(linearFailureReason(200, ["INVALID_INPUT"]), "failed"); + }); +}); + +describe("LinearIssueProvider", () => { + it.effect("lists a page of the project's team with the key as the whole header", () => + Effect.gen(function* () { + const { provider, requests } = harness({ + respond: () => ({ + json: { + data: { + issues: { + pageInfo: { hasNextPage: true, endCursor: "cursor-2" }, + nodes: [ + linearIssue("ENG-12", { + assignee: { name: "Grace Hopper", displayName: "grace", avatarUrl: null }, + }), + linearIssue("ENG-11", { + state: { name: "Done", type: "completed" }, + completedAt: "2026-10-02T00:00:00.000Z", + }), + ], + }, + }, + }, + }), + }); + const linear = yield* provider; + const page = yield* linear.listIssues(project, { + state: "all", + assignee: "anyone", + label: undefined, + query: undefined, + limit: 50, + cursor: "cursor-1", + }); + assert.strictEqual(requests[0]?.authorization, API_KEY); + assert.deepStrictEqual(requests[0]?.body.variables, { + filter: { team: { id: { eq: TEAM_ID } } }, + first: 50, + after: "cursor-1", + }); + assert.strictEqual(page.nextCursor, "cursor-2"); + assert.deepStrictEqual(page.items[0], { + id: "ENG-12", + reference: "ENG-12", + title: "Fix ENG-12", + url: "https://linear.app/acme/issue/ENG-12", + state: "open", + statusName: "In Progress", + author: { login: "ada", name: "Ada Lovelace", avatarUrl: "https://a/ada" }, + labels: [{ name: "Bug", color: "#eb5757" }], + assignees: [{ login: "grace", name: "Grace Hopper", avatarUrl: null }], + commentCount: null, + createdAt: "2026-10-01T10:00:00.000Z", + updatedAt: "2026-10-02T10:00:00.000Z", + }); + assert.strictEqual(page.items[1]?.state, "closed"); + }), + ); + + it.effect("reads an issue of the team with its comments in order", () => + Effect.gen(function* () { + const { provider } = harness({ + respond: () => ({ + json: { + data: { + issue: { + ...linearIssue("ENG-7", { + state: { name: "Canceled", type: "canceled" }, + canceledAt: "2026-10-03T00:00:00.000Z", + }), + description: "It breaks.", + team: { id: TEAM_ID }, + comments: { + pageInfo: { hasNextPage: true }, + nodes: [ + { id: "c2", body: "Later", createdAt: "2026-10-02T12:00:00.000Z", user: null }, + { + id: "c1", + body: "First", + createdAt: "2026-10-02T11:00:00.000Z", + url: "https://linear.app/acme/issue/ENG-7#comment-c1", + user: { name: "Ada Lovelace", displayName: "ada" }, + }, + ], + }, + }, + }, + }, + }), + }); + const issue = yield* (yield* provider).getIssue(project, "ENG-7"); + assert.strictEqual(issue.body, "It breaks."); + assert.strictEqual(issue.state, "closed"); + assert.strictEqual(issue.stateReason, "not-planned"); + assert.strictEqual(issue.closedAt, "2026-10-03T00:00:00.000Z"); + assert.deepStrictEqual( + issue.comments.map((comment) => [comment.id, comment.author?.login ?? null]), + [ + ["c1", "ada"], + ["c2", null], + ], + ); + assert.isTrue(issue.moreComments); + }), + ); + + it.effect("refuses an issue from another team", () => + Effect.gen(function* () { + const { provider } = harness({ + respond: () => ({ + json: { + data: { + issue: { + ...linearIssue("OPS-1"), + team: { id: "team-ops" }, + comments: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + }, + }, + }), + }); + const error = yield* Effect.flip((yield* provider).getIssue(project, "OPS-1")); + assert.strictEqual(error.reason, "not-found"); + }), + ); + + it.effect("is unsupported without a key and asks for a team without one", () => + Effect.gen(function* () { + const noKey = harness({ + settings: { linearApiKey: "" }, + respond: () => ({ json: {} }), + }); + const unsupported = yield* Effect.flip((yield* noKey.provider).sourceName(project)); + assert.strictEqual(unsupported.reason, "unsupported"); + assert.strictEqual(noKey.requests.length, 0); + + const noTeam = harness({ + settings: { linearTeamId: "" }, + respond: () => ({ + json: { + data: { + teams: { + nodes: [ + { id: "t2", key: "OPS", name: "Operations" }, + { id: "t1", key: "ENG", name: "Engineering" }, + ], + }, + }, + }, + }), + }); + const linear = yield* noTeam.provider; + const notConfigured = yield* Effect.flip(linear.sourceName(project)); + assert.strictEqual(notConfigured.reason, "not-configured"); + // The picker can still list the teams, which only needs the key. + assert.deepStrictEqual(yield* linear.sourceOptions(project), [ + { id: "t1", name: "Engineering (ENG)" }, + { id: "t2", name: "Operations (OPS)" }, + ]); + }), + ); + + it.effect("names the team, and reports a refused key without echoing it", () => + Effect.gen(function* () { + const named = harness({ + respond: () => ({ + json: { data: { team: { id: TEAM_ID, key: "ENG", name: "Engineering" } } }, + }), + }); + assert.strictEqual(yield* (yield* named.provider).sourceName(project), "Engineering"); + + const refused = harness({ + respond: () => ({ + status: 400, + json: { + errors: [ + { message: "Authentication required", extensions: { code: "AUTHENTICATION_ERROR" } }, + ], + }, + }), + }); + const error = yield* Effect.flip((yield* refused.provider).sourceName(project)); + assert.strictEqual(error.reason, "unauthenticated"); + assert.notInclude(error.detail, API_KEY); + }), + ); +}); diff --git a/apps/server/src/issue/LinearIssueProvider.ts b/apps/server/src/issue/LinearIssueProvider.ts new file mode 100644 index 000000000000..5377b98f808e --- /dev/null +++ b/apps/server/src/issue/LinearIssueProvider.ts @@ -0,0 +1,440 @@ +/** + * Linear issues through Linear's GraphQL API, authenticated with the personal API key saved in + * Settings → Integrations. A project's issues are one Linear team's, chosen per project through + * the `linearTeamId` setting; without a key the tracker is not offered at all. + */ +import type { + IssueAssigneeFilter, + IssueComment, + IssueDetail, + IssueListEntry, + IssueListState, + IssueStateReason, + PullRequestActor, + PullRequestLabel, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/http"; + +import * as ServerSettings from "../serverSettings.ts"; +import { IssueProviderError, type IssueProject, type IssueProviderApi } from "./IssueProvider.ts"; + +const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; +const TIMEOUT = Duration.seconds(30); +/** Linear's page size ceiling for a connection. */ +const MAX_PAGE_SIZE = 100; +const DETAIL_COMMENT_LIMIT = 100; +/** The workflow state types Linear counts as done with. */ +const CLOSED_STATE_TYPES = ["completed", "canceled", "duplicate"]; +const CLOSED_STATE_TYPE_SET = new Set(CLOSED_STATE_TYPES); + +const ACTOR_FIELDS = "name displayName avatarUrl"; +const ISSUE_FIELDS = ` + id identifier title url createdAt updatedAt completedAt canceledAt + state { name type } + creator { ${ACTOR_FIELDS} } + assignee { ${ACTOR_FIELDS} } + labels(first: 20) { nodes { name color } }`; + +const LIST_QUERY = ` +query LinearIssues($filter: IssueFilter!, $first: Int!, $after: String) { + issues(filter: $filter, first: $first, after: $after, orderBy: updatedAt) { + pageInfo { hasNextPage endCursor } + nodes { ${ISSUE_FIELDS} } + } +}`; + +const DETAIL_QUERY = ` +query LinearIssue($id: String!) { + issue(id: $id) { + ${ISSUE_FIELDS} + description + team { id } + comments(first: ${DETAIL_COMMENT_LIMIT}) { + pageInfo { hasNextPage } + nodes { id body createdAt url user { ${ACTOR_FIELDS} } } + } + } +}`; + +const TEAM_QUERY = `query LinearTeam($id: String!) { team(id: $id) { id key name } }`; +const TEAMS_QUERY = `query LinearTeams { teams(first: 100) { nodes { id key name } } }`; + +const RawActor = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + displayName: Schema.optional(Schema.NullOr(Schema.String)), + avatarUrl: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssue = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + title: Schema.String, + url: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + completedAt: Schema.optional(Schema.NullOr(Schema.String)), + canceledAt: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional( + Schema.NullOr(Schema.Struct({ name: Schema.String, type: Schema.String })), + ), + creator: Schema.optional(Schema.NullOr(RawActor)), + assignee: Schema.optional(Schema.NullOr(RawActor)), + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + }), + ), + ), +}); + +const RawTeam = Schema.Struct({ id: Schema.String, key: Schema.String, name: Schema.String }); + +const RawError = Schema.Struct({ + message: Schema.optional(Schema.String), + extensions: Schema.optional(Schema.Struct({ code: Schema.optional(Schema.String) })), +}); + +/** Every Linear answer: data where it could, errors where it could not. */ +const envelope = (data: A) => + Schema.Struct({ + data: Schema.optional(Schema.NullOr(data)), + errors: Schema.optional(Schema.Array(RawError)), + }); + +const decodeList = decodeJsonResult( + envelope( + Schema.Struct({ + issues: Schema.Struct({ + pageInfo: Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + nodes: Schema.Array(RawIssue), + }), + }), + ), +); + +const decodeDetail = decodeJsonResult( + envelope( + Schema.Struct({ + issue: Schema.NullOr( + Schema.Struct({ + ...RawIssue.fields, + description: Schema.optional(Schema.NullOr(Schema.String)), + team: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.String }))), + comments: Schema.Struct({ + pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), + nodes: Schema.Array( + Schema.Struct({ + id: Schema.String, + body: Schema.String, + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(RawActor)), + }), + ), + }), + }), + ), + }), + ), +); + +const decodeTeam = decodeJsonResult(envelope(Schema.Struct({ team: Schema.NullOr(RawTeam) }))); +const decodeTeams = decodeJsonResult( + envelope(Schema.Struct({ teams: Schema.Struct({ nodes: Schema.Array(RawTeam) }) })), +); + +function toActor(raw: typeof RawActor.Type | null | undefined): PullRequestActor | null { + const login = raw?.displayName?.trim() || raw?.name?.trim(); + if (!login) return null; + return { login, name: raw?.name?.trim() || null, avatarUrl: raw?.avatarUrl?.trim() || null }; +} + +function toEntry(raw: typeof RawIssue.Type): IssueListEntry { + const statusName = raw.state?.name.trim(); + return { + id: raw.identifier, + reference: raw.identifier, + title: raw.title, + url: raw.url, + state: CLOSED_STATE_TYPE_SET.has(raw.state?.type ?? "") ? "closed" : "open", + ...(statusName ? { statusName } : {}), + author: toActor(raw.creator), + labels: (raw.labels?.nodes ?? []).flatMap((label): PullRequestLabel[] => { + const name = label.name.trim(); + return name ? [{ name, color: label.color?.trim() || null }] : []; + }), + assignees: raw.assignee ? [toActor(raw.assignee)].flatMap((actor) => actor ?? []) : [], + // Linear does not count a connection's nodes, and paging every issue's comments to count them + // would cost more than the row is worth. + commentCount: null, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + }; +} + +const STATE_REASONS: Partial> = { + completed: "completed", + canceled: "not-planned", + duplicate: "duplicate", +}; + +/** Linear's `IssueFilter` for one listing of one team. */ +export function linearIssueFilter(input: { + readonly teamId: string; + readonly state: IssueListState; + readonly assignee: IssueAssigneeFilter; + readonly label: string | undefined; + readonly query: string | undefined; +}): Record { + const query = input.query?.trim(); + return { + team: { id: { eq: input.teamId } }, + ...(input.state === "all" + ? {} + : { + state: { + type: + input.state === "closed" ? { in: CLOSED_STATE_TYPES } : { nin: CLOSED_STATE_TYPES }, + }, + }), + ...(input.assignee === "me" ? { assignee: { isMe: { eq: true } } } : {}), + ...(input.assignee === "none" ? { assignee: { null: true } } : {}), + ...(input.label === undefined + ? {} + : { labels: { some: { name: { eqIgnoreCase: input.label } } } }), + ...(query + ? { + or: [ + { title: { containsIgnoreCase: query } }, + { description: { containsIgnoreCase: query } }, + ], + } + : {}), + }; +} + +type LinearReason = IssueProviderError["reason"]; + +/** What a refused answer means, from its HTTP status and Linear's own error codes. */ +export function linearFailureReason(status: number, codes: ReadonlyArray): LinearReason { + const upper = new Set(codes.map((code) => code.toUpperCase())); + if (status === 401 || upper.has("AUTHENTICATION_ERROR")) return "unauthenticated"; + if (status === 429 || upper.has("RATELIMITED")) return "rate-limited"; + return "failed"; +} + +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const settingsService = yield* ServerSettings.ServerSettingsService; + + const fail = (operation: string, reason: LinearReason, detail: string, cause?: unknown) => + new IssueProviderError({ + tracker: "linear", + operation, + reason, + detail, + ...(cause === undefined ? {} : { cause }), + }); + + /** The key and the project's team, read fresh so a saved or cleared setting applies at once. */ + const configuration = (project: IssueProject, operation: string) => + settingsService.getSettings.pipe( + Effect.mapError((cause) => fail(operation, "failed", "Settings could not be read.", cause)), + Effect.flatMap((settings) => { + const apiKey = settings.linearApiKey.trim(); + if (apiKey.length === 0) { + return Effect.fail( + fail(operation, "unsupported", "Add a Linear API key in Settings → Integrations."), + ); + } + const teamId = resolveProjectSettings(settings, project.id).settings.linearTeamId.trim(); + return Effect.succeed({ apiKey, teamId: teamId.length > 0 ? teamId : null }); + }), + ); + + const configuredTeam = (project: IssueProject, operation: string) => + configuration(project, operation).pipe( + Effect.flatMap(({ apiKey, teamId }) => + teamId === null + ? Effect.fail( + fail( + operation, + "not-configured", + "Choose the Linear team whose issues this project shows.", + ), + ) + : Effect.succeed({ apiKey, teamId }), + ), + ); + + const graphql = (input: { + readonly apiKey: string; + readonly operation: string; + readonly query: string; + readonly variables?: Readonly>; + readonly decode: (raw: string) => Result.Result< + { + readonly data?: A | null | undefined; + readonly errors?: ReadonlyArray | undefined; + }, + unknown + >; + }) => + Effect.gen(function* () { + const response = yield* httpClient + .execute( + HttpClientRequest.post(LINEAR_GRAPHQL_URL).pipe( + // Linear takes a personal API key as the whole header, without a scheme. + HttpClientRequest.setHeader("authorization", input.apiKey), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), + ), + ) + .pipe( + // The request's URL and headers stay out of traces: the header is the key itself. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + Effect.flatMap((response) => + response.text.pipe(Effect.map((text) => ({ status: response.status, text }))), + ), + Effect.timeout(TIMEOUT), + Effect.mapError((cause) => + fail(input.operation, "failed", "Could not reach Linear.", cause), + ), + ); + const decoded = input.decode(response.text); + if (Result.isFailure(decoded)) { + return yield* fail( + input.operation, + response.status >= 400 ? linearFailureReason(response.status, []) : "failed", + response.status >= 400 + ? `Linear refused the request (HTTP ${response.status}).` + : "Linear answered in a shape T3 Code cannot read.", + ); + } + const { data, errors = [] } = decoded.success; + if (errors.length > 0 || response.status >= 400 || data == null) { + const reason = linearFailureReason( + response.status, + errors.flatMap((error) => error.extensions?.code ?? []), + ); + const message = errors.flatMap((error) => error.message?.trim() || []).join("; "); + return yield* fail( + input.operation, + reason, + reason === "unauthenticated" + ? "Linear refused the API key. Replace it in Settings → Integrations." + : reason === "rate-limited" + ? "Linear's rate limit was reached. Try again in a minute." + : message || `Linear returned HTTP ${response.status}.`, + ); + } + return data; + }); + + return { + kind: "linear", + sourceName: (project) => + configuredTeam(project, "sourceName").pipe( + Effect.flatMap(({ apiKey, teamId }) => + graphql({ + apiKey, + operation: "sourceName", + query: TEAM_QUERY, + variables: { id: teamId }, + decode: decodeTeam, + }), + ), + Effect.flatMap(({ team }) => + team === null + ? Effect.fail( + fail("sourceName", "not-found", "The chosen Linear team is gone. Choose another."), + ) + : Effect.succeed(team.name), + ), + ), + sourceOptions: (project) => + configuration(project, "sourceOptions").pipe( + Effect.flatMap(({ apiKey }) => + graphql({ apiKey, operation: "sourceOptions", query: TEAMS_QUERY, decode: decodeTeams }), + ), + Effect.map(({ teams }) => + teams.nodes + .map((team) => ({ id: team.id, name: `${team.name} (${team.key})` })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + ), + ), + listIssues: (project, input) => + configuredTeam(project, "listIssues").pipe( + Effect.flatMap(({ apiKey, teamId }) => + graphql({ + apiKey, + operation: "listIssues", + query: LIST_QUERY, + variables: { + filter: linearIssueFilter({ ...input, teamId }), + first: Math.min(Math.max(input.limit, 1), MAX_PAGE_SIZE), + after: input.cursor ?? null, + }, + decode: decodeList, + }), + ), + Effect.map(({ issues }) => ({ + items: issues.nodes.map(toEntry), + nextCursor: issues.pageInfo.hasNextPage ? (issues.pageInfo.endCursor ?? null) : null, + })), + ), + getIssue: (project, id) => + configuredTeam(project, "getIssue").pipe( + Effect.flatMap(({ apiKey, teamId }) => + graphql({ + apiKey, + operation: "getIssue", + query: DETAIL_QUERY, + variables: { id }, + decode: decodeDetail, + }).pipe( + Effect.flatMap(({ issue }) => + // Only the project's own team: an id from the client must not read another team's. + issue === null || issue.team?.id !== teamId + ? Effect.fail(fail("getIssue", "not-found", `Issue ${id} was not found.`)) + : Effect.succeed(issue), + ), + ), + ), + Effect.map((issue): IssueDetail => ({ + ...toEntry(issue), + body: issue.description ?? "", + stateReason: STATE_REASONS[issue.state?.type ?? ""] ?? null, + closedAt: issue.completedAt ?? issue.canceledAt ?? null, + comments: issue.comments.nodes + .map((comment): IssueComment => ({ + id: comment.id, + author: toActor(comment.user), + body: comment.body, + createdAt: comment.createdAt, + url: comment.url ?? null, + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + ...(issue.comments.pageInfo.hasNextPage ? { moreComments: true } : {}), + })), + ), + } satisfies IssueProviderApi; +}); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..3b1ff0dedd37 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -96,6 +96,10 @@ const RPC_AGGREGATES = { [WS_METHODS.secretsAnswerRequest]: "secrets", [WS_METHODS.cloudGetRelayClientStatus]: "cloud", [WS_METHODS.cloudInstallRelayClient]: "cloud", + [WS_METHODS.issuesSources]: "issues", + [WS_METHODS.issuesSourceOptions]: "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/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 1cbd6c7bf813..bf0bbb191d75 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1654,6 +1654,37 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect( + "keeps the Linear API key in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ linearApiKey: "fixture-linear-key" }); + assert.equal(saved.linearApiKey, "fixture-linear-key"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "fixture-linear-key", + ); + assert.isTrue(Option.isSome(yield* secrets.get("linear-api-key"))); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).linearApiKey; + assert.notInclude(forClient, "fixture-linear-key"); + assert.isAbove(forClient.length, 0); + + // Echoing the marker back keeps the key; an empty value removes it. + yield* serverSettings.updateSettings({ linearApiKey: forClient, linearTeamId: "team-1" }); + assert.equal((yield* serverSettings.getSettings).linearApiKey, "fixture-linear-key"); + const cleared = yield* serverSettings.updateSettings({ linearApiKey: "" }); + assert.equal(cleared.linearApiKey, ""); + assert.equal(cleared.linearTeamId, "team-1"); + assert.isTrue(Option.isNone(yield* secrets.get("linear-api-key"))); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + it.effect( "keeps source control host secrets in the secret store and tells clients only that one is set", () => diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 42adf7977ebd..b0e891894318 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -146,5 +146,12 @@ export function mapSettingsSecrets( ]), ) as ServerSettings["usageLimitSources"]; - return { ...settings, providerInstances, sourceControlHosts, usageLimitSources }; + // One Linear API key per environment. + const { linearApiKey } = mapFields( + { linearApiKey: settings.linearApiKey }, + ["linearApiKey"], + () => "linear-api-key", + ) as { readonly linearApiKey: string }; + + return { ...settings, providerInstances, sourceControlHosts, usageLimitSources, linearApiKey }; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375c2ac78aa1..65b3fcda44ec 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,10 @@ const layerWsRpc = ( Effect.forkScoped, ), ), + [WS_METHODS.issuesSources]: (input) => issues.sources(input), + [WS_METHODS.issuesSourceOptions]: (input) => issues.sourceOptions(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 +3158,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 +3226,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/Icons.tsx b/apps/web/src/components/Icons.tsx index 909b9c7456f3..c29dd74d7fe9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -81,6 +81,15 @@ export const GitHubIcon: Icon = (props) => ( ); +export const LinearIcon: Icon = (props) => ( + + + +); + export const GitIcon: Icon = (props) => ( 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..8d2286ba1ad5 --- /dev/null +++ b/apps/web/src/components/issue/IssueBrowser.tsx @@ -0,0 +1,329 @@ +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 { IssueSourceMenu, IssueSourcePicker } from "./IssueSourcePicker"; +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 on GitHub, or from a Linear team once a Linear API key is saved in Settings → Integrations."} + + + + ); +} + +/** + * 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; + // A new source changes what every listing means, so the pages already held start over. + const refreshAfterSourceChange = () => { + sourcesQuery.refresh(); + issues.refresh(); + }; + + 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 !== undefined && + !source.available && + ISSUE_TRACKERS[source.tracker].source !== undefined && + (source.reason === "not-configured" || source.reason === "not-found") + ) { + 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 ? ( +
+ {ISSUE_TRACKERS[source.tracker].source !== undefined ? ( + + ) : null} + 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..f2b7c285c866 --- /dev/null +++ b/apps/web/src/components/issue/IssueDetailPanel.tsx @@ -0,0 +1,231 @@ +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}`; + // How many comments the tracker holds past those shown: 0 where it says there are more but not + // how many, null where there are none. + const moreComments = + issue === null + ? null + : issue.commentCount !== null + ? issue.commentCount > issue.comments.length + ? issue.commentCount - issue.comments.length + : null + : issue.moreComments === true + ? 0 + : null; + + 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 === null + ? "Comments" + : issue.commentCount === 1 + ? "1 comment" + : `${issue.commentCount} comments`} +

    + {issue.comments.length > 0 ? ( +
      + {issue.comments.map((comment) => ( + + ))} +
    + ) : null} + {moreComments !== null ? ( +
    + {moreComments === 0 ? "More" : `${moreComments} 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..c04e0b65ab90 --- /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/IssueSourcePicker.tsx b/apps/web/src/components/issue/IssueSourcePicker.tsx new file mode 100644 index 000000000000..d31168c4c52e --- /dev/null +++ b/apps/web/src/components/issue/IssueSourcePicker.tsx @@ -0,0 +1,185 @@ +import type { EnvironmentId, IssueTrackerKind, ProjectId } from "@t3tools/contracts"; +import { ChevronDownIcon } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { useEnvironmentSettings } from "~/hooks/useSettings"; +import { issueEnvironment } from "~/state/issues"; +import { useEnvironmentQuery } from "~/state/query"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { Spinner } from "../ui/spinner"; +import { issueSourceOverrideEntry } from "./issueSource.logic"; +import { ISSUE_TRACKERS } from "./issueTrackers"; + +interface IssueSourceTarget { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly tracker: IssueTrackerKind; +} + +/** + * Saves where a project's issues live on a tracker as the project's own setting, the way a project + * override saved from Settings is: the project's whole override entry, with this one key changed. + * `null` removes the project's choice, so it inherits the environment's again. + */ +function useSaveIssueSource({ environmentId, projectId, tracker }: IssueSourceTarget) { + const settingKey = ISSUE_TRACKERS[tracker].source?.settingKey; + const overrides = useEnvironmentSettings( + environmentId, + (settings) => settings.projectSettingsOverrides, + ); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: `save the ${ISSUE_TRACKERS[tracker].label} ${ISSUE_TRACKERS[tracker].source?.noun ?? "source"}`, + }); + return useCallback( + async (value: string | null) => { + if (settingKey === undefined) return false; + const result = await updateSettings({ + environmentId, + input: { + patch: { + projectSettingsOverrides: { + [projectId]: issueSourceOverrideEntry(overrides[projectId], settingKey, value), + }, + }, + }, + }); + return result._tag === "Success"; + }, + [environmentId, overrides, projectId, settingKey, updateSettings], + ); +} + +function useSourceOptions(target: IssueSourceTarget, enabled: boolean) { + return useEnvironmentQuery( + enabled + ? issueEnvironment.sourceOptions({ + environmentId: target.environmentId, + input: { projectId: target.projectId, tracker: target.tracker }, + }) + : null, + ); +} + +/** Asked in place of a list, for a tracker that has to be told where this project's issues live. */ +export function IssueSourcePicker({ + detail, + onChanged, + ...target +}: IssueSourceTarget & { readonly detail: string | null; readonly onChanged: () => void }) { + const presentation = ISSUE_TRACKERS[target.tracker]; + const noun = presentation.source?.noun ?? "source"; + const options = useSourceOptions(target, true); + const save = useSaveIssueSource(target); + const [saving, setSaving] = useState(false); + const choose = async (id: string) => { + setSaving(true); + try { + if (await save(id)) onChanged(); + } finally { + setSaving(false); + } + }; + return ( + + + + Choose the {presentation.label} {noun} for this project + + + {detail ?? `Issues are read from the ${noun} you choose.`} You can change it later here. + + + {options.data === null ? ( + options.error !== null ? ( + <> +

    {options.error}

    + + + ) : ( + + ) + ) : options.data.options.length === 0 ? ( +

    + The {presentation.label} account has no {noun}s to choose from. +

    + ) : ( + + } + aria-label={`Choose a ${noun}`} + > + {saving ? : null} + Choose a {noun} + + + + {options.data.options.map((option) => ( + void choose(option.id)}> + {option.name} + + ))} + + + )} +
    + ); +} + +/** + * The current source as a quiet menu beside the filters, to move the project to another one or + * stop reading this tracker for it. + */ +export function IssueSourceMenu({ + name, + onChanged, + ...target +}: IssueSourceTarget & { readonly name: string | null; readonly onChanged: () => void }) { + const presentation = ISSUE_TRACKERS[target.tracker]; + const noun = presentation.source?.noun ?? "source"; + const [open, setOpen] = useState(false); + const options = useSourceOptions(target, open); + const save = useSaveIssueSource(target); + const choose = async (id: string | null) => { + if (await save(id)) onChanged(); + }; + return ( + + } + className="min-w-0" + > + {name ?? `Choose a ${noun}`} + + + + {options.data === null ? ( +
    + {options.error !== null ? ( + {options.error} + ) : ( + + )} +
    + ) : ( + options.data.options.map((option) => ( + void choose(option.id)}> + {option.name} + + )) + )} + + void choose(null)}> + Stop showing {presentation.label} issues here + +
    +
    + ); +} 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/issueSource.logic.test.ts b/apps/web/src/components/issue/issueSource.logic.test.ts new file mode 100644 index 000000000000..34b1d97e2431 --- /dev/null +++ b/apps/web/src/components/issue/issueSource.logic.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { issueSourceOverrideEntry } from "./issueSource.logic"; + +describe("issueSourceOverrideEntry", () => { + it("sets the team and keeps the project's other overrides", () => { + expect( + issueSourceOverrideEntry( + { defaultAutoPull: true, linearTeamId: "old" }, + "linearTeamId", + "new", + ), + ).toEqual({ defaultAutoPull: true, linearTeamId: "new" }); + expect(issueSourceOverrideEntry(undefined, "linearTeamId", "team-1")).toEqual({ + linearTeamId: "team-1", + }); + }); + + it("removes the choice, and the entry once nothing is left in it", () => { + expect( + issueSourceOverrideEntry({ defaultAutoPull: true, linearTeamId: "t" }, "linearTeamId", null), + ).toEqual({ defaultAutoPull: true }); + expect(issueSourceOverrideEntry({ linearTeamId: "t" }, "linearTeamId", null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/issue/issueSource.logic.ts b/apps/web/src/components/issue/issueSource.logic.ts new file mode 100644 index 000000000000..c10de6825dab --- /dev/null +++ b/apps/web/src/components/issue/issueSource.logic.ts @@ -0,0 +1,16 @@ +import type { ProjectSettingsOverrides } from "@t3tools/contracts"; + +/** + * A project's override entry with one tracker source setting changed, as the settings patch stores + * it: the whole entry. `null` removes the project's choice so it inherits the environment's, and an + * entry left with nothing in it is removed outright. + */ +export function issueSourceOverrideEntry( + current: ProjectSettingsOverrides | undefined, + key: "linearTeamId", + value: string | null, +): ProjectSettingsOverrides | null { + const { [key]: _previous, ...rest } = current ?? {}; + const entry = value === null ? rest : { ...rest, [key]: value }; + return Object.keys(entry).length === 0 ? null : entry; +} diff --git a/apps/web/src/components/issue/issueTrackers.tsx b/apps/web/src/components/issue/issueTrackers.tsx new file mode 100644 index 000000000000..6550362c5f08 --- /dev/null +++ b/apps/web/src/components/issue/issueTrackers.tsx @@ -0,0 +1,50 @@ +import type { IssueStateReason, IssueTrackerKind } from "@t3tools/contracts"; +import { CircleCheckIcon, CircleDotIcon, CircleSlashIcon } from "lucide-react"; +import type { ElementType } from "react"; + +import { GitHubIcon, LinearIcon } from "../Icons"; + +export interface IssueTrackerPresentation { + readonly label: string; + readonly Icon: ElementType<{ className?: string }>; + /** + * For a tracker the page has to tell where a project's issues live: the project setting that + * holds the choice, and what one choice is called. + */ + readonly source?: { readonly settingKey: "linearTeamId"; readonly noun: string }; +} + +export const ISSUE_TRACKERS: Record = { + github: { label: "GitHub", Icon: GitHubIcon }, + linear: { + label: "Linear", + Icon: LinearIcon, + source: { settingKey: "linearTeamId", noun: "team" }, + }, +}; + +/** 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/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a0c0a79bebc6..81988e40e305 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -118,6 +118,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; +import { LinearIntegrationSettings } from "./LinearSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; import type { ImportOutcome } from "./browserImportWizard.logic"; @@ -1547,6 +1548,7 @@ export function IntegrationsSettingsPanel() { )} + ); } diff --git a/apps/web/src/components/settings/LinearSettings.tsx b/apps/web/src/components/settings/LinearSettings.tsx new file mode 100644 index 000000000000..7ee9fb1d6b01 --- /dev/null +++ b/apps/web/src/components/settings/LinearSettings.tsx @@ -0,0 +1,105 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button, InlineButton } from "../ui/button"; +import { Input } from "../ui/input"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { searchableSetting } from "./settingsSearch"; + +const NEW_KEY_URL = "https://linear.app/settings/account/security"; + +/** + * The Linear API key the Issues page reads with, kept in the server's secret store. Write-only: + * the saved key is never shown, only whether one is set. + */ +function LinearApiKeyForm({ environmentId }: { readonly environmentId: EnvironmentId }) { + const isSaved = + useEnvironmentSettings(environmentId, (settings) => settings.linearApiKey).length > 0; + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "save the Linear API key", + }); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + + const save = async (apiKey: string) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { linearApiKey: apiKey } }, + }); + if (result._tag === "Success") setDraft(""); + } finally { + setSaving(false); + } + }; + + return ( +
    { + event.preventDefault(); + if (draft.trim()) void save(draft.trim()); + }} + > + {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */} +
    + setDraft(event.target.value)} + /> + {isSaved ? ( + + ) : null} + +
    +
    + ); +} + +export function LinearIntegrationSettings() { + const { environment: selected } = useSettingsScope(); + const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; + return ( + + + Lets the Issues page read your Linear teams. Each project picks its team on the Issues + page. Create a personal API key in Linear's security settings.{" "} + } + > + Create a key + + + + } + > + {connected ? ( + + ) : ( +

    Connect an environment to save a key.

    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0ee55ef3785b..245595f58b70 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -725,6 +725,13 @@ export const SETTINGS_SEARCH_ITEMS = [ targetId: "devices", searchTerms: ["xcode android studio sdk avd runtime"], }, + { + id: "linear-api-key", + title: "Linear API key", + to: "/settings/integrations", + targetId: "linear", + searchTerms: ["linear issues tracker token key teams"], + }, { id: "browser-profiles", title: "Browser profiles", 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..b2da5f50b192 --- /dev/null +++ b/apps/web/src/lib/openIssueLink.test.ts @@ -0,0 +1,84 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { describe, expect, it } from "vite-plus/test"; + +import { + findProjectForIssue, + parseGitHubIssueUrl, + parseIssueUrl, + parseLinearIssueUrl, +} 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(); + }); +}); + +describe("parseLinearIssueUrl", () => { + it("reads the identifier of an issue link, slug or not", () => { + expect(parseLinearIssueUrl("https://linear.app/acme/issue/ENG-123/fix-the-login")).toEqual({ + tracker: "linear", + repositoryKey: null, + issueId: "ENG-123", + reference: "ENG-123", + }); + expect(parseLinearIssueUrl("https://linear.app/acme/issue/eng-7")?.issueId).toBe("ENG-7"); + }); + + it("leaves other Linear pages and lookalike hosts alone", () => { + expect(parseLinearIssueUrl("https://linear.app/acme/project/roadmap-1")).toBeNull(); + expect(parseLinearIssueUrl("https://linear.app.evil.test/acme/issue/ENG-1")).toBeNull(); + expect(parseLinearIssueUrl("http://linear.app/acme/issue/ENG-1")).toBeNull(); + }); + + it("is tried after GitHub by the combined parser", () => { + expect(parseIssueUrl("https://linear.app/acme/issue/ENG-1")?.tracker).toBe("linear"); + expect(parseIssueUrl("https://github.com/acme/web/issues/1")?.tracker).toBe("github"); + // A Linear link names no repository, so no project matches it by remote. + expect( + findProjectForIssue( + [{ id: "web", repositoryIdentity: { canonicalKey: "linear.app/acme" } } as never], + parseIssueUrl("https://linear.app/acme/issue/ENG-1")!, + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/openIssueLink.ts b/apps/web/src/lib/openIssueLink.ts new file mode 100644 index 000000000000..e0d26af739d6 --- /dev/null +++ b/apps/web/src/lib/openIssueLink.ts @@ -0,0 +1,147 @@ +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 { readThreadShell, 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. Null for a tracker whose + * links name no repository, which a thread then reads through its own project. + */ + readonly repositoryKey: string | null; + /** 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}`, + }; +} + +/** + * A Linear issue URL, `https://linear.app//issue//`. The identifier is + * what the Linear provider reads an issue by; the workspace and slug are only decoration. + */ +export function parseLinearIssueUrl(url: string): IssueLink | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== "https:" || parsed.host.toLowerCase() !== "linear.app") return null; + const match = + /^\/[^/]+\/issue\/([A-Za-z][A-Za-z0-9_]{0,15}-[1-9][0-9]{0,9})(?:\/[^/]*)?\/?$/u.exec( + parsed.pathname, + ); + if (match === null) return null; + const identifier = match[1]!.toUpperCase(); + return { tracker: "linear", repositoryKey: null, issueId: identifier, reference: identifier }; +} + +/** The issue any supported tracker's link names. */ +export function parseIssueUrl(url: string): IssueLink | null { + return parseGitHubIssueUrl(url) ?? parseLinearIssueUrl(url); +} + +/** The workspace project whose repository the issue lives in, if there is one. */ +export function findProjectForIssue( + projects: ReadonlyArray, + link: IssueLink, +): EnvironmentProject | undefined { + if (link.repositoryKey === null) return 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 = parseIssueUrl(url); + if (link === null) return false; + const scope = threadRef?.environmentId ?? environmentId; + const candidates = allProjects.filter( + (candidate) => + (scope === undefined || candidate.environmentId === scope) && + serverConfigs.get(candidate.environmentId)?.environment.capabilities.issues === true, + ); + // A link that names no repository is read through the thread's own project, whose tracker + // settings say where its issues live; with no thread there is no project to read it through. + const threadProjectId = + link.repositoryKey === null && threadRef + ? readThreadShell(threadRef)?.projectId + : undefined; + const project = + link.repositoryKey === null + ? candidates.find((candidate) => candidate.id === threadProjectId) + : findProjectForIssue(candidates, 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..55a1cd653cf9 --- /dev/null +++ b/docs/user/issues.md @@ -0,0 +1,33 @@ +# 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** or **Open in Linear** 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 the project's GitHub or Linear 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, a GitHub issue link opens the Issues page on that issue. + +## Linear + +Save a Linear personal API key in Settings → Integrations → Linear. The key is kept in the +environment's secret store and is never shown again. Once a key is saved, the Issues page offers +Linear beside GitHub for every project. + +The first time you open Linear for a project, choose the team whose issues it shows. The choice is +saved as a project setting. Use the team menu beside the filters to switch teams, or to stop +showing Linear issues for the project. Linear does not report comment counts, so Linear rows show +none. 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..d97527e80f60 --- /dev/null +++ b/packages/client-runtime/src/state/issues.ts @@ -0,0 +1,39 @@ +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, + }), + /** Where a project's issues could live on a tracker, read fresh each time a picker opens. */ + sourceOptions: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:issues:source-options", + tag: WS_METHODS.issuesSourceOptions, + staleTimeMs: 0, + }), + 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..f1473e2249f7 --- /dev/null +++ b/packages/contracts/src/issue.ts @@ -0,0 +1,168 @@ +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", "linear"]); +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, `ENG-123` on Linear. */ + 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), + /** Null where the tracker does not count an issue's comments, which Linear does not. */ + commentCount: Schema.NullOr(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), + /** The tracker holds comments past `comments`, where it gives no `commentCount` to say so. */ + moreComments: Schema.optional(Schema.Boolean), +}); +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, a team on Linear. */ + 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; + +/** One place a project's issues could live on a tracker, such as a Linear team. */ +export const IssueSourceOption = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type IssueSourceOption = typeof IssueSourceOption.Type; + +export const IssueSourceOptionsResult = Schema.Struct({ options: Schema.Array(IssueSourceOption) }); +export type IssueSourceOptionsResult = typeof IssueSourceOptionsResult.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..147103bbe535 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -162,6 +162,17 @@ import { } from "./provider.ts"; import { ProviderInstanceId, ProviderInstanceMutation } from "./providerInstance.ts"; import { PullRequestReviewGuideResult } from "./pullRequestReviewGuide.ts"; +import { + IssueDetail, + IssueError, + IssueListInput, + IssueListResult, + IssueRef, + IssueSourceInput, + IssueSourceOptionsResult, + IssueSourcesInput, + IssueSourcesResult, +} from "./issue.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -527,6 +538,12 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Issue methods + issuesSources: "issues.sources", + issuesSourceOptions: "issues.sourceOptions", + issuesList: "issues.list", + issuesDetail: "issues.detail", + // Pull request methods pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", @@ -956,6 +973,33 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); +const IssueRpcError = Schema.Union([IssueError, EnvironmentAuthorizationError]); + +const WsIssuesSourcesRpc = Rpc.make(WS_METHODS.issuesSources, { + payload: IssueSourcesInput, + success: IssueSourcesResult, + error: IssueRpcError, +}); + +/** Where a tracker could read a project's issues from, for a tracker that has to be told. */ +const WsIssuesSourceOptionsRpc = Rpc.make(WS_METHODS.issuesSourceOptions, { + payload: IssueSourceInput, + success: IssueSourceOptionsResult, + 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 +1985,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsIssuesSourcesRpc, + WsIssuesSourceOptionsRpc, + WsIssuesListRpc, + WsIssuesDetailRpc, WsPullRequestsListRpc, WsPullRequestsListStatsRpc, WsPullRequestsSummaryRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c0de88f4ac6e..07793c89f857 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -986,6 +986,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", "responseStreamingMode", + "linearTeamId", ] as const; export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; @@ -1036,6 +1037,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ providerInstances: Schema.optionalKey( Schema.Record(ProviderInstanceId, ProjectProviderInstanceOverride), ), + linearTeamId: Schema.optionalKey(TrimmedString), } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; @@ -1327,6 +1329,13 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** + * A Linear personal API key, for the Issues page. Kept in the secret store and redacted before + * reaching a client, like a hub management key. + */ + linearApiKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** The Linear team whose issues a project shows. Empty shows a team picker instead. */ + linearTeamId: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** Allows this server to read the Cursor CLI's macOS Keychain login for account usage. */ cursorKeychainUsageEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), @@ -1632,6 +1641,9 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + /** The redaction marker keeps the saved key; an empty string removes it. */ + linearApiKey: Schema.optionalKey(TrimmedString), + linearTeamId: Schema.optionalKey(TrimmedString), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), /** Each entry replaces one model's rates; `null` restores automatic pricing. */ usagePriceOverrides: Schema.optionalKey(