diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ae7215aff0794..a0685ba50c626 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -139,6 +139,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.issuesList]: AuthOrchestrationReadScope, [WS_METHODS.issuesDetail]: AuthOrchestrationReadScope, [WS_METHODS.issuesComments]: AuthOrchestrationReadScope, + [WS_METHODS.issuesTimeline]: AuthOrchestrationReadScope, [WS_METHODS.issuesCandidates]: AuthOrchestrationReadScope, [WS_METHODS.issuesTemplates]: AuthOrchestrationReadScope, [WS_METHODS.issuesCreate]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/issue/IssueCompletionReactor.test.ts b/apps/server/src/issue/IssueCompletionReactor.test.ts index c81c502f02fb9..f40b4ed987584 100644 --- a/apps/server/src/issue/IssueCompletionReactor.test.ts +++ b/apps/server/src/issue/IssueCompletionReactor.test.ts @@ -105,12 +105,17 @@ function runCase(input: { readonly repeat?: boolean; readonly noIssue?: boolean; readonly otherPullRequestState?: "open" | "closed" | "merged"; + readonly linkState?: "open" | "closed"; }) { return Effect.scoped( Effect.gen(function* () { const current = links(input.pullRequestState); const projectLinks: ProjectWorktreeLinks.ProjectWorktreeLinkSet = { - worktreeIssues: input.noIssue ? [] : current.worktreeIssues, + worktreeIssues: input.noIssue + ? [] + : current.worktreeIssues.map((link) => + input.linkState === undefined ? link : { ...link, state: input.linkState }, + ), worktreePullRequests: input.otherPullRequestState ? [ ...current.worktreePullRequests, @@ -128,6 +133,7 @@ function runCase(input: { const closes: IssueCloseInput[] = []; const comments: IssueCommentCreateInput[] = []; const calls: string[] = []; + let detailReads = 0; const savedComments: IssueComment[] = input.existingComment ? [{ id: "existing", author: null, body: COMMENT_BODY, createdAt: NOW, url: null }] : []; @@ -148,7 +154,7 @@ function runCase(input: { }), ServerSettingsService.layerTest({ completeLinkedIssueOnMerge: input.enabled }), Layer.mock(IssueService)({ - detail: () => Effect.succeed(currentDetail), + detail: () => Effect.sync(() => (detailReads += 1)).pipe(Effect.as(currentDetail)), invalidate: () => Effect.void, close: (closeInput) => Effect.gen(function* () { @@ -206,7 +212,7 @@ function runCase(input: { const cleanupAllowed = input.repeat ? yield* reactor.completeWorktree(PROJECT_ID, WORKTREE_PATH) : undefined; - return { closes, comments, calls, cleanupAllowed }; + return { closes, comments, calls, cleanupAllowed, detailReads }; }).pipe(Effect.provide(layer)); }), ); @@ -263,6 +269,20 @@ describe("IssueCompletionReactor", () => { }), ); + it.effect("reads linked issues once their worktree merges so their state refreshes", () => + Effect.gen(function* () { + const merged = yield* runCase({ enabled: false, pullRequestState: "merged" }); + assert.strictEqual(merged.detailReads, 1); + assert.deepStrictEqual(merged.calls, []); + for (const input of [ + { enabled: false, pullRequestState: "open" }, + { enabled: false, pullRequestState: "merged", linkState: "closed" }, + ] as const) { + assert.strictEqual((yield* runCase(input)).detailReads, 0); + } + }), + ); + it.effect("finds a previous completion comment on later pages without posting it again", () => Effect.gen(function* () { const result = yield* runCase({ diff --git a/apps/server/src/issue/IssueCompletionReactor.ts b/apps/server/src/issue/IssueCompletionReactor.ts index b3c02921a1e50..6d8984207b3d8 100644 --- a/apps/server/src/issue/IssueCompletionReactor.ts +++ b/apps/server/src/issue/IssueCompletionReactor.ts @@ -1,4 +1,4 @@ -import { type ProjectId } from "@t3tools/contracts"; +import { type IssueLinkedWork, type ProjectId } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Context from "effect/Context"; @@ -87,6 +87,28 @@ export const make = Effect.gen(function* () { ), ); + // GitHub closes issues a merged PR references; reading one records its state on the link. + const refreshState = (projectId: ProjectId, link: IssueLinkedWork, urls: string) => { + const key = `refresh:${link.issue.host}/${link.issue.repository}#${link.issue.number}:${urls}`; + if (handled.has(key)) return Effect.void; + handled.add(key); + const reference = { + projectId, + host: link.issue.host, + repository: link.issue.repository, + number: link.issue.number, + }; + return issues.invalidate({ reference }).pipe( + Effect.andThen(issues.detail(reference)), + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning("linked issue state refresh failed", { error }).pipe( + Effect.andThen(Effect.sync(() => handled.delete(key))), + ), + ), + ); + }; + const completeProject = Effect.fn("IssueCompletionReactor.completeProject")(function* ( projectId: ProjectId, ) { @@ -103,6 +125,24 @@ export const make = Effect.gen(function* () { concurrency: 4, discard: true, }); + yield* Effect.forEach( + (project.worktreeIssues ?? []).filter( + (link) => link.state !== "closed" && !candidates.includes(link), + ), + (link) => { + const pullRequests = (project.worktreePullRequests ?? []).filter( + (pullRequest) => pullRequest.worktreePath === link.worktreePath, + ); + if ( + pullRequests.length === 0 || + pullRequests.some((pullRequest) => pullRequest.snapshot?.state !== "merged") + ) + return Effect.void; + const urls = [...new Set(pullRequests.map((pullRequest) => pullRequest.url))].sort(); + return refreshState(projectId, link, urls.join(",")); + }, + { concurrency: 4, discard: true }, + ); }); const worker = yield* makeDrainableWorker((projectId: ProjectId) => diff --git a/apps/server/src/issue/IssueService.test.ts b/apps/server/src/issue/IssueService.test.ts index f704d09185072..b0ee3583295f0 100644 --- a/apps/server/src/issue/IssueService.test.ts +++ b/apps/server/src/issue/IssueService.test.ts @@ -1,12 +1,41 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { describe, expect, it } from "@effect/vitest"; +import * as ServerConfig from "../config.ts"; +import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProjectWorktreeLinks from "../project/ProjectWorktreeLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as WorktreeRunManager from "../worktreeRun/Manager.ts"; +import * as IssueService from "./IssueService.ts"; import { isSafeIssueWorktreePath, issueWorktreeBranch, issueWorktreeFragment, normalizeIssue, + issueFilterSearchTerms, + issueSortOrder, + compareIssueListEntries, + issueListEntry, + normalizeLinkedPullRequests, + normalizeIssueTimelineEvents, } from "./IssueService.ts"; -import { ProjectId } from "@t3tools/contracts"; +import { + IssueListEntry, + IssueTimelineEvent, + ProjectId, + ThreadId, + type OrchestrationProjectShell, +} from "@t3tools/contracts"; describe("IssueService pure issue helpers", () => { it("normalizes a GitHub issue without trusting missing fields", () => { @@ -88,3 +117,606 @@ describe("IssueService pure issue helpers", () => { ).toBe(false); }); }); + +const context = { + projectId: ProjectId.make("project-1"), + host: "github.com", + repository: "owner/repo", +}; +const createdAt = "2026-01-01T00:00:00.000Z"; +const pr = { + __typename: "PullRequest", + number: 7, + title: "Fix checkout", + url: "https://github.com/owner/repo/pull/7", + state: "MERGED", + isDraft: false, + repository: { nameWithOwner: "owner/repo" }, +}; + +describe("IssueService list queries", () => { + it("translates OR groups, exclusions and safely quoted values before host paging", () => { + expect( + issueFilterSearchTerms({ + labels: [["bug", 'good "first" issue'], ["web\\ui"]], + excludedLabels: ["wont fix", 'bad"label'], + author: "octocat", + assignee: "hubot", + milestone: 'Release "1"', + }), + ).toEqual([ + 'label:"bug","good first issue"', + 'label:"webui"', + '-label:"wont fix"', + '-label:"badlabel"', + 'author:"octocat"', + 'assignee:"hubot"', + 'milestone:"Release 1"', + ]); + expect(issueFilterSearchTerms(undefined)).toEqual([]); + }); + + it.each([ + ["updated", "updated-desc", "UPDATED_AT", "DESC"], + ["created-desc", "created-desc", "CREATED_AT", "DESC"], + ["created-asc", "created-asc", "CREATED_AT", "ASC"], + ["comments", "comments-desc", "COMMENTS", "DESC"], + ] as const)("maps %s to both host sort representations", (sort, search, field, direction) => { + expect(issueSortOrder(sort)).toEqual({ search, orderBy: { field, direction } }); + }); + + it("defaults to updated order and respects the selected in-memory order", () => { + expect(issueSortOrder()).toEqual(issueSortOrder("updated")); + const older = normalizeIssue( + { number: 1, createdAt, updatedAt: "2026-01-03T00:00:00.000Z", comments: { totalCount: 9 } }, + context, + )!; + const newer = normalizeIssue( + { + number: 2, + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: createdAt, + comments: { totalCount: 1 }, + }, + context, + )!; + expect(compareIssueListEntries(older, newer)).toBeLessThan(0); + expect(compareIssueListEntries(older, newer, "created-desc")).toBeGreaterThan(0); + expect(compareIssueListEntries(older, newer, "created-asc")).toBeLessThan(0); + expect(compareIssueListEntries(older, newer, "comments")).toBeLessThan(0); + }); + + it("carries rich row fields through wire encoding without body or reactions", () => { + const issue = normalizeIssue( + { + number: 42, + title: "Fix checkout", + body: "Detail only", + createdAt, + updatedAt: createdAt, + state: "CLOSED", + stateReason: "NOT_PLANNED", + assignees: { nodes: [{ login: "octocat", name: "Octocat", avatarUrl: null }] }, + milestone: { number: 3, title: "Release", state: "OPEN", dueOn: null }, + }, + context, + )!; + const linkedWork = { + threadId: ThreadId.make("thread-1"), + branch: "fix/42", + worktreePath: "/tmp/worktree", + source: "manual" as const, + }; + const entry = issueListEntry(issue, linkedWork); + const encoded = Schema.encodeSync(IssueListEntry)(entry); + expect(encoded.assignees).toEqual(issue.assignees); + expect(encoded.milestone).toEqual(issue.milestone); + expect(encoded.stateReason).toBe("not-planned"); + expect(encoded.linkedWork).toEqual(linkedWork); + expect(entry).not.toHaveProperty("body"); + expect(entry).not.toHaveProperty("reactions"); + expect(issueListEntry(issue, null).linkedWork).toBeNull(); + }); +}); + +describe("IssueService related work and timeline", () => { + it("deduplicates closing, cross-referenced and connected PRs by URL", () => { + expect( + normalizeLinkedPullRequests({ + closedByPullRequestsReferences: { nodes: [pr] }, + timelineItems: { + nodes: [{ source: pr }, { subject: pr }, { source: { ...pr, __typename: "Issue" } }], + }, + }), + ).toEqual([ + { + number: 7, + title: pr.title, + url: pr.url, + state: "merged", + repository: "owner/repo", + isDraft: false, + }, + ]); + }); + + it.each([ + [ + { __typename: "LabeledEvent", label: { name: "bug", color: "b60205" } }, + { _tag: "labeled", label: { name: "bug", color: "b60205" } }, + ], + [ + { __typename: "UnlabeledEvent", label: { name: "bug", color: null } }, + { _tag: "unlabeled", label: { name: "bug", color: null } }, + ], + [ + { __typename: "AssignedEvent", assignee: { login: "octocat" } }, + { _tag: "assigned", assignee: { login: "octocat", name: null, avatarUrl: null } }, + ], + [ + { __typename: "UnassignedEvent", assignee: null }, + { _tag: "unassigned", assignee: null }, + ], + [ + { __typename: "ClosedEvent", stateReason: "COMPLETED" }, + { _tag: "closed", stateReason: "completed" }, + ], + [{ __typename: "ReopenedEvent" }, { _tag: "reopened" }], + [ + { __typename: "RenamedTitleEvent", previousTitle: "Before", currentTitle: "After" }, + { _tag: "renamed", from: "Before", to: "After" }, + ], + [ + { __typename: "MilestonedEvent", milestoneTitle: "Release" }, + { _tag: "milestoned", title: "Release" }, + ], + [ + { __typename: "DemilestonedEvent", milestoneTitle: "Release" }, + { _tag: "demilestoned", title: "Release" }, + ], + ])("normalizes $0.__typename with its actor and timestamp", (raw, expected) => { + const event = normalizeIssueTimelineEvents({ + ...raw, + id: "event-1", + actor: { login: "hubot" }, + createdAt, + })[0]!; + expect(Schema.decodeSync(IssueTimelineEvent)(event)).toMatchObject({ + ...expected, + id: "event-1", + actor: { login: "hubot", name: null, avatarUrl: null }, + createdAt, + }); + }); + + it("normalizes issue and PR references, connected PRs, and interleaved comments", () => { + const base = { id: "event-1", createdAt, actor: null }; + expect( + normalizeIssueTimelineEvents({ ...base, __typename: "CrossReferencedEvent", source: pr })[0], + ).toMatchObject({ + _tag: "cross-referenced", + source: { kind: "pull-request", number: 7, state: "merged" }, + }); + expect( + normalizeIssueTimelineEvents({ + ...base, + __typename: "CrossReferencedEvent", + source: { ...pr, __typename: "Issue", state: "OPEN" }, + })[0], + ).toMatchObject({ _tag: "cross-referenced", source: { kind: "issue", state: "open" } }); + expect( + normalizeIssueTimelineEvents({ ...base, __typename: "ConnectedEvent", subject: pr })[0], + ).toMatchObject({ _tag: "connected", pullRequest: { number: 7, state: "merged" } }); + const comment = normalizeIssueTimelineEvents({ + ...base, + __typename: "IssueComment", + author: { login: "octocat" }, + body: "Hello", + url: null, + })[0]!; + expect(Schema.decodeSync(IssueTimelineEvent)(comment)).toMatchObject({ + _tag: "comment", + actor: { login: "octocat" }, + comment: { id: "event-1", body: "Hello", createdAt }, + }); + expect( + normalizeIssueTimelineEvents({ + ...base, + __typename: "ReferencedEvent", + commit: { + associatedPullRequests: { + nodes: [pr, { ...pr, number: 8, url: "https://github.com/owner/repo/pull/8" }], + }, + }, + }), + ).toHaveLength(2); + expect( + normalizeIssueTimelineEvents({ ...base, __typename: "ReferencedEvent", commit: null }), + ).toEqual([]); + expect(normalizeIssueTimelineEvents({ ...base, __typename: "UnknownEvent" })).toEqual([]); + expect(normalizeIssueTimelineEvents({ __typename: "ReopenedEvent" })).toEqual([]); + }); +}); + +type GhCall = { + readonly args: ReadonlyArray; + readonly endpoint: string; + readonly method: string; + readonly query: string; + readonly variables: Record; + readonly body: unknown; +}; + +type GhPayload = { readonly query?: string; readonly variables?: Record }; +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const projectShell = (id: string, repository: string): OrchestrationProjectShell => ({ + id: ProjectId.make(id), + title: repository, + workspaceRoot: `/repos/${id}`, + repositoryIdentity: { + canonicalKey: `github.com/${repository}`, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://github.com/${repository}.git`, + }, + provider: "github", + owner: repository.split("/")[0]!, + name: repository.split("/")[1]!, + }, + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, +}); + +const PROJECTS = [projectShell("project-1", "owner/repo"), projectShell("project-2", "owner/api")]; + +const notFound = new GitHubCli.GitHubPullRequestNotFoundError({ + command: "gh", + cwd: "/repos/project-1", + cause: "HTTP 404", +}); + +const rawIssue = (number: number, extra: Record = {}) => ({ + number, + title: `Issue ${number}`, + state: "OPEN", + createdAt, + updatedAt: `2026-01-0${(number % 9) + 1}T00:00:00.000Z`, + ...extra, +}); + +const page = (numbers: ReadonlyArray, endCursor: string | null) => ({ + nodes: numbers.map((number) => rawIssue(number)), + pageInfo: { hasNextPage: endCursor !== null, endCursor }, +}); + +/** Runs the real service against a scripted `gh` and in-memory storage. */ +const withIssueService = ( + respond: (call: GhCall) => unknown, + use: ( + service: IssueService.IssueService["Service"], + calls: ReadonlyArray, + ) => Effect.Effect, +) => + Effect.gen(function* () { + const calls: Array = []; + const gh = Layer.mock(GitHubCli.GitHubCli)({ + execute: (input) => + Effect.suspend(() => { + const payload = + input.stdin === undefined ? null : (decodeJson(input.stdin) as GhPayload | null); + const methodIndex = input.args.indexOf("--method"); + const call: GhCall = { + args: input.args, + endpoint: input.args[input.args.indexOf("--hostname") + 2] ?? "", + method: methodIndex === -1 ? "GET" : input.args[methodIndex + 1]!, + query: payload?.query ?? "", + variables: payload?.variables ?? {}, + body: payload, + }; + calls.push(call); + const reply = respond(call); + return GitHubCli.isGitHubCliError(reply) + ? Effect.fail(reply) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: reply === undefined ? "" : encodeJson(reply), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }), + }); + const layer = IssueService.layer.pipe( + Layer.provide( + Layer.mergeAll( + gh, + Layer.mock(ProjectService.ProjectService)({ + getShell: (projectId) => + Effect.succeed(Option.fromNullishOr(PROJECTS.find((item) => item.id === projectId))), + listShells: (options) => + Effect.succeed( + PROJECTS.filter( + (item) => + options?.projectIds === undefined || options.projectIds.includes(item.id), + ), + ), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ProjectWorktreeLinks.ProjectWorktreeLinks)({ publish: () => Effect.void }), + Layer.mock(GitWorkflowService.GitWorkflowService)({}), + Layer.mock(WorktreeRunManager.WorktreeRunManager)({}), + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({}), + ServerConfig.layerTest("/repos", { prefix: "issue-service-test" }), + ServerSettings.layerTest(), + ), + ), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(NodeServices.layer), + ); + return yield* Effect.gen(function* () { + return yield* use(yield* IssueService.IssueService, calls); + }).pipe(Effect.provide(layer)); + }); + +const graphQl = (data: unknown) => ({ data }); +const graphQlError = (message: string) => ({ errors: [{ message }] }); + +describe("IssueService against GitHub", () => { + it.effect("joins only attached links into list rows and picks the latest per issue", () => + withIssueService( + (call) => { + if (call.query.includes("issues(first")) + return graphQl({ repository: { issues: page([1, 2, 3], null) } }); + if (call.query.includes("viewer")) return graphQl({ repository: { issue: rawIssue(1) } }); + return graphQl({ repository: { issue: {} } }); + }, + (service, calls) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const insert = ( + thread: string, + number: number, + linkedAt: string, + branch: string | null, + detachedAt: string | null = null, + ) => sql` + INSERT INTO projection_issue_links ( + thread_id, project_id, host, repository, number, source, linked_at, + branch, worktree_path, detached_at + ) VALUES (${thread}, 'project-1', 'github.com', 'owner/repo', ${number}, 'manual', + ${linkedAt}, ${branch}, ${null}, ${detachedAt}) + `; + yield* insert("older", 1, "2026-01-01T00:00:00.000Z", "fix/old"); + yield* insert("newer", 1, "2026-01-03T00:00:00.000Z", "fix/new"); + yield* insert("detached-newest", 1, "2026-01-05T00:00:00.000Z", null, createdAt); + yield* insert("only-detached", 2, "2026-01-05T00:00:00.000Z", null, createdAt); + yield* insert("blank", 3, "2026-01-02T00:00:00.000Z", ""); + + const result = yield* service.list({ state: "open", projectId: PROJECTS[0]!.id }); + const linked = new Map(result.entries.map((entry) => [entry.number, entry.linkedWork])); + expect(linked.get(1)).toMatchObject({ threadId: "newer", branch: "fix/new" }); + expect(linked.get(2)).toBeNull(); + expect(linked.get(3)).toMatchObject({ threadId: "blank", branch: null }); + expect(calls[0]!.query).not.toContain("description"); + + const detail = yield* service.detail({ ...context, number: 1 }); + expect(detail.linkedWork).toMatchObject({ threadId: "newer" }); + }), + ), + ); + + it.effect("searches with a bare viewer qualifier and keeps rows it cannot re-check", () => + withIssueService( + (call) => + call.query.includes("search(") + ? graphQl({ + search: page([4], null), + }) + : undefined, + (service, calls) => + Effect.gen(function* () { + const result = yield* service.list({ + state: "open", + projectId: PROJECTS[0]!.id, + filters: { assignee: "@me", author: "ME" }, + }); + expect(result.entries.map((entry) => entry.number)).toEqual([4]); + expect(calls).toHaveLength(1); + const query = String(calls[0]!.variables.query); + expect(query).toContain("assignee:@me"); + expect(query).toContain("author:@me"); + expect(query).not.toContain('"@me"'); + }), + ), + ); + + it.effect("returns every fetched row across repositories and keeps searching the rest", () => + withIssueService( + (call) => { + const query = String(call.variables.query); + if (query.includes("repo:owner/repo")) + return graphQl({ + search: + call.variables.after === "repo-next" ? page([3], null) : page([1, 2], "repo-next"), + }); + if (query.includes("repo:owner/api")) return graphQl({ search: page([5, 6], null) }); + return undefined; + }, + (service) => + Effect.gen(function* () { + const first = yield* service.list({ state: "open", limit: 2 }); + expect( + first.entries.map((entry) => `${entry.repository}#${entry.number}`).sort(), + ).toEqual(["owner/api#5", "owner/api#6", "owner/repo#1", "owner/repo#2"]); + expect(first.truncated).toBe(true); + expect(first.nextCursors).toEqual({ + "project-1:github.com:owner/repo": "search:repo-next", + }); + + const next = yield* service.list({ + state: "open", + limit: 2, + repositories: [{ projectId: PROJECTS[0]!.id, repository: "owner/repo" }], + cursors: first.nextCursors, + }); + expect(next.entries.map((entry) => entry.number)).toEqual([3]); + expect(next.truncated).toBe(false); + }), + ), + ); + + it.effect("degrades to timeline pull requests and no sub-issues on older hosts", () => + withIssueService( + (call) => { + if (call.query.includes("subIssues")) + return graphQlError("Field 'subIssues' doesn't exist"); + if (call.query.includes("closedByPullRequestsReferences")) + return graphQlError( + "Field 'closedByPullRequestsReferences' doesn't accept argument 'includeClosedPrs'", + ); + if (call.query.includes("viewer")) + return graphQl({ + repository: { + issue: rawIssue(42, { timelineItems: { nodes: [{ source: pr }] } }), + }, + viewer: { login: "octocat" }, + }); + return undefined; + }, + (service, calls) => + Effect.gen(function* () { + const detail = yield* service.detail({ ...context, number: 42 }); + expect(detail.linkedPullRequests?.map((item) => item.number)).toEqual([7]); + expect(detail).not.toHaveProperty("subIssues"); + expect(detail.viewer).toBe("octocat"); + expect(calls.filter((call) => call.query.includes("viewer"))).toHaveLength(2); + }), + ), + ); + + it.effect("still fails detail for errors unrelated to closing pull requests", () => + withIssueService( + (call) => + call.query.includes("subIssues") + ? graphQl({ repository: { issue: {} } }) + : graphQlError("Could not resolve to a Repository"), + (service, calls) => + Effect.gen(function* () { + const error = yield* service.detail({ ...context, number: 42 }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "IssueOperationError" }); + expect(calls.filter((call) => call.query.includes("viewer"))).toHaveLength(1); + }), + ), + ); + + it.effect("normalizes timeline pages, including bot assignees", () => + withIssueService( + (call) => + call.query.includes("timelineItems(first: $first") + ? graphQl({ + repository: { + issue: { + timelineItems: { + nodes: [ + { + __typename: "AssignedEvent", + id: "assigned-1", + createdAt, + actor: { login: "octocat" }, + assignee: { login: "renovate[bot]", avatarUrl: null }, + }, + { __typename: "UnknownEvent", id: "skip", createdAt }, + { + __typename: "IssueComment", + id: "comment-node", + databaseId: 99, + body: "On it", + createdAt, + author: { login: "hubot" }, + }, + ], + totalCount: 3, + pageInfo: { hasNextPage: true, endCursor: "timeline-next" }, + }, + }, + }, + }) + : undefined, + (service, calls) => + Effect.gen(function* () { + const result = yield* service.timeline({ ...context, number: 42, limit: 2 }); + expect(calls[0]!.query).toContain("... on Bot"); + expect(calls[0]!.variables).toMatchObject({ number: 42, first: 2, after: null }); + expect(result).toMatchObject({ + totalCount: 3, + nextCursor: "timeline-next", + truncated: true, + events: [ + { _tag: "assigned", assignee: { login: "renovate[bot]" } }, + { _tag: "comment", actor: { login: "hubot" }, comment: { body: "On it" } }, + ], + }); + }), + ), + ); + + it.effect("applies additive label and assignee edits through their own endpoints", () => + withIssueService( + (call) => { + if (call.endpoint.endsWith("/labels/gone%20label")) return notFound; + if (call.query.includes("viewer")) + return graphQl({ repository: { issue: rawIssue(42) }, viewer: { login: "octocat" } }); + if (call.query.includes("subIssues")) return graphQl({ repository: { issue: {} } }); + return {}; + }, + (service, calls) => + Effect.gen(function* () { + const result = yield* service.update({ + ...context, + number: 42, + title: "Renamed", + addLabels: ["bug"], + removeLabels: ["gone label", "area/web"], + addAssignees: ["octocat"], + removeAssignees: ["hubot"], + }); + expect(result.issue.number).toBe(42); + const issue = "repos/owner/repo/issues/42"; + expect( + calls + .filter((call) => !call.args.includes("graphql")) + .map((call) => [call.method, call.endpoint, call.body]), + ).toEqual([ + ["PATCH", issue, { title: "Renamed" }], + ["POST", `${issue}/labels`, { labels: ["bug"] }], + ["DELETE", `${issue}/labels/gone%20label`, null], + ["DELETE", `${issue}/labels/area%2Fweb`, null], + ["POST", `${issue}/assignees`, { assignees: ["octocat"] }], + ["DELETE", `${issue}/assignees`, { assignees: ["hubot"] }], + ]); + }), + ), + ); + + it.effect("fails an additive edit on errors other than an already removed label", () => + withIssueService( + (call) => + call.endpoint.endsWith("/assignees") + ? new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: "/repos", cause: "422" }) + : {}, + (service, calls) => + Effect.gen(function* () { + const error = yield* service + .update({ ...context, number: 42, addAssignees: ["ghost"] }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "IssueOperationError" }); + expect(calls.map((call) => call.method)).toEqual(["POST"]); + }), + ), + ); +}); diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 21da6199d69cc..4b09d91b96709 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -31,6 +31,14 @@ import { type IssueLinkedWork, type IssueListEntry, type IssueListInput, + type IssueListSort, + type IssueLinkedPullRequest, + type IssueLinkedWorkSummary, + type IssueRelatedIssue, + type IssueTimelineEvent, + type IssueTimelineInput, + type IssueTimelineResult, + type IssueTimelineSource, type IssueListResult, type IssueMilestone, type IssueReaction, @@ -101,42 +109,82 @@ import { parseGitHubAuthStatus, } from "../sourceControl/gitHubAuthStatus.ts"; -const GRAPHQL_ISSUE_FIELDS = ` - number title body url state stateReason createdAt updatedAt closedAt +const GRAPHQL_ROW_FIELDS = ` + number title url state stateReason createdAt updatedAt closedAt author { login avatarUrl } - labels(first: 100) { nodes { name color description } } assignees(first: 100) { nodes { login name avatarUrl } } - milestone { number title state description dueOn } comments { totalCount } +`; +// List rows never show label or milestone descriptions, so only the detail fetches them. +const GRAPHQL_LIST_FIELDS = `${GRAPHQL_ROW_FIELDS} + labels(first: 100) { nodes { name color } } + milestone { number title state dueOn } +`; +const GRAPHQL_ISSUE_FIELDS = `${GRAPHQL_ROW_FIELDS} + labels(first: 100) { nodes { name color description } } + milestone { number title state description dueOn } + body reactions(first: 100) { nodes { content user { login } } } `; const GRAPHQL_ISSUE_FRAGMENT = ` fragment IssueFields on Issue { ${GRAPHQL_ISSUE_FIELDS} } `; -const LIST_QUERY = `${GRAPHQL_ISSUE_FRAGMENT} -query($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!]) { +const GRAPHQL_LIST_FRAGMENT = ` +fragment IssueListFields on Issue { ${GRAPHQL_LIST_FIELDS} } +`; +const GRAPHQL_RELATED_FIELDS = `number title url state repository { nameWithOwner }`; +const GRAPHQL_PR_FIELDS = `${GRAPHQL_RELATED_FIELDS} isDraft`; +const GRAPHQL_SOURCE_FIELDS = ` + __typename + ... on Issue { ${GRAPHQL_RELATED_FIELDS} } + ... on PullRequest { ${GRAPHQL_PR_FIELDS} } +`; +const LIST_QUERY = `${GRAPHQL_LIST_FRAGMENT} +query($owner: String!, $name: String!, $first: Int!, $after: String, $states: [IssueState!], $orderBy: IssueOrder!) { repository(owner: $owner, name: $name) { - issues(first: $first, after: $after, states: $states, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { ...IssueFields } + issues(first: $first, after: $after, states: $states, orderBy: $orderBy) { + nodes { ...IssueListFields } pageInfo { hasNextPage endCursor } } } } `; -const SEARCH_QUERY = `${GRAPHQL_ISSUE_FRAGMENT} +const SEARCH_QUERY = `${GRAPHQL_LIST_FRAGMENT} query($query: String!, $first: Int!, $after: String) { search(query: $query, type: ISSUE, first: $first, after: $after) { - nodes { ... on Issue { ...IssueFields } } + nodes { ... on Issue { ...IssueListFields } } pageInfo { hasNextPage endCursor } } } `; -const DETAIL_QUERY = `${GRAPHQL_ISSUE_FRAGMENT} +const detailQuery = (closingPullRequests: boolean) => `${GRAPHQL_ISSUE_FRAGMENT} query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { issue(number: $number) { ...IssueFields } } + repository(owner: $owner, name: $name) { issue(number: $number) { + ...IssueFields + ${closingPullRequests ? `closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { ${GRAPHQL_PR_FIELDS} } }` : ""} + timelineItems(itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT], first: 50) { + nodes { + ... on CrossReferencedEvent { source { ${GRAPHQL_SOURCE_FIELDS} } } + ... on ConnectedEvent { source { ${GRAPHQL_SOURCE_FIELDS} } subject { ${GRAPHQL_SOURCE_FIELDS} } } + } + } + } } viewer { login } } `; +const DETAIL_QUERY = detailQuery(true); +// Older GitHub Enterprise Server releases lack closedByPullRequestsReferences or its +// includeClosedPrs argument; the detail then reads only timeline-linked pull requests. +const DETAIL_QUERY_WITHOUT_CLOSING_PRS = detailQuery(false); +const SUB_ISSUES_QUERY = ` +query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { issue(number: $number) { + parent { ${GRAPHQL_RELATED_FIELDS} } + subIssues(first: 50) { nodes { ${GRAPHQL_RELATED_FIELDS} } } + subIssuesSummary { total completed percentCompleted } + } } +} +`; const COMMENTS_QUERY = ` query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { repository(owner: $owner, name: $name) { @@ -154,6 +202,43 @@ query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: Stri } `; +const GRAPHQL_EVENT_FIELDS = `id createdAt actor { login avatarUrl }`; +const TIMELINE_QUERY = ` +query($owner: String!, $name: String!, $number: Int!, $first: Int!, $after: String) { + repository(owner: $owner, name: $name) { issue(number: $number) { + timelineItems(first: $first, after: $after, itemTypes: [LABELED_EVENT, UNLABELED_EVENT, + ASSIGNED_EVENT, UNASSIGNED_EVENT, CLOSED_EVENT, REOPENED_EVENT, RENAMED_TITLE_EVENT, + REFERENCED_EVENT, CROSS_REFERENCED_EVENT, MILESTONED_EVENT, DEMILESTONED_EVENT, + CONNECTED_EVENT, ISSUE_COMMENT]) { + nodes { + __typename + ... on LabeledEvent { ${GRAPHQL_EVENT_FIELDS} label { name color } } + ... on UnlabeledEvent { ${GRAPHQL_EVENT_FIELDS} label { name color } } + ... on AssignedEvent { ${GRAPHQL_EVENT_FIELDS} assignee { ... on User { login name avatarUrl } ... on Bot { login avatarUrl } ... on Mannequin { login avatarUrl } } } + ... on UnassignedEvent { ${GRAPHQL_EVENT_FIELDS} assignee { ... on User { login name avatarUrl } ... on Bot { login avatarUrl } ... on Mannequin { login avatarUrl } } } + ... on ClosedEvent { ${GRAPHQL_EVENT_FIELDS} stateReason } + ... on ReopenedEvent { ${GRAPHQL_EVENT_FIELDS} } + ... on RenamedTitleEvent { ${GRAPHQL_EVENT_FIELDS} previousTitle currentTitle } + ... on ReferencedEvent { ${GRAPHQL_EVENT_FIELDS} + commit { associatedPullRequests(first: 20) { nodes { __typename ${GRAPHQL_PR_FIELDS} } } } + } + ... on CrossReferencedEvent { ${GRAPHQL_EVENT_FIELDS} source { ${GRAPHQL_SOURCE_FIELDS} } } + ... on MilestonedEvent { ${GRAPHQL_EVENT_FIELDS} milestoneTitle } + ... on DemilestonedEvent { ${GRAPHQL_EVENT_FIELDS} milestoneTitle } + ... on ConnectedEvent { ${GRAPHQL_EVENT_FIELDS} source { ${GRAPHQL_SOURCE_FIELDS} } subject { ${GRAPHQL_SOURCE_FIELDS} } } + ... on IssueComment { + id databaseId body url createdAt updatedAt author { login avatarUrl } + reactions(first: 100) { nodes { content user { login } } } + } + } + totalCount pageInfo { hasNextPage endCursor } + } + } } +} +`; + +const SEARCH_CURSOR_PREFIX = "search:"; + const REACTION_CONTENT: Readonly> = { "thumbs-up": "+1", "thumbs-down": "-1", @@ -207,10 +292,10 @@ export interface IssueNormalizationContext { } export type NormalizedIssue = IssueListEntry & { - readonly body: string; readonly stateReason: IssueDetail["stateReason"]; - readonly assignees: IssueDetail["assignees"]; + readonly assignees: ReadonlyArray; readonly milestone: IssueMilestone | null; + readonly body: string; readonly reactions: ReadonlyArray; }; @@ -363,6 +448,19 @@ const reactions = (raw: unknown, viewer?: string): ReadonlyArray })); }; +const stateReasonFrom = (value: unknown): IssueDetail["stateReason"] => { + switch (stringValue(value).toUpperCase().replaceAll("-", "_")) { + case "COMPLETED": + return "completed"; + case "NOT_PLANNED": + return "not-planned"; + case "DUPLICATE": + return "duplicate"; + default: + return null; + } +}; + /** Normalize one GitHub GraphQL issue into the stable wire DTO. */ export function normalizeIssue( raw: unknown, @@ -374,14 +472,7 @@ export function normalizeIssue( const titleValue = stringValue(value.title).trim(); const title = (titleValue.length > 0 ? titleValue : `Issue #${number}`).slice(0, 256); const state = value.state === "CLOSED" || value.state === "closed" ? "closed" : "open"; - const stateReason = - value.stateReason === "COMPLETED" || value.stateReason === "completed" - ? "completed" - : value.stateReason === "NOT_PLANNED" || value.stateReason === "not_planned" - ? "not-planned" - : value.stateReason === "DUPLICATE" || value.stateReason === "duplicate" - ? "duplicate" - : null; + const stateReason = stateReasonFrom(value.stateReason); const urlValue = stringValue(value.url).trim(); return { provider: "github", @@ -463,6 +554,191 @@ const commentFrom = (raw: unknown): IssueComment | null => { }; }; +const relatedIssueFrom = (raw: unknown): IssueRelatedIssue | null => { + const value = record(raw); + const number = positiveInteger(value.number); + const title = stringValue(value.title).trim(); + const url = stringValue(value.url).trim(); + const repository = stringValue(record(value.repository).nameWithOwner).trim(); + if (number === null || !title || !url || !repository) return null; + return { + number, + title: title.slice(0, 256), + url, + repository, + state: value.state === "CLOSED" || value.state === "closed" ? "closed" : "open", + }; +}; + +const pullRequestFrom = (raw: unknown): IssueLinkedPullRequest | null => { + const value = record(raw); + const related = relatedIssueFrom(raw); + if (related === null) return null; + return { + ...related, + state: value.state === "MERGED" || value.state === "merged" ? "merged" : related.state, + ...(typeof value.isDraft === "boolean" ? { isDraft: value.isDraft } : {}), + }; +}; + +/** Combine closing and timeline PR references without repeating the same PR. */ +export function normalizeLinkedPullRequests(raw: unknown): ReadonlyArray { + const value = record(raw); + const candidates = [ + ...arrayValue(record(value.closedByPullRequestsReferences).nodes), + ...arrayValue(record(value.timelineItems).nodes).flatMap((event) => + [record(event).source, record(event).subject].filter( + (source) => record(source).__typename === "PullRequest", + ), + ), + ]; + const unique = new Map(); + for (const candidate of candidates) { + const pr = pullRequestFrom(candidate); + if (pr !== null) unique.set(pr.url, pr); + } + return [...unique.values()]; +} + +const timelineSourceFrom = (raw: unknown): IssueTimelineSource | null => { + const value = record(raw); + const related = pullRequestFrom(raw); + if (related === null || (value.__typename !== "Issue" && value.__typename !== "PullRequest")) + return null; + return { ...related, kind: value.__typename === "PullRequest" ? "pull-request" : "issue" }; +}; + +/** Normalize a timeline node; commit references can point to several associated PRs. */ +export function normalizeIssueTimelineEvents(raw: unknown): ReadonlyArray { + const value = record(raw); + const id = stringValue(value.id).trim(); + if (!id) return []; + const base = { id, actor: actor(value.actor), createdAt: iso(value.createdAt) }; + switch (value.__typename) { + case "LabeledEvent": + case "UnlabeledEvent": { + const label = labels({ nodes: [value.label] })[0]; + return label + ? [{ ...base, _tag: value.__typename === "LabeledEvent" ? "labeled" : "unlabeled", label }] + : []; + } + case "AssignedEvent": + case "UnassignedEvent": + return [ + { + ...base, + _tag: value.__typename === "AssignedEvent" ? "assigned" : "unassigned", + assignee: actor(value.assignee), + }, + ]; + case "ClosedEvent": + return [{ ...base, _tag: "closed", stateReason: stateReasonFrom(value.stateReason) }]; + case "ReopenedEvent": + return [{ ...base, _tag: "reopened" }]; + case "RenamedTitleEvent": + return [ + { + ...base, + _tag: "renamed", + from: stringValue(value.previousTitle), + to: stringValue(value.currentTitle), + }, + ]; + case "CrossReferencedEvent": { + const source = timelineSourceFrom(value.source); + return source ? [{ ...base, _tag: "cross-referenced", source }] : []; + } + case "ReferencedEvent": { + // GitHub's ReferencedEvent points to a commit, not another issue. + const sources = arrayValue(record(record(value.commit).associatedPullRequests).nodes); + return sources.flatMap((rawSource) => { + const source = timelineSourceFrom(rawSource); + return source + ? [{ ...base, id: `${id}:${source.url}`, _tag: "referenced" as const, source }] + : []; + }); + } + case "MilestonedEvent": + case "DemilestonedEvent": + return [ + { + ...base, + _tag: value.__typename === "MilestonedEvent" ? "milestoned" : "demilestoned", + title: stringValue(value.milestoneTitle), + }, + ]; + case "ConnectedEvent": { + const rawPr = [value.source, value.subject].find( + (source) => record(source).__typename === "PullRequest", + ); + const pullRequest = pullRequestFrom(rawPr); + return pullRequest ? [{ ...base, _tag: "connected", pullRequest }] : []; + } + case "IssueComment": { + const comment = commentFrom(value); + return comment ? [{ ...base, _tag: "comment", actor: comment.author, comment }] : []; + } + default: + return []; + } +} + +/** `me` and `@me` stand for the authenticated viewer, whose login only the host knows. */ +const isViewerFilter = (value: string): boolean => /^@?me$/i.test(value.trim()); + +/** Translate structured filters into host search qualifiers before pagination. */ +export function issueFilterSearchTerms(filters: IssueListInput["filters"]): ReadonlyArray { + if (!filters) return []; + const quote = (value: string) => `"${value.replace(/["\\]/g, "").replace(/\s+/g, " ").trim()}"`; + // A quoted "@me" would search for a literal login, so the viewer qualifier stays bare. + const person = (value: string) => (isViewerFilter(value) ? "@me" : quote(value)); + return [ + ...(filters.labels ?? []).map((group) => `label:${group.map(quote).join(",")}`), + ...(filters.excludedLabels ?? []).map((label) => `-label:${quote(label)}`), + ...(filters.author === undefined ? [] : [`author:${person(filters.author)}`]), + ...(filters.assignee === undefined ? [] : [`assignee:${person(filters.assignee)}`]), + ...(filters.milestone === undefined ? [] : [`milestone:${quote(filters.milestone)}`]), + ]; +} + +export function issueSortOrder(sort: IssueListSort = "updated") { + return { + search: sort === "updated" ? "updated-desc" : sort === "comments" ? "comments-desc" : sort, + orderBy: { + field: sort === "updated" ? "UPDATED_AT" : sort === "comments" ? "COMMENTS" : "CREATED_AT", + direction: sort === "created-asc" ? "ASC" : "DESC", + }, + }; +} + +export function compareIssueListEntries( + left: IssueListEntry, + right: IssueListEntry, + sort: IssueListSort = "updated", +): number { + const order = + sort === "comments" + ? right.commentsCount - left.commentsCount + : sort === "created-asc" + ? left.createdAt.localeCompare(right.createdAt) + : sort === "created-desc" + ? right.createdAt.localeCompare(left.createdAt) + : right.updatedAt.localeCompare(left.updatedAt); + return ( + order || + `${left.repository}#${left.number}`.localeCompare(`${right.repository}#${right.number}`) + ); +} + +/** Keep list rows compact while retaining the fields encoded by IssueListEntry. */ +export function issueListEntry( + issue: NormalizedIssue, + linkedWork: IssueLinkedWorkSummary | null, +): IssueListEntry { + const { body: _body, reactions: _reactions, ...entry } = issue; + return { ...entry, linkedWork }; +} + const templateList = (value: unknown): ReadonlyArray => arrayValue(value).flatMap((entry) => { const item = record(entry); @@ -532,12 +808,14 @@ export const matchesFilters = (entry: NormalizedIssue, input: IssueListInput): b } if ( filters.author !== undefined && + !isViewerFilter(filters.author) && entry.author?.login.toLowerCase() !== filters.author.toLowerCase() ) { return false; } if ( filters.assignee !== undefined && + !isViewerFilter(filters.assignee) && !entry.assignees.some( (assignee) => assignee.login.toLowerCase() === filters.assignee?.toLowerCase(), ) @@ -561,6 +839,9 @@ export class IssueService extends Context.Service< readonly comments: ( input: IssueCommentsInput, ) => Effect.Effect; + readonly timeline: ( + input: IssueTimelineInput, + ) => Effect.Effect; readonly candidates: ( input: IssueCandidatesInput, ) => Effect.Effect; @@ -630,6 +911,7 @@ export const make = Effect.gen(function* () { const listCache = new Map>(); const detailCache = new Map>(); const commentsCache = new Map>(); + const timelineCache = new Map>(); const templatesCache = new Map>(); const maxCacheEntries = 256; const flows = new Map(); @@ -737,11 +1019,20 @@ export const make = Effect.gen(function* () { repo: Repo, query: string, variables: JsonRecord, + subIssues = false, ): Effect.Effect => gh .execute({ cwd: repo.cwd, - args: ["api", "graphql", "--hostname", repo.host, "--input", "-"], + args: [ + "api", + "graphql", + "--hostname", + repo.host, + "--input", + "-", + ...(subIssues ? ["-H", "GraphQL-Features: sub_issues"] : []), + ], stdin: JSON.stringify({ query, variables }), }) .pipe( @@ -850,6 +1141,7 @@ export const make = Effect.gen(function* () { detached_at AS "detachedAt" FROM projection_issue_links WHERE host = ${host} AND repository = ${repository} AND number = ${number} + ORDER BY detached_at IS NOT NULL, linked_at DESC, thread_id LIMIT 1 `.pipe( Effect.map((rows) => rows[0] ?? null), @@ -934,12 +1226,14 @@ export const make = Effect.gen(function* () { return "manual"; }; + const blankToNull = (value: string | null): string | null => (value === "" ? null : value); + const linkedWorkFromRow = (repo: Repo, number: number, row: IssueLinkRow): IssueLinkedWork => ({ issue: { provider: "github", host: repo.host, repository: repo.repository, number }, threadId: ThreadId.make(row.threadId), projectId: ProjectId.make(row.projectId), - branch: row.branch, - worktreePath: row.worktreePath, + branch: blankToNull(row.branch), + worktreePath: blankToNull(row.worktreePath), linkedAt: iso(row.linkedAt), source: sourceValue(row.source), }); @@ -952,6 +1246,28 @@ export const make = Effect.gen(function* () { Effect.map((row) => (row === null ? null : linkedWorkFromRow(repo, number, row))), ); + // Keeps the stored state of linked issues current; worktree chips read it from shells. + const recordLinkedState = (repo: Repo, number: number, state: IssueDetail["state"]) => + sql<{ readonly projectId: string }>` + UPDATE projection_issue_links SET state = ${state} + WHERE host = ${repo.host} AND repository = ${repo.repository} AND number = ${number} + AND state IS NOT ${state} + RETURNING project_id AS "projectId" + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + new Set(rows.map((row) => ProjectId.make(row.projectId))), + worktreeLinks.publish, + { discard: true }, + ), + ), + Effect.catch((cause) => Effect.logWarning("failed to record linked issue state", { cause })), + ); + + const lacksClosingPullRequests = (error: IssueError): boolean => + error._tag === "IssueOperationError" && + /closedByPullRequestsReferences|includeClosedPrs/.test(error.detail); + const detail = (input: IssueRef): Effect.Effect => cached( detailCache, @@ -962,19 +1278,49 @@ export const make = Effect.gen(function* () { const parsed = splitRepository(repo.repository); if (parsed === null) return yield* operationError("detail", "Repository must be owner/name."); - const response = yield* graph(repo, DETAIL_QUERY, { - owner: parsed.owner, - name: parsed.name, - number: input.number, - }); + const variables = { owner: parsed.owner, name: parsed.name, number: input.number }; + const mainQuery = Effect.catchIf( + graph(repo, DETAIL_QUERY, variables), + lacksClosingPullRequests, + () => graph(repo, DETAIL_QUERY_WITHOUT_CLOSING_PRS, variables), + ); + // Hosts without sub-issues fail this query; the detail then omits them. + const subIssuesQuery = graph(repo, SUB_ISSUES_QUERY, variables, true).pipe( + Effect.catchTag("IssueOperationError", () => Effect.succeed(null)), + ); + const [response, subIssueResponse, linked] = yield* Effect.all( + [mainQuery, subIssuesQuery, linkedWork(repo, input.number)], + { concurrency: "unbounded" }, + ); const repository = record(record(response.data).repository); const rawIssue = repository.issue; const issue = normalizeIssue(rawIssue, repo); if (issue === null) return yield* operationError("detail", "Issue was not found."); + const subIssueData = + subIssueResponse === null + ? null + : record(record(record(subIssueResponse.data).repository).issue); const viewer = stringValue(record(record(response.data).viewer).login).trim(); - const linked = yield* linkedWork(repo, input.number); + if (linked !== null) yield* recordLinkedState(repo, input.number, issue.state); return { ...issue, + linkedPullRequests: normalizeLinkedPullRequests(rawIssue), + ...(subIssueData === null + ? {} + : { + parent: relatedIssueFrom(subIssueData.parent), + subIssues: arrayValue(record(subIssueData.subIssues).nodes).flatMap((raw) => { + const related = relatedIssueFrom(raw); + return related === null ? [] : [related]; + }), + subIssuesSummary: { + total: nonNegativeInteger(record(subIssueData.subIssuesSummary).total), + completed: nonNegativeInteger(record(subIssueData.subIssuesSummary).completed), + percentCompleted: nonNegativeInteger( + record(subIssueData.subIssuesSummary).percentCompleted, + ), + }, + }), projectTitle: repo.projectTitle, workspaceRoot: repo.cwd, reactions: reactions(record(rawIssue).reactions, viewer.length > 0 ? viewer : undefined), @@ -1008,11 +1354,18 @@ export const make = Effect.gen(function* () { const previous = grouped.get(repo.host) ?? []; grouped.set(repo.host, [...previous, repo]); } + // Search and repository cursors are not interchangeable. A continuation may name + // fewer repositories than the first page, so a search cursor keeps it searching. + const continuesSearch = Object.values(input.cursors ?? {}).some((cursor) => + cursor.startsWith(SEARCH_CURSOR_PREFIX), + ); const providers: IssueListResult["providers"] = [...grouped.entries()].map( ([host, hostRepos]) => ({ provider: "github", host, searchesOnHost: + continuesSearch || + input.filters !== undefined || input.query !== undefined || (input.involvement !== undefined && input.involvement !== "all") || hostRepos.length > 1, @@ -1022,6 +1375,8 @@ export const make = Effect.gen(function* () { }), ); const shouldSearch = + continuesSearch || + input.filters !== undefined || input.query !== undefined || (input.involvement !== undefined && input.involvement !== "all") || repos.some((repo) => @@ -1039,7 +1394,13 @@ export const make = Effect.gen(function* () { continue; } const cursorKey = `${repo.projectId}:${repo.host}:${repo.repository}`; - const after = input.cursors?.[cursorKey] ?? null; + const cursor = input.cursors?.[cursorKey]; + const after = + cursor === undefined + ? null + : cursor.startsWith(SEARCH_CURSOR_PREFIX) + ? cursor.slice(SEARCH_CURSOR_PREFIX.length) + : cursor; const searchTerms = [ `repo:${repo.repository}`, "is:issue", @@ -1048,6 +1409,8 @@ export const make = Effect.gen(function* () { ...(input.involvement === "assigned" ? ["assignee:@me"] : []), ...(input.involvement === "mentioned" ? ["mentions:@me"] : []), ...(input.query === undefined ? [] : [input.query]), + ...issueFilterSearchTerms(input.filters), + `sort:${issueSortOrder(input.sort).search}`, ]; const pageEffect = shouldSearch ? graph(repo, SEARCH_QUERY, { @@ -1061,8 +1424,37 @@ export const make = Effect.gen(function* () { first: Math.min(100, limit), after, states, + orderBy: issueSortOrder(input.sort).orderBy, }); yield* pageEffect.pipe( + Effect.flatMap((response) => { + const data = record(response.data); + const container = shouldSearch + ? record(data.search) + : record(record(data.repository).issues); + const numbers = arrayValue(container.nodes).flatMap((raw) => { + const number = positiveInteger(record(raw).number); + return number === null ? [] : [number]; + }); + // Same pick as findLinked for attached links: the most recent one per issue. + return sql` + SELECT number, thread_id AS "threadId", project_id AS "projectId", branch, + worktree_path AS "worktreePath", linked_at AS "linkedAt", source, detached_at AS "detachedAt" + FROM projection_issue_links + WHERE host = ${repo.host} AND repository = ${repo.repository} + AND detached_at IS NULL AND ${sql.in("number", numbers)} + ORDER BY linked_at DESC, thread_id + `.pipe( + Effect.mapError((cause) => + operationError("link.read", "Unable to read issue links.", cause), + ), + Effect.map((rows) => { + const links = new Map(); + for (const row of rows) if (!links.has(row.number)) links.set(row.number, row); + return { response, links }; + }), + ); + }), Effect.matchEffect({ onFailure: (error) => Effect.sync(() => { @@ -1072,7 +1464,7 @@ export const make = Effect.gen(function* () { message: error.message, }); }), - onSuccess: (response) => + onSuccess: ({ response, links }) => Effect.sync(() => { const data = record(response.data); const container = shouldSearch @@ -1080,30 +1472,43 @@ export const make = Effect.gen(function* () { : record(record(data.repository).issues); for (const rawIssue of arrayValue(container.nodes)) { const issue = normalizeIssue(rawIssue, repo); - if (issue !== null && matchesFilters(issue, input)) entries.push(issue); + if (issue !== null && matchesFilters(issue, input)) { + const row = links.get(issue.number); + entries.push( + issueListEntry( + issue, + row === undefined + ? null + : { + threadId: ThreadId.make(row.threadId), + branch: blankToNull(row.branch), + worktreePath: blankToNull(row.worktreePath), + source: sourceValue(row.source), + }, + ), + ); + } } const pageInfo = record(container.pageInfo); const hasNext = pageInfo.hasNextPage === true; const endCursor = stringValue(pageInfo.endCursor).trim(); - if (hasNext && endCursor.length > 0) nextCursors[cursorKey] = endCursor; + if (hasNext && endCursor.length > 0) { + nextCursors[cursorKey] = shouldSearch + ? `${SEARCH_CURSOR_PREFIX}${endCursor}` + : endCursor; + } }), }), ); } - entries.sort((left, right) => { - const updated = right.updatedAt.localeCompare(left.updatedAt); - return updated !== 0 - ? updated - : `${left.repository}#${left.number}`.localeCompare( - `${right.repository}#${right.number}`, - ); - }); - const truncated = entries.length > limit || Object.keys(nextCursors).length > 0; + entries.sort((left, right) => compareIssueListEntries(left, right, input.sort)); + // Every fetched row is returned: each repository asks for at most `limit` rows, and + // cutting the merged list would lose rows whose repository cursor already advanced. return { providers, - entries: entries.slice(0, limit), + entries, errors, - truncated, + truncated: Object.keys(nextCursors).length > 0, nextCursors, }; }), @@ -1143,23 +1548,57 @@ export const make = Effect.gen(function* () { }), ); + const timeline = (input: IssueTimelineInput): Effect.Effect => + cached( + timelineCache, + `timeline:${JSON.stringify(input)}`, + 15_000, + Effect.gen(function* () { + const repo = yield* repoFor(input); + const parsed = splitRepository(repo.repository); + if (parsed === null) + return yield* operationError("timeline", "Repository must be owner/name."); + const response = yield* graph(repo, TIMELINE_QUERY, { + owner: parsed.owner, + name: parsed.name, + number: input.number, + first: Math.min(100, input.limit ?? 50), + after: input.cursor ?? null, + }); + const issue = record(record(record(response.data).repository).issue); + const page = record(issue.timelineItems); + const pageInfo = record(page.pageInfo); + const endCursor = stringValue(pageInfo.endCursor).trim(); + const hasNext = pageInfo.hasNextPage === true && endCursor.length > 0; + return { + events: arrayValue(page.nodes).flatMap(normalizeIssueTimelineEvents), + totalCount: nonNegativeInteger(page.totalCount), + nextCursor: hasNext ? endCursor : null, + truncated: hasNext, + }; + }), + ); + + /** Runs the writes, then re-reads the issue; caches drop even when a later write fails. */ const mutationIssue = ( - repo: Repo, - endpoint: string, - method: "POST" | "PATCH", - body: JsonRecord, + writes: ReadonlyArray>, input: IssueRef, ): Effect.Effect => - rest(repo, method, endpoint, body).pipe( - Effect.tap(() => + Effect.all(writes, { discard: true }).pipe( + Effect.ensuring( Effect.sync(() => { listCache.clear(); detailCache.clear(); + timelineCache.clear(); }), ), Effect.flatMap(() => detail(input)), ); + const isGitHubNotFound = Schema.is(GitHubCli.GitHubPullRequestNotFoundError); + const isNotFound = (error: IssueError): boolean => + error._tag === "IssueOperationError" && isGitHubNotFound(error.cause); + const create = (input: IssueCreateInput): Effect.Effect => Effect.gen(function* () { const repo = yield* repoFor(input); @@ -1208,15 +1647,27 @@ export const make = Effect.gen(function* () { "Milestone must be a numeric GitHub milestone id.", ); } - return { - issue: yield* mutationIssue( - repo, - `repos/${repo.repository}/issues/${input.number}`, - "PATCH", - body, - input, + const issuePath = `repos/${repo.repository}/issues/${input.number}`; + // Additive edits leave labels and assignees others set since the issue was read. + const writes = [ + ...(Object.keys(body).length > 0 ? [rest(repo, "PATCH", issuePath, body)] : []), + ...(input.addLabels?.length + ? [rest(repo, "POST", `${issuePath}/labels`, { labels: input.addLabels })] + : []), + ...(input.removeLabels ?? []).map((name) => + rest(repo, "DELETE", `${issuePath}/labels/${encodeURIComponent(name)}`).pipe( + // A label that is already gone is the outcome the caller asked for. + Effect.catchIf(isNotFound, () => Effect.void), + ), ), - }; + ...(input.addAssignees?.length + ? [rest(repo, "POST", `${issuePath}/assignees`, { assignees: input.addAssignees })] + : []), + ...(input.removeAssignees?.length + ? [rest(repo, "DELETE", `${issuePath}/assignees`, { assignees: input.removeAssignees })] + : []), + ]; + return { issue: yield* mutationIssue(writes, input) }; }); const close = (input: IssueCloseInput): Effect.Effect => @@ -1228,10 +1679,7 @@ export const make = Effect.gen(function* () { } return { issue: yield* mutationIssue( - repo, - `repos/${repo.repository}/issues/${input.number}`, - "PATCH", - body, + [rest(repo, "PATCH", `repos/${repo.repository}/issues/${input.number}`, body)], input, ), }; @@ -1242,10 +1690,11 @@ export const make = Effect.gen(function* () { const repo = yield* repoFor(input); return { issue: yield* mutationIssue( - repo, - `repos/${repo.repository}/issues/${input.number}`, - "PATCH", - { state: "open" }, + [ + rest(repo, "PATCH", `repos/${repo.repository}/issues/${input.number}`, { + state: "open", + }), + ], input, ), }; @@ -1264,6 +1713,7 @@ export const make = Effect.gen(function* () { if (comment === null) return yield* operationError("comment.create", "GitHub did not return a comment."); commentsCache.clear(); + timelineCache.clear(); detailCache.clear(); return { comment }; }); @@ -1281,6 +1731,7 @@ export const make = Effect.gen(function* () { if (comment === null) return yield* operationError("comment.update", "GitHub did not return a comment."); commentsCache.clear(); + timelineCache.clear(); return { comment }; }); @@ -1291,6 +1742,7 @@ export const make = Effect.gen(function* () { Effect.tap(() => Effect.sync(() => { commentsCache.clear(); + timelineCache.clear(); detailCache.clear(); }), ), @@ -1328,6 +1780,7 @@ export const make = Effect.gen(function* () { } detailCache.clear(); commentsCache.clear(); + timelineCache.clear(); }); const candidates = ( @@ -1495,6 +1948,7 @@ export const make = Effect.gen(function* () { operationError("link.write", "Unable to save issue link.", cause), ), ); + listCache.clear(); yield* publishWorktreeIssues(input.projectId); return linkedWork; }); @@ -2309,6 +2763,7 @@ export const make = Effect.gen(function* () { list, detail, comments, + timeline, candidates, templates, create, @@ -2324,6 +2779,7 @@ export const make = Effect.gen(function* () { listCache.clear(); detailCache.clear(); commentsCache.clear(); + timelineCache.clear(); templatesCache.clear(); }), link, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5c8f146bcadfc..8cde2df1f7d36 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -49,6 +49,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { IssuesToolkitHandlersLive } from "./toolkits/issues/handlers.ts"; +import { IssuesToolkit } from "./toolkits/issues/tools.ts"; import { DeviceScreenshotToolkitHandlersLive, DeviceStandardToolkitHandlersLive, @@ -695,6 +697,10 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest Layer.provide(PullRequestsToolkitHandlersLive), ); +const IssuesToolkitRegistrationLive = McpServer.toolkit(IssuesToolkit).pipe( + Layer.provide(IssuesToolkitHandlersLive), +); + const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe( Layer.provide(DeviceStandardToolkitHandlersLive), ); @@ -725,5 +731,6 @@ export const layer = Layer.mergeAll( PreviewControlsRegistrationLive, WorktreeToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, + IssuesToolkitRegistrationLive, DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/issues/handlers.test.ts b/apps/server/src/mcp/toolkits/issues/handlers.test.ts new file mode 100644 index 0000000000000..9156854883108 --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.test.ts @@ -0,0 +1,419 @@ +import { + EnvironmentId, + IssueOperationError, + IssueUnavailableError, + ProjectId, + ProviderInstanceId, + ThreadId, + type IssueComment, + type IssueCommentsResult, + type IssueDetail, + type IssueListResult, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import type { Tool } from "effect/unstable/ai"; + +import * as IssueService from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import { v2PullRequestThread } from "../../../orchestration-v2/testkit/pullRequestFixtures.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { IssuesToolkitHandlersLive } from "./handlers.ts"; +import { IssuesToolkit } from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const DATE = "2026-08-01T00:00:00.000Z"; +const REF = { projectId: PROJECT_ID, repository: "t3tools/t3code", number: 42 }; +const ISSUE: IssueDetail = { + ...REF, + provider: "github", + host: "github.com", + projectTitle: "T3 Code", + title: "Issue tools", + body: "Expose issues through MCP.", + url: "https://github.com/t3tools/t3code/issues/42", + author: null, + state: "open", + stateReason: null, + createdAt: DATE, + updatedAt: DATE, + closedAt: null, + labels: [], + assignees: [], + milestone: null, + commentsCount: 2, +}; +const COMMENT: IssueComment = { + id: "comment-1", + author: null, + body: "Working on this.", + createdAt: DATE, + url: `${ISSUE.url}#issuecomment-1`, +}; +const COMMENTS: IssueCommentsResult = { + comments: [COMMENT], + nextCursor: "comments-next", + commentCount: 2, + truncated: true, +}; +const LIST: IssueListResult = { + providers: [], + entries: [ISSUE], + errors: [{ projectId: PROJECT_ID, repository: "t3tools/other", message: "Unavailable" }], + truncated: true, + nextCursors: { "github.com/t3tools/t3code": "issues-next" }, +}; + +type Method = + | "list" + | "detail" + | "comments" + | "commentCreate" + | "close" + | "reopen" + | "update" + | "link"; + +const REQUESTS = [ + { name: "list_issues", input: { state: "open" }, method: "list", error: "IssueListFailedError" }, + { name: "read_issue", input: REF, method: "detail", error: "IssueReadFailedError" }, + { + name: "comment_on_issue", + input: { ...REF, body: "Hello" }, + method: "commentCreate", + error: "IssueCommentFailedError", + }, + { name: "close_issue", input: REF, method: "close", error: "IssueCloseFailedError" }, + { name: "reopen_issue", input: REF, method: "reopen", error: "IssueReopenFailedError" }, + { + name: "update_issue", + input: { ...REF, title: "Updated" }, + method: "update", + error: "IssueUpdateFailedError", + }, + { name: "link_issue", input: REF, method: "link", error: "IssueLinkFailedError" }, +] as const; + +const makeHarness = Effect.fn("makeIssuesToolkitHarness")(function* ( + options: { + readonly fail?: Method; + readonly unavailable?: boolean; + readonly missingThread?: boolean; + readonly threadFailure?: boolean; + } = {}, +) { + const calls: Array<{ readonly method: Method; readonly input: unknown }> = []; + const record = (method: Method, input: unknown, result: A) => + Effect.gen(function* () { + calls.push({ method, input }); + if (options.fail === method) { + return yield* options.unavailable + ? new IssueUnavailableError({ reason: "cli-unauthenticated", host: "github.com" }) + : new IssueOperationError({ operation: method, detail: "private host failure" }); + } + return result; + }); + const dependencies = Layer.mergeAll( + Layer.mock(IssueService.IssueService)({ + list: (input) => record("list", input, LIST), + detail: (input) => record("detail", input, ISSUE), + comments: (input) => record("comments", input, COMMENTS), + commentCreate: (input) => + record("commentCreate", input, { comment: { ...COMMENT, body: input.body } }), + close: (input) => + record("close", input, { + issue: { + ...ISSUE, + state: "closed" as const, + stateReason: input.reason ?? "completed", + closedAt: DATE, + }, + }), + reopen: (input) => record("reopen", input, { issue: ISSUE }), + update: (input) => + record("update", input, { + issue: { ...ISSUE, title: input.title ?? ISSUE.title, body: input.body ?? ISSUE.body }, + }), + link: (input) => + record("link", input, { + linkedWork: { + issue: { + provider: "github" as const, + host: "github.com", + repository: input.repository, + number: input.number, + }, + threadId: input.threadId, + projectId: input.projectId, + branch: "feat/issues", + worktreePath: null, + linkedAt: DATE, + source: input.source ?? "manual", + }, + }), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadShell: (id) => + options.threadFailure + ? Effect.fail( + new Orchestrator.OrchestratorProjectionError({ + threadId: id, + cause: "private storage failure", + }), + ) + : Effect.succeed( + options.missingThread + ? null + : v2PullRequestThread({ + id, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feat/issues", + worktreePath: null, + pullRequests: [], + createdAt: DATE, + updatedAt: DATE, + archivedAt: null, + settledOverride: null, + settledAt: null, + latestUserMessageAt: null, + }), + ), + }), + ); + const toolkit = yield* IssuesToolkit.pipe( + Effect.provide(IssuesToolkitHandlersLive.pipe(Layer.provide(dependencies))), + ); + const call = ( + name: Name, + params: unknown, + capabilities: ReadonlyArray = ["pull-requests"], + ) => + toolkit.handle(name, params as Parameters>[1]).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map( + (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof IssuesToolkit.tools)[Name]>, + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment-1"), + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, + }), + Effect.provide(dependencies), + ); + return { calls, call }; +}); + +describe("issues toolkit handlers", () => { + for (const request of REQUESTS) { + it.effect(`${request.name} refuses credentials without the pull-requests capability`, () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call(request.name, request.input, ["preview"]) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "McpCapabilityUnavailableError", + capability: "pull-requests", + threadId: THREAD_ID, + }); + expect(harness.calls).toEqual([]); + }), + ); + + it.effect(`${request.name} maps service failures without exposing their message`, () => + Effect.gen(function* () { + const harness = yield* makeHarness({ fail: request.method }); + const error = yield* harness.call(request.name, request.input).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: request.error }); + expect(error.message).not.toContain("private host failure"); + }), + ); + } + + it.effect( + "passes list filters and cursors through and preserves pagination and repository errors", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const input = { + state: "closed" as const, + projectId: PROJECT_ID, + query: " tools ", + involvement: "assigned" as const, + filters: { + labels: [["bug", "feature"]], + excludedLabels: ["wontfix"], + author: "maintainer", + assignee: "@me", + milestone: "v1", + }, + cursors: { "github.com/t3tools/t3code": "previous-page" }, + limit: 25, + }; + expect(yield* harness.call("list_issues", input)).toEqual(LIST); + expect(harness.calls).toEqual([{ method: "list", input: { ...input, query: "tools" } }]); + }), + ); + + it.effect("reads issue details with the first page of comments and its next cursor", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + expect(yield* harness.call("read_issue", REF)).toEqual({ issue: ISSUE, comments: COMMENTS }); + expect(harness.calls).toEqual([ + { method: "detail", input: REF }, + { method: "comments", input: REF }, + ]); + }), + ); + + it.effect("maps a comments failure to the read error", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ fail: "comments" }); + expect(yield* harness.call("read_issue", REF).pipe(Effect.flip)).toMatchObject({ + _tag: "IssueReadFailedError", + }); + }), + ); + + it.effect("maps an unauthenticated GitHub CLI to the operation error", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ fail: "list", unavailable: true }); + expect(yield* harness.call("list_issues", { state: "open" }).pipe(Effect.flip)).toMatchObject( + { + _tag: "IssueListFailedError", + cause: { _tag: "IssueUnavailableError", reason: "cli-unauthenticated" }, + }, + ); + }), + ); + + it.effect("posts the supplied comment and returns the created comment", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("comment_on_issue", { + ...REF, + body: "Comment **markdown**", + }); + expect(result.comment.body).toBe("Comment **markdown**"); + expect(result.comment.url).toBe(COMMENT.url); + }), + ); + + it.effect("closes with the requested reason and supports reopening", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const closed = yield* harness.call("close_issue", { ...REF, reason: "not-planned" }); + expect(closed.issue).toMatchObject({ state: "closed", stateReason: "not-planned" }); + const reopened = yield* harness.call("reopen_issue", REF); + expect(reopened.issue).toMatchObject({ state: "open", stateReason: null }); + expect(harness.calls).toEqual([ + { method: "close", input: { ...REF, reason: "not-planned" } }, + { method: "reopen", input: REF }, + ]); + }), + ); + + it.effect("preserves explicit empty updates and decodes titles", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const input = { + ...REF, + title: " Updated title ", + body: "", + labels: [], + assignees: [], + milestone: null, + }; + const result = yield* harness.call("update_issue", input); + expect(result.issue).toMatchObject({ title: "Updated title", body: "" }); + expect(harness.calls).toEqual([ + { method: "update", input: { ...input, title: "Updated title" } }, + ]); + }), + ); + + it.effect("links only to the credential's thread with source agent", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const input = { ...REF, threadId: "other-thread", source: "manual" }; + const result = yield* harness.call("link_issue", input); + expect(result.linkedWork).toMatchObject({ + threadId: THREAD_ID, + source: "agent", + issue: { repository: REF.repository, number: REF.number }, + }); + expect(harness.calls).toEqual([ + { method: "link", input: { ...REF, threadId: THREAD_ID, source: "agent" } }, + ]); + }), + ); + + it.effect("refuses to change issues outside the calling thread's project but reads them", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const other = { ...REF, projectId: ProjectId.make("project-2") }; + for (const request of REQUESTS.slice(2)) { + const error = yield* harness + .call(request.name, { ...request.input, projectId: other.projectId }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "IssueOutsideThreadProjectError", + projectId: "project-2", + }); + } + expect(harness.calls).toEqual([]); + yield* harness.call("read_issue", other); + expect(harness.calls.map((call) => call.method)).toEqual(["detail", "comments"]); + }), + ); + + it.effect("rejects an invalid issue number before calling the service", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.call("close_issue", { ...REF, number: 0 }).pipe(Effect.flip); + expect(harness.calls).toEqual([]); + }), + ); + + it.effect("rejects an invalid close reason before calling the service", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.call("close_issue", { ...REF, reason: "invalid" }).pipe(Effect.flip); + expect(harness.calls).toEqual([]); + }), + ); + + it.effect("refuses a credential whose thread no longer exists", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ missingThread: true }); + expect(yield* harness.call("list_issues", { state: "open" }).pipe(Effect.flip)).toMatchObject( + { _tag: "IssueThreadNotFoundError", threadId: THREAD_ID }, + ); + expect(harness.calls).toEqual([]); + }), + ); + + it.effect("maps a caller lookup failure before invoking the issue service", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ threadFailure: true }); + const error = yield* harness.call("link_issue", REF).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "IssueLinkFailedError" }); + expect(error.message).toBe("Could not link the issue."); + expect(harness.calls).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/mcp/toolkits/issues/handlers.ts b/apps/server/src/mcp/toolkits/issues/handlers.ts new file mode 100644 index 0000000000000..7ab43af0b1c53 --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/handlers.ts @@ -0,0 +1,115 @@ +import * as Effect from "effect/Effect"; + +import * as IssueService from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { + IssueCloseFailedError, + IssueCommentFailedError, + IssueLinkFailedError, + IssueListFailedError, + IssueOutsideThreadProjectError, + IssueReadFailedError, + IssueReopenFailedError, + IssuesToolkit, + IssueThreadNotFoundError, + IssueUpdateFailedError, +} from "./tools.ts"; + +const make = Effect.gen(function* () { + const issues = yield* IssueService.IssueService; + const engine = yield* Orchestrator.OrchestratorV2; + + const requireThread = Effect.fn("IssuesToolkit.requireThread")(function* ( + Failure: + | typeof IssueListFailedError + | typeof IssueReadFailedError + | typeof IssueCommentFailedError + | typeof IssueCloseFailedError + | typeof IssueReopenFailedError + | typeof IssueUpdateFailedError + | typeof IssueLinkFailedError, + ) { + const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + const thread = yield* engine + .getThreadShell(scope.threadId) + .pipe(Effect.mapError((cause) => new Failure({ cause }))); + if (thread === null) { + return yield* new IssueThreadNotFoundError({ threadId: scope.threadId }); + } + return thread; + }); + + // Like the pull request tools, writes stay inside the calling thread's own work; + // reads may span projects so an agent can find related issues. + const requireOwnProject = Effect.fn("IssuesToolkit.requireOwnProject")(function* ( + Failure: + | typeof IssueCommentFailedError + | typeof IssueCloseFailedError + | typeof IssueReopenFailedError + | typeof IssueUpdateFailedError + | typeof IssueLinkFailedError, + projectId: string, + ) { + const thread = yield* requireThread(Failure); + if (thread.projectId !== projectId) { + return yield* new IssueOutsideThreadProjectError({ projectId }); + } + return thread; + }); + + return IssuesToolkit.of({ + list_issues: (input) => + Effect.gen(function* () { + yield* requireThread(IssueListFailedError); + return yield* issues + .list(input) + .pipe(Effect.mapError((cause) => new IssueListFailedError({ cause }))); + }), + read_issue: (input) => + Effect.gen(function* () { + yield* requireThread(IssueReadFailedError); + return yield* Effect.all({ + issue: issues.detail(input), + comments: issues.comments(input), + }).pipe(Effect.mapError((cause) => new IssueReadFailedError({ cause }))); + }), + comment_on_issue: (input) => + Effect.gen(function* () { + yield* requireOwnProject(IssueCommentFailedError, input.projectId); + return yield* issues + .commentCreate(input) + .pipe(Effect.mapError((cause) => new IssueCommentFailedError({ cause }))); + }), + close_issue: (input) => + Effect.gen(function* () { + yield* requireOwnProject(IssueCloseFailedError, input.projectId); + return yield* issues + .close(input) + .pipe(Effect.mapError((cause) => new IssueCloseFailedError({ cause }))); + }), + reopen_issue: (input) => + Effect.gen(function* () { + yield* requireOwnProject(IssueReopenFailedError, input.projectId); + return yield* issues + .reopen(input) + .pipe(Effect.mapError((cause) => new IssueReopenFailedError({ cause }))); + }), + update_issue: (input) => + Effect.gen(function* () { + yield* requireOwnProject(IssueUpdateFailedError, input.projectId); + return yield* issues + .update(input) + .pipe(Effect.mapError((cause) => new IssueUpdateFailedError({ cause }))); + }), + link_issue: (input) => + Effect.gen(function* () { + const thread = yield* requireOwnProject(IssueLinkFailedError, input.projectId); + return yield* issues + .link({ ...input, threadId: thread.id, source: "agent" }) + .pipe(Effect.mapError((cause) => new IssueLinkFailedError({ cause }))); + }), + }); +}); + +export const IssuesToolkitHandlersLive = IssuesToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/issues/tools.ts b/apps/server/src/mcp/toolkits/issues/tools.ts new file mode 100644 index 0000000000000..87f65c47fdacf --- /dev/null +++ b/apps/server/src/mcp/toolkits/issues/tools.ts @@ -0,0 +1,231 @@ +import { + IssueCloseInput, + IssueCloseResult, + IssueCommentCreateInput, + IssueCommentCreateResult, + IssueCommentsResult, + IssueDetail, + IssueLinkResult, + IssueListInput, + IssueListResult, + IssueRef, + IssueReopenInput, + IssueReopenResult, + IssueUpdateInput, + IssueUpdateResult, + McpCapabilityUnavailableError, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as IssueService from "../../../issue/IssueService.ts"; +import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + Orchestrator.OrchestratorV2, + IssueService.IssueService, +]; + +export class IssueThreadNotFoundError extends Schema.TaggedError()( + "IssueThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class IssueOutsideThreadProjectError extends Schema.TaggedError()( + "IssueOutsideThreadProjectError", + { projectId: Schema.String }, +) { + override get message(): string { + return `Issues can only be changed in this thread's own project, not project ${this.projectId}.`; + } +} + +export class IssueListFailedError extends Schema.TaggedError()( + "IssueListFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not list the issues."; + } +} + +export class IssueReadFailedError extends Schema.TaggedError()( + "IssueReadFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read the issue."; + } +} + +export class IssueCommentFailedError extends Schema.TaggedError()( + "IssueCommentFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not comment on the issue."; + } +} + +export class IssueCloseFailedError extends Schema.TaggedError()( + "IssueCloseFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not close the issue."; + } +} + +export class IssueReopenFailedError extends Schema.TaggedError()( + "IssueReopenFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not reopen the issue."; + } +} + +export class IssueUpdateFailedError extends Schema.TaggedError()( + "IssueUpdateFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not update the issue."; + } +} + +export class IssueLinkFailedError extends Schema.TaggedError()( + "IssueLinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not link the issue."; + } +} + +const IssueToolError = Schema.Union([ + McpCapabilityUnavailableError, + IssueThreadNotFoundError, + IssueOutsideThreadProjectError, + IssueListFailedError, + IssueReadFailedError, + IssueCommentFailedError, + IssueCloseFailedError, + IssueReopenFailedError, + IssueUpdateFailedError, + IssueLinkFailedError, +]); + +const ListIssuesTool = Tool.make("list_issues", { + description: + "List GitHub issues across configured projects, with state, query, involvement, filters and pagination. Set projectId to limit the search to one project.", + parameters: IssueListInput, + success: IssueListResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "List issues") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const ReadIssueTool = Tool.make("read_issue", { + description: + "Read a GitHub issue's details and first page of comments. The comments result includes a cursor when more comments are available.", + parameters: IssueRef, + success: Schema.Struct({ issue: IssueDetail, comments: IssueCommentsResult }), + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Read issue") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const CommentOnIssueTool = Tool.make("comment_on_issue", { + description: + "Post a comment on a GitHub issue. Each call creates a new comment; do not retry a successful call.", + parameters: IssueCommentCreateInput, + success: IssueCommentCreateResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Comment on issue") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +const CloseIssueTool = Tool.make("close_issue", { + description: + "Close a GitHub issue, optionally with reason completed, not-planned or duplicate. Use reopen_issue to reopen it.", + parameters: IssueCloseInput, + success: IssueCloseResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Close issue") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const ReopenIssueTool = Tool.make("reopen_issue", { + description: "Reopen a closed GitHub issue.", + parameters: IssueReopenInput, + success: IssueReopenResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Reopen issue") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const UpdateIssueTool = Tool.make("update_issue", { + description: + "Update a GitHub issue's title, body, labels, assignees or milestone. Omitted fields are preserved. Prefer addLabels, removeLabels, addAssignees and removeAssignees, which keep changes others made; labels and assignees replace the current lists.", + parameters: IssueUpdateInput, + success: IssueUpdateResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Update issue") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const LinkIssueTool = Tool.make("link_issue", { + description: + "Link a GitHub issue to the current thread so T3 Code tracks the work. The thread comes from the calling credential; linking again is safe.", + parameters: IssueRef, + success: IssueLinkResult, + failure: IssueToolError, + dependencies, +}) + .annotate(Tool.Title, "Link issue to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const IssuesToolkit = Toolkit.make( + ListIssuesTool, + ReadIssueTool, + CommentOnIssueTool, + CloseIssueTool, + ReopenIssueTool, + UpdateIssueTool, + LinkIssueTool, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index c9d6955a634db..c7cfcaca77454 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -13,6 +13,7 @@ import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; +import * as IssueService from "../../../issue/IssueService.ts"; import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; @@ -36,6 +37,7 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(ProjectService.ProjectService)({}), ServerSettings.layerTest({}), Layer.mock(GitWorkflowService.GitWorkflowService)({}), + Layer.mock(IssueService.IssueService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}), ); @@ -51,6 +53,7 @@ const ToolsListPayload = Schema.fromJsonString( Schema.Struct({ readOnlyHint: Schema.optional(Schema.Boolean), destructiveHint: Schema.optional(Schema.Boolean), + idempotentHint: Schema.optional(Schema.Boolean), openWorldHint: Schema.optional(Schema.Boolean), }), ), @@ -123,6 +126,45 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + expect(toolNames).toEqual( + expect.arrayContaining([ + "list_issues", + "read_issue", + "comment_on_issue", + "close_issue", + "reopen_issue", + "update_issue", + "link_issue", + ]), + ); + const issueRead = tools.find((tool) => tool.name === "read_issue"); + expect(issueRead?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }); + const issueComment = tools.find((tool) => tool.name === "comment_on_issue"); + expect(issueComment?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }); + for (const name of ["close_issue", "reopen_issue", "update_issue"]) { + expect(tools.find((tool) => tool.name === name)?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }); + } + const issueLink = tools.find((tool) => tool.name === "link_issue"); + expect(issueLink?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/persistence/ForkSchema/IssueLinks.ts b/apps/server/src/persistence/ForkSchema/IssueLinks.ts index 705fc5d1fa60e..5b517bcf23497 100644 --- a/apps/server/src/persistence/ForkSchema/IssueLinks.ts +++ b/apps/server/src/persistence/ForkSchema/IssueLinks.ts @@ -17,6 +17,11 @@ export default Effect.gen(function* () { detached_at TEXT ) `; + // Last issue state the server observed, so worktree chips can show closed issues. + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_issue_links)`; + if (!columns.some((column) => column.name === "state")) { + yield* sql`ALTER TABLE projection_issue_links ADD COLUMN state TEXT`; + } yield* sql` CREATE INDEX IF NOT EXISTS idx_projection_issue_links_issue ON projection_issue_links(host, repository, number) diff --git a/apps/server/src/project/ProjectWorktreeLinks.ts b/apps/server/src/project/ProjectWorktreeLinks.ts index b07d4e6317aa9..c89422563b71a 100644 --- a/apps/server/src/project/ProjectWorktreeLinks.ts +++ b/apps/server/src/project/ProjectWorktreeLinks.ts @@ -97,6 +97,7 @@ const IssueLinkRow = Schema.Struct({ linkedAt: IsoDateTime, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + state: Schema.NullOr(Schema.String), }); function toIssueLinkedWork(row: typeof IssueLinkRow.Type): IssueLinkedWork { @@ -108,6 +109,7 @@ function toIssueLinkedWork(row: typeof IssueLinkRow.Type): IssueLinkedWork { worktreePath: row.worktreePath === "" ? null : row.worktreePath, linkedAt: row.linkedAt, source: row.source === "created" || row.source === "agent" ? row.source : "manual", + ...(row.state === "open" || row.state === "closed" ? { state: row.state } : {}), }; } @@ -136,7 +138,8 @@ export const make = Effect.gen(function* () { source, linked_at AS "linkedAt", branch, - worktree_path AS "worktreePath" + worktree_path AS "worktreePath", + state FROM projection_issue_links WHERE detached_at IS NULL ${projectIds === undefined ? sql`` : sql`AND ${sql.in("project_id", projectIds)}`} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d7f29b4ae6fd6..0e6e5b4f6f69c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3146,6 +3146,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.issuesComments, issues.comments(input), { "rpc.aggregate": "issues", }), + [WS_METHODS.issuesTimeline]: (input) => + observeRpcEffect(WS_METHODS.issuesTimeline, issues.timeline(input), { + "rpc.aggregate": "issues", + }), [WS_METHODS.issuesCandidates]: (input) => observeRpcEffect(WS_METHODS.issuesCandidates, issues.candidates(input), { "rpc.aggregate": "issues", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a12fb7a0790c..0991f66365ee1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10534,6 +10534,7 @@ export default function ChatView(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "issue" ? ( { const url = issueLinkExternalUrl(issue.issue); - const label = `GitHub issue #${issue.issue.number} · ${issue.issue.repository}`; + const closed = issue.state === "closed"; + const label = `GitHub issue #${issue.issue.number}${closed ? " (closed)" : ""} · ${issue.issue.repository}`; + const Icon = closed ? CircleCheckIcon : CircleDotIcon; return ( event.stopPropagation()} onClick={(event) => handleIssueOpen(event, issue)} - className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap text-xs tabular-nums text-emerald-600 underline-offset-2 hover:text-emerald-700 hover:underline focus-visible:outline-2 focus-visible:outline-ring dark:text-emerald-400 dark:hover:text-emerald-300" + className={cn( + "inline-flex h-7 shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap text-xs tabular-nums underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring", + ISSUE_CHIP_TONE[closed ? "closed" : "open"], + )} /> } > - #{issue.issue.number} + #{issue.issue.number} {label} diff --git a/apps/web/src/components/chat/ChatCanvas.test.tsx b/apps/web/src/components/chat/ChatCanvas.test.tsx new file mode 100644 index 0000000000000..e7f20ce0efe5e --- /dev/null +++ b/apps/web/src/components/chat/ChatCanvas.test.tsx @@ -0,0 +1,397 @@ +// @vitest-environment jsdom +import { + EnvironmentId, + NodeId, + ProviderDriverKind, + RunId, + ThreadId, + type OrchestrationV2ThreadProjection, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { act, StrictMode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useRightPanelStore } from "../../rightPanelStore"; +import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { PopoverCreateHandle } from "../ui/popover"; +import { ChatCanvas } from "./ChatCanvas"; +import { useChatCanvas } from "./ChatCanvasContext"; +import type { ChatCanvasPreview } from "./chatCanvasLayout"; +import { ThreadDetailsCard } from "./ThreadDetailsCard"; +import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; + +const snapshots = vi.hoisted(() => ({ + projection: null as OrchestrationV2ThreadProjection | null, + pullRequests: [] as ThreadPullRequestLink[], + command: vi.fn(), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => snapshots.command })); +vi.mock("../../state/entities", () => ({ + useThreadProjection: () => ({ projection: snapshots.projection }), + useThreadShells: () => [], + useProjects: () => [], + useServerConfigs: () => new Map(), +})); +vi.mock("../../lib/archivedThreadsState", () => ({ + useArchivedThreadSnapshots: () => ({ snapshots: [] }), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => snapshots.command })); +vi.mock("../../hooks/useT3ProjectFileScripts", () => ({ useT3ProjectFileScripts: () => [] })); +vi.mock("../ProjectScriptsControl", () => ({ default: () => null })); +vi.mock("../GitActionsControl", () => ({ default: () => null })); +vi.mock("./ThreadAutomationsPanel", () => ({ ThreadAutomationsPanel: () => null })); +vi.mock("./ThreadDetailsPrRow", () => ({ + ThreadDetailsPrRow: ({ number, label }: { number: number; label: string }) => ( +
{label}
+ ), +})); +vi.mock("../BranchToolbar", async () => { + const { ThreadDetailsPrRows } = await import("./ThreadDetailsPrRows"); + return { + BranchToolbar: ({ panelSection }: { panelSection: string }) => { + const link = snapshots.pullRequests[0]; + return panelSection === "branch" && link ? ( + + ) : null; + }, + }; +}); + +const threadRef = { + environmentId: EnvironmentId.make("canvas-test"), + threadId: ThreadId.make("parent"), +}; +const preview: ChatCanvasPreview = { + key: "browser:test", + width: 267, + position: null, + source: { width: 1600, height: 1000 }, +}; +const canvasChanges = vi.fn<(canvas: ReturnType) => void>(); +const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "getAnimations"); +let dimensions: { width: number; height: number }; +let contentHeights: { full: number; compact: number; essential: number }; +let root: Root; +let container: HTMLDivElement; +let composer: HTMLDivElement; +let anchor: HTMLButtonElement; +let handle: ReturnType; +let errors: unknown[]; +const observers = new Map< + ResizeObserver, + { callback: ResizeObserverCallback; targets: Set } +>(); + +function LayoutProbe() { + const canvas = useChatCanvas(); + useLayoutEffect(() => { + canvasChanges(canvas); + }, [canvas]); + return null; +} + +function FloatingPreview({ input }: { input: ChatCanvasPreview }) { + const canvas = useChatCanvas(); + const report = canvas?.reportPreview; + const clear = canvas?.clearPreview; + useLayoutEffect(() => report?.(input), [report, input]); + useLayoutEffect(() => () => clear?.(input.key), [clear, input.key]); + return null; +} + +beforeEach(() => { + dimensions = { width: 1266, height: 499 }; + contentHeights = { full: 300, compact: 180, essential: 80 }; + errors = []; + snapshots.projection = makeThreadProjectionFixture(); + snapshots.pullRequests = []; + canvasChanges.mockClear(); + observers.clear(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + vi.stubGlobal( + "ResizeObserver", + class implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + observers.set(this, { callback, targets: new Set() }); + } + observe(target: Element) { + observers.get(this)?.targets.add(target); + } + unobserve(target: Element) { + observers.get(this)?.targets.delete(target); + } + disconnect() { + observers.delete(this); + } + }, + ); + const computedStyle = getComputedStyle; + const probeStyles = document.createElement("div").style; + probeStyles.width = "736px"; + probeStyles.minWidth = "640px"; + probeStyles.paddingLeft = "20px"; + vi.spyOn(window, "getComputedStyle").mockImplementation((element, pseudo) => { + if ( + element.parentElement?.hasAttribute("data-chat-canvas") && + element.hasAttribute("aria-hidden") + ) { + return probeStyles; + } + return computedStyle(element, pseudo); + }); + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.hasAttribute("data-chat-canvas") ? dimensions.width : 0; + }); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.hasAttribute("data-chat-canvas") ? dimensions.height : 0; + }); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { + if ( + this.firstElementChild?.matches( + "section[aria-labelledby=thread-details-workspace-heading],section[aria-label=Workspace]", + ) + ) { + return ( + 160 + + this.querySelectorAll("li").length * 36 + + this.querySelectorAll("[data-pr-row]").length * 36 + ); + } + if (!this.firstElementChild?.hasAttribute("data-card-body")) return 0; + const density = this.closest("[data-density]")?.getAttribute("data-density"); + return density === "compact" + ? contentHeights.compact + : density === "essential" + ? contentHeights.essential + : contentHeights.full; + }); + useRightPanelStore.setState({ threadPanelVisibilityByThreadKey: {}, byThreadKey: {} }); + container = document.createElement("div"); + composer = document.createElement("div"); + anchor = document.createElement("button"); + document.body.append(container, composer, anchor); + vi.spyOn(composer, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 736, 275)); + handle = PopoverCreateHandle(); + root = createRoot(container, { onUncaughtError: (error) => errors.push(error) }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + composer.remove(); + anchor.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (getAnimationsDescriptor) + Object.defineProperty(Element.prototype, "getAnimations", getAnimationsDescriptor); + else Reflect.deleteProperty(Element.prototype, "getAnimations"); +}); + +async function render(input: ChatCanvasPreview | null = null, ref: ScopedThreadRef = threadRef) { + await act(async () => + root.render( + + + + {input ? : null} + {}} + > + {(density) =>
{density}
} +
+
+
, + ), + ); +} + +async function resize() { + await act(async () => { + const pendingObservers = [...observers]; + for (const [observer, { callback }] of pendingObservers) callback([], observer); + }); +} + +it("keeps the chat canvas unchanged when details resize without a floating preview", async () => { + await render(); + const changes = canvasChanges.mock.calls.length; + contentHeights.full = 350; + await resize(); + expect(errors).toEqual([]); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + expect(canvasChanges).toHaveBeenCalledTimes(changes); +}); + +it("settles card and preview placement instead of exceeding React's update limit", async () => { + contentHeights.full = 820; + await render(preview); + expect(errors).toEqual([]); + const card = container.querySelector("[data-thread-details-panel=inline]"); + expect(card).not.toBeNull(); + expect(card?.style.width).toBe("267px"); + const changes = canvasChanges.mock.calls.length; + await resize(); + expect(canvasChanges).toHaveBeenCalledTimes(changes); +}); + +it("restores full details after space returns and survives closing and switching threads", async () => { + await render(); + dimensions.height = 250; + await resize(); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("compact"); + dimensions.height = 499; + await resize(); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + await act(async () => + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", false), + ); + expect(container.querySelector("[data-thread-details-panel=inline]")).toBeNull(); + await act(async () => + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", true), + ); + await render(null, { ...threadRef, threadId: ThreadId.make("child") }); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + expect(errors).toEqual([]); +}); + +it("releases preview bounds when closed and restores the card after a docked panel closes", async () => { + await render(preview); + await render(); + expect(canvasChanges.mock.lastCall?.[0]?.layout.frame).toBeNull(); + dimensions.width = 900; + await resize(); + expect(container.querySelector("[data-thread-details-panel=inline]")).toBeNull(); + dimensions.width = 1266; + await resize(); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + await render(preview); + expect( + container.querySelector("[data-thread-details-panel=inline]")?.style.width, + ).toBe("267px"); + expect(errors).toEqual([]); +}); + +it("renders agent and PR updates through the details panel without a floating preview", async () => { + dimensions.height = 900; + const projection = snapshots.projection!; + const props: ThreadDetailsPanelProps = { + environmentId: threadRef.environmentId, + threadId: projection.thread.id, + anchor: { current: anchor }, + handle, + onPresentationChange: snapshots.command, + activeProjectName: "Project", + activeProjectScripts: [], + preferredScriptId: null, + keybindings: [], + availableEditors: [], + showOpenInPicker: false, + gitCwd: "/workspace", + isGitRepo: true, + envLocked: false, + availableEnvironments: [], + onEnvironmentChange: snapshots.command, + onEnvModeChange: snapshots.command, + envMode: "local", + startFromOrigin: false, + onStartFromOriginChange: snapshots.command, + onComposerFocusRequest: snapshots.command, + versionMismatch: null, + onDismissVersionMismatch: snapshots.command, + onRunProjectScript: snapshots.command, + onAddProjectScript: snapshots.command, + onUpdateProjectScript: snapshots.command, + onDeleteProjectScript: snapshots.command, + }; + const update = async () => { + await act(async () => + root.render( + + + + + + , + ), + ); + const changes = canvasChanges.mock.calls.length; + await resize(); + expect(errors).toEqual([]); + expect(canvasChanges).toHaveBeenCalledTimes(changes); + }; + await update(); + for (const status of ["pending", "running", "waiting", "completed", "failed"] as const) { + snapshots.projection = { + ...projection, + subagents: Array.from({ length: 8 }, (_, index) => ({ + id: NodeId.make(`agent-${index}`), + threadId: projection.thread.id, + runId: RunId.make("run"), + parentNodeId: NodeId.make("parent-node"), + origin: "app_owned" as const, + createdBy: "agent" as const, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: ThreadId.make(`child-${index}`), + nativeTaskRef: null, + prompt: "Check the change", + title: `Worker ${index}`, + model: "gpt-5.4", + status, + result: null, + startedAt: null, + completedAt: null, + updatedAt: projection.updatedAt, + })), + }; + snapshots.pullRequests = [1, 2].map((number) => ({ + host: "github.com", + repository: "acme/project", + number, + url: `https://github.com/acme/project/pull/${number}`, + source: "agent", + linkedAt: "2026-10-03T14:43:00Z", + snapshot: null, + stack: null, + })); + await update(); + expect(container.textContent).toContain("#1"); + expect(container.textContent).toContain("Show 1 more"); + expect(container.textContent).toContain( + status === "completed" || status === "failed" ? "Previous agents" : "Worker 0", + ); + } + snapshots.projection = projection; + snapshots.pullRequests = []; + await update(); + expect(container.textContent).not.toContain("Previous agents"); + expect(container.textContent).not.toContain("#1"); +}); diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index bc222d07852e6..a2d7e2e048018 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -24,15 +24,20 @@ export function ChatCanvas({ const [timelineElement, registerTimeline] = useState(null); const [preview, setPreview] = useState(null); const [detailsCard, setDetailsCard] = useState(null); - const reportDetailsCard = useCallback((next: PreviewMiniPlayerObstacles["detailsCard"]) => { - setDetailsCard((current) => - current?.left === next?.left && - current?.right === next?.right && - current?.bottom === next?.bottom - ? current - : next, - ); - }, []); + const hasPreview = preview !== null; + const reportDetailsCard = useCallback( + (next: PreviewMiniPlayerObstacles["detailsCard"]) => { + if (!hasPreview) return; + setDetailsCard((current) => + current?.left === next?.left && + current?.right === next?.right && + current?.bottom === next?.bottom + ? current + : next, + ); + }, + [hasPreview], + ); const [measurements, setMeasurements] = useState({ width: 0, height: 0, @@ -94,9 +99,14 @@ export function ChatCanvas({ }, [composerOverlayElement, timelineElement]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; + const preferredLayout = resolveChatCanvasLayout({ ...measurements, container, preview }); return { container, - layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), + layout: detailsCard + ? resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }) + : preferredLayout, + // Card avoidance must not change the width the card measures and reports. + preferredChat: preferredLayout.chat, previewKey: preview?.key ?? null, reportPreview, clearPreview, diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index b810b02392405..9447c0caf2e90 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -5,6 +5,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay export const ChatCanvasContext = createContext<{ container: { width: number; height: number }; layout: ReturnType; + preferredChat: ReturnType["chat"]; previewKey: string | null; reportPreview: (preview: ChatCanvasPreview) => void; clearPreview: (key: string) => void; diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 1886aa1ec483c..6d1b5e744e90f 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -29,12 +29,16 @@ export function ThreadDetailsCard({ const preferredPlacement = canvas ? resolveThreadDetailsCardLayout({ container: canvas.container, - chat: canvas.layout.chat, + chat: canvas.preferredChat, frame: null, }) : null; const placement = canvas - ? resolveThreadDetailsCardLayout({ container: canvas.container, ...canvas.layout }) + ? resolveThreadDetailsCardLayout({ + container: canvas.container, + ...canvas.layout, + chat: canvas.preferredChat, + }) : null; const mode = placement ? "inline" : "popover"; const inlineOpen = useRightPanelStore((state) => diff --git a/apps/web/src/components/issue/IssueBulkActionBar.tsx b/apps/web/src/components/issue/IssueBulkActionBar.tsx new file mode 100644 index 0000000000000..812089e157996 --- /dev/null +++ b/apps/web/src/components/issue/IssueBulkActionBar.tsx @@ -0,0 +1,237 @@ +import { + CircleCheckIcon, + CircleDotIcon, + CircleSlashIcon, + SearchIcon, + TagIcon, + UserRoundPlusIcon, + XIcon, +} from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { pullRequestLabelColor } from "../pullRequest/pullRequestList.logic"; +import { PullRequestActorAvatar } from "../pullRequest/pullRequestPresentation"; +import { Button } from "../ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { IssueActorFacet, IssueBulkAction, IssueLabelFacet } from "./issue.logic"; + +/** A name picker for the bar: the loaded names, filtered as typed, or the typed name itself. */ +function BulkPickerMenu({ + trigger, + noun, + disabled, + options, + onPick, + onOpenChange, +}: { + trigger: ReactNode; + /** Singular, lowercase: "label", "assignee". */ + noun: string; + disabled: boolean; + options: ReadonlyArray<{ + readonly value: string; + readonly label: ReactNode; + readonly search: string; + }>; + onPick: (value: string) => void; + onOpenChange: (open: boolean) => void; +}) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const visible = options + .filter((option) => needle.length === 0 || option.search.toLowerCase().includes(needle)) + .slice(0, 12); + const exact = options.some((option) => option.value.toLowerCase() === needle); + return ( + { + if (!open) setQuery(""); + onOpenChange(open); + }} + > + }> + {trigger} + + +
+ + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + }} + placeholder={`Find or type a ${noun}`} + aria-label={`Find a ${noun}`} + /> + +
+ {visible.map((option) => ( + onPick(option.value)}> + {option.label} + + ))} + {needle.length > 0 && !exact ? ( + <> + {visible.length > 0 ? : null} + onPick(query.trim())}> + Use “{query.trim().slice(0, 60)}” + + + ) : visible.length === 0 ? ( + No {noun}s loaded + ) : null} +
+
+ ); +} + +/** + * What the reader can do to every checked issue at once. It floats at the bottom of the list + * while anything is checked; the page runs each change in turn and reports per issue. + */ +export function IssueBulkActionBar({ + count, + canClose, + canReopen, + running, + outdated, + labelOptions, + assigneeOptions, + onPickerOpenChange, + onAction, + onClear, +}: { + count: number; + canClose: boolean; + canReopen: boolean; + /** A batch is going; the controls wait for it rather than queue a second. */ + running: boolean; + /** The rows are saved or carried over, not a live answer, so nothing acts on them yet. */ + outdated: boolean; + labelOptions: ReadonlyArray; + assigneeOptions: ReadonlyArray; + /** A picker opened, so the page can fetch the repository's own candidates. */ + onPickerOpenChange: (open: boolean) => void; + onAction: (action: IssueBulkAction) => void; + onClear: () => void; +}) { + const blocked = running || outdated; + const actions = ( + <> + + }> + + Close + + + onAction({ kind: "close", reason: "completed" })}> + + Close as completed + + onAction({ kind: "close", reason: "not-planned" })}> + + Close as not planned + + + + + + + Add label + + } + options={labelOptions.map((option) => { + const dot = pullRequestLabelColor(option.color); + return { + value: option.name, + search: option.name, + label: ( + + + {option.name} + + ), + }; + })} + onPick={(label) => onAction({ kind: "add-label", label })} + onOpenChange={onPickerOpenChange} + /> + + + Assign + + } + options={assigneeOptions.map((option) => ({ + value: option.actor.login, + search: `${option.actor.login} ${option.actor.name ?? ""}`, + label: ( + + + {option.actor.login} + + ), + }))} + onPick={(login) => onAction({ kind: "assign", login })} + onOpenChange={onPickerOpenChange} + /> + + ); + return ( +
+
+ + {running ? : null} + {count} selected + + {outdated ? ( + + }> + {actions} + + + These issues may have changed on GitHub. Refresh the list to act on them. + + + ) : ( + actions + )} + +
+
+ ); +} diff --git a/apps/web/src/components/issue/IssueComposer.tsx b/apps/web/src/components/issue/IssueComposer.tsx new file mode 100644 index 0000000000000..fd3c6ebad225b --- /dev/null +++ b/apps/web/src/components/issue/IssueComposer.tsx @@ -0,0 +1,225 @@ +/** + * The floating control over an issue, in the place and shape of the pull request composer: a + * comment written with a preview, optionally the one that closes or reopens the issue. Closing + * keeps GitHub's three reasons behind the split button's chevron. + */ +import type { + EnvironmentId, + IssueCloseReason, + IssueDetail, + IssueRef, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { ChevronDownIcon, MessageSquareIcon, SendIcon, XIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { issueEnvironment } from "~/state/issues"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; +import { toastManager } from "../ui/toast"; +import { MarkdownWritePreview } from "../pullRequest/PullRequestMarkdownEditor"; +import { ISSUE_STATE_PRESENTATION } from "./issuePresentation"; + +export const ISSUE_CLOSE_REASON_LABELS: Record = { + completed: "Close as completed", + "not-planned": "Close as not planned", + duplicate: "Close as duplicate", +}; + +export function IssueComposer({ + environmentId, + threadRef, + reference, + issue, + cwd, + statePending, + onSetState, + onCommented, +}: { + environmentId: EnvironmentId; + threadRef: ScopedThreadRef | null; + reference: IssueRef; + issue: IssueDetail; + cwd: string; + /** A close or reopen is already on its way, from here or from the panel's menu. */ + statePending: boolean; + /** Closes with a reason, or reopens; resolves whether the host did it. */ + onSetState: (change: IssueCloseReason | "reopen") => Promise; + onCommented: () => void; +}) { + const [open, setOpen] = useState(false); + const [body, setBody] = useState(""); + const [submitting, setSubmitting] = useState(false); + const textareaRef = useRef(null); + const commentCreate = useAtomCommand(issueEnvironment.commentCreate, { reportFailure: false }); + const permissions = issue.viewerPermissions; + const canComment = permissions?.comment !== false; + const stateAction = + issue.state === "open" + ? permissions?.close !== false + ? ("close" as const) + : null + : permissions?.reopen !== false + ? ("reopen" as const) + : null; + if (!canComment && stateAction === null) return null; + + const trimmed = body.trim(); + const busy = submitting || statePending; + + const postComment = async (): Promise => { + const result = await commentCreate({ environmentId, input: { ...reference, body: trimmed } }); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not post the comment" }); + return false; + } + return true; + }; + + const submit = async (change: IssueCloseReason | "reopen" | null) => { + if (busy || (change === null && trimmed.length === 0)) return; + setSubmitting(true); + // The comment goes first, as it does on GitHub, so it reads above the state change it explains. + const commented = trimmed.length > 0 ? await postComment() : true; + if (!commented) { + setSubmitting(false); + return; + } + if (trimmed.length > 0) { + setBody(""); + onCommented(); + } + const changed = change === null ? true : await onSetState(change); + setSubmitting(false); + if (changed) setOpen(false); + }; + + const closePresentation = ISSUE_STATE_PRESENTATION.completed; + const reopenPresentation = ISSUE_STATE_PRESENTATION.open; + + return ( + + } + aria-label={canComment ? "Comment on issue" : "Close or reopen issue"} + > + + + +
+ Comment on issue + } + aria-label="Close composer" + > + + +
+
{ + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey) && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat && canComment) void submit(null); + } + }} + > + {canComment ? ( + + ) : null} +
+ {stateAction === "close" ? ( + + + + + + } + > + + + + {(["completed", "not-planned", "duplicate"] as const).map((reason) => { + const presentation = ISSUE_STATE_PRESENTATION[reason]; + return ( + void submit(reason)}> + + {ISSUE_CLOSE_REASON_LABELS[reason]} + + ); + })} + + + + ) : stateAction === "reopen" ? ( + + ) : null} + {canComment ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/issue/IssueCreateDialog.tsx b/apps/web/src/components/issue/IssueCreateDialog.tsx new file mode 100644 index 0000000000000..e506c581a6fb0 --- /dev/null +++ b/apps/web/src/components/issue/IssueCreateDialog.tsx @@ -0,0 +1,429 @@ +import type { + EnvironmentId, + IssueRef, + IssueRepositorySelection, + ProjectId, +} from "@t3tools/contracts"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { CircleDotIcon, ExternalLinkIcon, GitBranchIcon, TagIcon, UsersIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { readLocalApi } from "../../localApi"; +import { useProject } from "../../state/entities"; +import { refreshEnvironmentShell } from "../../state/shell"; +import { + issueEnvironment, + readIssueDraft, + useIssueCandidates, + useIssueTemplates, + writeIssueDraft, +} from "../../state/issues"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Spinner } from "../ui/spinner"; +import { MetaRow } from "../workItem/WorkItemSummaryParts"; +import { MarkdownWritePreview } from "../pullRequest/PullRequestMarkdownEditor"; +import { + PullRequestActorLabel, + PullRequestLabelChip, +} from "../pullRequest/pullRequestPresentation"; +import { issueRepositoryUrl } from "./issue.logic"; +import { issueCandidatesInput, toggleIssueName } from "./issueDetail.logic"; +import { IssueAssigneePicker, IssueLabelPicker } from "./IssuePickers"; +import { IssueWorktreeDialog } from "./IssueWorktreeDialog"; + +const BLANK_ISSUE_TEMPLATE = "__blank__"; + +interface IssueCreateDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly selection: IssueRepositorySelection | null; + readonly environmentId: EnvironmentId | null; + readonly onCreated: (issue: { + readonly projectId: ProjectId; + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; + }) => void; +} + +/** The issue just created with "Create & start work", waiting for its worktree to be chosen. */ +interface StartWorkTarget { + readonly environmentId: EnvironmentId; + readonly reference: IssueRef; + readonly title: string; + readonly canLink: boolean; +} + +export function IssueCreateDialog({ + open, + onOpenChange, + selection, + environmentId, + onCreated, +}: IssueCreateDialogProps) { + const active = open && selection !== null && environmentId !== null; + const templatesQuery = useIssueTemplates(active ? { environmentId, input: selection } : null); + const project = useProject( + selection && environmentId ? scopeProjectRef(environmentId, selection.projectId) : null, + ); + const [templateId, setTemplateId] = useState(""); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [labels, setLabels] = useState>([]); + const [assignees, setAssignees] = useState>([]); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState<"create" | "start" | null>(null); + const [startWork, setStartWork] = useState(null); + // The same reads the pickers make, so chips carry their label colors and faces. + const labelCandidatesQuery = useIssueCandidates( + active ? { environmentId, input: issueCandidatesInput(selection, "labels") } : null, + ); + const assigneeCandidatesQuery = useIssueCandidates( + active ? { environmentId, input: issueCandidatesInput(selection, "assignees") } : null, + ); + const create = useAtomCommand(issueEnvironment.create, { reportFailure: false }); + useEffect(() => { + if (!open || !selection) return; + const draft = readIssueDraft( + typeof window === "undefined" ? undefined : window.localStorage, + selection, + ); + setTemplateId(draft?.templateId ?? ""); + setTitle(draft?.title ?? ""); + setBody(draft?.body ?? ""); + setLabels(draft?.labels ?? []); + setAssignees(draft?.assignees ?? []); + setError(null); + setSubmitting(null); + }, [open, selection]); + useEffect(() => { + if (!open || !selection) return; + const timer = window.setTimeout( + () => + writeIssueDraft( + typeof window === "undefined" ? undefined : window.localStorage, + selection, + { ...(templateId ? { templateId } : {}), title, body, labels, assignees }, + ), + 250, + ); + return () => window.clearTimeout(timer); + }, [assignees, body, labels, open, selection, templateId, title]); + const selectedTemplate = + templatesQuery.data?.templates.find((template) => template.id === templateId) ?? null; + const isForm = selectedTemplate?.kind === "issue-form"; + const isSubmitting = submitting !== null; + const applyTemplate = (id: string) => { + setTemplateId(id); + const template = templatesQuery.data?.templates.find((entry) => entry.id === id); + setTitle(template?.title ?? ""); + setBody(template?.body ?? ""); + setLabels(template?.labels ?? []); + setAssignees(template?.assignees ?? []); + }; + const canSubmit = + !isSubmitting && + !isForm && + selection !== null && + environmentId !== null && + title.trim().length > 0; + const submit = async (mode: "create" | "start") => { + if (!canSubmit || !selection || environmentId === null) return; + setSubmitting(mode); + setError(null); + try { + const confirmed = + (await readLocalApi()?.dialogs.confirm("Create this issue on GitHub?")) ?? true; + if (!confirmed) return; + const result = await create({ + environmentId, + input: { + ...selection, + title: title.trim(), + body, + ...(templateId ? { templateId } : {}), + ...(labels.length > 0 ? { labels } : {}), + ...(assignees.length > 0 ? { assignees } : {}), + }, + }); + if (result._tag === "Success") { + const issue = result.value.issue; + if (mode === "start") { + setStartWork({ + environmentId, + reference: { + projectId: issue.projectId, + host: issue.host, + repository: issue.repository, + number: issue.number, + }, + title: issue.title, + canLink: issue.viewerPermissions?.link !== false, + }); + } + onCreated({ + projectId: issue.projectId, + host: issue.host, + repository: issue.repository, + number: issue.number, + url: issue.url, + }); + return; + } + setError("Could not create the issue. Your draft is still saved; try again."); + } catch { + setError("Could not create the issue. Your draft is still saved; try again."); + } finally { + setSubmitting(null); + } + }; + const openExternalForm = () => { + if (!selection || isSubmitting) return; + void readLocalApi()?.shell.openExternal(`${issueRepositoryUrl(selection)}/issues/new`); + }; + const labelColors = new Map( + labelCandidatesQuery.data?._tag === "labels" + ? labelCandidatesQuery.data.candidates.map( + (candidate) => [candidate.name.toLowerCase(), candidate.color] as const, + ) + : [], + ); + const assigneeActors = new Map( + assigneeCandidatesQuery.data?._tag === "assignees" + ? assigneeCandidatesQuery.data.candidates.map( + (candidate) => [candidate.login.toLowerCase(), candidate] as const, + ) + : [], + ); + const fieldsDisabled = isSubmitting || isForm; + + return ( + <> + + + + + + + New issue + + + + Create an issue in {selection?.repository ?? "the selected repository"}. + + + + {templatesQuery.isPending ? ( +
+ Checking available templates… +
+ ) : null} + {templatesQuery.error ? ( +
+ + Templates could not be loaded. You can still create a blank issue. + + +
+ ) : null} +
+ + +
+ {isForm ? ( +
+

This template is a structured GitHub form.

+

+ T3 Code keeps the form fields on GitHub so validation and dropdowns work + correctly. +

+
+ +
+
+ ) : null} + + {environmentId ? ( +
+ Description + +
+ ) : null} + {selection && environmentId ? ( +
+ } label="Assignees"> + + {assignees.length === 0 ? ( + None + ) : ( + assignees.map((login) => ( + + )) + )} + + setAssignees((current) => toggleIssueName(current, login, applied)) + } + /> + + + } label="Labels"> + + {labels.length === 0 ? ( + None + ) : ( + labels.map((name) => ( + + )) + )} + + setLabels((current) => toggleIssueName(current, name, applied)) + } + /> + + +
+ ) : null} + {error ?

{error}

: null} +
+ + + + + +
+
+ {/* Outside the create dialog, which the caller closes once the issue exists. */} + {startWork ? ( + { + if (!next) setStartWork(null); + }} + environmentId={startWork.environmentId} + reference={startWork.reference} + issueTitle={startWork.title} + linkedWork={null} + canLink={startWork.canLink} + onActed={() => refreshEnvironmentShell(startWork.environmentId)} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx index 9c4e5df5fe1eb..4dafe3c7f8060 100644 --- a/apps/web/src/components/issue/IssueDetailPanel.tsx +++ b/apps/web/src/components/issue/IssueDetailPanel.tsx @@ -1,234 +1,104 @@ -import { WorktreeIssueDecisions } from "./WorktreeIssueDecisions"; -import type { IssueWorktreeDeleteDecision } from "@t3tools/contracts"; import type { EnvironmentId, - IssueComment, - IssueLinkedWork, + IssueCloseReason, IssueRef, - IssueReactionContent, - IssueWorktreeDeletePreflightResult, - IssueWorktreeDeleteResult, - IssueWorktreePrepareResult, + IssueRelatedIssue, + ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { useAtomValue } from "@effect/atom-react"; -import { resolveDefaultProviderModelSelection } from "~/providerInstances"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; -import * as Cause from "effect/Cause"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useNavigate } from "@tanstack/react-router"; import { - CheckIcon, - ChevronDownIcon, - CircleAlertIcon, - CircleDotIcon, + ArrowLeftIcon, + ArrowUpRightIcon, + CopyIcon, + EllipsisIcon, ExternalLinkIcon, GitBranchIcon, LinkIcon, - MessageCircleIcon, + MessageSquareIcon, MoreHorizontalIcon, PencilIcon, - RefreshCwIcon, - RotateCcwIcon, - SearchIcon, - SendIcon, - SquarePenIcon, - Trash2Icon, - XIcon, + TagIcon, + UsersIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import ChatMarkdown from "../ChatMarkdown"; import { - issueEnvironment, - useIssueCandidates, - useIssueComments, - useIssueDetail, -} from "~/state/issues"; + useCallback, + useEffect, + useMemo, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useProject } from "~/state/entities"; +import { issueEnvironment, useIssueComments, useIssueDetail } from "~/state/issues"; import { refreshEnvironmentShell } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; -import { useProject, useThreadShells } from "~/state/entities"; -import { usePaginatedBranches } from "~/state/queries"; -import { resolveExistingWorktreeOptions } from "../BranchToolbar.logic"; +import { buildThreadRouteParams } from "~/threadRoutes"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + import { Button } from "../ui/button"; -import { IssueLabelPill } from "./IssueLabelPill"; -import { Badge } from "../ui/badge"; -import { Checkbox } from "../ui/checkbox"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; import { Input } from "../ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; -import { Spinner } from "../ui/spinner"; -import { Textarea } from "../ui/textarea"; -import { cn, newThreadId } from "~/lib/utils"; -import { threadEnvironment } from "~/state/threads"; -import { useEnvironmentSettings } from "~/hooks/useSettings"; -import { serverEnvironment } from "~/state/server"; -import { readLocalApi } from "~/localApi"; -import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; -import { issueWorktreeIsLinked, issueWorktreePrimaryAction } from "./issue.logic"; - -const REACTION_CONTENT: readonly IssueReactionContent[] = [ - "thumbs-up", - "heart", - "hooray", - "rocket", - "eyes", +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { toastManager } from "../ui/toast"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { useCondensingChrome } from "../workItem/useCondensingChrome"; +import { PullRequestActivityUnavailableState } from "../pullRequest/PullRequestActivityUnavailableState"; +import { PullRequestEditButton } from "../pullRequest/PullRequestEditButton"; +import { GhostBar } from "../pullRequest/PullRequestGhosts"; +import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; +import { showPullRequestLinkContextMenu } from "../pullRequest/pullRequestLinkContextMenu"; +import { PullRequestMarkdownContext } from "../pullRequest/PullRequestMarkdown"; +import { PullRequestActorLabel, PullRequestMetaLine } from "../pullRequest/pullRequestPresentation"; +import { formatActionError } from "./issueActions"; +import { issueRepositoryUrl } from "./issue.logic"; +import { issueSubIssueProgress } from "./issueDetail.logic"; +import { ISSUE_CLOSE_REASON_LABELS, IssueComposer } from "./IssueComposer"; +import { ISSUE_STATE_PRESENTATION } from "./issuePresentation"; +import { resolveIssueState } from "./IssueStateGlyph"; +import { IssueSummaryTab, issueActorProfileUrl } from "./IssueSummaryTab"; +import { IssueTimelineTab } from "./IssueTimelineTab"; +import { IssueWorktreeDialog } from "./IssueWorktreeDialog"; + +export { IssueWorktreeDialog, type IssueWorktreeDialogProps } from "./IssueWorktreeDialog"; + +type DetailTab = "summary" | "timeline"; + +const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ + { value: "summary", label: "Summary" }, + { value: "timeline", label: "Timeline" }, ]; -function displayReaction(content: IssueReactionContent): string { - return content === "thumbs-up" - ? "Like" - : content.charAt(0).toUpperCase() + content.slice(1).replace("-", " "); -} - -type CloseReason = "completed" | "not-planned" | "duplicate"; +const OPEN_ON_GITHUB = "Open on GitHub"; -const CLOSE_REASON_LABELS: Record = { - completed: "Completed", - "not-planned": "Not planned", - duplicate: "Duplicate", +/** The number is a link everywhere it is written, so its right-click copies it. */ +const openNumberContextMenu = (event: ReactMouseEvent, url: string): void => { + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url, + openLabel: OPEN_ON_GITHUB, + position: { x: event.clientX, y: event.clientY }, + }); }; -const CLOSE_REASONS: readonly CloseReason[] = ["completed", "not-planned", "duplicate"]; - -function failureDetail(cause: unknown): string { - let error = cause; - if (cause !== undefined) { - try { - error = Cause.squash(cause as Cause.Cause); - } catch { - error = cause; - } - } - if (typeof error === "string") return error.trim(); - if (error && typeof error === "object") { - if ("detail" in error && typeof error.detail === "string") return error.detail.trim(); - if ("message" in error && typeof error.message === "string") return error.message.trim(); - } - return error instanceof Error ? error.message.trim() : ""; -} - -function formatActionError(fallback: string, detail: unknown): string { - const message = failureDetail(detail) - .replace(/^Issue operation [^:]+ failed:\s*/i, "") - .trim(); - if (message.length === 0) return `${fallback}. Try again.`; - const sentence = /[.!?]$/.test(message) ? message : `${message}.`; - return `${fallback}: ${sentence}${/try again/i.test(message) ? "" : " Try again."}`; -} - -function errorMessage( - result: { readonly _tag: string; readonly cause?: unknown }, - fallback: string, -): string { - if (result._tag !== "Failure") return ""; - return formatActionError(fallback, result.cause); -} - -function isInterruptedAction(result: { readonly _tag: string; readonly cause?: unknown }): boolean { - return ( - result._tag === "Failure" && - result.cause !== undefined && - Cause.hasInterruptsOnly(result.cause as Cause.Cause) - ); -} - -type ScopedActionOperation = () => Promise<{ - readonly _tag: string; - readonly value?: unknown; - readonly cause?: unknown; -}>; - -function useScopedActions() { - const [pendingScopes, setPendingScopes] = useState>(() => new Set()); - const [errors, setErrors] = useState>( - () => new Map(), - ); - const pendingRef = useRef>(new Set()); - - const hasPending = (scope: string): boolean => pendingScopes.has(scope); - const errorFor = (scope: string): string | null => errors.get(scope) ?? null; - const run = useCallback( - async ( - scope: string, - fallback: string, - operation: ScopedActionOperation, - onSuccess?: (value: unknown) => void, - ): Promise => { - if (pendingRef.current.has(scope)) return false; - pendingRef.current.add(scope); - setPendingScopes((current) => { - const next = new Set(current); - next.add(scope); - return next; - }); - setErrors((current) => { - if (!current.has(scope)) return current; - const next = new Map(current); - next.delete(scope); - return next; - }); - try { - const result = await operation(); - if (result._tag === "Success") { - onSuccess?.(result.value); - return true; - } - if (!isInterruptedAction(result)) { - setErrors((current) => new Map(current).set(scope, errorMessage(result, fallback))); - } - return false; - } catch (cause) { - setErrors((current) => new Map(current).set(scope, formatActionError(fallback, cause))); - return false; - } finally { - pendingRef.current.delete(scope); - setPendingScopes((current) => { - const next = new Set(current); - next.delete(scope); - return next; - }); - } - }, - [], - ); - - return { pendingScopes, hasPending, errorFor, run }; -} - -function ActionFeedback({ - pending, - pendingLabel, - error, -}: { - readonly pending: boolean; - readonly pendingLabel: string; - readonly error: string | null; -}) { - if (!pending && !error) return null; - return pending ? ( -

- {pendingLabel} -

- ) : ( -

- {error} -

- ); -} - export interface IssueDetailPanelProps { readonly environmentId: EnvironmentId; readonly reference: IssueRef; readonly onActed?: () => void; readonly onBack?: () => void; + /** The thread this panel sits beside, if any: linked pull requests and issues open beside it. */ + readonly threadRef?: ScopedThreadRef | null; + /** + * Opens a related issue from the same repository in whatever holds this panel. Beside a thread + * they open in its right panel; with neither, they open on GitHub. + */ + readonly onOpenIssue?: (issue: IssueRef & { readonly url: string }) => void; } export function IssueDetailPanel({ @@ -236,588 +106,628 @@ export function IssueDetailPanel({ reference, onActed, onBack, + threadRef = null, + onOpenIssue, }: IssueDetailPanelProps) { + const issueKey = `${environmentId}:${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const detailQuery = useIssueDetail({ environmentId, input: reference }); const commentsQuery = useIssueComments({ environmentId, input: reference }); + const project = useProject(scopeProjectRef(environmentId, reference.projectId)); const update = useAtomCommand(issueEnvironment.update, { reportFailure: false }); const close = useAtomCommand(issueEnvironment.close, { reportFailure: false }); const reopen = useAtomCommand(issueEnvironment.reopen, { reportFailure: false }); - const commentCreate = useAtomCommand(issueEnvironment.commentCreate, { reportFailure: false }); - const reactionUpdate = useAtomCommand(issueEnvironment.reactionUpdate, { reportFailure: false }); - const actions = useScopedActions(); - const updatePending = actions.hasPending("update"); - const [editing, setEditing] = useState(false); - const [title, setTitle] = useState(""); - const [body, setBody] = useState(""); - const [labels, setLabels] = useState(""); - const [assignees, setAssignees] = useState(""); - const [comment, setComment] = useState(""); + const navigate = useNavigate(); + const [tab, setTab] = useState("summary"); + // Like the pull request panel, a visited tab stays mounted behind the active one so its scroll + // position and rendered markdown survive switching back. + const [tabMountState, setTabMountState] = useState(() => ({ + key: issueKey, + tabs: new Set(["summary"]), + })); + const mountedTabs = + tabMountState.key === issueKey ? tabMountState.tabs : new Set([tab]); + useEffect(() => { + setTabMountState((previous) => { + if (previous.key !== issueKey) return { key: issueKey, tabs: new Set([tab]) }; + if (previous.tabs.has(tab)) return previous; + return { key: issueKey, tabs: new Set(previous.tabs).add(tab) }; + }); + }, [issueKey, tab]); + const { condensed, foldRef, condensedRowRef, onScrollCapture } = useCondensingChrome(tab); const [worktreeOpen, setWorktreeOpen] = useState(false); + // Drafts are scoped to the issue they were typed against: this panel can be handed another one. + const [titleScope, setTitleScope] = useState<{ + readonly key: string; + readonly text: string; + } | null>(null); + const titleDraft = titleScope?.key === issueKey ? titleScope.text : null; + const [titleSaving, setTitleSaving] = useState(false); + const [bodyEditingKey, setBodyEditingKey] = useState(null); + const [statePending, setStatePending] = useState(false); + const [timelineToken, setTimelineToken] = useState(0); + const { copyToClipboard } = useCopyToClipboard({ + target: "issue reference", + onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }), + onError: (error, label) => + toastManager.add({ + type: "error", + title: `Failed to copy ${label}`, + description: error.message, + }), + }); const issue = detailQuery.data; - const labelCandidatesQuery = useIssueCandidates( - editing - ? { - environmentId, - input: { ...reference, kind: "labels", limit: 100 }, - } - : null, - ); - const labelColors = new Map( - labelCandidatesQuery.data?._tag === "labels" - ? labelCandidatesQuery.data.candidates.map((candidate) => [candidate.name, candidate.color]) - : (issue?.labels.map((label) => [label.name, label.color]) ?? []), - ); - - useEffect(() => { - if (!issue || editing) return; - setTitle(issue.title); - setBody(issue.body); - setLabels(issue.labels.map((label) => label.name).join(", ")); - setAssignees(issue.assignees.map((assignee) => assignee.login).join(", ")); - }, [editing, issue]); + const repositoryUrl = issueRepositoryUrl({ + ...(reference.host === undefined ? {} : { host: reference.host }), + repository: reference.repository, + }); + const markdownContext = useMemo(() => ({ repositoryUrl, threadRef }), [repositoryUrl, threadRef]); - const refreshIssue = () => { - detailQuery.refresh(); + const { refresh: refreshDetail } = detailQuery; + const refreshIssue = useCallback(() => { + refreshDetail(); refreshEnvironmentShell(environmentId); + setTimelineToken((token) => token + 1); onActed?.(); + }, [environmentId, onActed, refreshDetail]); + + const setIssueState = async (change: IssueCloseReason | "reopen"): Promise => { + if (statePending) return false; + setStatePending(true); + const result = + change === "reopen" + ? await reopen({ environmentId, input: reference }) + : await close({ environmentId, input: { ...reference, reason: change } }); + setStatePending(false); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: change === "reopen" ? "Could not reopen this issue" : "Could not close this issue", + description: formatActionError("The host refused it", result.cause), + }); + return false; + } + refreshIssue(); + return true; }; - const runMutation = ( - scope: string, - fallback: string, - operation: ScopedActionOperation, - onSuccess?: () => void, - ) => - actions.run(scope, fallback, operation, () => { - refreshIssue(); - onSuccess?.(); - }); - - const save = async () => { - const saved = await runMutation("update", "Unable to save issue", () => - update({ - environmentId, - input: { - ...reference, - title: title.trim(), - body, - labels: labels - .split(",") - .map((value) => value.trim()) - .filter(Boolean), - assignees: assignees - .split(",") - .map((value) => value.trim()) - .filter(Boolean), - }, - }), - ); - if (saved) setEditing(false); + const saveTitle = async (next: string) => { + const title = next.trim(); + if (issue === null || titleSaving) return; + if (title.length === 0 || title === issue.title) { + setTitleScope(null); + return; + } + setTitleSaving(true); + const result = await update({ environmentId, input: { ...reference, title } }); + setTitleSaving(false); + if (result._tag === "Failure") { + // The draft stays open with the words still in it. + toastManager.add({ + type: "error", + title: "The title could not be saved", + description: formatActionError("The host refused the new title", result.cause), + }); + return; + } + setTitleScope(null); + refreshIssue(); }; - const createComment = () => { - void actions.run( - "comment-create", - "Unable to add comment", - () => - commentCreate({ - environmentId, - input: { ...reference, body: comment }, - }), - () => { - setComment(""); - commentsQuery.refresh(); - refreshIssue(); - }, - ); + const openRelatedIssue = (related: IssueRelatedIssue) => { + const sameRepository = related.repository.toLowerCase() === reference.repository.toLowerCase(); + const target = { + projectId: reference.projectId, + ...(reference.host === undefined ? {} : { host: reference.host }), + repository: related.repository, + number: related.number, + url: related.url, + }; + if (sameRepository && threadRef !== null) { + useRightPanelStore.getState().openIssue(threadRef, { environmentId, ...target }); + return; + } + if (sameRepository && onOpenIssue) { + onOpenIssue(target); + return; + } + void readLocalApi()?.shell.openExternal(related.url); }; - const openExternal = () => { - if (!issue) return; - void (readLocalApi()?.shell.openExternal(issue.url) ?? Promise.resolve()); + const openLinkedWork = () => { + if (!issue?.linkedWork) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(environmentId, issue.linkedWork.threadId)), + }); }; - if (detailQuery.isPending && issue === null) { - return ( -
- -
- ); - } + const backButton = (focusable: boolean) => + onBack ? ( + + + + + } + /> + Back to issues + + ) : null; + if (issue === null) { - return ( -
- -

- {detailQuery.error - ? formatActionError("Unable to load issue", detailQuery.error) - : "Unable to load issue. Try again."} -

- + return detailQuery.error ? ( +
+ {onBack ? ( +
{backButton(true)}
+ ) : null} +
+ ) : ( + ); } - const commentsResult = commentsQuery.data; - const comments = commentsResult?.comments ?? []; const permissions = issue.viewerPermissions; - const issueStatePending = actions.hasPending("issue-state"); - const commentCreatePending = actions.hasPending("comment-create"); - const reactionContents = - issue.reactions && issue.reactions.length > 0 - ? issue.reactions.map((reaction) => reaction.content) - : REACTION_CONTENT; - const reactionPending = reactionContents.some((content) => - actions.hasPending(`reaction:${content}`), + const canUpdate = permissions?.update !== false; + const statePresentation = resolveIssueState(issue); + const authorProfileUrl = issueActorProfileUrl(issue, issue.author); + const cwd = issue.workspaceRoot ?? project?.workspaceRoot ?? ""; + const linkedPullRequestCount = issue.linkedPullRequests?.length ?? 0; + const progress = issueSubIssueProgress(issue.subIssuesSummary, issue.subIssues); + const refreshing = detailQuery.isPending; + + const numberButton = (focusable: boolean) => ( + + void readLocalApi()?.shell.openExternal(issue.url)} + onContextMenu={(event) => openNumberContextMenu(event, issue.url)} + className={cn( + "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", + statePresentation.toneClassName, + )} + aria-label={`Open issue #${issue.number} on GitHub`} + > + #{issue.number} + + + } + /> + {OPEN_ON_GITHUB} + ); - const reactionError = - reactionContents - .map((content) => actions.errorFor(`reaction:${content}`)) - .find((message): message is string => message !== null) ?? null; - const commentsInitialLoading = commentsResult === null && commentsQuery.error === null; - const commentsRefreshing = commentsResult !== null && commentsQuery.isPending; - const commentsError = commentsQuery.error - ? formatActionError("Unable to load comments", commentsQuery.error) - : null; - const issueStateError = actions.errorFor("issue-state"); return ( -
-
-
-
- {onBack ? ( - - ) : null} - - {issue.state === "open" ? "Open" : "Closed"} - - #{issue.number} - {issue.repository} - {issue.linkedWork ? ( - - Linked - - ) : null} +
+
+
+
+ {backButton(!condensed)} + + void readLocalApi()?.shell.openExternal(repositoryUrl)} + className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" + > + {issue.repository} + + } + /> + {`Open ${issue.repository} repository`} + + {numberButton(!condensed)}
- {editing ? ( - setTitle(event.target.value)} - aria-label="Issue title" - disabled={updatePending} - /> - ) : ( -

{issue.title}

- )} -

- Updated {new Date(issue.updatedAt).toLocaleString()} -

-
-
- - - - } - > - - - - - - Open issue on GitHub - - {permissions?.update !== false ? ( - setEditing((value) => !value)} disabled={updatePending}> - - {editing ? "Cancel editing" : "Edit issue"} - - ) : null} - {issue.state !== "open" ? ( - - void runMutation("issue-state", "Unable to reopen issue", () => - reopen({ environmentId, input: reference }), - ) + {backButton(condensed)} + {numberButton(condensed)} + + + {issue.title} + + } + /> + {issue.title} + +
+
+
+ + + + + + } + /> + + {issue.linkedWork + ? "Continue in a worktree for this issue" + : "Create a worktree and thread for this issue"} + + + + + + } + > + {refreshing ? ( + + ) : ( + + )} + } + /> + + {refreshing ? "Refreshing issue" : "More issue actions"} + + + + { + refreshIssue(); + commentsQuery.refresh(); + }} > - - Reopen issue + + Refresh - ) : null} - - -
-
- {issue.linkedWork ? ( -

- Linked work:{" "} - {issue.linkedWork.branch ?? issue.linkedWork.worktreePath ?? "Linked work"} -

- ) : null} - + {canUpdate ? ( + { + setTab("summary"); + setTitleScope({ key: issueKey, text: issue.title }); + setBodyEditingKey(issueKey); + }} + > + + Edit issue + + ) : null} + + void readLocalApi()?.shell.openExternal(issue.url)}> + + {OPEN_ON_GITHUB} + + copyToClipboard(issue.url, "Issue link")}> + + Copy link + + copyToClipboard(`#${issue.number}`, "Issue number")}> + + Copy issue number + + {issue.state === "open" && permissions?.close !== false ? ( + <> + + {(["completed", "not-planned", "duplicate"] as const).map((reason) => { + const presentation = ISSUE_STATE_PRESENTATION[reason]; + return ( + void setIssueState(reason)} + > + + {ISSUE_CLOSE_REASON_LABELS[reason]} + + ); + })} + + ) : issue.state === "closed" && permissions?.reopen !== false ? ( + <> + + void setIssueState("reopen")}> + + Reopen issue + + + ) : null} + + +
-
-
-
- {editing ? ( -
-