diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index aadddfc9f001..29931e4d05d4 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -133,6 +133,35 @@ describe("buildReviewSectionItems", () => { ]); expect(getDefaultReviewSectionId(items)).toBe("git:working-tree"); }); + + it.each([ + ["staged", "Staged changes", "Changes staged for commit"], + ["unstaged", "Unstaged changes", "Changes not staged for commit"], + ] as const)("keeps the %s source distinct from branch changes", (kind, title, subtitle) => { + const diff = `diff --git a/${kind}.ts b/${kind}.ts`; + const items = buildReviewSectionItems({ + checkpoints: [], + gitSections: [ + { + id: kind, + kind, + title, + baseRef: kind === "staged" ? "HEAD" : null, + headRef: null, + diff, + diffHash: kind, + truncated: false, + }, + ], + turnDiffById: {}, + loadingTurnIds: {}, + loadingGitSections: false, + }); + expect(items).toMatchObject([ + { id: `git:${kind}`, kind, title, subtitle, diff, isLoading: false }, + ]); + expect(getDefaultReviewSectionId(items)).toBe(`git:${kind}`); + }); }); describe("buildReviewParsedDiff", () => { diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 06fe4c9597d9..9a69bec6717f 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -6,7 +6,7 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; -export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; +export type ReviewSectionKind = "turn" | ReviewDiffPreviewSource["kind"]; const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; const DIRTY_WORKTREE_TITLE = "Dirty worktree"; @@ -125,6 +125,12 @@ function gitSubtitle(section: ReviewDiffPreviewSource): string | null { if (section.kind === "working-tree") { return DIRTY_WORKTREE_SUBTITLE; } + if (section.kind === "staged") { + return "Changes staged for commit"; + } + if (section.kind === "unstaged") { + return "Changes not staged for commit"; + } if (section.baseRef) { return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 71a2c769ef4e..2b40ea1f4487 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,7 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; -import { AssetAccessError, AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; +import { + AssetAccessError, + AssetPreviewTypeValidationError, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -1169,3 +1174,37 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); }); + +it.effect("signs private media with its PR scope and rejects other hosts", () => + Effect.gen(function* () { + const reference = { + projectId: "p1" as ProjectId, + host: "gitlab.example", + repository: "owner/repo", + number: 7, + expectedAccountId: "selected-account", + }; + const resource = { + _tag: "pull-request-media" as const, + reference, + provider: "gitlab" as const, + url: `/uploads/${"a".repeat(32)}/shot.png`, + }; + const issued = yield* issueAssetUrl({ resource }); + const suffix = issued.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ + kind: "pull-request-media", + reference, + provider: "gitlab", + url: `https://gitlab.example/owner/repo${resource.url}`, + expiresAt: issued.expiresAt, + }); + const invalid = yield* issueAssetUrl({ + resource: { ...resource, url: `https://other.example${resource.url}` }, + }).pipe(Effect.flip); + expect(invalid._tag).toBe("AssetPullRequestMediaUrlValidationError"); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index e1f6633fbeca..0b5c4cabb9e7 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -2,6 +2,9 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, AssetGitHubMediaUrlValidationError, + AssetPullRequestMediaUrlValidationError, + PullRequestRef, + SourceControlProviderKind, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -28,6 +31,7 @@ import { readImageDimensions, type ImageDimensions, } from "@t3tools/shared/imageDimensions"; +import { pullRequestMediaUrl } from "@t3tools/shared/pullRequestMedia"; import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; @@ -83,6 +87,14 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ]); const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("pull-request-media"), + reference: PullRequestRef, + provider: SourceControlProviderKind, + url: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("workspace-file"), @@ -153,6 +165,7 @@ const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); export type ResolvedAsset = + | Extract | { readonly kind: "file"; readonly path: string; @@ -676,6 +689,26 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = "native-app-icon.png"; break; } + case "pull-request-media": { + const resource = input.resource; + const url = pullRequestMediaUrl({ + ...resource.reference, + provider: resource.provider, + host: resource.reference.host, + url: resource.url, + }); + if (url === null) return yield* new AssetPullRequestMediaUrlValidationError({}); + claims = { + version: 1, + kind: "pull-request-media", + reference: resource.reference, + provider: resource.provider, + url, + expiresAt, + }; + fileName = githubMediaFileName(url); + break; + } case "github-media": { const fetchUrl = githubMediaFetchUrl(input.resource.url); if (fetchUrl === null) { @@ -791,6 +824,8 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( : null; } + if (claims.kind === "pull-request-media") return claims; + if (claims.kind === "github-media") { return { kind: "github-media", diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts index 495c2fa14c3b..69c53b3dfa7d 100644 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -1,5 +1,6 @@ import * as Mime from "effect/unstable/http/Mime"; import { githubMediaFileName } from "@t3tools/shared/githubMedia"; +import { isPullRequestMediaRedirectAllowed } from "@t3tools/shared/pullRequestMedia"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -131,8 +132,9 @@ const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedire if (response.status < 300 || response.status >= 400) return response; // A chain this long is not GitHub answering with bytes, and its body is not the media. if (!location || hop >= MAX_REDIRECTS) return null; - const next = new URL(location, target); - if (next.protocol !== "https:") return null; + const next = URL.parse(location, target); + if (next === null || !isPullRequestMediaRedirectAllowed("github", new URL(url).origin, next)) + return null; target = next.toString(); } }); @@ -153,6 +155,13 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon if (value !== undefined) forwarded[name] = value; } const response = yield* fetchFollowingRedirects(asset.url, forwarded, token); + return yield* mediaResponse(asset, response); +}); + +export const mediaResponse = Effect.fn("GitHubMediaFetch.mediaResponse")(function* ( + asset: { readonly url: string; readonly expiresAt: number }, + response: HttpClientResponse.HttpClientResponse | null, +) { // An upload GitHub hosts never changes under its URL, so the only thing a cached copy must // not outlive is the signed URL that granted it — which is the same bound the URL itself has. const remainingSeconds = Math.floor((asset.expiresAt - (yield* Clock.currentTimeMillis)) / 1000); diff --git a/apps/server/src/assets/PullRequestMediaFetch.test.ts b/apps/server/src/assets/PullRequestMediaFetch.test.ts new file mode 100644 index 000000000000..4889adb1f0e0 --- /dev/null +++ b/apps/server/src/assets/PullRequestMediaFetch.test.ts @@ -0,0 +1,289 @@ +import { expect, it } from "@effect/vitest"; +import { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { pullRequestMediaResponse } from "./PullRequestMediaFetch.ts"; +import { githubMediaResponse } from "./GitHubMediaFetch.ts"; +import { GitHubCli } from "../sourceControl/GitHubCli.ts"; + +const asset = { + version: 1 as const, + kind: "pull-request-media" as const, + provider: "gitlab" as const, + reference: { + projectId: Schema.decodeSync(ProjectId)("p1"), + repository: "owner/repo", + number: 7, + host: "gitlab.example", + expectedAccountId: "account-1", + }, + url: `https://gitlab.example/owner/repo/uploads/${"a".repeat(32)}/clip.mp4`, + expiresAt: Number.MAX_SAFE_INTEGER, +}; + +it.effect("applies the same redirect boundary to legacy GitHub media", () => { + const requests: string[] = []; + return Effect.gen(function* () { + const response = yield* githubMediaResponse( + { + url: "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/user-attachments/assets/id", + cwd: "/repo", + expiresAt: asset.expiresAt, + }, + {}, + ); + expect(response.status).toBe(502); + expect(requests).toEqual([ + "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/user-attachments/assets/id", + "https://private-user-images.githubusercontent.com/object", + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubCli)({ + execute: () => + Effect.succeed({ + stdout: "test-token", + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + expect(request.headers.authorization).toBe( + requests.length === 1 ? "Bearer test-token" : undefined, + ); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: 302, + headers: { + location: + requests.length === 1 + ? "https://private-user-images.githubusercontent.com/object" + : "https://127.0.0.1/internal", + }, + }), + ), + ); + }), + ), + Effect.scoped, + ); +}); + +it.effect("preserves signed PR scope and ranges while stripping auth on redirected media", () => { + const redirects: string[] = []; + return Effect.gen(function* () { + const response = yield* pullRequestMediaResponse(asset, { + range: "bytes=1-3", + "if-range": "etag", + authorization: "browser-token", + cookie: "browser-cookie", + }); + expect(response.status).toBe(206); + expect(response.headers["content-range"]).toBe("bytes 1-3/5"); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(redirects).toEqual(["https://storage.googleapis.com/bucket/signed-object"]); + }).pipe( + Effect.provide( + Layer.mock(PullRequestService)({ + readAttachment: (input) => { + expect(input.expectedAccountId).toBe("account-1"); + expect(input.repository).toBe("owner/repo"); + expect(input.headers).toEqual({ + range: "bytes=1-3", + "if-range": "etag", + "accept-encoding": "identity", + }); + return Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get(asset.url), + new Response(null, { + status: 302, + headers: { location: "https://storage.googleapis.com/bucket/signed-object" }, + }), + ), + ); + }, + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + redirects.push(request.url); + expect(request.headers.authorization).toBeUndefined(); + expect(request.headers.cookie).toBeUndefined(); + expect(request.headers.range).toBe("bytes=1-3"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(new Uint8Array([0, 128, 255]), { + status: 206, + headers: { "content-type": "video/mp4", "content-range": "bytes 1-3/5" }, + }), + ), + ); + }), + ), + Effect.scoped, + ); +}); + +it.effect("refuses unapproved redirects at every hop before making a request", () => + Effect.gen(function* () { + for (const location of [ + "https://127.0.0.1/private", + "https://[::1]/private", + "https://169.254.169.254/private", + "https://10.0.0.1/private", + "https://internal/private", + "https://storage.googleapis.com.evil.example/private", + "https://objects.githubusercontent.com/private", + "https://storage.googleapis.com:444/private", + "https://user@storage.googleapis.com/private", + "http://storage.googleapis.com/private", + "https://[invalid", + ]) { + let calls = 0; + const response = yield* pullRequestMediaResponse(asset, {}).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + calls++; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { status: 302, headers: { location } }), + ), + ); + }), + ), + ); + expect(response.status).toBe(502); + expect(calls).toBe(1); + } + }).pipe( + Effect.provide( + Layer.mock(PullRequestService)({ + readAttachment: () => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get(asset.url), + new Response(null, { + status: 302, + headers: { location: "https://storage.googleapis.com/bucket/object" }, + }), + ), + ), + }), + ), + Effect.scoped, + ), +); + +it.effect("keeps redirects within the verified private host and provider storage origins", () => + Effect.gen(function* () { + for (const [provider, original, location] of [ + ["forgejo", "https://10.0.0.4:3000/attachments/id", "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/attachments/id?download=1"], + ["gitlab", asset.url, "https://bucket.s3.eu-west-1.amazonaws.com/object"], + ["gitlab", asset.url, "https://storage.googleapis.com/bucket/object"], + [ + "github", + "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/user-attachments/assets/id", + "https://private-user-images.githubusercontent.com/object", + ], + [ + "github", + "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/user-attachments/assets/id", + "https://github-production-user-asset-6210df.s3.amazonaws.com/object", + ], + [ + "github", + "https://media.githubusercontent.com/media/o/r/main/video.mp4", + "https://github-cloud.s3.amazonaws.com/object", + ], + [ + "bitbucket", + "https://api.bitbucket.org/2.0/repositories/o/r/downloads/image.png", + "https://bbuseruploads.s3.amazonaws.com/object", + ], + [ + "azure-devops", + "https://dev.azure.com/org/project/attachment", + "https://account.blob.core.windows.net/object", + ], + ] as const) { + const response = yield* pullRequestMediaResponse({ ...asset, provider }, {}).pipe( + Effect.provide( + Layer.mock(PullRequestService)({ + readAttachment: () => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get(original), + new Response(null, { status: 302, headers: { location } }), + ), + ), + }), + ), + ); + expect(response.status).toBe(200); + } + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("image", { headers: { "content-type": "image/png" } }), + ), + ), + ), + ), + Effect.scoped, + ), +); + +for (const [contentType, expected] of [ + ["image/svg+xml", 200], + ["text/html", 415], +] as const) { + it.effect(`keeps SVG sandbox and refuses active ${contentType} responses`, () => + Effect.gen(function* () { + const response = yield* pullRequestMediaResponse( + { ...asset, url: asset.url.replace("clip.mp4", "document") }, + {}, + ); + expect(response.status).toBe(expected); + if (expected === 200) + expect(response.headers["content-security-policy"]).toContain("sandbox"); + }).pipe( + Effect.provide( + Layer.mock(PullRequestService)({ + readAttachment: () => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get(asset.url), + new Response("", { headers: { "content-type": contentType } }), + ), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("No redirect expected")), + ), + Effect.scoped, + ), + ); +} diff --git a/apps/server/src/assets/PullRequestMediaFetch.ts b/apps/server/src/assets/PullRequestMediaFetch.ts new file mode 100644 index 000000000000..d5df4fbc7564 --- /dev/null +++ b/apps/server/src/assets/PullRequestMediaFetch.ts @@ -0,0 +1,47 @@ +import * as Effect from "effect/Effect"; +import { isPullRequestMediaRedirectAllowed } from "@t3tools/shared/pullRequestMedia"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; +import type { ResolvedAsset } from "./AssetAccess.ts"; +import { mediaResponse } from "./GitHubMediaFetch.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; + +export const pullRequestMediaResponse = Effect.fn("PullRequestMediaFetch.response")(function* ( + asset: Extract, + requestHeaders: Readonly>, +) { + const headers: Record = { "accept-encoding": "identity" }; + for (const name of ["range", "if-range"]) { + const value = requestHeaders[name]; + if (value !== undefined) headers[name] = value; + } + const service = yield* PullRequestService.PullRequestService; + let response: HttpClientResponse.HttpClientResponse | null = yield* service.readAttachment({ + ...asset.reference, + provider: asset.provider, + url: asset.url, + headers, + }); + const originalOrigin = new URL(response.request.url).origin; + const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + for (let hop = 0; response.status >= 300 && response.status < 400; hop++) { + const location = response.headers.location; + if (!location || hop >= 3) { + response = null; + break; + } + const next = URL.parse(location, response.request.url); + if (next === null || !isPullRequestMediaRedirectAllowed(asset.provider, originalOrigin, next)) { + response = null; + break; + } + response = yield* httpClient + .execute(HttpClientRequest.get(next.href).pipe(HttpClientRequest.setHeaders(headers))) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + } + return yield* mediaResponse(asset, response); +}); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..7821f515f9bd 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -63,6 +63,13 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write scope for PR uploads and read scope for signed media", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsUploadAttachment)).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.assetsCreateUrl)).toBe(AuthOrchestrationReadScope); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 20f2893b8335..ad7a0d525beb 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsUploadAttachment]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, @@ -134,6 +135,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, + [WS_METHODS.reviewApplyPatch]: AuthOrchestrationOperateScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6837d849d779..a19ccc83ae8a 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -28,6 +28,7 @@ import { GitCommandError, ProviderDriverKind, ProviderInstanceId, + ProjectId, TextGenerationError, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; @@ -37,11 +38,11 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; import { ForgejoPullRequestSchema, toForgejoChangeRequest, } from "../sourceControl/forgejoPullRequests.ts"; -import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -602,7 +603,11 @@ function runStackedAction( actionId?: string; commitMessage?: string; featureBranch?: boolean; + stagedOnly?: boolean; filePaths?: readonly string[]; + expectedBranch?: string; + pullRequestUrl?: string; + projectId?: ProjectId; }, options?: Parameters[1], ) { @@ -2992,6 +2997,140 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("generates a commit from the index without staging later edits", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "approved\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "later edit\n"); + let patch = ""; + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + patch = input.stagedPatch; + return { subject: "Reviewed change", body: "" }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + stagedOnly: true, + }); + expect(result.commit.status).toBe("created"); + expect(patch).toContain("+approved"); + expect(patch).not.toContain("later edit"); + expect((yield* runGit(repoDir, ["show", "HEAD:README.md"])).stdout).toBe("approved\n"); + expect((yield* runGit(repoDir, ["diff"])).stdout).toContain("+later edit"); + const empty = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + stagedOnly: true, + }); + expect(empty.commit.status).toBe("skipped_no_changes"); + expect((yield* runGit(repoDir, ["diff"])).stdout).toContain("+later edit"); + }), + ); + + it.effect( + "rejects changes staged during commit-title generation without changing the index", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const git = yield* GitVcsDriver.GitVcsDriver; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "approved\n"); + yield* runGit(repoDir, ["add", "README.md"]); + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.gen(function* () { + NodeFS.writeFileSync(NodePath.join(repoDir, "later.txt"), "later\n"); + yield* runGit(repoDir, ["add", "later.txt"]); + return { subject: "Reviewed change", body: "" }; + }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git), Effect.orDie), + }, + }); + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + stagedOnly: true, + }).pipe(Effect.flip); + expect(error.message).toContain("Staged changes changed"); + expect((yield* runGit(repoDir, ["show", ":README.md"])).stdout).toBe("approved\n"); + expect((yield* runGit(repoDir, ["diff", "--cached", "--name-only"])).stdout.trim()).toBe( + "README.md\nlater.txt", + ); + expect((yield* runGit(repoDir, ["rev-list", "--count", "HEAD"])).stdout.trim()).toBe("1"); + }), + ); + + it.effect( + "rejects a staged snapshot when another commit advances HEAD during title generation", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const git = yield* GitVcsDriver.GitVcsDriver; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "approved\n"); + yield* runGit(repoDir, ["add", "README.md"]); + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.gen(function* () { + NodeFS.writeFileSync(NodePath.join(repoDir, "other.txt"), "other commit\n"); + yield* runGit(repoDir, ["add", "other.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Other commit"]); + return { subject: "Stale review", body: "" }; + }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git), Effect.orDie), + }, + }); + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + stagedOnly: true, + }).pipe(Effect.flip); + expect(error.message).toContain("branch changed"); + expect((yield* runGit(repoDir, ["log", "-1", "--format=%s"])).stdout.trim()).toBe( + "Other commit", + ); + expect((yield* runGit(repoDir, ["show", "HEAD:other.txt"])).stdout).toBe("other commit\n"); + expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(""); + }), + ); + + it.effect("rejects a branch switch at the same HEAD during commit-title generation", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const git = yield* GitVcsDriver.GitVcsDriver; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "approved\n"); + yield* runGit(repoDir, ["add", "README.md"]); + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: () => + Effect.gen(function* () { + yield* runGit(repoDir, ["switch", "-c", "other"]); + return { subject: "Stale review", body: "" }; + }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git), Effect.orDie), + }, + }); + const error = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + stagedOnly: true, + }).pipe(Effect.flip); + expect(error.message).toContain("branch changed"); + expect((yield* runGit(repoDir, ["symbolic-ref", "--short", "HEAD"])).stdout.trim()).toBe( + "other", + ); + expect((yield* runGit(repoDir, ["rev-list", "--count", "HEAD"])).stdout.trim()).toBe("1"); + expect((yield* runGit(repoDir, ["show", ":README.md"])).stdout).toBe("approved\n"); + }), + ); + it.effect("commits only selected files when filePaths is provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4573,6 +4712,310 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect.each([ + ["github", "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/pingdotgg/codething-mvp/pull/77"], + ["gitlab", "https://gitlab.com/team/repo/-/merge_requests/77"], + ["bitbucket", "https://bitbucket.org/team/repo/pull-requests/77"], + ["azure-devops", "https://dev.azure.com/team/project/_git/repo/pullrequest/77"], + ["forgejo", "https://forgejo.example/forgejo/team/repo/pulls/77"], + ["forgejo", "https://gitea.example/team/repo/pulls/77"], + ] as const)( + "publishes %s review edits from an isolated worktree with the writing agent", + ([provider, url]) => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-pr-review-"); + yield* initRepo(cwd); + const remote = yield* createBareRemote(); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", "main"]); + yield* runGit(cwd, ["checkout", "-b", "feature/review"]); + NodeFS.writeFileSync(NodePath.join(cwd, "review.txt"), "original\n"); + yield* runGit(cwd, ["add", "review.txt"]); + yield* runGit(cwd, ["commit", "-m", "PR head"]); + yield* runGit(cwd, ["push", "-u", "origin", "feature/review"]); + yield* runGit(cwd, ["push", "origin", "HEAD:refs/pull/77/head"]); + NodeFS.writeFileSync(NodePath.join(cwd, "review.txt"), "unrelated local work\n"); + let generated = false; + let state: "open" | "closed" | "merged" = "open"; + const projectId = ProjectId.make("review-project"); + const forgejoPullRequest = + provider === "forgejo" + ? toForgejoChangeRequest( + yield* decodeForgejoPullRequest({ + number: 77, + title: "Review", + html_url: url, + state: "open", + merged: false, + base: { + ref: "main", + sha: "base", + repo: { full_name: "team/repo", owner: { login: "team" } }, + }, + head: { + ref: "feature/review", + sha: "head", + repo: { full_name: "team/repo", owner: { login: "team" } }, + }, + }), + ) + : null; + const adapter = yield* SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider)({ + kind: provider, + getChangeRequest: () => + Effect.succeed({ + ...(forgejoPullRequest ?? { + provider, + number: 77, + title: "Review", + url, + baseRefName: "main", + headRefName: "feature/review", + state: "open", + updatedAt: Option.none(), + }), + state, + }), + getRepositoryCloneUrls: ({ repository }) => { + expect(repository).toBe("team/repo"); + return Effect.succeed({ nameWithOwner: repository!, url: remote, sshUrl: remote }); + }, + getDefaultBranch: () => Effect.succeed("main"), + listChangeRequests: () => Effect.succeed([]), + }), + ), + ); + const { manager } = yield* makeManager({ + sourceControlProvider: adapter, + serverSettings: { + projectSettingsOverrides: { + [projectId]: { + sourceControlWritingStyle: { + mode: "custom", + customInstructions: "Use the project review style.", + }, + }, + }, + }, + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generated = true; + expect(input.stagedPatch).toContain("review edit"); + expect(input.policy?.commitInstructions).toContain("Use the project review style."); + return { subject: "Fix review feedback", body: "" }; + }), + }, + }); + const prepared = yield* preparePullRequestThread(manager, { + cwd, + reference: "77", + mode: "review", + }); + expect(prepared.branch).toBe("t3-review/feature/review"); + expect(prepared.isOnPullRequestHead).toBe(true); + const reviewCwd = prepared.worktreePath!; + NodeFS.writeFileSync(NodePath.join(reviewCwd, "review.txt"), "review edit\n"); + NodeFS.writeFileSync(NodePath.join(reviewCwd, "unrelated.txt"), "do not publish\n"); + const result = yield* runStackedAction(manager, { + cwd: reviewCwd, + action: "commit_push", + projectId, + expectedBranch: prepared.branch, + pullRequestUrl: url, + filePaths: ["review.txt"], + }); + expect(generated).toBe(true); + expect(result.push.status).toBe("pushed"); + expect((yield* runGit(cwd, ["show", "origin/feature/review:review.txt"])).stdout).toBe( + "review edit\n", + ); + expect( + (yield* runGit(cwd, ["log", "-1", "--format=%s", "origin/feature/review"])).stdout.trim(), + ).toBe("Fix review feedback"); + expect((yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim()).toBe( + "feature/review", + ); + expect(NodeFS.readFileSync(NodePath.join(cwd, "review.txt"), "utf8")).toBe( + "unrelated local work\n", + ); + expect((yield* runGit(reviewCwd, ["status", "--porcelain"])).stdout).toContain( + "unrelated.txt", + ); + const wrongBranch = yield* runStackedAction(manager, { + cwd: reviewCwd, + action: "commit_push", + expectedBranch: "other", + filePaths: ["unrelated.txt"], + }).pipe(Effect.result); + expect(wrongBranch._tag).toBe("Failure"); + const before = (yield* runGit(reviewCwd, ["rev-parse", "HEAD"])).stdout; + const worktrees = (yield* runGit(cwd, ["worktree", "list", "--porcelain"])).stdout; + for (const nextState of ["closed", "merged"] as const) { + state = nextState; + const preparation = yield* preparePullRequestThread(manager, { + cwd, + reference: url, + mode: "review", + }).pipe(Effect.flip); + expect(preparation.message).toContain("no longer open for edits"); + expect((yield* runGit(cwd, ["worktree", "list", "--porcelain"])).stdout).toBe(worktrees); + const publish = yield* runStackedAction(manager, { + cwd: reviewCwd, + action: "commit_push", + expectedBranch: prepared.branch, + pullRequestUrl: url, + filePaths: ["unrelated.txt"], + commitMessage: "Do not publish", + }).pipe(Effect.flip); + expect(publish.message).toContain("no longer open for edits"); + expect((yield* runGit(reviewCwd, ["rev-parse", "HEAD"])).stdout).toBe(before); + expect(NodeFS.readFileSync(NodePath.join(reviewCwd, "unrelated.txt"), "utf8")).toBe( + "do not publish\n", + ); + } + }), + ); + + it.effect.each(["title", "push"] as const)( + "stops publishing when the PR merges during %s", + (phase) => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-pr-merge-race-"); + yield* initRepo(cwd); + const remote = yield* createBareRemote(); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", "main"]); + const before = (yield* runGit(cwd, ["rev-parse", "HEAD"])).stdout; + let state: "open" | "merged" = "open"; + const url = "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/acme/web/pull/7"; + const adapter = yield* SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider)({ + kind: "github", + getChangeRequest: () => + Effect.succeed({ + provider: "github", + number: 7, + title: "Review", + url, + baseRefName: "main", + headRefName: "main", + state, + updatedAt: Option.none(), + }), + getDefaultBranch: () => Effect.succeed("main"), + listChangeRequests: () => Effect.succeed([]), + }), + ), + ); + const { manager } = yield* makeManager({ + sourceControlProvider: adapter, + textGeneration: { + generateCommitMessage: () => + Effect.sync(() => { + if (phase === "title") state = "merged"; + return { subject: "Review changes", body: "" }; + }), + }, + }); + NodeFS.writeFileSync(NodePath.join(cwd, "README.md"), "pending review edit\n"); + const error = yield* runStackedAction( + manager, + { + cwd, + action: "commit_push", + expectedBranch: "main", + pullRequestUrl: url, + filePaths: ["README.md"], + }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + if (phase === "push" && event.kind === "phase_started" && event.phase === "push") + state = "merged"; + }), + }, + }, + ).pipe(Effect.flip); + expect(error.message).toContain("no longer open for edits"); + expect((yield* runGit(cwd, ["rev-parse", "origin/main"])).stdout).toBe(before); + const head = (yield* runGit(cwd, ["rev-parse", "HEAD"])).stdout; + if (phase === "title") expect(head).toBe(before); + else expect(head).not.toBe(before); + expect(NodeFS.readFileSync(NodePath.join(cwd, "README.md"), "utf8")).toBe( + "pending review edit\n", + ); + }), + ); + + it.effect("does not publish a Forgejo review whose source repository is missing", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-forgejo-review-"); + yield* initRepo(cwd); + const remote = yield* createBareRemote(); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", "main"]); + yield* runGit(cwd, ["push", "origin", "HEAD:refs/pull/77/head"]); + const before = (yield* runGit(cwd, ["rev-parse", "HEAD"])).stdout; + const pullRequest = toForgejoChangeRequest( + yield* decodeForgejoPullRequest({ + number: 77, + title: "Unavailable source", + html_url: "https://forgejo.example/forgejo/team/repo/pulls/77", + state: "open", + merged: false, + base: { + ref: "main", + sha: before.trim(), + repo: { full_name: "team/repo", owner: { login: "team" } }, + }, + head: { ref: "main", sha: before.trim(), repo: null }, + }), + ); + const adapter = yield* SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider)({ + kind: "forgejo", + getChangeRequest: () => Effect.succeed(pullRequest), + getDefaultBranch: () => Effect.succeed("main"), + listChangeRequests: () => Effect.succeed([]), + }), + ), + ); + const { manager } = yield* makeManager({ sourceControlProvider: adapter }); + const prepared = yield* preparePullRequestThread(manager, { + cwd, + reference: pullRequest.url, + mode: "review", + }); + NodeFS.writeFileSync(NodePath.join(prepared.worktreePath!, "README.md"), "review edit\n"); + const result = yield* runStackedAction(manager, { + cwd: prepared.worktreePath!, + action: "commit_push", + expectedBranch: prepared.branch, + pullRequestUrl: pullRequest.url, + filePaths: ["README.md"], + }).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("PR source repository is unavailable"); + } + expect((yield* runGit(cwd, ["rev-parse", "origin/main"])).stdout).toBe(before); + expect((yield* runGit(prepared.worktreePath!, ["rev-parse", "HEAD"])).stdout).toBe(before); + expect( + (yield* runGit(prepared.worktreePath!, [ + "for-each-ref", + "--format=%(upstream)", + `refs/heads/${prepared.branch}`, + ])).stdout.trim(), + ).toBe(""); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4685,7 +5128,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => + it.effect.each([ + ["github", "worktree"], + ["github", "review"], + ["forgejo", "review"], + ] as const)("preserves fork upstream tracking when preparing a %s %s PR", ([provider, mode]) => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4701,19 +5148,47 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); yield* runGit(repoDir, ["checkout", "main"]); + const pullRequest = { + provider, + number: 81, + title: "Fork PR", + url: + provider === "forgejo" + ? "https://forgejo.example/forgejo/pingdotgg/codething-mvp/pulls/81" + : "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/pingdotgg/codething-mvp/pull/81", + baseRefName: "main", + headRefName: "feature/pr-fork", + state: "open" as const, + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }; + const adapter = + provider === "forgejo" + ? yield* SourceControlProvider.pipe( + Effect.provide( + Layer.mock(SourceControlProvider)({ + kind: provider, + getChangeRequest: () => Effect.succeed(pullRequest), + getRepositoryCloneUrls: ({ repository }) => { + expect(repository).toBe("octocat/codething-mvp"); + return Effect.succeed({ + nameWithOwner: repository!, + url: forkDir, + sshUrl: forkDir, + }); + }, + getDefaultBranch: () => Effect.succeed("main"), + listChangeRequests: () => Effect.succeed([]), + }), + ), + ) + : undefined; const { manager } = yield* makeManager({ + ...(adapter ? { sourceControlProvider: adapter } : {}), ghScenario: { - pullRequest: { - number: 81, - title: "Fork PR", - url: "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/pingdotgg/codething-mvp/pull/81", - baseRefName: "main", - headRefName: "feature/pr-fork", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, + pullRequest, repositoryCloneUrls: { "octocat/codething-mvp": { url: forkDir, @@ -4726,9 +5201,25 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "81", - mode: "worktree", + mode, }); + if (mode === "review") { + NodeFS.writeFileSync(NodePath.join(result.worktreePath!, "fork.txt"), "reviewed fork\n"); + yield* runStackedAction(manager, { + cwd: result.worktreePath!, + action: "commit_push", + expectedBranch: result.branch, + pullRequestUrl: pullRequest.url, + filePaths: ["fork.txt"], + }); + expect( + (yield* runGit(repoDir, ["show", "fork-seed/feature/pr-fork:fork.txt"])).stdout, + ).toBe("reviewed fork\n"); + expect((yield* runGit(repoDir, ["show", "origin/main:README.md"])).stdout).not.toContain( + "reviewed fork", + ); + } expect(result.worktreePath).not.toBeNull(); const upstreamRef = (yield* runGit(result.worktreePath as string, [ "rev-parse", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 033cc192ccfe..748bbdb447b8 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -550,6 +550,8 @@ function sanitizeProgressText(value: string): string | null { } interface CommitAndBranchSuggestion { + stagedTree?: string; + headCommit?: string | null; subject: string; body: string; branch?: string | undefined; @@ -677,6 +679,23 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + const requireOpenPullRequest = Effect.fn("requireOpenPullRequest")(function* ( + cwd: string, + reference: string, + ) { + const pullRequest = yield* (yield* sourceControlProvider(cwd)).getChangeRequest({ + cwd, + reference, + }); + if (pullRequest.state !== "open") { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd, + detail: "This PR is no longer open for edits.", + }); + } + return pullRequest; + }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; // Optional: git actions also run from the CLI and tests without orchestration. const projectionQuery = yield* Effect.serviceOption( @@ -686,9 +705,13 @@ export const make = Effect.gen(function* () { const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; + readonly projectId?: ProjectId | undefined; }) { const settings = yield* serverSettingsService.getSettings; - if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; + if (!hasProjectSettingsOverrides(settings)) return settings; + if (input.projectId !== undefined) + return resolveProjectSettings(settings, input.projectId).settings; + if (Option.isNone(projectionQuery)) return settings; const projectId = yield* ( input.threadId !== undefined ? projectionQuery.value @@ -1814,9 +1837,14 @@ export const make = Effect.gen(function* () { /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; filePaths?: readonly string[]; + stagedOnly?: boolean; settings: SourceControlTextGenerationSettings; }) { - const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); + const context = yield* gitCore.prepareCommitContext( + input.cwd, + input.filePaths, + input.stagedOnly, + ); if (!context) { return null; } @@ -1830,6 +1858,9 @@ export const make = Effect.gen(function* () { ? { branch: sanitizeFeatureBranchName(customCommit.subject) } : {}), commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), + ...(context.stagedTree + ? { stagedTree: context.stagedTree, headCommit: context.headCommit ?? null } + : {}), }; } @@ -1852,6 +1883,9 @@ export const make = Effect.gen(function* () { body: generated.body, ...(generated.branch !== undefined ? { branch: generated.branch } : {}), commitMessage: formatCommitMessage(generated.subject, generated.body), + ...(context.stagedTree + ? { stagedTree: context.stagedTree, headCommit: context.headCommit ?? null } + : {}), }; }, ); @@ -1866,6 +1900,8 @@ export const make = Effect.gen(function* () { filePaths?: readonly string[], progressReporter?: GitActionProgressReporter, actionId?: string, + stagedOnly?: boolean, + pullRequestUrl?: string, ) { const emit = (event: GitActionProgressPayload) => progressReporter && actionId @@ -1892,6 +1928,7 @@ export const make = Effect.gen(function* () { branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), + ...(stagedOnly ? { stagedOnly } : {}), settings, }); } @@ -1949,7 +1986,15 @@ export const make = Effect.gen(function* () { }, } : null; + if (pullRequestUrl) yield* requireOpenPullRequest(cwd, pullRequestUrl); const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { + ...(suggestion.stagedTree + ? { + stagedTree: suggestion.stagedTree, + expectedHead: suggestion.headCommit ?? null, + expectedBranch: branch, + } + : {}), timeoutMs: COMMIT_TIMEOUT_MS, ...(commitProgress ? { progress: commitProgress } : {}), }); @@ -2327,6 +2372,14 @@ export const make = Effect.gen(function* () { }); const pullRequest = toResolvedPullRequest(pullRequestSummary); + if (input.mode === "review" && pullRequest.state !== "open") { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: "This PR is no longer open for edits.", + }); + } + if (input.mode === "local") { yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ cwd: input.cwd, @@ -2368,8 +2421,7 @@ export const make = Effect.gen(function* () { ...pullRequest, ...toPullRequestHeadRemoteInfo(pullRequestSummary), } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + const localPullRequestBranch = `${input.mode === "review" ? "t3-review/" : ""}${resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo)}`; // Git refuses to move a branch that is checked out in a worktree, so the // reuse paths cannot go through materializePullRequestHeadBranch and instead @@ -2475,7 +2527,7 @@ export const make = Effect.gen(function* () { if (localBranch) { return localBranch; } - if (localPullRequestBranch === pullRequest.headBranch) { + if (input.mode === "review" || localPullRequestBranch === pullRequest.headBranch) { return null; } @@ -2566,6 +2618,7 @@ export const make = Effect.gen(function* () { branch: string | null, commitMessage?: string, filePaths?: readonly string[], + stagedOnly?: boolean, ) { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, @@ -2573,6 +2626,7 @@ export const make = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, + ...(stagedOnly ? { stagedOnly } : {}), settings, }); if (!suggestion) { @@ -2607,6 +2661,42 @@ export const make = Effect.gen(function* () { GitManagerServiceError > { const initialStatus = yield* gitCore.statusDetails(input.cwd); + if (input.expectedBranch !== undefined && initialStatus.branch !== input.expectedBranch) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "The checkout changed. Reopen the review before publishing.", + }); + } + if (input.pullRequestUrl) { + const pullRequest = yield* requireOpenPullRequest(input.cwd, input.pullRequestUrl); + if (!initialStatus.branch) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "This PR is no longer open for edits.", + }); + } + const head = { + ...toResolvedPullRequest(pullRequest), + ...toPullRequestHeadRemoteInfo(pullRequest), + }; + if (head.isCrossRepository && !resolveHeadRepositoryNameWithOwner(head)) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "The PR source repository is unavailable. Refresh the review and try again.", + }); + } + yield* configurePullRequestHeadUpstreamBase(input.cwd, head, initialStatus.branch); + } + if (input.stagedOnly && input.filePaths?.length) { + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Choose staged changes or whole files for this commit, not both.", + }); + } const wantsCommit = isCommitAction(input.action); const wantsPush = input.action === "push" || @@ -2703,6 +2793,7 @@ export const make = Effect.gen(function* () { initialStatus.branch, input.commitMessage, input.filePaths, + input.stagedOnly, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -2733,6 +2824,8 @@ export const make = Effect.gen(function* () { input.filePaths, options?.progressReporter, progress.actionId, + input.stagedOnly, + input.pullRequestUrl, ), ), ) @@ -2747,7 +2840,18 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), - Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), + Effect.tap(() => + input.pullRequestUrl + ? requireOpenPullRequest(input.cwd, input.pullRequestUrl) + : Effect.void, + ), + Effect.flatMap(() => + gitCore.pushCurrentBranch( + input.cwd, + currentBranch, + input.pullRequestUrl ? { pushToUpstream: true } : undefined, + ), + ), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 5e7545eafdf8..54321f619cd7 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,8 +1,12 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -10,11 +14,14 @@ import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsDriver from "../vcs/VcsDriver.ts"; function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; }) { return GitWorkflowService.layer.pipe( + Layer.provide(Path.layer), + Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: input.detect, @@ -26,6 +33,146 @@ function makeLayer(input: { } describe("GitWorkflowService", () => { + it.effect.each(["checkout", "pull", "refresh", "remove", "publish", "commit"])( + "serializes %s with writes", + (action) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const commandResolved = yield* Deferred.make(); + const events: string[] = []; + const observedAt = yield* DateTime.now; + const driver = yield* VcsDriver.VcsDriver.pipe( + Effect.provide( + Layer.mock(VcsDriver.VcsDriver)({ + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native", + }, + }), + ), + ); + const resolve: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"] = ({ cwd }) => + Effect.gen(function* () { + if (cwd === "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/repo/nested") yield* Deferred.succeed(commandResolved, undefined); + return { + kind: "git" as const, + driver, + repository: { + kind: "git" as const, + rootPath: cwd === "/other" ? "/other" : cwd === "/pr-worktree" ? cwd : "/repo", + metadataPath: + cwd === "/pr-worktree" + ? "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/repo/.git" + : cwd === "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/repo/nested" + ? "../.git" + : ".git", + freshness: { + source: "live-local" as const, + observedAt, + expiresAt: Option.none(), + }, + }, + }; + }); + const workflow = yield* GitWorkflowService.make.pipe( + Effect.provide( + Layer.mergeAll( + Path.layer, + FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) }), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve, + detect: resolve, + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + removeWorktree: () => + Effect.sync(() => { + events.push("remove"); + }), + switchRef: ({ refName }) => + Effect.sync(() => { + events.push("checkout"); + return { refName }; + }), + pullCurrentBranch: () => + Effect.sync(() => { + events.push("pull"); + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + }), + Layer.mock(GitManager.GitManager)({ + runStackedAction: (input) => + Effect.sync(() => { + events.push(input.expectedBranch ? "publish" : "commit"); + return {} as never; + }), + preparePullRequestThread: () => + Effect.sync(() => { + events.push("refresh"); + return { worktreePath: "/pr-worktree" } as never; + }), + }), + ), + ), + ); + const write = yield* workflow + .withRepositoryLock( + action === "refresh" || action === "remove" ? "/pr-worktree" : "/repo", + Effect.gen(function* () { + events.push("validate"); + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + events.push("write"); + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(entered); + const command = yield* ( + action === "checkout" + ? workflow.switchRef({ cwd: "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/repo/nested", refName: "other" }) + : action === "pull" + ? workflow.pullCurrentBranch("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/repo/nested") + : action === "publish" || action === "commit" + ? workflow.runStackedAction({ + cwd: "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/repo/nested", + actionId: "publish", + action: "commit_push", + ...(action === "publish" ? { expectedBranch: "review" } : {}), + }) + : action === "remove" + ? workflow.removeWorktree({ + cwd: "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/repo/nested", + path: "/pr-worktree", + force: false, + }) + : workflow.preparePullRequestThread({ + cwd: "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/repo/nested", + reference: "42", + mode: "worktree", + }) + ).pipe(Effect.forkScoped); + yield* Deferred.await(commandResolved); + yield* Effect.yieldNow; + yield* workflow.withRepositoryLock( + "/other", + Effect.sync(() => events.push("other repository")), + ); + assert.deepStrictEqual(events, ["validate", "other repository"]); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(write); + yield* Fiber.join(command); + assert.deepStrictEqual(events, ["validate", "other repository", "write", action]); + }).pipe(Effect.scoped), + ); + it.effect("reports a non-Git VCS repository as not a Git repository", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; @@ -118,6 +265,8 @@ describe("GitWorkflowService", () => { const status = vi.fn(); const testLayer = GitWorkflowService.layer.pipe( + Layer.provide(Path.layer), + Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: () => Effect.succeed(null), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f5f5a6d39336..14362a7d8f5c 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,6 +1,9 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Semaphore from "effect/Semaphore"; import { GitManagerError, @@ -35,6 +38,10 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, { + readonly withRepositoryLock: ( + cwd: string, + effect: Effect.Effect, + ) => Effect.Effect; readonly isRepository: (cwd: string) => Effect.Effect; readonly hasCommit: (input: { readonly cwd: string; @@ -152,8 +159,39 @@ function nonRepositoryListRefs(): VcsListRefsResult { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const repositoryLocks = new Map(); + const withRepositoryLock: GitWorkflowService["Service"]["withRepositoryLock"] = (cwd, effect) => + Effect.gen(function* () { + const key = yield* Effect.gen(function* () { + const handle = yield* registry.detect({ cwd }); + if (!handle) return yield* fileSystem.realPath(cwd); + if (handle.repository.metadataPath === null) { + return yield* Effect.fail("The repository metadata path is unavailable."); + } + return yield* fileSystem.realPath(path.resolve(cwd, handle.repository.metadataPath)); + }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitWorkflowService.withRepositoryLock", + command: "vcs-route", + cwd, + detail: "Failed to resolve the repository before changing its files.", + cause, + }), + ), + ); + let lock = repositoryLocks.get(key); + if (!lock) { + lock = Semaphore.makeUnsafe(1); + repositoryLocks.set(key, lock); + } + return yield* lock.withPermits(1)(effect); + }); const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -270,6 +308,7 @@ export const make = Effect.gen(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ + withRepositoryLock, isRepository: (cwd) => registry.detect({ cwd }).pipe( Effect.map((handle) => handle?.kind === "git"), @@ -321,19 +360,25 @@ export const make = Effect.gen(function* () { pullCurrentBranch: (cwd) => ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( Effect.andThen(git.pullCurrentBranch(cwd)), + (effect) => withRepositoryLock(cwd, effect), ), runStackedAction: (input, options) => ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.andThen(gitManager.runStackedAction(input, options)), + (effect) => withRepositoryLock(input.cwd, effect), ), resolvePullRequest: routeGitManager( "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), - preparePullRequestThread: routeGitManager( - "GitWorkflowService.preparePullRequestThread", - gitManager.preparePullRequestThread, - ), + preparePullRequestThread: (input) => + withRepositoryLock( + input.cwd, + routeGitManager( + "GitWorkflowService.preparePullRequestThread", + gitManager.preparePullRequestThread, + )(input), + ), listRefs: (input) => detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( Effect.flatMap((isGitRepository) => @@ -363,6 +408,7 @@ export const make = Effect.gen(function* () { removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), + (effect) => withRepositoryLock(input.cwd, effect), ), pruneWorktrees: (input) => ensureGitCommand("GitWorkflowService.pruneWorktrees", input.cwd).pipe( @@ -371,14 +417,17 @@ export const make = Effect.gen(function* () { createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( Effect.andThen(git.createRef(input)), + (effect) => (input.switchRef ? withRepositoryLock(input.cwd, effect) : effect), ), switchRef: (input) => ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( Effect.andThen(Effect.scoped(git.switchRef(input))), + (effect) => withRepositoryLock(input.cwd, effect), ), renameBranch: (input) => ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( Effect.andThen(git.renameBranch(input)), + (effect) => withRepositoryLock(input.cwd, effect), ), }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 879ca66ca60a..e365bab6b18c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,3 +1,5 @@ +import { PullRequestService } from "./pullRequest/PullRequestService.ts"; +import { pullRequestMediaResponse } from "./assets/PullRequestMediaFetch.ts"; import * as Mime from "effect/unstable/http/Mime"; import { AuthOrchestrationOperateScope, @@ -372,49 +374,73 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( ), ); -export const assetRouteLayer = HttpRouter.add( - "GET", - `${ASSET_ROUTE_PREFIX}/*`, +export const assetRouteLayer = Layer.unwrap( Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); - } + const pullRequests = yield* PullRequestService; + return HttpRouter.add( + "GET", + `${ASSET_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } - const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - if (separatorIndex <= 0) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } + const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + if (separatorIndex <= 0) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } - const asset = yield* resolveAsset( - suffix.slice(0, separatorIndex), - suffix.slice(separatorIndex + 1), - ); - if (!asset) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - if (asset.kind === "github-media") { - return yield* githubMediaResponse(asset, request.headers).pipe( - Effect.tapError((cause) => - Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), - ), - Effect.orElseSucceed(() => - HttpServerResponse.empty({ - status: 502, - headers: { "cache-control": "private, no-store", "x-content-type-options": "nosniff" }, - }), - ), - ); - } - return yield* assetFileResponse( - asset, - request.method === "GET" ? request.headers.range : undefined, - request.headers["if-range"], - request.method === "HEAD" ? "HEAD" : "GET", - ).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + ); + if (!asset) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + if (asset.kind === "pull-request-media") { + return yield* pullRequestMediaResponse(asset, request.headers).pipe( + Effect.provideService(PullRequestService, pullRequests), + Effect.orElseSucceed(() => + HttpServerResponse.empty({ + status: 502, + headers: { + "cache-control": "private, no-store", + "x-content-type-options": "nosniff", + }, + }), + ), + ); + } + if (asset.kind === "github-media") { + return yield* githubMediaResponse(asset, request.headers).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), + ), + Effect.orElseSucceed(() => + HttpServerResponse.empty({ + status: 502, + headers: { + "cache-control": "private, no-store", + "x-content-type-options": "nosniff", + }, + }), + ), + ); + } + return yield* assetFileResponse( + asset, + request.method === "GET" ? request.headers.range : undefined, + request.headers["if-range"], + request.method === "HEAD" ? "HEAD" : "GET", + ).pipe( + Effect.orElseSucceed(() => + HttpServerResponse.text("Internal Server Error", { status: 500 }), + ), + ); + }), ); }), ); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 6e378c43b1b6..985d7909d6dc 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -1,5 +1,17 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; +import { NodeFileSystem } from "@effect/platform-node"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -7,10 +19,38 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeDefect = Schema.encodeSync(Schema.fromJsonString(Schema.Defect())); + const mockedExecute = vi.fn(); +const attachmentRequests: HttpClientRequest.HttpClientRequest[] = []; +const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + attachmentRequests.push(request); + const options = yield* Effect.serviceOption(FetchHttpClient.RequestInit); + expect(Option.getOrUndefined(options)?.redirect).toBe("manual"); + return HttpClientResponse.fromWeb( + request, + request.method === "GET" + ? new Response(new Uint8Array([0, 255, 128]), { + status: 206, + headers: { "content-type": "image/png", "content-range": "bytes 0-2/3" }, + }) + : new Response( + '{"url":"https://dev.azure.com/acme/project/_apis/git/attachments/proof.png"}', + { + status: 201, + }, + ), + ); + }), +); + const layer = it.layer( AzureDevOpsPullRequestCli.layer.pipe( + Layer.provide(NodeFileSystem.layer), + Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, httpClient)), Layer.provide( Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({ execute: mockedExecute, @@ -183,9 +223,336 @@ function iterationRows(count: number): ReadonlyArray> { afterEach(() => { mockedExecute.mockReset(); + attachmentRequests.length = 0; }); layer("AzureDevOpsPullRequestCli.layer", (it) => { + it.effect( + "reads private attachment bytes only from the verified PR, including Azure GUID URLs", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + json({ + ...pullRequestRow, + repository: { + name: "web", + id: "repo-guid", + project: { name: "platform", id: "project-guid" }, + }, + }), + ), + ), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.readAttachment); + for (const path of [ + "platform/_apis/git/repositories/web", + "project-guid/_apis/git/repositories/repo-guid", + "_apis/git/repositories/repo-guid", + ]) { + const response = yield* provider.readAttachment({ + cwd: "/w", + host: "dev.azure.com", + repository: "web", + number: 42, + url: `https://dev.azure.com/acme/${path}/pullRequests/42/attachments/proof.png`, + headers: { Range: "bytes=0-2", authorization: "untrusted", cookie: "untrusted" }, + }); + expect(response.status).toBe(206); + expect(new Uint8Array(yield* response.arrayBuffer)).toEqual( + new Uint8Array([0, 255, 128]), + ); + const request = attachmentRequests.at(-1)!; + expect(request.url).toBe( + "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/attachments/proof.png?api-version=7.1", + ); + expect(request.headers.authorization).toBe("Basic OnRlc3QtcGF0"); + expect(request.headers.range).toBe("bytes=0-2"); + expect(request.headers.cookie).toBeUndefined(); + } + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ AZURE_DEVOPS_EXT_PAT: "test-pat" }), + ), + ), + ); + + it.effect("rejects private media outside the current Azure PR before sending credentials", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(json(pullRequestRow)))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.readAttachment); + const url = + "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/attachments/proof.png"; + for (const invalid of [ + url.replace("dev.azure.com", "other.example"), + url.replace("/platform/", "/other/"), + url.replace("/web/", "/other/"), + url.replace("/42/", "/43/"), + url.replace("proof.png", "dir%2Fproof.png"), + url.replace("https://", "https://untrusted@"), + ]) { + const result = yield* provider + .readAttachment({ + cwd: "/w", + host: "dev.azure.com", + repository: "web", + number: 42, + url: invalid, + headers: {}, + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + } + expect(attachmentRequests).toHaveLength(0); + expect( + mockedExecute.mock.calls.every(([request]) => !request.args.includes("get-access-token")), + ).toBe(true); + }), + ); + + for (const operation of ["readAttachment", "uploadAttachment"] as const) { + for (const failure of ["token", "http", "config"] as const) { + it.effect(`preserves ${failure} causes for ${operation} without exposing credentials`, () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))); + const tokenFailure = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + command: "az", + cwd: "/w", + operation: "execute", + argumentCount: 1, + cause: new Error("Token lookup refused"), + }); + mockedExecute.mockReturnValueOnce(Effect.fail(tokenFailure)); + let httpFailure: HttpClientError.HttpClientError | undefined; + const provider = yield* AzureDevOpsPullRequestProvider.make.pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + httpFailure = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("Transport unavailable"), + }), + }); + return Effect.fail(httpFailure); + }), + ), + ); + assert.isDefined(provider.readAttachment); + assert.isDefined(provider.uploadAttachment); + const ref = { cwd: "/w", host: "dev.azure.com", repository: "web", number: 42 }; + const result = yield* ( + operation === "readAttachment" + ? provider + .readAttachment({ + ...ref, + url: "https://dev.azure.com/acme/platform/_apis/git/repositories/web/pullRequests/42/attachments/proof.png", + headers: {}, + }) + .pipe(Effect.asVoid) + : provider + .uploadAttachment({ + ...ref, + name: "proof.png", + mimeType: "image/png", + data: new Uint8Array([1]), + filePath: "/tmp/proof.png", + }) + .pipe(Effect.asVoid) + ).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + failure === "config" + ? ConfigProvider.make(() => + Effect.fail( + new ConfigProvider.SourceError({ message: "Configuration unavailable" }), + ), + ) + : ConfigProvider.fromUnknown( + failure === "token" ? {} : { AZURE_DEVOPS_EXT_PAT: "private-test-token" }, + ), + ), + Effect.flip, + ); + expect(result.operation).toBe(operation); + if (failure === "config") expect(result.cause).toMatchObject({ _tag: "ConfigError" }); + else expect(result.cause).toBe(failure === "token" ? tokenFailure : httpFailure); + expect(result.detail).not.toContain("private-test-token"); + expect(result.message).not.toContain("private-test-token"); + const encodedFailure = encodeDefect(result); + expect(encodedFailure).not.toContain("private-test-token"); + if (failure === "http") { + const authorization = httpFailure?.request.headers.authorization; + assert.isDefined(authorization); + expect(encodedFailure).not.toContain(authorization); + } + }), + ); + } + } + + it.effect("uses a server PAT without asking Azure CLI for another token", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.uploadAttachment); + yield* provider.uploadAttachment({ + cwd: "/w", + host: "dev.azure.com", + repository: "web", + number: 42, + name: "proof.png", + mimeType: "image/png", + data: new Uint8Array([0, 255]), + filePath: "/tmp/proof.png", + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(attachmentRequests.at(-1)?.headers.authorization).toBe("Basic OnRlc3QtcGF0"); + }).pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ AZURE_DEVOPS_EXT_PAT: "test-pat" }), + ), + ), + ); + + it.effect("uploads binary attachments with an Azure DevOps access token", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output('"test-access-token"'))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + assert.isDefined(provider.uploadAttachment); + const uploaded = yield* provider.uploadAttachment({ + cwd: "/w", + host: "dev.azure.com", + repository: "web", + number: 42, + name: "proof.png", + mimeType: "image/png", + data: new Uint8Array([0, 255, 128]), + filePath: "/tmp/proof.png", + }); + expect(argsOfCall(1)).toEqual( + expect.arrayContaining(["get-access-token", "499b84ac-1321-427f-aa17-267ca6975798"]), + ); + const request = attachmentRequests.at(-1)!; + expect(request.url).toContain( + "/acme/platform/_apis/git/repositories/web/pullRequests/42/attachments/pending-", + ); + expect(request.url).toContain("-proof.png?api-version=7.1"); + expect(request.body._tag).toBe("Uint8Array"); + if (request.body._tag === "Uint8Array") expect([...request.body.body]).toEqual([0, 255, 128]); + expect(request.headers.authorization).toBe("Bearer test-access-token"); + expect(uploaded.markdown).toBe( + "![proof.png]()", + ); + }).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({}))), + ); + + it.effect( + "writes and edits comments in their native thread and resolves in both directions", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const writes: { args: ReadonlyArray; body: unknown; file: string }[] = []; + mockedExecute.mockImplementation((input) => + Effect.gen(function* () { + const file = input.args[input.args.indexOf("--in-file") + 1]; + if (!input.args.includes("--in-file") || !file) return output(json(pullRequestRow)); + writes.push({ + args: input.args, + body: yield* decodeJson(yield* fs.readFileString(file)), + file, + }); + return output("{}"); + }).pipe(Effect.orDie), + ); + const provider = yield* AzureDevOpsPullRequestProvider.make; + const ref = { cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }; + yield* provider.comment({ ...ref, body: "First **comment**\nsecond line" }); + yield* provider.replyToThread({ ...ref, threadId: "7", body: "A reply" }); + assert.isDefined(provider.updateComment); + yield* provider.updateComment({ + ...ref, + commentId: "7:2", + kind: "review-comment", + body: "Edited", + }); + yield* provider.setThreadResolution({ ...ref, threadId: "7", resolved: true }); + yield* provider.setThreadResolution({ ...ref, threadId: "7", resolved: false }); + expect(writes.map(({ body }) => body)).toEqual([ + { + status: "active", + comments: [ + { + parentCommentId: 0, + content: "First **comment**\nsecond line", + commentType: "text", + }, + ], + }, + { parentCommentId: 1, content: "A reply", commentType: "text" }, + { content: "Edited" }, + { status: "fixed" }, + { status: "active" }, + ]); + expect(writes[1]?.args).toEqual( + expect.arrayContaining(["pullRequestThreadComments", "POST", "threadId=7"]), + ); + expect(writes[2]?.args).toEqual( + expect.arrayContaining(["PATCH", "threadId=7", "commentId=2"]), + ); + for (const { file } of writes) expect(yield* fs.exists(file)).toBe(false); + }).pipe(Effect.provide(NodeFileSystem.layer)), + ); + + it.effect("sets native approve and wait-for-author votes", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + for (const vote of ["approve", "wait-for-author"] as const) { + yield* cli.setReviewVote({ cwd: "/w", number: 42, vote }); + } + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "set-vote", + "--detect", + "true", + "--id", + "42", + "--vote", + "approve", + "--only-show-errors", + "--output", + "json", + ]); + expect(argsOfCall(1)).toContain("wait-for-author"); + }), + ); + + it.effect("rejects invalid thread IDs before a provider request", () => + Effect.gen(function* () { + const provider = yield* AzureDevOpsPullRequestProvider.make; + const result = yield* provider + .replyToThread({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + threadId: "7/thread", + body: "reply", + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(mockedExecute).not.toHaveBeenCalled(); + }), + ); + it.effect("asks for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); @@ -614,8 +981,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); const provider = yield* AzureDevOpsPullRequestProvider.make; - // False for a remark because nothing here can post one, so there is none to rewrite. - expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); assert.isDefined(provider.updateChangeRequest); yield* provider.updateChangeRequest({ cwd: "/w", @@ -1235,13 +1601,17 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - const comments = yield* cli.listThreads({ + const { comments } = yield* cli.listThreads({ cwd: "/w", location: { project: "platform", repository: "web" }, number: 42, + pullRequestUrl: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42", }); assert.strictEqual(comments.length, 1); + expect(comments[0]?.url).toBe( + "https://dev.azure.com/acme/platform/_git/web/pullrequest/42?discussionId=1", + ); // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension // does, and `az rest` mints its own token against whichever tenant `az` defaults to. expect(argsOfCall(0)).toContain("invoke"); @@ -1266,7 +1636,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - const comments = yield* cli.listThreads({ + const { comments } = yield* cli.listThreads({ cwd: "/w", location: { project: "platform", repository: "web" }, number: 42, diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 5f5453a9b3ba..461d64046d5b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -1,11 +1,13 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { PullRequestAction, PullRequestComment, + PullRequestReviewThread, PullRequestInvolvement, PullRequestListState, PullRequestMergeMethod, @@ -118,6 +120,8 @@ export type AzureDevOpsPullRequestCliError = /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ const REST_API_VERSION = "7.1"; +const decodeAccessToken = Schema.decodeEffect(Schema.fromJsonString(Schema.String)); +const encodeThreadBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; /** * A full page of change entries is two thousand files, each carrying its path, its url and @@ -195,9 +199,37 @@ export class AzureDevOpsPullRequestCli extends Context.Service< /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ readonly listThreads: (input: { readonly cwd: string; + readonly pullRequestUrl?: string; readonly location: AzureDevOpsRepositoryLocation; readonly number: number; - }) => Effect.Effect, AzureDevOpsPullRequestCliError>; + }) => Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly reviewThreads: ReadonlyArray; + }, + AzureDevOpsPullRequestCliError + >; + + readonly getAttachmentAccessToken: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly setReviewVote: (input: { + readonly cwd: string; + readonly number: number; + readonly vote: "approve" | "wait-for-author"; + }) => Effect.Effect; + + readonly writeThread: (input: { + readonly cwd: string; + readonly location: AzureDevOpsRepositoryLocation; + readonly number: number; + readonly threadId?: string; + readonly commentId?: string; + readonly method: "POST" | "PATCH"; + readonly body: unknown; + readonly resource: "pullRequestThreads" | "pullRequestThreadComments"; + }) => Effect.Effect; /** * The pushes a pull request has had, oldest first. Azure hangs the changed files off an @@ -342,6 +374,7 @@ function isReviewerName(value: string): boolean { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; + const fileSystem = yield* FileSystem.FileSystem; // Every command resolves the organization, project and repository from the checkout, which is // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows @@ -603,9 +636,92 @@ export const make = Effect.gen(function* () { resource: "pullRequestThreads", routeParameters: pullRequestRoute(input), maxOutputBytes: REVIEW_HISTORY_MAX_OUTPUT_BYTES, - decode: decodeThreadsJson, + decode: (raw) => decodeThreadsJson(raw, input.pullRequestUrl), }), + getAttachmentAccessToken: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "account", + "get-access-token", + "--resource", + "499b84ac-1321-427f-aa17-267ca6975798", + "--query", + "accessToken", + ], + maxOutputBytes: 32_768, + }).pipe( + Effect.flatMap((result) => + decodeAccessToken(result.stdout).pipe( + Effect.mapError( + () => + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "getAttachmentAccessToken", + cause: "Azure CLI returned an invalid access token.", + }), + ), + ), + ), + ), + + writeThread: Effect.fn("AzureDevOpsPullRequestCli.writeThread")(function* (input) { + const body = yield* encodeThreadBody(input.body).pipe( + Effect.mapError( + (cause) => + new AzureDevOpsPullRequestReadError({ + command: "az", + cwd: input.cwd, + operation: "writeThread", + cause, + }), + ), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const bodyFile = yield* fileSystem.makeTempFileScoped({ prefix: "t3-azure-pr-" }); + yield* fileSystem.writeFileString(bodyFile, body); + yield* executeJson({ + cwd: input.cwd, + args: [ + "devops", + "invoke", + ...detectArgs, + "--area", + "git", + "--resource", + input.resource, + "--api-version", + REST_API_VERSION, + "--http-method", + input.method, + "--route-parameters", + ...pullRequestRoute(input), + ...(input.threadId === undefined ? [] : [`threadId=${input.threadId}`]), + ...(input.commentId === undefined ? [] : [`commentId=${input.commentId}`]), + "--in-file", + bodyFile, + ], + }); + }), + ).pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new AzureDevOpsCli.AzureDevOpsCommandFailedError({ + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: 0, + cause, + }), + ), + }), + ); + }), + listIterations: (input) => invoke({ cwd: input.cwd, @@ -697,6 +813,21 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.asVoid), + setReviewVote: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "set-vote", + ...detectArgs, + "--id", + String(input.number), + "--vote", + input.vote, + ], + }).pipe(Effect.asVoid), + runPullRequestAction: (input) => azure .execute({ diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index 1b96984cf76b..51b21bc52748 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/unstable/http"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { LOCATION_CACHE_CAPACITY, - make, + make as makeProvider, MAX_DIFF_SPAWNS, } from "./AzureDevOpsPullRequestProvider.ts"; import { @@ -17,6 +18,8 @@ import { } from "./azureDevOpsDiff.ts"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; +const make = makeProvider.pipe(Effect.provide(FetchHttpClient.layer)); + const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" }; const PULL_REQUEST = { @@ -418,3 +421,143 @@ describe("what one diff slice spends", () => { }), ); }); + +it.effect("submits Azure line reviews against the merge base with native votes", () => + Effect.gen(function* () { + const writes: Array< + Parameters[0] + > = []; + const votes: string[] = []; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed([ITERATION]), + listIterationChanges: () => + Effect.succeed({ + changes: [{ ...change("new.ts"), oldPath: "old.ts", changeTrackingId: 11 }], + truncated: false, + }), + writeThread: (input) => + Effect.sync(() => { + writes.push(input); + }), + setReviewVote: (input) => + Effect.sync(() => { + votes.push(input.vote); + }), + }), + ), + ); + const ref = { cwd: "/w", repository: "acme/web", host: "dev.azure.com", number: 7 }; + const positions = [ + { kind: "added", newLine: 8 }, + { kind: "deleted", oldLine: 4 }, + { kind: "context", side: "left", oldLine: 5, newLine: 9 }, + { kind: "context", side: "right", oldLine: 6, newLine: 10 }, + ] as const; + yield* provider.submitReview({ + ...ref, + verdict: "approve", + body: "Summary", + comments: positions.map((position) => ({ + path: "new.ts", + oldPath: "old.ts", + position, + body: "Check this", + })), + }); + expect(provider.capabilities.review).toMatchObject({ + inlineComment: true, + verdicts: ["comment", "approve", "request-changes"], + }); + expect(writes.slice(0, 4).map((write) => write.body)).toEqual( + [ + { rightFileStart: { line: 8, offset: 1 }, rightFileEnd: { line: 8, offset: 1 } }, + { leftFileStart: { line: 4, offset: 1 }, leftFileEnd: { line: 4, offset: 1 } }, + { leftFileStart: { line: 5, offset: 1 }, leftFileEnd: { line: 5, offset: 1 } }, + { rightFileStart: { line: 10, offset: 1 }, rightFileEnd: { line: 10, offset: 1 } }, + ].map((position) => ({ + status: "active", + comments: [{ parentCommentId: 0, content: "Check this", commentType: "text" }], + threadContext: { filePath: "/new.ts", ...position }, + pullRequestThreadContext: { + changeTrackingId: 11, + iterationContext: { firstComparingIteration: 3, secondComparingIteration: 3 }, + }, + })), + ); + expect(writes[4]?.body).toEqual({ + status: "active", + comments: [{ parentCommentId: 0, content: "Summary", commentType: "text" }], + }); + yield* provider.submitReview({ ...ref, verdict: "request-changes", body: "", comments: [] }); + yield* provider.submitReview({ + ...ref, + verdict: "comment", + body: "Comment only", + comments: [], + }); + expect(votes).toEqual(["approve", "wait-for-author"]); + expect(writes).toHaveLength(6); + }), +); + +it.effect.each(["missing-file", "missing-tracking", "no-iteration", "write-denied"] as const)( + "refuses an Azure review with %s before sending its vote", + (failure) => + Effect.gen(function* () { + let writes = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.succeed(failure === "no-iteration" ? [] : [ITERATION]), + listIterationChanges: () => + Effect.succeed({ + changes: [ + { ...change("a.ts"), changeTrackingId: 1 }, + ...(failure === "missing-file" + ? [] + : [ + { + ...change("b.ts"), + ...(failure === "missing-tracking" ? {} : { changeTrackingId: 2 }), + }, + ]), + ], + truncated: false, + }), + writeThread: () => + Effect.gen(function* () { + writes++; + return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ + command: "az", + cwd: "/w", + operation: "writeThread", + cause: "denied", + }); + }), + setReviewVote: () => Effect.die("Must not vote after a failed review"), + }), + ), + ); + const result = yield* provider + .submitReview({ + cwd: "/w", + repository: "acme/web", + host: "dev.azure.com", + number: 7, + verdict: "approve", + body: "Summary", + comments: ["a.ts", "b.ts"].map((path) => ({ + path, + body: "Review", + position: { kind: "added" as const, newLine: 1 }, + })), + }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(writes).toBe(failure === "write-denied" ? 1 : 0); + }), +); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index b194e5004734..9c8e0643e89e 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -1,8 +1,19 @@ import * as Effect from "effect/Effect"; +import * as Config from "effect/Config"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; +import { attachmentMarkdown } from "./PullRequestAttachments.ts"; +import { createPendingAttachmentId } from "../attachmentStore.ts"; import { azureDevOpsFilePatch, azureDevOpsUnreadableFilePatch, @@ -57,9 +68,13 @@ const CAPABILITIES: PullRequestCapabilities = { // Azure serves no patch of its own, so the one the Code tab reads is built here out of the // files an iteration changed and both sides of each of them. diff: true, - // Reading a conversation is a plain REST read, but posting one is not something this can - // claim without having run it, so the composer stays hidden. - comment: false, + comment: true, + attachments: { + supported: true, + maxBytes: 25 * 1024 * 1024, + destination: "pull-request", + reason: "Requires az login or AZURE_DEVOPS_EXT_PAT on the server.", + }, actions: [ "merge", "ready", @@ -74,18 +89,19 @@ const CAPABILITIES: PullRequestCapabilities = { // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. search: false, reactions: false, - // The patch has lines to write against, but writing a remark at all is what Azure is not - // offered for here, so nothing in a review is either. - review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` // lists the ones this repository could name — that lives behind the identity and graph APIs, a // different service with its own permissions. So the page takes a name here rather than being // handed a menu built out of a guess. reviewers: { request: true, listCandidates: false }, // A new title and description travel on the same `az repos pr update` that moves a pull request. - // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on - // Azure DevOps at all, so there is nothing here it could rewrite either. - edit: { changeRequest: true, comment: false }, + edit: { changeRequest: true, comment: true }, // Azure does keep a viewed record of its own, but only behind the undocumented contribution // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. // So they are kept here instead, and the client says whose they are rather than implying the @@ -148,6 +164,7 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq export const make = Effect.gen(function* () { const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + const httpClient = yield* HttpClient.HttpClient; // Made once with the provider, which the registry builds once, so this is the whole build's // allowance rather than one request's. const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); @@ -292,6 +309,116 @@ export const make = Effect.gen(function* () { }); }; + const writeThread = Effect.fn("AzureDevOpsPullRequestProvider.writeThread")(function* ( + input: { readonly cwd: string; readonly number: number }, + update: Omit[0], "cwd" | "number" | "location">, + ) { + if ( + [update.threadId, update.commentId].some((id) => id !== undefined && !/^[1-9]\d*$/.test(id)) + ) { + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "writeThread", + reason: "failed", + detail: "Invalid Azure DevOps thread or comment ID.", + }); + } + const location = yield* locationOf(input).pipe(Effect.mapError(fail("writeThread"))); + if (location === null) { + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "writeThread", + reason: "failed", + detail: "Azure DevOps did not return the pull request repository.", + }); + } + yield* cli + .writeThread({ ...input, ...update, location }) + .pipe(Effect.mapError(fail("writeThread"))); + }); + + const attachmentScope = Effect.fn("AzureDevOpsPullRequestProvider.attachmentScope")(function* ( + input: { readonly cwd: string; readonly number: number }, + operation: "uploadAttachment" | "readAttachment", + ) { + const pullRequest = yield* cli.getPullRequest(input).pipe(Effect.mapError(fail(operation))); + const url = URL.canParse(pullRequest.url) ? new URL(pullRequest.url) : null; + const projectPath = url?.pathname.split("/_git/")[0]; + if ( + !url || + url.protocol !== "https:" || + (url.hostname !== "dev.azure.com" && !url.hostname.endsWith(".visualstudio.com")) || + !projectPath || + !pullRequest.location + ) { + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Azure DevOps did not return a supported pull request URL.", + }); + } + const location = pullRequest.location; + return { + url, + projectPath, + location, + endpoint: `${url.origin}${projectPath}/_apis/git/repositories/${encodeURIComponent(location.repository)}/pullRequests/${input.number}/attachments/`, + }; + }); + + const attachmentRequest = Effect.fn("AzureDevOpsPullRequestProvider.attachmentRequest")( + function* ( + input: { readonly cwd: string }, + request: HttpClientRequest.HttpClientRequest, + operation: "uploadAttachment" | "readAttachment", + ) { + const pat = yield* Config.String("AZURE_DEVOPS_EXT_PAT").pipe( + Config.option, + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Could not read Azure DevOps attachment credentials.", + cause, + }), + ), + ); + if (Option.isSome(pat) && pat.value.trim()) { + request = request.pipe(HttpClientRequest.basicAuth("", pat.value)); + } else { + const token = yield* cli.getAttachmentAccessToken(input).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Attachments require az login or AZURE_DEVOPS_EXT_PAT on the server.", + cause, + }), + ), + ); + request = request.pipe(HttpClientRequest.bearerToken(token)); + } + return yield* httpClient.execute(request).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "azure-devops", + operation, + reason: "failed", + detail: "Could not access the Azure DevOps attachment.", + cause, + }), + ), + ); + }, + ); + const provider: PullRequestProviderApi = { kind: "azure-devops", capabilities: CAPABILITIES, @@ -390,23 +517,28 @@ export const make = Effect.gen(function* () { Effect.mapError(fail("getChangeRequestActivity")), Effect.flatMap((pullRequest) => (pullRequest.location === null - ? Effect.succeed({ comments: [], truncated: true }) + ? Effect.succeed({ comments: [], reviewThreads: [], truncated: true }) : cli .listThreads({ cwd: input.cwd, + pullRequestUrl: pullRequest.url, location: pullRequest.location, number: input.number, }) .pipe( - Effect.map((comments) => ({ comments, truncated: false })), - Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + Effect.map((conversation) => ({ ...conversation, truncated: false })), + Effect.orElseSucceed(() => ({ + comments: [], + reviewThreads: [], + truncated: true, + })), ) ).pipe( Effect.map((conversation): ProviderChangeRequestActivity => ({ comments: conversation.comments, commentCount: conversation.comments.length, commentsTruncated: conversation.truncated, - reviewThreads: [], + reviewThreads: conversation.reviewThreads, commits: [], })), ), @@ -626,16 +758,241 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("setReviewerRequest"))), - // Never called: `capabilities.comment` is false, and the service refuses a comment without it. - comment: () => unsupported("comment"), - - // Declared unsupported above, so the service refuses these before a provider is reached. - // They exist because every provider answers the whole port. - submitReview: () => unsupported("submitReview"), + uploadAttachment: Effect.fn("AzureDevOpsPullRequestProvider.uploadAttachment")( + function* (input) { + const scope = yield* attachmentScope(input, "uploadAttachment"); + const name = `${createPendingAttachmentId()}-${input.name}`; + const response = yield* attachmentRequest( + input, + HttpClientRequest.post( + `${scope.endpoint}${encodeURIComponent(name)}?api-version=7.1`, + ).pipe( + HttpClientRequest.bodyUint8Array(input.data, "application/octet-stream"), + HttpClientRequest.acceptJson, + ), + "uploadAttachment", + ); + if (response.status < 200 || response.status >= 300) + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "uploadAttachment", + reason: "failed", + detail: `Azure DevOps rejected the attachment (HTTP ${response.status}).`, + }); + const attachment = yield* HttpClientResponse.schemaBodyJson( + Schema.Struct({ url: Schema.String }), + )(response).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "azure-devops", + operation: "uploadAttachment", + reason: "failed", + detail: "Azure DevOps returned an invalid attachment URL.", + cause, + }), + ), + ); + if (!URL.canParse(attachment.url) || new URL(attachment.url).protocol !== "https:") + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "uploadAttachment", + reason: "failed", + detail: "Azure DevOps returned an invalid attachment URL.", + }); + return { + url: attachment.url, + markdown: attachmentMarkdown(attachment.url, input.name, input.mimeType), + }; + }, + ), + + readAttachment: Effect.fn("AzureDevOpsPullRequestProvider.readAttachment")(function* (input) { + const scope = yield* attachmentScope(input, "readAttachment"); + const url = URL.canParse(input.url) ? new URL(input.url) : null; + if (!url || url.origin !== scope.url.origin || url.username || url.password) { + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "readAttachment", + reason: "failed", + detail: "The attachment URL does not belong to this pull request.", + }); + } + const [path, projectPath] = yield* Effect.try({ + try: () => + [decodeURIComponent(url.pathname), decodeURIComponent(scope.projectPath)] as const, + catch: (cause) => + new PullRequestProviderError({ + provider: "azure-devops", + operation: "readAttachment", + reason: "failed", + detail: "Invalid attachment path.", + cause, + }), + }); + const match = + /^(.*)\/_apis\/git\/repositories\/([^/]+)\/pullRequests\/(\d+)\/attachments\/([^/]+)$/i.exec( + path, + ); + const projectRoot = projectPath.slice(0, projectPath.lastIndexOf("/")); + const projects = [ + projectPath, + ...(scope.location.projectId ? [`${projectRoot}/${scope.location.projectId}`] : []), + ]; + const repository = match?.[2]; + const isRepositoryId = + scope.location.repositoryId !== undefined && + repository?.toLowerCase() === scope.location.repositoryId.toLowerCase(); + if ( + !match || + (repository?.toLowerCase() !== scope.location.repository.toLowerCase() && + !isRepositoryId) || + Number(match[3]) !== input.number || + (!projects.some((project) => project.toLowerCase() === match[1]!.toLowerCase()) && + !(isRepositoryId && match[1]!.toLowerCase() === projectRoot.toLowerCase())) || + /[\\\p{Cc}]/u.test(match[4]!) || + match[4] === "." || + match[4] === ".." + ) { + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "readAttachment", + reason: "failed", + detail: "The attachment URL does not belong to this pull request.", + }); + } + const headers = Object.fromEntries( + Object.entries(input.headers).filter(([name]) => + ["range", "if-range", "if-none-match", "if-modified-since", "accept"].includes( + name.toLowerCase(), + ), + ), + ); + return yield* attachmentRequest( + input, + HttpClientRequest.get( + `${scope.endpoint}${encodeURIComponent(match[4]!)}?api-version=7.1`, + ).pipe(HttpClientRequest.setHeaders(headers)), + "readAttachment", + ); + }), + + comment: (input) => + writeThread(input, { + resource: "pullRequestThreads", + method: "POST", + body: { + status: "active", + comments: [{ parentCommentId: 0, content: input.body, commentType: "text" }], + }, + }), - replyToThread: () => unsupported("replyToThread"), + updateComment: (input) => { + const ids = /^([1-9]\d*):([1-9]\d*)$/.exec(input.commentId); + if (!ids) + return Effect.fail( + new PullRequestProviderError({ + provider: "azure-devops", + operation: "updateComment", + reason: "failed", + detail: "Invalid Azure DevOps comment ID.", + }), + ); + return writeThread(input, { + resource: "pullRequestThreadComments", + method: "PATCH", + threadId: ids[1]!, + commentId: ids[2]!, + body: { content: input.body }, + }); + }, + + submitReview: Effect.fn("AzureDevOpsPullRequestProvider.submitReview")(function* (input) { + const threads: Array[0]> = []; + if (input.comments.length > 0) { + const scope = yield* diffScope(input).pipe(Effect.mapError(fail("submitReview"))); + const latest = scope?.iterations.at(-1); + if (!scope || !latest) + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "submitReview", + reason: "failed", + detail: + "Azure DevOps did not return the review iteration. Refresh the pull request before submitting.", + }); + const changes = yield* listLatestChanges({ ...input, ...scope }).pipe( + Effect.mapError(fail("submitReview")), + ); + for (const comment of input.comments) { + const change = changes.changes.find( + (entry) => + entry.path === comment.path && + (comment.oldPath === undefined || entry.oldPath === comment.oldPath), + ); + if (!change?.changeTrackingId || change.changeTrackingId < 1) + return yield* new PullRequestProviderError({ + provider: "azure-devops", + operation: "submitReview", + reason: "failed", + detail: `Azure DevOps did not return the review position for ${comment.path}. Refresh the pull request before submitting.`, + }); + const position = comment.position; + const left = + position.kind === "deleted" || + (position.kind === "context" && position.side === "left"); + const anchor = { line: left ? position.oldLine : position.newLine, offset: 1 }; + threads.push({ + ...input, + location: scope.location, + resource: "pullRequestThreads", + method: "POST", + body: { + status: "active", + comments: [{ parentCommentId: 0, content: comment.body, commentType: "text" }], + threadContext: { + filePath: `/${change.path}`, + ...(left + ? { leftFileStart: anchor, leftFileEnd: anchor } + : { rightFileStart: anchor, rightFileEnd: anchor }), + }, + pullRequestThreadContext: { + changeTrackingId: change.changeTrackingId, + iterationContext: { + firstComparingIteration: latest.id, + secondComparingIteration: latest.id, + }, + }, + }, + }); + } + } + for (const thread of threads) + yield* cli.writeThread(thread).pipe(Effect.mapError(fail("submitReview"))); + if (input.body.trim()) yield* provider.comment(input); + if (input.verdict !== "comment") + yield* cli + .setReviewVote({ + ...input, + vote: input.verdict === "approve" ? "approve" : "wait-for-author", + }) + .pipe(Effect.mapError(fail("submitReview"))); + }), + + replyToThread: (input) => + writeThread(input, { + resource: "pullRequestThreadComments", + method: "POST", + threadId: input.threadId, + body: { parentCommentId: 1, content: input.body, commentType: "text" }, + }), - setThreadResolution: () => unsupported("setThreadResolution"), + setThreadResolution: (input) => + writeThread(input, { + resource: "pullRequestThreads", + method: "PATCH", + threadId: input.threadId, + body: { status: input.resolved ? "fixed" : "active" }, + }), setReaction: () => unsupported("setReaction"), }; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 49cdaa90f7d2..609c450ccc60 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -909,7 +909,7 @@ layer("BitbucketPullRequestApi.layer", (it) => { const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); - assert.strictEqual(threads.length, 1); + assert.strictEqual(threads.length, 2); expect(threads[0]).toMatchObject({ id: "10", path: "src/a.ts", @@ -918,6 +918,7 @@ layer("BitbucketPullRequestApi.layer", (it) => { isResolved: true, }); expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); + expect(threads[1]).toMatchObject({ id: "13", path: null, line: null }); }), ); @@ -1141,3 +1142,36 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); }); + +layer("attachments", (it) => { + it.effect("uploads attachments as unique files in the current repository Downloads", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response(""))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + const input = { + cwd: "/repo", + repository: "owner/repo", + host: "bitbucket.org", + number: 7, + name: "log.txt", + mimeType: "text/plain", + data: new Uint8Array([0, 255, 128]), + filePath: "/tmp/log.txt", + }; + const first = yield* api.uploadAttachment(input); + const second = yield* api.uploadAttachment(input); + expect(first.url).not.toBe(second.url); + expect(first.url).toMatch( + /^https:\/\/bitbucket\.org\/owner\/repo\/downloads\/pending-[a-f0-9-]+-log\.txt$/, + ); + const request = mockedRequest.mock.calls.at(-1)?.[0]; + expect(request?.url).toBe("/repositories/owner/repo/downloads"); + expect(request?.method).toBe("POST"); + const file = request?.formData?.get("files"); + expect(file).toBeInstanceOf(File); + if (!(file instanceof File)) throw new Error("Expected attachment file"); + expect(file.name).toBe(new URL(second.url).pathname.split("/").at(-1)); + expect(new Uint8Array(yield* Effect.promise(() => file.arrayBuffer()))).toEqual(input.data); + }), + ); +}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index c7d017071fc6..0e7fe3c7950d 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,3 +1,7 @@ +import { createPendingAttachmentId } from "../attachmentStore.ts"; +import { attachmentMarkdown } from "./PullRequestAttachments.ts"; +import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; +import { PullRequestProviderError } from "./PullRequestProvider.ts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -275,6 +279,8 @@ export class BitbucketPullRequestApi extends Context.Service< readonly body: string; }) => Effect.Effect; + readonly uploadAttachment: NonNullable; + readonly readAttachment: BitbucketApi.BitbucketApi["Service"]["readAttachment"]; readonly updateComment: (input: { readonly repository: string; readonly number: number; @@ -856,6 +862,33 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid), ), + readAttachment: bitbucket.readAttachment, + uploadAttachment: (input) => + withRepository(input.repository, (path) => + Effect.gen(function* () { + const name = `${createPendingAttachmentId()}-${input.name}`; + const formData = new FormData(); + formData.set( + "files", + new Blob([new Uint8Array(input.data)], { type: input.mimeType }), + name, + ); + yield* bitbucket.request({ method: "POST", url: `${path}/downloads`, formData }); + const url = `https://bitbucket.org${path.slice("/repositories".length)}/downloads/${encodeURIComponent(name)}`; + return { url, markdown: attachmentMarkdown(url, input.name, input.mimeType) }; + }), + ).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "bitbucket", + operation: "uploadAttachment", + reason: "failed", + detail: error.message, + }), + ), + ), + updateComment: (input) => withRepository(input.repository, (path) => bitbucket diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 634519d467b9..a87c1e39cc42 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { NATIVE_ATTACHMENT_CAPABILITY } from "./PullRequestAttachments.ts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -13,6 +14,11 @@ import { import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { + attachments: { + ...NATIVE_ATTACHMENT_CAPABILITY, + destination: "repository-downloads", + reason: "Files are stored in this repository’s Downloads. Requires repository write access.", + }, diff: true, comment: true, // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that @@ -153,6 +159,8 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: "bitbucket", capabilities: CAPABILITIES, + readAttachment: (input) => + api.readAttachment(input).pipe(Effect.mapError(fail("readAttachment"))), // Bitbucket credentials come from the server's environment rather than a checkout, so the // account is the same whichever workspace asks. @@ -316,6 +324,7 @@ export const make = Effect.gen(function* () { .comment({ repository: input.repository, number: input.number, body: input.body }) .pipe(Effect.mapError(fail("comment"))), + uploadAttachment: (input) => api.uploadAttachment(input), updateComment: (input) => api .updateComment({ diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.test.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.test.ts new file mode 100644 index 000000000000..e94e720286d3 --- /dev/null +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { ForgejoCli, ForgejoCliError, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts"; +import { make } from "./ForgejoPullRequestProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +it.effect("uses released Gitea resolution APIs without advertising them on Forgejo", () => + Effect.gen(function* () { + for (const [version, canResolve] of [ + ["1.25.3", false], + ["1.26.0", true], + ["16.0.0", false], + ["1.21.0+forgejo", false], + ] as const) { + const calls: ForgejoApiInput[] = []; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(ForgejoCli)({ + resolveRepository: () => + Effect.fail( + new ForgejoCliError({ + command: "fj", + cwd: "/repo", + detail: "No attachment account", + reason: "authentication", + }), + ), + api: (input) => { + calls.push(input); + const body = + input.path === "user" + ? { login: "reviewer" } + : input.path === "repos/owner/repo" + ? { full_name: "owner/repo", permissions: { push: false, admin: false } } + : input.path === "repos/owner/repo/pulls/4" + ? { + number: 4, + title: "PR", + body: "", + html_url: "https://git.example/owner/repo/pulls/4", + user: { login: "author" }, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-09-18T00:00:00Z", + updated_at: "2026-09-18T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + } + : input.path.includes("/statuses/") + ? [] + : { version }; + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: encodeJson(body), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }), + ), + ); + const ref = { cwd: "/repo", repository: "owner/repo", host: "git.example", number: 4 }; + const capabilities = yield* provider.getCapabilities!(ref); + expect(capabilities.review).toMatchObject({ resolve: canResolve, reply: false }); + if (canResolve) { + yield* provider.setThreadResolution({ ...ref, threadId: "42", resolved: true }); + yield* provider.setThreadResolution({ ...ref, threadId: "42", resolved: false }); + expect(calls.slice(1).map(({ path, method }) => ({ path, method }))).toEqual([ + { path: "repos/owner/repo/pulls/comments/42/resolve", method: "POST" }, + { path: "repos/owner/repo/pulls/comments/42/unresolve", method: "POST" }, + ]); + const permissions = yield* provider.getViewerPermissions!(ref); + expect(permissions.resolve).toBe(true); + } else { + const result = yield* provider + .setThreadResolution({ ...ref, threadId: "42", resolved: true }) + .pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(calls).toHaveLength(1); + } + expect(calls.filter((call) => call.path === "version")).toHaveLength(1); + const detail = yield* provider.getChangeRequest(ref); + expect(detail.title).toBe("PR"); + expect(detail.attachments?.supported).toBe(false); + expect(detail.attachments?.reason).toContain("fj authentication"); + } + }), +); diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts index fddedbf6059c..0d47590456e9 100644 --- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -1,7 +1,9 @@ +import { attachmentMarkdown, NATIVE_ATTACHMENT_CAPABILITY } from "./PullRequestAttachments.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; +import { pullRequestMediaUrl } from "@t3tools/shared/pullRequestMedia"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts"; import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts"; @@ -33,7 +35,13 @@ import { forgejoReactions, } from "./forgejoPullRequestJson.ts"; +const ForgejoAttachment = Schema.Struct({ browser_download_url: Schema.String }); + const CAPABILITIES: PullRequestCapabilities = { + attachments: { + ...NATIVE_ATTACHMENT_CAPABILITY, + reason: "Requires fj authentication. The server may set a smaller file limit.", + }, diff: true, viewedFiles: "environment", comment: true, @@ -175,7 +183,7 @@ export const make = Effect.gen(function* () { ) : [], comment: active && (!pr.is_locked || canWrite), - resolve: false, + resolve: active, verdicts: active ? pr.user?.login === viewer ? ["comment"] @@ -187,6 +195,34 @@ export const make = Effect.gen(function* () { active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [], }; }; + const hostCapabilities = new Map(); + const getCapabilities = Effect.fn("ForgejoPullRequestProvider.getCapabilities")(function* ( + input: ProviderRepositoryRef, + ) { + const key = `${input.cwd}:${input.host}`; + const cached = hostCapabilities.get(key); + if (cached) return cached; + const version = yield* read( + { ...input, path: "version" }, + Schema.Struct({ version: Schema.String }), + ).pipe(Effect.orElseSucceed(() => null)); + if (version === null) return CAPABILITIES; + const giteaVersion = /^1\.(\d+)\.\d+(?:[+-][\w.-]+)?$/.exec(version.version); + const capabilities = { + ...CAPABILITIES, + review: { + ...CAPABILITIES.review, + resolve: + giteaVersion !== null && + Number(giteaVersion[1]) >= 26 && + !/forgejo/i.test(version.version), + }, + }; + if (hostCapabilities.size >= 128) + hostCapabilities.delete(hostCapabilities.keys().next().value!); + hostCapabilities.set(key, capabilities); + return capabilities; + }); const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* ( input: ProviderRepositoryRef & { readonly number: number }, ) { @@ -201,6 +237,7 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: "forgejo", capabilities: CAPABILITIES, + getCapabilities, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( function* (input) { @@ -240,6 +277,9 @@ export const make = Effect.gen(function* () { [getPull(input), getRepo(input), getViewer(input)], { concurrency: 3 }, ); + const attachmentAuth = yield* cli + .resolveRepository(input) + .pipe(Effect.orElseSucceed(() => null)); const statuses = yield* page( { ...input, @@ -249,6 +289,14 @@ export const make = Effect.gen(function* () { ); return { ...forgejoChangeRequest(pr), + attachments: { + ...NATIVE_ATTACHMENT_CAPABILITY, + supported: attachmentAuth?.command === "fj", + reason: + attachmentAuth?.command === "fj" + ? "Uploads use this server’s fj account." + : "Attachment uploads require fj authentication. tea does not support multipart uploads.", + }, body: pr.body ?? "", changedFiles: pr.changed_files ?? 0, reviewers: (pr.requested_reviewers ?? []).flatMap((user) => { @@ -454,6 +502,52 @@ export const make = Effect.gen(function* () { method: "POST", body: { body: input.body }, }), + readAttachment: Effect.fn("ForgejoPullRequestProvider.readAttachment")(function* (input) { + const url = pullRequestMediaUrl({ ...input, provider: "forgejo" }); + if (!url || !cli.readAttachment) + return yield* failure( + "readAttachment", + "This Forgejo account cannot read private attachments.", + ); + const assets = yield* page( + { ...input, path: `${issuePath(input)}/assets` }, + ForgejoAttachment, + ); + if (!assets.items.some((asset) => asset.browser_download_url === url)) + return yield* failure( + "readAttachment", + "The attachment does not belong to this pull request.", + ); + return yield* cli + .readAttachment(input) + .pipe(Effect.mapError((cause) => failure("readAttachment", cause.detail))); + }), + uploadAttachment: Effect.fn("ForgejoPullRequestProvider.uploadAttachment")(function* (input) { + const formData = new FormData(); + formData.set( + "attachment", + new Blob([new Uint8Array(input.data)], { type: input.mimeType }), + input.name, + ); + const asset = yield* read( + { + cwd: input.cwd, + repository: input.repository, + host: input.host, + path: `${issuePath(input)}/assets`, + method: "POST", + formData, + }, + Schema.Struct({ + browser_download_url: Schema.String.check(Schema.isPattern(/^https?:\/\//)), + }), + ); + return { + url: asset.browser_download_url, + markdown: attachmentMarkdown(asset.browser_download_url, input.name, input.mimeType), + }; + }), + updateComment: (input) => write({ ...input, @@ -591,7 +685,19 @@ export const make = Effect.gen(function* () { }); }), replyToThread: () => unsupported("thread replies"), - setThreadResolution: () => unsupported("thread resolution"), + setThreadResolution: Effect.fn("ForgejoPullRequestProvider.setThreadResolution")( + function* (input) { + if (!/^[1-9]\d*$/.test(input.threadId)) + return yield* failure("setThreadResolution", "Invalid review comment ID."); + const capabilities = yield* getCapabilities(input); + if (!capabilities.review.resolve) return yield* unsupported("thread resolution"); + yield* write({ + ...input, + path: `${repoPath(input)}/pulls/comments/${input.threadId}/${input.resolved ? "resolve" : "unresolve"}`, + method: "POST", + }); + }, + ), }; return provider; }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index fd5ab04e8a81..52f097324a1f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1747,6 +1747,31 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("requests an admin merge only when explicitly selected", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "merge", + mergeMethod: "squash", + bypassMergeChecks: true, + }); + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--squash", + "--admin", + ]); + }), + ); + it.effect("merges with the strategy it was asked for", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(""))); @@ -3221,6 +3246,25 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("keeps explicit admin bypass permission in the core detail read", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + for (const [index, allowed] of [true, false, undefined].entries()) { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(encodeJson(coreResponse({ viewerCanMergeAsAdmin: allowed })))), + ); + const detail = yield* cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: index + 70, + }); + expect(detail.viewerAccess.canBypassMergeChecks === true).toBe(allowed === true); + expect(callAt(index).args.at(-1)).toContain("viewerCanMergeAsAdmin"); + } + }), + ); + it.effect("keeps the core detail read separate from conversation activity", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 723fcfc4d9b9..d67e8bde9899 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,5 @@ +import { uploadGitHubAttachment, readGitHubAttachment } from "./PullRequestAttachments.ts"; +import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; @@ -652,6 +654,9 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor: string; }) => Effect.Effect; + readonly readAttachment?: NonNullable; + readonly uploadAttachment: NonNullable; + /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ readonly getViewerAccess: (input: { readonly cwd: string; @@ -710,6 +715,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly stackNumber?: number; readonly expectedStackHeads?: ReadonlyArray; readonly mergeMethod?: PullRequestMergeMethod; + readonly bypassMergeChecks?: boolean; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -1031,10 +1037,11 @@ function actionArgs( action: PullRequestAction, mergeMethod: PullRequestMergeMethod | undefined, updateMethod: PullRequestUpdateMethod | undefined, + bypassMergeChecks = false, ): ReadonlyArray { switch (action) { case "merge": - return ["merge", `--${mergeMethod ?? "merge"}`]; + return ["merge", `--${mergeMethod ?? "merge"}`, ...(bypassMergeChecks ? ["--admin"] : [])]; // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub // stores the strategy with the standing instruction rather than choosing one at merge time. case "enable-auto-merge": @@ -1728,6 +1735,8 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map(([, runs]) => runs)); return GitHubPullRequestCli.of({ + uploadAttachment: (input) => uploadGitHubAttachment(github.execute, input), + readAttachment: (input) => readGitHubAttachment(github.execute, input), withVerifiedCredential, getRoutingIdentity, getViewerLogin: (input) => @@ -2539,6 +2548,7 @@ export const make = Effect.gen(function* () { input.action, input.mergeMethod, input.updateMethod, + input.bypassMergeChecks, ); return github .execute({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 1882b692f2b0..5a4fa3ab0e4a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -1017,3 +1017,17 @@ describe("loginAvatarUrl", () => { } }); }); + +it("forwards only the host's explicit permission to bypass merge checks", () => { + for (const allowed of [true, false, undefined]) { + expect( + gitHubViewerPermissions({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + ...(allowed === undefined ? {} : { canBypassMergeChecks: allowed }), + }).bypassMergeChecks === true, + ).toBe(allowed === true); + } +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 0b76fdda88fc..81e1d2772935 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { GITHUB_ATTACHMENT_CAPABILITY } from "./PullRequestAttachments.ts"; import * as Effect from "effect/Effect"; import type { PullRequestActor, @@ -18,6 +19,8 @@ import { import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { + attachments: GITHUB_ATTACHMENT_CAPABILITY, + bypassMergeChecks: true, diff: true, comment: true, actions: [ @@ -70,6 +73,7 @@ const CAPABILITIES: PullRequestCapabilities = { export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { return { ...(access.canWrite ? { stackRebase: true } : {}), + ...(access.canBypassMergeChecks === true ? { bypassMergeChecks: true } : {}), actions: [ // Arming a merge and taking the arming back are the merge, deferred: whoever may not // merge here may not leave an instruction to merge later either. @@ -349,6 +353,15 @@ export const make = Effect.gen(function* () { return approvals.pipe( Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({ ...pullRequest, + attachments: { + ...GITHUB_ATTACHMENT_CAPABILITY, + supported: + pullRequest.viewerAccess.canWrite && + (input.host === "github.com" || /^[a-z0-9-]+\.ghe\.com$/.test(input.host)), + ...(!pullRequest.viewerAccess.canWrite + ? { reason: "Attaching files requires write access to this repository." } + : {}), + }, author: withAvatar(pullRequest.author, new Map(), input.host), checks: withWorkflowApprovals( pullRequest.checks, @@ -571,6 +584,7 @@ export const make = Effect.gen(function* () { ? {} : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.bypassMergeChecks === true ? { bypassMergeChecks: true } : {}), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) .pipe(Effect.mapError(fail("runAction"))), @@ -589,6 +603,8 @@ export const make = Effect.gen(function* () { comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + uploadAttachment: (input) => cli.uploadAttachment(input), + ...(cli.readAttachment ? { readAttachment: cli.readAttachment } : {}), updateComment: (input) => cli .updateComment({ diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index daa85ee9eb8f..3158e9c3e865 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1,11 +1,14 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const mockedExecute = vi.fn(); const layer = it.layer( @@ -963,8 +966,11 @@ layer("GitLabPullRequestCli.layer", (it) => { }, ], }, - // A plain note is the timeline's business, not the diff's. - { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, + { + id: "def456", + individual_note: true, + notes: [{ id: 3, body: "ship it", created_at: "2026-07-01T00:00:00Z" }], + }, ]), ), ), @@ -977,7 +983,7 @@ layer("GitLabPullRequestCli.layer", (it) => { number: 7, }); - assert.strictEqual(threads.length, 1); + assert.strictEqual(threads.length, 2); expect(threads[0]).toMatchObject({ id: "abc123", path: "src/a.ts", @@ -989,6 +995,190 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + for (const [name, response, expected] of [ + [ + "18.8 without mutation", + { data: { __type: null, currentUser: { username: "octocat" } } }, + null, + ], + [ + "18.9 with mutation", + { + data: { + __type: { name: "MergeRequestRequestChangesInput" }, + currentUser: { username: "octocat" }, + }, + }, + "octocat", + ], + ["disabled introspection", { errors: [{ message: "Introspection is disabled" }] }, null], + ] as const) { + it.effect(`detects request-changes support: ${name}`, () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + expect(yield* cli.getRequestChangesViewer({ cwd: "/w" })).toBe(expected); + expect(decodeJson(callAt(0).stdin ?? "{}")).toMatchObject({ + query: expect.stringContaining('__type(name: "MergeRequestRequestChangesInput")'), + }); + }), + ); + } + + for (const ErrorType of [ + GitLabCli.GitLabCliCommandError, + GitLabCli.GitLabCliRateLimitError, + GitLabCli.GitLabCliAuthenticationError, + GitLabCli.GitLabCliUnavailableError, + ]) { + it.effect(`handles capability probe failure: ${ErrorType.name}`, () => + Effect.gen(function* () { + const failure = new ErrorType({ + command: "glab", + cwd: "/w", + operation: "execute", + cause: "host refusal", + }); + mockedExecute.mockReturnValueOnce(Effect.fail(failure)); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const result = yield* Effect.result(cli.getRequestChangesViewer({ cwd: "/w" })); + if (failure._tag !== "GitLabCliCommandError") { + expect(result).toMatchObject({ _tag: "Failure", failure }); + } else { + expect(result).toMatchObject({ _tag: "Success", success: null }); + } + }), + ); + } + + for (const failure of [ + "unsupported", + "no-permission", + "not-reviewer", + "payload-error", + "graphql-error", + null, + ]) { + it.effect(`requests changes without publishing unrelated drafts: ${failure ?? "success"}`, () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + __type: + failure === "unsupported" ? null : { name: "MergeRequestRequestChangesInput" }, + currentUser: { username: "octocat" }, + project: { + mergeRequest: { + userPermissions: { updateMergeRequest: failure !== "no-permission" }, + }, + }, + }, + }), + ), + ), + ); + if (failure !== "unsupported" && failure !== "no-permission") { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + mergeRequestJson({ + reviewers: failure === "not-reviewer" ? [] : [reviewer], + }), + ), + ), + ); + } + if ( + failure !== "unsupported" && + failure !== "no-permission" && + failure !== "not-reviewer" + ) { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + failure === "graphql-error" + ? { errors: [{ message: "Permission denied" }] } + : { + data: { + mergeRequestRequestChanges: { + errors: failure === "payload-error" ? ["Reviewer not found"] : [], + }, + }, + }, + ), + ), + ), + ); + } + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const result = yield* Effect.result( + cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "request-changes", + body: "Please fix this", + comments: [], + }), + ); + expect(result._tag).toBe(failure === null ? "Success" : "Failure"); + const writes = mockedExecute.mock.calls + .map(([input]) => input) + .filter( + (input) => + input.args.some((arg) => arg.endsWith("/notes")) || + input.stdin?.includes("mutation("), + ); + if ( + failure === "unsupported" || + failure === "no-permission" || + failure === "not-reviewer" + ) { + expect(writes).toEqual([]); + } else { + expect(writes).toHaveLength(2); + const mutation = decodeJson(writes[1]!.stdin ?? "{}"); + expect(mutation).toMatchObject({ + query: expect.stringContaining("mergeRequestRequestChanges"), + variables: { projectPath: "acme/web", iid: "7" }, + }); + } + expect( + mockedExecute.mock.calls.some(([input]) => + input.args.some((arg) => arg.includes("draft_notes")), + ), + ).toBe(false); + if (result._tag === "Failure") { + const unavailable = failure === "unsupported" || failure === "no-permission"; + expect(result.failure).toMatchObject({ + _tag: unavailable + ? "GitLabRequestChangesUnavailableError" + : failure === "not-reviewer" + ? "GitLabReviewerRequiredError" + : "GitLabRequestChangesRejectedError", + number: 7, + }); + expect(result.failure.detail).toBe( + unavailable + ? "Request changes is unavailable for this account on this GitLab server." + : failure === "not-reviewer" + ? "You must be an assigned reviewer to request changes on this merge request." + : "GitLab did not confirm the request for changes. Check your reviewer assignment and permission to update this merge request.", + ); + if (failure === "payload-error") { + expect(result.failure.cause).toEqual({ + data: { mergeRequestRequestChanges: { errors: ["Reviewer not found"] } }, + }); + } + } + }), + ); + } + it.effect("sends a review as its comments, then its summary, then the verdict", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -1088,6 +1278,15 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(argsOfCall(0)[1]).toContain("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/discussions/abc123"); // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); + yield* cli.setDiscussionResolution({ + cwd: "/w", + repository: "acme/web", + number: 7, + discussionId: "abc123", + resolved: false, + }); + expect(argsOfCall(1)).toContain("PUT"); + expect(callAt(1).stdin).toBe('{"resolved":false}'); }), ); @@ -1402,6 +1601,26 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(callAt(0).stdin).toBe('{"body":"true"}'); }), ); + it.effect("edits a discussion note through its encoded thread and note ids", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateNote({ + cwd: "/w", + repository: "acme/web", + number: 7, + discussionId: "discussion/id", + noteId: "42/part", + body: "Updated reply", + }); + + expect(argsOfCall(0)[1]).toBe( + "projects/acme%2Fweb/merge_requests/7/discussions/discussion%2Fid/notes/42%2Fpart", + ); + expect(callAt(0).stdin).toBe('{"body":"Updated reply"}'); + }), + ); it.effect("reads blob ids for the marked paths at the merge request's head", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 5a07bb1ae37c..2d09883ead65 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -1,3 +1,5 @@ +import { uploadGitLabAttachment, readGitLabAttachment } from "./PullRequestAttachments.ts"; +import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -177,6 +179,86 @@ export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError()( + "GitLabRequestChangesUnavailableError", + { number: Schema.Int }, +) { + get detail(): string { + return "Request changes is unavailable for this account on this GitLab server."; + } + + override get message(): string { + return this.detail; + } +} + +export class GitLabReviewerRequiredError extends Schema.TaggedError()( + "GitLabReviewerRequiredError", + { number: Schema.Int }, +) { + get detail(): string { + return "You must be an assigned reviewer to request changes on this merge request."; + } + + override get message(): string { + return this.detail; + } +} + +export class GitLabRequestChangesRejectedError extends Schema.TaggedError()( + "GitLabRequestChangesRejectedError", + { number: Schema.Int, cause: Schema.Defect() }, +) { + get detail(): string { + return "GitLab did not confirm the request for changes. Check your reviewer assignment and permission to update this merge request."; + } + + override get message(): string { + return this.detail; + } +} + export type GitLabPullRequestCliError = | GitLabCli.GitLabCliError | GitLabMergeRequestReadError @@ -185,7 +267,10 @@ export type GitLabPullRequestCliError = | GitLabDiffCommitParentUnavailableError | GitLabDiffFileContentsUnavailableError | GitLabDiffRefsUnavailableError - | GitLabViewerUnavailableError; + | GitLabViewerUnavailableError + | GitLabRequestChangesUnavailableError + | GitLabReviewerRequiredError + | GitLabRequestChangesRejectedError; /** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ const MAX_PAGE_SIZE = 100; @@ -219,6 +304,12 @@ export interface GitLabMergeRequestDiffSlice { export class GitLabPullRequestCli extends Context.Service< GitLabPullRequestCli, { + readonly getRequestChangesViewer: (input: { + readonly cwd: string; + readonly repository?: string; + readonly number?: number; + }) => Effect.Effect; + readonly getViewerUsername: (input: { readonly cwd: string; }) => Effect.Effect; @@ -348,6 +439,7 @@ export class GitLabPullRequestCli extends Context.Service< readonly repository: string; readonly number: number; readonly noteId: string; + readonly discussionId?: string; readonly body: string; }) => Effect.Effect; @@ -369,6 +461,8 @@ export class GitLabPullRequestCli extends Context.Service< readonly comments: ReadonlyArray; }) => Effect.Effect; + readonly readAttachment?: NonNullable; + readonly uploadAttachment: NonNullable; readonly replyToDiscussion: (input: { readonly cwd: string; readonly repository: string; @@ -791,7 +885,6 @@ export const make = Effect.gen(function* () { }), ); - /** The positioned discussions, walked the same way and stopped by the same bound. */ const discussionsPage = (input: { readonly cwd: string; readonly repository: string; @@ -1150,8 +1243,47 @@ export const make = Effect.gen(function* () { }), ); + const getRequestChangesViewer = (input: { + readonly cwd: string; + readonly repository?: string; + readonly number?: number; + }) => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: encodeGraphqlRequest( + input.number === undefined + ? { + query: + '{ __type(name: "MergeRequestRequestChangesInput") { name } currentUser { username } }', + } + : { + query: + 'query($projectPath: ID!, $iid: String!) { __type(name: "MergeRequestRequestChangesInput") { name } currentUser { username } project(fullPath: $projectPath) { mergeRequest(iid: $iid) { userPermissions { updateMergeRequest } } } }', + variables: { projectPath: input.repository, iid: String(input.number) }, + }, + ), + }).pipe( + Effect.flatMap((result) => decodeRequestChangesViewer(result.stdout)), + Effect.map((result) => + result.data.__type?.name === "MergeRequestRequestChangesInput" && + (input.number === undefined || + result.data.project?.mergeRequest?.userPermissions.updateMergeRequest === true) + ? (result.data.currentUser?.username ?? null) + : null, + ), + Effect.catchTags({ + GitLabCliCommandError: () => Effect.succeed(null), + SchemaError: () => Effect.succeed(null), + }), + ); + return GitLabPullRequestCli.of({ + uploadAttachment: (input) => uploadGitLabAttachment(gitlab.execute, input), + readAttachment: (input) => readGitLabAttachment(gitlab.execute, input), getViewerUsername: viewerUsername, + getRequestChangesViewer, listMergeRequests: (input) => { const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); @@ -1409,7 +1541,7 @@ export const make = Effect.gen(function* () { updateNote: (input) => api({ cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/${input.discussionId === undefined ? "" : `discussions/${encodeURIComponent(input.discussionId)}/`}notes/${encodeURIComponent( input.noteId, )}`, method: "PUT", @@ -1422,10 +1554,16 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const project = projectPath(input.repository); const mergeRequest = `projects/${project}/merge_requests/${input.number}`; - // GitLab has no pending review to attach comments to, so a review is replayed as the - // requests it is made of: the line comments, then the summary, then the verdict. A - // failure part-way therefore leaves what was already posted in place, which is why - // the verdict goes last — a half-sent review is never an approval. + if (input.verdict === "request-changes") { + const viewer = yield* getRequestChangesViewer(input); + if (viewer === null) { + return yield* new GitLabRequestChangesUnavailableError({ number: input.number }); + } + const detail = yield* mergeRequestDetail(input); + if (!detail.reviewers.some((reviewer) => reviewer.login === viewer)) { + return yield* new GitLabReviewerRequiredError({ number: input.number }); + } + } if (input.comments.length > 0) { const refs = yield* getDiffRefs(input); yield* Effect.forEach( @@ -1468,6 +1606,39 @@ export const make = Effect.gen(function* () { if (input.verdict === "approve") { yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); } + if (input.verdict === "request-changes") { + const result = yield* api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: encodeGraphqlRequest({ + query: + "mutation($projectPath: ID!, $iid: String!) { mergeRequestRequestChanges(input: { projectPath: $projectPath, iid: $iid }) { errors } }", + variables: { projectPath: input.repository, iid: String(input.number) }, + }), + }); + const response = yield* decodeRequestChangesResult(result.stdout).pipe( + Effect.mapError( + (cause) => + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "requestChanges", + cause, + }), + ), + ); + if ( + (response.errors?.length ?? 0) > 0 || + !response.data?.mergeRequestRequestChanges || + response.data.mergeRequestRequestChanges.errors.length > 0 + ) { + return yield* new GitLabRequestChangesRejectedError({ + number: input.number, + cause: response, + }); + } + } }), replyToDiscussion: (input) => diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts index 5d36d58dfc2b..1f3378200813 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -42,6 +42,15 @@ describe("gitLabViewerPermissions", () => { }); }); + it("offers request changes only to an eligible reviewer", () => { + expect( + gitLabViewerPermissions({ viewerCanMerge: true, canRequestChanges: true }).verdicts, + ).toContain("request-changes"); + expect( + gitLabViewerPermissions({ viewerCanMerge: true, canRequestChanges: false }).verdicts, + ).not.toContain("request-changes"); + }); + it("names no way of updating a branch it will not let this viewer update", () => { // The action and the strategy behind it go together: a button offered with nothing to press // it with, or a strategy left standing next to a withheld button, is a half-refusal. @@ -101,12 +110,43 @@ describe("getChangeRequest base freshness", () => { Effect.provide( Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), + getRequestChangesViewer: () => Effect.succeed(null), getProjectMergeCapabilities: () => Effect.succeed({ merge: true, squash: true, rebase: true }), }), ), ); + for (const viewer of [null, "bilal", "octocat"]) { + it.effect(`gates request changes on host support and reviewer assignment: ${viewer}`, () => + Effect.gen(function* () { + const provider = yield* make; + const input = { cwd: "/w", repository: "acme/web", host: "gitlab.com", number: 7 }; + const capabilities = yield* provider.getCapabilities!(input); + const result = yield* provider.getChangeRequest(input); + const permissions = yield* provider.getViewerPermissions(input); + expect(capabilities.review.verdicts.includes("request-changes")).toBe(viewer !== null); + expect(result.viewerPermissions?.verdicts.includes("request-changes")).toBe( + viewer === "octocat", + ); + expect(permissions.verdicts.includes("request-changes")).toBe(viewer === "octocat"); + }).pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getRequestChangesViewer: () => Effect.succeed(viewer), + getMergeRequestDetail: () => + Effect.succeed({ + ...detail, + reviewers: [{ login: "octocat", avatarUrl: null, name: null }], + }), + getProjectMergeCapabilities: () => + Effect.succeed({ merge: true, squash: true, rebase: true }), + }), + ), + ), + ); + } + it.effect("reads a counted divergence as a branch that has fallen behind", () => Effect.gen(function* () { const changeRequest = yield* readWith({ divergedCommits: 3 }); @@ -170,7 +210,7 @@ describe("rewriting what has already been said", () => { }), ); - it.effect("rewrites a positioned comment through the same note as any other", () => + it.effect("keeps the discussion id when editing a thread comment", () => Effect.gen(function* () { const provider = yield* providerWith; assert.isDefined(provider.updateComment); @@ -181,6 +221,7 @@ describe("rewriting what has already been said", () => { host: "gitlab.com", number: 7, commentId: "42", + threadId: "discussion", kind: "review-comment", body: "Reworded.", }); @@ -190,8 +231,66 @@ describe("rewriting what has already been said", () => { repository: "acme/web", number: 7, noteId: "42", + discussionId: "discussion", body: "Reworded.", }); }), ); }); + +describe("comment links", () => { + it.effect("links notes and discussion replies to their own anchors on the source host", () => + Effect.gen(function* () { + const comment = { + id: "5", + kind: "review-comment" as const, + author: null, + body: "Please check this.", + createdAt: "2026-07-02T00:00:00Z", + url: null, + path: "src/app.ts", + reviewState: null, + }; + const reply = { ...comment, id: "6", body: "Updated.", createdAt: "2026-07-03T00:00:00Z" }; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + listNotes: () => Effect.succeed({ comments: [reply], truncated: true }), + listCommits: () => Effect.succeed([]), + listDiscussions: () => + Effect.succeed({ + threads: [ + { + id: "discussion", + path: "src/app.ts", + line: 1, + side: "right" as const, + isResolved: false, + isOutdated: false, + comments: [comment, reply], + }, + ], + truncated: false, + }), + listReactions: () => Effect.succeed({ reactions: [], reactionsByNoteId: new Map() }), + }), + ), + ); + + const activity = yield* provider.getChangeRequestActivity({ + cwd: "/w", + host: "gitlab.example.com:8443", + repository: "team/sub group/web", + number: 7, + }); + + const links = [ + "https://gitlab.example.com:8443/team/sub%20group/web/-/merge_requests/7#note_5", + "https://gitlab.example.com:8443/team/sub%20group/web/-/merge_requests/7#note_6", + ]; + expect(activity.comments.map((item) => item.url)).toEqual(links); + expect(activity.commentCount).toBe(2); + expect(activity.reviewThreads[0]?.comments.map((item) => item.url)).toEqual(links); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index c1482d601bda..93363b03fef4 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { NATIVE_ATTACHMENT_CAPABILITY } from "./PullRequestAttachments.ts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, @@ -15,6 +16,10 @@ import { } from "./PullRequestProvider.ts"; const CAPABILITIES: PullRequestCapabilities = { + attachments: { + ...NATIVE_ATTACHMENT_CAPABILITY, + reason: "Requires glab 1.91 or later. The GitLab server may set a smaller file limit.", + }, diff: true, comment: true, actions: [ @@ -44,9 +49,7 @@ const CAPABILITIES: PullRequestCapabilities = { inlineComment: true, reply: true, resolve: true, - // No "changes requested": GitLab has approval and unresolved discussions, and nothing that - // says a merge request has been reviewed and rejected. - verdicts: ["comment", "approve"], + verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, @@ -80,6 +83,7 @@ const MERGE_ACTIONS: ReadonlySet = new Set([ */ export function gitLabViewerPermissions(input: { readonly viewerCanMerge: boolean; + readonly canRequestChanges?: boolean; }): PullRequestViewerPermissions { return { // Arming the merge and taking the arming back are the merge, deferred, so they answer to @@ -89,7 +93,9 @@ export function gitLabViewerPermissions(input: { ), comment: true, resolve: true, - verdicts: CAPABILITIES.review.verdicts, + verdicts: CAPABILITIES.review.verdicts.filter( + (verdict) => verdict !== "request-changes" || input.canRequestChanges === true, + ), requestReviewers: true, ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), }; @@ -105,6 +111,11 @@ export function gitLabProviderFailure( return { reason: "failed" }; } +function commentUrl(input: { host: string; repository: string; number: number }, id: string) { + const repository = input.repository.split("/").map(encodeURIComponent).join("/"); + return `https://${input.host}/${repository}/-/merge_requests/${input.number}#note_${id}`; +} + export const make = Effect.gen(function* () { const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -120,6 +131,19 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: "gitlab", capabilities: CAPABILITIES, + getCapabilities: (input) => + cli.getRequestChangesViewer(input).pipe( + Effect.mapError(fail("getCapabilities")), + Effect.map((viewer) => ({ + ...CAPABILITIES, + review: { + ...CAPABILITIES.review, + verdicts: CAPABILITIES.review.verdicts.filter( + (verdict) => verdict !== "request-changes" || viewer !== null, + ), + }, + })), + ), getViewer: (input) => cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), @@ -148,14 +172,20 @@ export const make = Effect.gen(function* () { [ cli.getMergeRequestDetail(input), cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + cli.getRequestChangesViewer(input), ], { concurrency: 2 }, ).pipe( Effect.mapError(fail("getChangeRequest")), - Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + Effect.map(([mergeRequest, mergeCapabilities, viewer]): ProviderChangeRequestDetail => ({ ...mergeRequest, mergeCapabilities, - viewerPermissions: gitLabViewerPermissions(mergeRequest), + viewerPermissions: gitLabViewerPermissions({ + ...mergeRequest, + canRequestChanges: + viewer !== null && + mergeRequest.reviewers.some((reviewer) => reviewer.login === viewer), + }), // A GitLab too old to count the divergence says nothing here rather than "up to // date": the banner is worth missing, and a wrong all-clear is not worth showing. baseComparison: @@ -192,34 +222,57 @@ export const make = Effect.gen(function* () { { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ - reactions: awards.reactions, - comments: notes.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. - commentCount: notes.comments.length, - commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => { + const comments = new Map(notes.comments.map((comment) => [comment.id, comment])); + for (const thread of discussions.threads) { + for (const comment of thread.comments) { + comments.set(comment.id, { + ...comment, + kind: thread.path === null ? "issue-comment" : "review-comment", + path: thread.path, + reviewState: null, + }); + } + } + return { + reactions: awards.reactions, + comments: [...comments.values()] + .map((comment) => ({ + ...comment, + url: commentUrl(input, comment.id), + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + commentCount: comments.size, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + url: commentUrl(input, comment.id), + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), })), - })), - commits, - })), + commits, + }; + }), ), // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge // request, so there is no cheaper thing to ask GitLab. getViewerPermissions: (input) => - cli - .getMergeRequestDetail(input) - .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), + Effect.all([cli.getMergeRequestDetail(input), cli.getRequestChangesViewer(input)], { + concurrency: 2, + }).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map(([detail, viewer]) => + gitLabViewerPermissions({ + ...detail, + canRequestChanges: + viewer !== null && detail.reviewers.some((reviewer) => reviewer.login === viewer), + }), + ), + ), getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), @@ -278,8 +331,8 @@ export const make = Effect.gen(function* () { comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), - // The kind is not read: every comment this provider hands out, positioned or not, carries a - // plain REST note id, and one endpoint rewrites both. + uploadAttachment: (input) => cli.uploadAttachment(input), + ...(cli.readAttachment ? { readAttachment: cli.readAttachment } : {}), updateComment: (input) => cli .updateNote({ @@ -287,6 +340,7 @@ export const make = Effect.gen(function* () { repository: input.repository, number: input.number, noteId: input.commentId, + ...(input.threadId === undefined ? {} : { discussionId: input.threadId }), body: input.body, }) .pipe(Effect.mapError(fail("updateComment"))), diff --git a/apps/server/src/pullRequest/PullRequestAttachments.test.ts b/apps/server/src/pullRequest/PullRequestAttachments.test.ts new file mode 100644 index 000000000000..6b4c26c36197 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestAttachments.test.ts @@ -0,0 +1,454 @@ +import * as Option from "effect/Option"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientError, + HttpClientResponse, +} from "effect/unstable/http"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { ProjectId } from "@t3tools/contracts"; +import { attachmentFileExtension, createPendingAttachmentId } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import { GitHubCliCommandError, type GitHubCli } from "../sourceControl/GitHubCli.ts"; +import { GitLabCliCommandError, type GitLabCli } from "../sourceControl/GitLabCli.ts"; +import { ForgejoCli } from "../sourceControl/ForgejoCli.ts"; +import * as ForgejoProvider from "./ForgejoPullRequestProvider.ts"; +import { + attachmentMarkdown, + readGitLabAttachment, + readGitHubAttachment, + uploadGitHubAttachment, + uploadGitLabAttachment, + withPullRequestAttachment, +} from "./PullRequestAttachments.ts"; + +const projectId = Schema.decodeSync(ProjectId)("p1"); +const encodeDefect = Schema.encodeEffect(Schema.fromJsonString(Schema.Defect())); + +const input = { + cwd: "/repo", + repository: "owner/repo", + host: "github.com", + number: 7, + name: "screen[1].png", + mimeType: "image/png", + data: new Uint8Array([0, 128, 255]), + filePath: "/tmp/attachment/screen[1].png", +}; +const output = (stdout: string) => ({ + stdout, + stderr: "", + exitCode: ChildProcessSpawner.ExitCode(0), + stdoutTruncated: false, + stderrTruncated: false, +}); + +describe("pull request attachments", () => { + it.effect("uploads GitHub bytes only to the selected repository with original filename", () => + Effect.gen(function* () { + const execute = vi + .fn() + .mockReturnValueOnce(Effect.succeed(output('{"id":42,"permissions":{"push":true}}'))) + .mockReturnValueOnce( + Effect.succeed(output('{"url":"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/user-attachments/assets/example"}')), + ); + const result = yield* uploadGitHubAttachment(execute, input); + expect(execute.mock.calls[0]?.[0].args).toEqual([ + "api", + "repos/owner/repo", + "--hostname", + "github.com", + ]); + const request = execute.mock.calls[1]?.[0]; + expect(request?.args).toContain(input.filePath); + const url = new URL(request?.args[1] ?? ""); + expect(url.origin).toBe("https://uploads.github.com"); + expect(url.searchParams.get("repository_id")).toBe("42"); + expect(url.searchParams.get("name")).toBe(input.name); + expect(result.markdown).toBe( + "![screen\\[1\\].png]()", + ); + }), + ); + + it.effect("refuses GitHub read-only repositories before uploading", () => + Effect.gen(function* () { + const execute = vi + .fn() + .mockReturnValue(Effect.succeed(output('{"id":42,"permissions":{"push":false}}'))); + const error = yield* uploadGitHubAttachment(execute, input).pipe(Effect.flip); + expect(error.detail).toContain("write access"); + expect(execute).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("rejects GHES, unlisted types, and oversized GitHub images before a request", () => + Effect.gen(function* () { + const execute = vi.fn(); + for (const override of [ + { host: "github.example.com" }, + { name: "secret.txt" }, + { data: new Uint8Array(10 * 1024 * 1024 + 1) }, + ]) { + const error = yield* uploadGitHubAttachment(execute, { ...input, ...override }).pipe( + Effect.flip, + ); + expect(error.operation).toBe("uploadAttachment"); + } + expect(execute).not.toHaveBeenCalled(); + }), + ); + + it.effect("uses GitLab multipart and its full native attachment path on the selected host", () => + Effect.gen(function* () { + const execute = vi + .fn() + .mockReturnValue( + Effect.succeed(output('{"full_path":"/-/project/42/uploads/hash/file.png"}')), + ); + const result = yield* uploadGitLabAttachment(execute, { + ...input, + host: "gitlab.example.com", + repository: "group/nested/repo", + }); + expect(execute.mock.calls[0]?.[0].args).toEqual([ + "api", + "projects/group%2Fnested%2Frepo/uploads", + "--hostname", + "gitlab.example.com", + "--method", + "POST", + "--form", + `file=@${input.filePath}`, + ]); + expect(result.url).toBe("https://gitlab.example.com/-/project/42/uploads/hash/file.png"); + }), + ); + + it.effect("uploads Forgejo assets to the issue behind the current pull request", () => + Effect.gen(function* () { + const api = vi + .fn() + .mockReturnValue( + Effect.succeed( + output('{"browser_download_url":"https://forgejo.example/attachments/id"}'), + ), + ); + const provider = yield* ForgejoProvider.make.pipe( + Effect.provide(Layer.mock(ForgejoCli)({ api })), + ); + const result = yield* provider.uploadAttachment!({ ...input, host: "forgejo.example" }); + const request = api.mock.calls[0]?.[0]; + expect(request?.path).toBe("repos/owner/repo/issues/7/assets"); + expect(request?.method).toBe("POST"); + const file = request?.formData?.get("attachment"); + expect(file).toBeInstanceOf(File); + if (!(file instanceof File)) throw new Error("Expected attachment file"); + expect(file.name).toBe(input.name); + const data = yield* Effect.promise(() => file.arrayBuffer()); + expect(new Uint8Array(data)).toEqual(input.data); + expect(result.url).toBe("https://forgejo.example/attachments/id"); + }), + ); + + it("escapes names and URL delimiters in Markdown", () => { + expect(attachmentMarkdown("https://host/file (1)>x", "[a]\\b.txt", "text/plain")).toBe( + "[\\[a\\]\\\\b.txt]()", + ); + }); +}); + +const storageLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-pr-upload-" }).pipe( + Layer.provideMerge(NodeServices.layer), +); + +it.effect("reads only pending uploads, preserves bytes, and removes temporary copies", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const attachmentId = createPendingAttachmentId(attachmentFileExtension("screen.png")); + yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fs.writeFile(path.join(config.attachmentsDir, `${attachmentId}.png`), input.data); + const request = { + projectId, + repository: input.repository, + number: input.number, + attachmentId, + name: input.name, + mimeType: input.mimeType, + }; + const temporaryPath = yield* withPullRequestAttachment(request, (attachment) => + Effect.gen(function* () { + expect([...attachment.data]).toEqual([...input.data]); + expect([...(yield* fs.readFile(attachment.filePath))]).toEqual([...input.data]); + expect(path.basename(attachment.filePath)).toBe(input.name); + return attachment.filePath; + }), + ); + expect(yield* fs.exists(temporaryPath)).toBe(false); + expect(yield* fs.exists(path.join(config.attachmentsDir, `${attachmentId}.png`))).toBe(true); + const use = vi.fn(() => Effect.void); + for (const invalidId of ["../secrets", attachmentId.replace("pending-", "thread-")]) { + const error = yield* withPullRequestAttachment( + { ...request, attachmentId: invalidId }, + use, + ).pipe(Effect.flip); + expect(error.detail).toContain("Upload the attachment again"); + } + expect(use).not.toHaveBeenCalled(); + }).pipe(Effect.provide(storageLayer)), +); + +for (const provider of ["github", "gitlab"] as const) { + it.effect(`reads private ${provider} media with selected CLI auth and manual redirects`, () => { + const host = provider === "github" ? "github.com" : "gitlab.example"; + const url = + provider === "github" + ? "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/owner/repo/blob/main/shot.png" + : `https://${host}/-/project/42/uploads/${"a".repeat(32)}/shot.png`; + const execute = vi.fn((_request: Parameters[0]) => + Effect.succeed({ + ...output(provider === "github" ? "test-token\n" : ""), + stderr: provider === "gitlab" ? " ✓ Token found in keyring: test-token\n" : "", + }), + ); + const client = HttpClient.make((request) => + Effect.gen(function* () { + expect(request.headers.authorization).toBe("Bearer test-token"); + expect(request.headers.range).toBe("bytes=0-2"); + expect(request.headers.cookie).toBeUndefined(); + expect( + Option.getOrThrow(yield* Effect.serviceOption(FetchHttpClient.RequestInit)).redirect, + ).toBe("manual"); + expect(request.url).toBe( + provider === "github" + ? "https://raw.githubusercontent.com/owner/repo/main/shot.png" + : `https://${host}/api/v4/projects/owner%2Frepo/uploads/${"a".repeat(32)}/shot.png`, + ); + return HttpClientResponse.fromWeb( + request, + new Response(new Uint8Array([0, 128, 255]), { + status: 206, + headers: { "content-type": "image/png", "content-range": "bytes 0-2/3" }, + }), + ); + }), + ); + return Effect.gen(function* () { + const read = provider === "github" ? readGitHubAttachment : readGitLabAttachment; + const response = yield* read(execute, { + ...input, + host, + url, + headers: { range: "bytes=0-2", cookie: "never-forward" }, + }); + expect(response.status).toBe(206); + expect(new Uint8Array(yield* response.arrayBuffer)).toEqual(new Uint8Array([0, 128, 255])); + expect(execute.mock.calls[0]?.[0].args).toContain(host); + expect(execute.mock.calls[0]?.[0].args).toEqual( + provider === "github" + ? ["auth", "token", "--hostname", host] + : ["auth", "status", "--hostname", host, "--show-token"], + ); + }).pipe(Effect.provideService(HttpClient.HttpClient, client), Effect.scoped); + }); +} + +it.effect("reads only assets listed on the selected Forgejo pull request", () => + Effect.gen(function* () { + const url = "https://forgejo.example/attachments/12345678-1234-1234-1234-123456789012"; + const api = vi + .fn() + .mockReturnValue(Effect.succeed(output(`[{"browser_download_url":"${url}"}]`))); + const readAttachment = vi + .fn>() + .mockReturnValue( + Effect.succeed( + HttpClientResponse.fromWeb(HttpClientRequest.get(url), new Response("image")), + ), + ); + const provider = yield* ForgejoProvider.make.pipe( + Effect.provide(Layer.mock(ForgejoCli)({ api, readAttachment })), + ); + expect( + (yield* provider.readAttachment!({ ...input, host: "forgejo.example", url, headers: {} })) + .status, + ).toBe(200); + expect(api.mock.calls[0]?.[0].path).toContain("repos/owner/repo/issues/7/assets"); + const wrong = yield* provider.readAttachment!({ + ...input, + host: "forgejo.example", + url: url.replace("123456789012", "123456789013"), + headers: {}, + }).pipe(Effect.flip); + expect(wrong.detail).toContain("does not belong"); + expect(readAttachment).toHaveBeenCalledTimes(1); + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Provider owns read")), + ), + Effect.scoped, + ), +); + +it.effect("retries GitLab once when glab refreshes an expired OAuth token", () => { + const execute = vi + .fn() + .mockReturnValueOnce( + Effect.succeed({ ...output(""), stderr: "Token found in keyring: expired" }), + ) + .mockReturnValueOnce( + Effect.succeed({ ...output(""), stderr: "Token found in keyring: refreshed" }), + ); + const tokens: string[] = []; + return Effect.gen(function* () { + const response = yield* readGitLabAttachment(execute, { + ...input, + host: "gitlab.example", + url: `/uploads/${"a".repeat(32)}/shot.png`, + headers: {}, + }); + expect(response.status).toBe(200); + expect(tokens).toEqual(["Bearer expired", "Bearer refreshed"]); + expect(execute).toHaveBeenCalledTimes(2); + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + tokens.push(request.headers.authorization!); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { status: tokens.length === 1 ? 401 : 200 }), + ), + ); + }), + ), + Effect.scoped, + ); +}); + +for (const provider of ["github", "gitlab"] as const) { + it.effect(`retains ${provider} upload failures without exposing raw output`, () => + Effect.gen(function* () { + const githubFailure = new GitHubCliCommandError({ + command: "gh", + cwd: "/repo", + cause: new Error("Command failed"), + }); + const gitlabFailure = new GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error("Command failed"), + }); + for (const stage of provider === "github" + ? ["permissions", "upload", "decode"] + : ["upload", "decode"]) { + const github = vi + .fn() + .mockReturnValue( + stage === "decode" + ? Effect.succeed(output("private-test-token")) + : Effect.fail(githubFailure), + ); + if (stage !== "permissions") + github.mockReturnValueOnce( + Effect.succeed(output('{"id":42,"permissions":{"push":true}}')), + ); + const gitlab = vi + .fn() + .mockReturnValue( + stage === "decode" + ? Effect.succeed(output("private-test-token")) + : Effect.fail(gitlabFailure), + ); + const error = yield* ( + provider === "github" + ? uploadGitHubAttachment(github, input) + : uploadGitLabAttachment(gitlab, { ...input, host: "gitlab.example" }) + ).pipe(Effect.flip); + if (stage === "decode") expect(error.cause).toMatchObject({ _tag: "SchemaError" }); + else expect(error.cause).toBe(provider === "github" ? githubFailure : gitlabFailure); + expect(error.detail).not.toContain("private-test-token"); + expect(error.message).not.toContain("private-test-token"); + } + }), + ); + for (const stage of ["auth", "http"] as const) { + it.effect(`retains ${provider} ${stage} read failures without exposing credentials`, () => { + const githubFailure = new GitHubCliCommandError({ + command: "gh", + cwd: "/repo", + cause: new Error("Command failed"), + }); + const gitlabFailure = new GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error("Command failed"), + }); + const github = vi + .fn() + .mockReturnValue( + stage === "auth" + ? Effect.fail(githubFailure) + : Effect.succeed(output("private-test-token")), + ); + const gitlab = vi.fn().mockReturnValue( + stage === "auth" + ? Effect.fail(gitlabFailure) + : Effect.succeed({ + ...output(""), + stderr: "Token found in keyring: private-test-token", + }), + ); + let httpFailure: HttpClientError.HttpClientError | undefined; + return Effect.gen(function* () { + const error = yield* ( + provider === "github" + ? readGitHubAttachment(github, { + ...input, + url: "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/owner/repo/blob/main/shot.png", + headers: {}, + }) + : readGitLabAttachment(gitlab, { + ...input, + host: "gitlab.example", + url: `/uploads/${"a".repeat(32)}/shot.png`, + headers: {}, + }) + ).pipe(Effect.flip); + expect(error.cause).toBe( + stage === "http" ? httpFailure : provider === "github" ? githubFailure : gitlabFailure, + ); + expect(error.detail).not.toContain("private-test-token"); + expect(yield* encodeDefect(error)).not.toContain("private-test-token"); + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + httpFailure = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("Command failed"), + }), + }); + return Effect.fail(httpFailure); + }), + ), + Effect.scoped, + ); + }); + } +} diff --git a/apps/server/src/pullRequest/PullRequestAttachments.ts b/apps/server/src/pullRequest/PullRequestAttachments.ts new file mode 100644 index 000000000000..e0453f794a8f --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestAttachments.ts @@ -0,0 +1,424 @@ +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { pullRequestMediaUrl } from "@t3tools/shared/pullRequestMedia"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PullRequestOperationError, + type PullRequestAttachmentCapability, + type PullRequestUploadAttachmentInput, +} from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import { + parseThreadSegmentFromAttachmentId, + PENDING_ATTACHMENT_THREAD_SEGMENT, + resolveAttachmentPathById, +} from "../attachmentStore.ts"; +import { PullRequestProviderError, type PullRequestProviderApi } from "./PullRequestProvider.ts"; +import type { GitHubCli } from "../sourceControl/GitHubCli.ts"; +import type { GitLabCli } from "../sourceControl/GitLabCli.ts"; + +const decodeGitHubRepository = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Int.check(Schema.isGreaterThan(0)), + permissions: Schema.Struct({ push: Schema.Boolean }), + }), + ), +); +const decodeGitHubAsset = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ url: Schema.String.check(Schema.isPattern(/^https:\/\//)) }), + ), +); +const decodeGitLabAsset = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ full_path: Schema.String.check(Schema.isPattern(/^\/(?!\/)/)) }), + ), +); + +type UploadInput = Parameters>[0]; + +export const NATIVE_ATTACHMENT_CAPABILITY: PullRequestAttachmentCapability = { + supported: true, + maxBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES, + destination: "pull-request", +}; + +const GITHUB_CONTENT_TYPES: Readonly> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", +}; + +export const GITHUB_ATTACHMENT_CAPABILITY: PullRequestAttachmentCapability = { + ...NATIVE_ATTACHMENT_CAPABILITY, + acceptedExtensions: Object.keys(GITHUB_CONTENT_TYPES), + reason: + "Requires write access. Images: up to 10 MB. Videos: up to 50 MB; your GitHub plan may allow less. GitHub Enterprise Server is not supported.", +}; + +export function attachmentMarkdown(url: string, name: string, mimeType: string) { + const label = name.replace(/[\\[\]]/g, "\\$&"); + const target = url.replace(/[<>\s]/g, (character) => encodeURIComponent(character)); + return `${mimeType.startsWith("image/") ? "!" : ""}[${label}](<${target}>)`; +} + +export const withPullRequestAttachment = Effect.fn("PullRequestAttachments.read")(function* < + A, + E, + R, +>( + input: PullRequestUploadAttachmentInput, + use: (attachment: { + readonly filePath: string; + readonly data: Uint8Array; + }) => Effect.Effect, +) { + const config = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const source = + parseThreadSegmentFromAttachmentId(input.attachmentId) === PENDING_ATTACHMENT_THREAD_SEGMENT + ? resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: input.attachmentId, + }) + : null; + if ( + !source || + path.basename(input.name) !== input.name || + input.name === "." || + input.name === ".." + ) { + return yield* new PullRequestOperationError({ + operation: "uploadAttachment", + detail: "Upload the attachment again before attaching it.", + }); + } + const prepared = Effect.gen(function* () { + const stat = yield* fs.stat(source); + if (stat.type !== "File" || stat.size <= 0 || stat.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + return yield* new PullRequestOperationError({ + operation: "uploadAttachment", + detail: "Attachments must be a non-empty file of at most 50 MB.", + }); + } + const data = yield* fs.readFile(source); + if (data.byteLength > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + return yield* new PullRequestOperationError({ + operation: "uploadAttachment", + detail: "Attachments must be at most 50 MB.", + }); + } + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-attachment-" }); + const filePath = path.join(directory, input.name); + yield* fs.writeFile(filePath, data, { mode: 0o600 }); + return { filePath, data }; + }).pipe( + Effect.catchTags({ + PlatformError: () => + Effect.fail( + new PullRequestOperationError({ + operation: "uploadAttachment", + detail: "The attachment could not be read. Upload it again.", + }), + ), + }), + ); + return yield* Effect.scoped(Effect.flatMap(prepared, use)); +}); + +export const uploadGitHubAttachment = Effect.fn("PullRequestAttachments.github")(function* ( + execute: GitHubCli["Service"]["execute"], + input: UploadInput, +) { + const host = input.host.toLowerCase(); + if (host !== "github.com" && !/^[a-z0-9-]+\.ghe\.com$/.test(host)) + return yield* new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "Attachments are not supported on GitHub Enterprise Server.", + }); + const extension = /\.[^.]+$/.exec(input.name)?.[0].toLowerCase() ?? ""; + const contentType = GITHUB_CONTENT_TYPES[extension]; + if (!contentType) + return yield* new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "GitHub accepts PNG, JPG, GIF, WebP, SVG, MP4, MOV, and WebM attachments.", + }); + if (contentType.startsWith("image/") && input.data.byteLength > 10 * 1024 * 1024) + return yield* new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "GitHub images must be at most 10 MB.", + }); + const result = yield* execute({ + cwd: input.cwd, + args: [ + "api", + `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`, + "--hostname", + host, + ], + }).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "GitHub repository upload permissions could not be checked.", + cause, + }), + ), + ); + const repository = yield* decodeGitHubRepository(result.stdout).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "GitHub did not return repository upload permissions.", + cause, + }), + ), + ); + if (!repository.permissions.push) + return yield* new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "Attaching files requires write access to this repository.", + }); + const url = new URL(`https://uploads.${host}/user-attachments/assets`); + url.search = new URLSearchParams({ + name: input.name, + content_type: contentType, + repository_id: String(repository.id), + }).toString(); + const uploaded = yield* execute({ + cwd: input.cwd, + args: [ + "api", + url.toString(), + "--hostname", + host, + "--method", + "POST", + "--header", + "Content-Type: application/octet-stream", + "--input", + input.filePath, + ], + timeoutMs: 120_000, + }).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "GitHub attachment upload failed.", + cause, + }), + ), + ); + const asset = yield* decodeGitHubAsset(uploaded.stdout).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "github", + operation: "uploadAttachment", + reason: "failed", + detail: "GitHub returned no attachment URL.", + cause, + }), + ), + ); + return { + url: asset.url, + markdown: contentType.startsWith("video/") + ? asset.url + : attachmentMarkdown(asset.url, input.name, contentType), + }; +}); + +export const uploadGitLabAttachment = Effect.fn("PullRequestAttachments.gitlab")(function* ( + execute: GitLabCli["Service"]["execute"], + input: UploadInput, +) { + const uploaded = yield* execute({ + cwd: input.cwd, + args: [ + "api", + `projects/${encodeURIComponent(input.repository)}/uploads`, + "--hostname", + input.host, + "--method", + "POST", + "--form", + `file=@${input.filePath}`, + ], + timeoutMs: 120_000, + }).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "gitlab", + operation: "uploadAttachment", + reason: "failed", + detail: "GitLab attachment upload failed. Update glab and try again.", + cause, + }), + ), + ); + const asset = yield* decodeGitLabAsset(uploaded.stdout).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "gitlab", + operation: "uploadAttachment", + reason: "failed", + detail: "GitLab returned no attachment URL.", + cause, + }), + ), + ); + const url = new URL(asset.full_path, `https://${input.host}`).toString(); + return { url, markdown: attachmentMarkdown(url, input.name, input.mimeType) }; +}); + +export const readGitLabAttachment = Effect.fn("PullRequestAttachments.readGitLabAttachment")( + function* ( + execute: GitLabCli["Service"]["execute"], + input: Parameters>[0], + ) { + const url = pullRequestMediaUrl({ ...input, provider: "gitlab" }); + if (!url) + return yield* new PullRequestProviderError({ + provider: "gitlab", + operation: "readAttachment", + reason: "failed", + detail: "This attachment does not belong to the selected GitLab repository.", + }); + const path = new URL(url).pathname.split("/").slice(-2).join("/"); + const endpoint = `https://${input.host}/api/v4/projects/${encodeURIComponent(input.repository)}/uploads/${path}`; + const client = HttpClient.withScope(yield* HttpClient.HttpClient); + for (let attempt = 0; ; attempt++) { + const auth = yield* execute({ + cwd: input.cwd, + args: ["auth", "status", "--hostname", input.host, "--show-token"], + env: { NO_COLOR: "1", GLAB_DEBUG_HTTP: "" }, + }).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "gitlab", + operation: "readAttachment", + reason: "failed", + detail: "Sign in with glab to view private GitLab attachments.", + cause, + }), + ), + ); + const token = /Token found in [^:\r\n]+: ([^\r\n]+)/.exec(auth.stderr)?.[1]?.trim(); + if (!token || /[\s\p{Cc}]/u.test(token)) + return yield* new PullRequestProviderError({ + provider: "gitlab", + operation: "readAttachment", + reason: "failed", + detail: "glab did not provide an attachment credential. Update glab and sign in again.", + }); + const headers: Record = { + "accept-encoding": "identity", + authorization: `Bearer ${token}`, + }; + for (const name of ["range", "if-range"]) + if (input.headers[name]) headers[name] = input.headers[name]; + const response = yield* client + .execute(HttpClientRequest.get(endpoint).pipe(HttpClientRequest.setHeaders(headers))) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "gitlab", + operation: "readAttachment", + reason: "failed", + detail: "GitLab attachment download failed. GitLab 17.4 or later is required.", + cause, + }), + ), + ); + if (response.status !== 401 || attempt === 1) return response; + } + }, +); + +export const readGitHubAttachment = Effect.fn("PullRequestAttachments.readGitHubAttachment")( + function* ( + execute: GitHubCli["Service"]["execute"], + input: Parameters>[0], + ) { + const url = pullRequestMediaUrl({ ...input, provider: "github" }); + if (!url) + return yield* new PullRequestProviderError({ + provider: "github", + operation: "readAttachment", + reason: "failed", + detail: "This attachment does not belong to the selected GitHub repository.", + }); + const output = yield* execute({ + cwd: input.cwd, + args: ["auth", "token", "--hostname", input.host], + env: { GH_DEBUG: "" }, + }).pipe( + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "github", + operation: "readAttachment", + reason: "failed", + detail: "Sign in with gh to view private GitHub attachments.", + cause, + }), + ), + ); + const headers: Record = { + "accept-encoding": "identity", + authorization: `Bearer ${output.stdout.trim()}`, + }; + for (const name of ["range", "if-range"]) + if (input.headers[name]) headers[name] = input.headers[name]; + const client = HttpClient.withScope(yield* HttpClient.HttpClient); + return yield* client + .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers))) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.mapError( + (cause) => + new PullRequestProviderError({ + provider: "github", + operation: "readAttachment", + reason: "failed", + detail: "GitHub attachment download failed.", + cause, + }), + ), + ); + }, +); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index b4361c125c38..ca2ababf4baa 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import type { HttpClient, HttpClientResponse } from "effect/unstable/http"; import type { PullRequestStackMembership, PullRequestAction, @@ -7,6 +9,7 @@ import type { PullRequestActor, PullRequestBaseComparison, PullRequestCapabilities, + PullRequestAttachmentCapability, PullRequestChecksState, PullRequestCheck, PullRequestComment, @@ -207,6 +210,7 @@ export interface ProviderChangeRequestStat { } export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly attachments?: PullRequestAttachmentCapability; readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; @@ -321,6 +325,9 @@ export interface PullRequestProviderApi { >; readonly kind: SourceControlProviderKind; readonly capabilities: PullRequestCapabilities; + readonly getCapabilities?: ( + input: ProviderRepositoryRef, + ) => Effect.Effect; /** The signed-in account, which is what involvement filtering compares against. */ readonly getViewer: (input: { @@ -526,6 +533,7 @@ export interface PullRequestProviderApi { readonly expectedStackHeads?: ReadonlyArray; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; + readonly bypassMergeChecks?: boolean; /** Only meaningful for `update-branch`; absent takes the host's own default. */ readonly updateMethod?: PullRequestUpdateMethod; }, @@ -548,6 +556,28 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, ) => Effect.Effect; + readonly readAttachment?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly url: string; + readonly headers: Readonly>; + }, + ) => Effect.Effect< + HttpClientResponse.HttpClientResponse, + PullRequestProviderError, + Scope.Scope | HttpClient.HttpClient + >; + + readonly uploadAttachment?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly name: string; + readonly mimeType: string; + readonly data: Uint8Array; + readonly filePath: string; + }, + ) => Effect.Effect<{ readonly url: string; readonly markdown: string }, PullRequestProviderError>; + /** * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is * true, with an id exactly as the conversation carried it. @@ -560,6 +590,7 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly commentId: string; + readonly threadId?: string; readonly kind: "issue-comment" | "review-comment"; readonly body: string; }, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 9d563d3965c3..8f9b890cdaa9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,3 +1,4 @@ +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; @@ -380,7 +381,7 @@ function fakeProvider( requestReviewers: true, }), listChangeRequests: () => Effect.succeed({ items: [], truncated: false, continues: true }), - getChangeRequest: () => Effect.die("unused"), + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), getChangeRequestActivity: () => Effect.die("unused"), getDiff: () => Effect.die("unused"), runAction: () => Effect.void, @@ -1556,6 +1557,57 @@ it.effect("refuses an auto-merge the host never claimed, without asking it", () }), ); +it.effect("uses host capabilities for thread writes instead of the provider defaults", () => + Effect.gen(function* () { + let allowed = true; + let writes = 0; + const defaults = fakeProvider("github").capabilities; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + ...defaults, + review: { ...defaults.review, reply: false, resolve: false }, + }, + getCapabilities: () => + Effect.succeed({ + ...defaults, + review: { ...defaults.review, reply: allowed, resolve: allowed }, + }), + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + replyToThread: () => + Effect.sync(() => { + writes++; + }), + setThreadResolution: () => + Effect.sync(() => { + writes++; + }), + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + threadId: "t1", + }; + const detail = yield* service.detail(reference); + assert.isTrue(detail.capabilities.review.resolve); + yield* service.replyToThread({ ...reference, body: "Reply" }); + yield* service.setThreadResolution({ ...reference, resolved: true }); + allowed = false; + const reply = yield* service.replyToThread({ ...reference, body: "Reply" }).pipe(Effect.result); + const resolve = yield* service + .setThreadResolution({ ...reference, resolved: false }) + .pipe(Effect.result); + assert.strictEqual(reply._tag, "Failure"); + assert.strictEqual(resolve._tag, "Failure"); + assert.strictEqual(writes, 2); + }), +); + it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => Effect.gen(function* () { const service = yield* makeService({ @@ -2463,6 +2515,7 @@ it.effect( ], providers: [ fakeProvider("github", { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), submitReview: () => { approved = true; return Effect.void; @@ -2487,6 +2540,111 @@ it.effect( }), ); +for (const state of ["open", "closed", "merged"] as const) { + it.effect(`limits formal reviews and reviewer requests to open change requests: ${state}`, () => + Effect.gen(function* () { + let currentState: ProviderChangeRequest["state"] = "open"; + let isDraft = false; + const writes: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("Description"), + state: currentState, + isDraft, + }), + submitReview: (input) => + Effect.sync(() => { + writes.push(input.verdict); + }), + setReviewerRequest: () => + Effect.sync(() => { + writes.push("request-reviewers"); + }), + comment: () => + Effect.sync(() => { + writes.push("comment"); + }), + replyToThread: () => + Effect.sync(() => { + writes.push("reply"); + }), + setThreadResolution: () => + Effect.sync(() => { + writes.push("resolve"); + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.detail(reference); + currentState = state; + for (const draft of [false, true]) { + isDraft = draft; + for (const verdict of ["approve", "request-changes"] as const) { + const submit = service.submitReview({ + ...reference, + verdict, + body: "Review", + comments: [ + { path: "file.ts", position: { kind: "added", newLine: 1 }, body: "Line comment" }, + ], + }); + if (state === "open") yield* submit; + else assert.include((yield* Effect.flip(submit)).message, "Only open change requests"); + } + const request = service.requestReviewers({ + ...reference, + reviewers: [{ id: "reviewer", kind: "user" }], + requested: true, + }); + if (state === "open") yield* request; + else assert.include((yield* Effect.flip(request)).message, "Only open change requests"); + } + assert.deepStrictEqual( + writes, + state === "open" + ? [ + "approve", + "request-changes", + "request-reviewers", + "approve", + "request-changes", + "request-reviewers", + ] + : [], + ); + writes.length = 0; + yield* service.requestReviewers({ + ...reference, + reviewers: [{ id: "reviewer", kind: "user" }], + requested: false, + }); + yield* service.submitReview({ + ...reference, + verdict: "comment", + body: "Discussion", + comments: [], + }); + yield* service.comment({ ...reference, body: "Discussion" }); + yield* service.replyToThread({ ...reference, threadId: "thread", body: "Reply" }); + yield* service.setThreadResolution({ ...reference, threadId: "thread", resolved: true }); + assert.deepStrictEqual(writes, [ + "request-reviewers", + "comment", + "comment", + "reply", + "resolve", + ]); + }), + ); +} + it.effect("refuses to resolve a conversation on a host that cannot", () => Effect.gen(function* () { const service = yield* makeService({ @@ -6826,3 +6984,422 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s assert.deepStrictEqual(seen, ["/org-b"]); }), ); + +it.effect("requires current host permission for an explicit merge-check bypass", () => + Effect.gen(function* () { + let allowed = false; + let hostAllowsBypass = true; + const calls: boolean[] = []; + const provider = fakeProvider("github"); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getCapabilities: () => + Effect.succeed({ ...provider.capabilities, bypassMergeChecks: hostAllowsBypass }), + getViewerPermissions: (input) => + provider + .getViewerPermissions(input) + .pipe(Effect.map((permissions) => ({ ...permissions, bypassMergeChecks: allowed }))), + getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), + runAction: (input) => { + calls.push(input.bypassMergeChecks === true); + return Effect.void; + }, + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "merge" as const, + bypassMergeChecks: true, + }; + const denied = yield* Effect.flip(service.runAction(input)); + assert.include(denied.message, "permission to bypass"); + assert.deepStrictEqual(calls, []); + allowed = true; + yield* service.runAction(input); + allowed = false; + yield* Effect.flip(service.runAction(input)); + yield* service.runAction({ ...input, bypassMergeChecks: false }); + yield* Effect.flip(service.runAction({ ...input, action: "close" })); + allowed = true; + hostAllowsBypass = false; + const hostDenied = yield* Effect.flip(service.runAction(input)); + assert.include(hostDenied.message, "cannot bypass"); + assert.deepStrictEqual(calls, [true, false]); + }), +); + +it.effect("checks the current lifecycle before confirmed actions reach a permissive host", () => + Effect.gen(function* () { + let state: ProviderChangeRequest["state"] = "open"; + let isDraft = false; + const writes: string[] = []; + const provider = fakeProvider("azure-devops"); + const actions = ["merge", "enable-auto-merge", "close", "revert"] as const; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "org/project/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + capabilities: { ...provider.capabilities, actions }, + getViewerPermissions: (input) => + provider + .getViewerPermissions(input) + .pipe(Effect.map((permissions) => ({ ...permissions, actions }))), + getChangeRequest: () => + Effect.succeed({ ...hostedChangeRequest("Description"), state, isDraft }), + runAction: (input) => + Effect.sync(() => { + writes.push(input.action); + }), + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "org/project/_git/web", + host: "dev.azure.com", + number: 1, + }; + yield* service.detail(reference); + for (const nextState of ["open", "closed", "merged"] as const) { + state = nextState; + for (const draft of [false, true]) { + isDraft = draft; + for (const action of actions) { + writes.length = 0; + const valid = + action === "revert" + ? state === "merged" + : state === "open" && (action === "close" || !isDraft); + const run = service.runAction({ ...reference, action }); + if (valid) { + yield* run; + assert.deepStrictEqual(writes, [action]); + } else { + assert.include((yield* Effect.flip(run)).message, "current state"); + assert.deepStrictEqual(writes, []); + } + } + } + } + }), +); + +it.effect("refuses bypass requests on every host without the capability", () => + Effect.gen(function* () { + for (const kind of ["github", "gitlab", "bitbucket", "azure-devops"] as const) { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: kind, + }), + ], + providers: [fakeProvider(kind, { runAction: () => Effect.die("must not run") })], + }); + const error = yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "merge", + bypassMergeChecks: true, + }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + } + }), +); + +it.effect("uploads attachments without posting a comment and checks provider limits first", () => + Effect.gen(function* () { + const received: string[] = []; + const attachmentCapability = { + supported: true, + maxBytes: 3, + destination: "pull-request" as const, + }; + let supported = true; + let attachmentOverride: typeof attachmentCapability | undefined; + const provider = fakeProvider("gitlab", { + getCapabilities: () => + Effect.succeed({ + ...provider.capabilities, + attachments: { ...attachmentCapability, supported }, + }), + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("Description"), + ...(attachmentOverride === undefined ? {} : { attachments: attachmentOverride }), + }), + uploadAttachment: (input) => + Effect.sync(() => { + received.push(`${input.host}/${input.repository}/${input.name}`); + return { url: "https://gitlab.com/file", markdown: "[file](https://gitlab.com/file)" }; + }), + comment: () => Effect.die("upload must not post a comment"), + updateChangeRequest: () => Effect.die("upload must not edit a description"), + }); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + { + ...provider, + capabilities: { + ...provider.capabilities, + attachments: { ...attachmentCapability, supported: false, maxBytes: 100 }, + }, + }, + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + attachmentId: "pending-file", + name: "log.txt", + mimeType: "text/plain", + filePath: "/tmp/safe/log.txt", + data: new Uint8Array([1, 2, 3]), + }; + const uploaded = yield* service.uploadAttachment(input); + assert.strictEqual(uploaded.url, "https://gitlab.com/file"); + const error = yield* service + .uploadAttachment({ ...input, data: new Uint8Array(4) }) + .pipe(Effect.flip); + assert.include(error.message, "between 1 and 3 bytes"); + supported = false; + const unsupported = yield* service.uploadAttachment(input).pipe(Effect.flip); + assert.include(unsupported.message, "does not support"); + attachmentOverride = { ...attachmentCapability, maxBytes: 2 }; + const tooLarge = yield* service.uploadAttachment(input).pipe(Effect.flip); + assert.include(tooLarge.message, "between 1 and 2 bytes"); + yield* service.uploadAttachment({ ...input, data: new Uint8Array(2) }); + attachmentOverride = { ...attachmentCapability, supported: false }; + const disabled = yield* service.uploadAttachment(input).pipe(Effect.flip); + assert.include(disabled.message, "does not support"); + assert.strictEqual(received.length, 2); + }), +); + +it.effect("pauses attachment reads after host rate limits and resumes them when quota resets", () => + Effect.gen(function* () { + for (const source of ["attachment", "action"] as const) { + let reads = 0; + let limited = true; + let preflightLimited = false; + const writes: string[] = []; + const capabilities = { + ...fakeProvider("github").capabilities, + attachments: { supported: true, maxBytes: 10, destination: "pull-request" as const }, + }; + const rateLimit = new PullRequestProviderError({ + provider: "github", + operation: "readAttachment", + reason: "rate-limited", + detail: "GitHub API rate limit exceeded.", + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getCapabilities: () => + preflightLimited ? Effect.fail(rateLimit) : Effect.succeed(capabilities), + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + runAction: () => + limited + ? Effect.fail(rateLimit) + : Effect.sync(() => { + writes.push("action"); + }), + replyToThread: () => + Effect.sync(() => { + writes.push("reply"); + }), + setThreadResolution: () => + Effect.sync(() => { + writes.push("resolve"); + }), + submitReview: () => + Effect.sync(() => { + writes.push("review"); + }), + setReviewerRequest: () => + Effect.sync(() => { + writes.push("request-reviewers"); + }), + uploadAttachment: () => + Effect.sync(() => { + writes.push("upload"); + return { + url: "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/user-attachments/assets/file", + markdown: "[file](https://github.com/user-attachments/assets/file)", + }; + }), + readAttachment: (input) => + Effect.gen(function* () { + reads++; + if (limited) return yield* rateLimit; + return HttpClientResponse.fromWeb( + HttpClientRequest.get(input.url), + new Response("image"), + ); + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + host: "github.com", + number: 1, + provider: "github" as const, + url: "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/acme/web/blob/main/shot.png", + headers: {}, + }; + yield* Effect.flip( + source === "attachment" + ? service.readAttachment(input) + : service.runAction({ ...input, action: "close" }), + ); + limited = false; + const paused = yield* service.readAttachment(input).pipe(Effect.flip); + assert.include(paused.message, "paused"); + assert.strictEqual(reads, source === "attachment" ? 1 : 0); + const upload = { + ...input, + attachmentId: "pending-file", + name: "file.png", + mimeType: "image/png", + filePath: "/tmp/file.png", + data: new Uint8Array([1]), + }; + yield* service.uploadAttachment(upload); + yield* service.runAction({ ...input, action: "close" }); + yield* service.replyToThread({ ...input, threadId: "thread", body: "Reply" }); + yield* service.setThreadResolution({ ...input, threadId: "thread", resolved: true }); + yield* service.submitReview({ ...input, verdict: "approve", body: "", comments: [] }); + yield* service.requestReviewers({ + ...input, + reviewers: [{ id: "reviewer", kind: "user" }], + requested: true, + }); + assert.deepStrictEqual(writes, [ + "upload", + "action", + "reply", + "resolve", + "review", + "request-reviewers", + ]); + assert.include((yield* service.readAttachment(input).pipe(Effect.flip)).message, "paused"); + assert.include((yield* service.detail(input).pipe(Effect.flip)).message, "paused"); + yield* TestClock.adjust("30 seconds"); + assert.strictEqual((yield* service.readAttachment(input)).status, 200); + assert.strictEqual(reads, source === "attachment" ? 2 : 1); + preflightLimited = true; + const denied = yield* service.uploadAttachment(upload).pipe(Effect.flip); + assert.include(denied.message, "GitHub API rate limit exceeded"); + assert.deepStrictEqual(writes, [ + "upload", + "action", + "reply", + "resolve", + "review", + "request-reviewers", + ]); + } + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Provider owns read")), + ), + Effect.scoped, + ), +); + +it.effect("validates private attachment provider and account before provider reads", () => + Effect.gen(function* () { + const received: string[] = []; + const provider = fakeProvider("github", { + withVerifiedCredential: (_, use) => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: "credential-a" }), + readAttachment: (input) => + Effect.sync(() => { + received.push(input.url); + return HttpClientResponse.fromWeb( + HttpClientRequest.get(input.url), + new Response("image"), + ); + }), + }); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "github", + }), + ], + providers: [provider], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + host: "github.com", + number: 1, + provider: "github" as const, + expectedAccountId: "101", + url: "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/other/repo/blob/main/shot.png", + headers: {}, + }; + assert.strictEqual((yield* service.readAttachment(input)).status, 200); + for (const invalid of [ + { ...input, provider: "gitlab" as const }, + { ...input, url: "https://other.example/shot.png" }, + { ...input, expectedAccountId: "202" }, + ]) { + assert.strictEqual( + (yield* service.readAttachment(invalid).pipe(Effect.flip))._tag, + "PullRequestOperationError", + ); + } + assert.deepStrictEqual(received, [ + "https://raw.githubusercontent.com/other/repo/main/shot.png", + ]); + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Provider owns read")), + ), + Effect.scoped, + ), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a653ef94c713..6924ac3314e7 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,5 @@ +import { pullRequestMediaUrl } from "@t3tools/shared/pullRequestMedia"; +import type { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { canonicalRepositoryKey, isSshRemoteUrl, @@ -31,6 +33,8 @@ import { type PullRequestActivity, type PullRequestCommentInput, type PullRequestCommentUpdateInput, + type PullRequestUploadAttachmentInput, + type PullRequestUploadAttachmentResult, type PullRequestDetail, type PullRequestPreview, type PullRequestDiffFileContentsInput, @@ -231,6 +235,23 @@ export class PullRequestService extends Context.Service< readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; + readonly readAttachment: ( + input: PullRequestRef & { + readonly provider: SourceControlProviderKind; + readonly url: string; + readonly headers: Readonly>; + }, + ) => Effect.Effect< + HttpClientResponse.HttpClientResponse, + PullRequestError, + Scope.Scope | HttpClient.HttpClient + >; + readonly uploadAttachment: ( + input: PullRequestUploadAttachmentInput & { + readonly data: Uint8Array; + readonly filePath: string; + }, + ) => Effect.Effect; readonly updateComment: ( input: PullRequestCommentUpdateInput, ) => Effect.Effect; @@ -479,12 +500,12 @@ function withRateLimitBackoff( api: PullRequestProviderApi, host: string, limits: SourceControlRateLimit.SourceControlRateLimit["Service"], - options?: { readonly viewerAllowsPause: boolean }, + options?: { readonly viewerAllowsPause?: boolean; readonly preflightAllowsPause?: boolean }, ): PullRequestProviderApi { const key = { provider: api.kind, host }; - const protect = ( + const protect = ( operation: string, - effect: Effect.Effect, + effect: Effect.Effect, allowPaused: boolean, ) => limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe( @@ -516,9 +537,9 @@ function withRateLimitBackoff( ), ); const wrap = - , A>( + , A, R>( operation: string, - call: (...args: Args) => Effect.Effect, + call: (...args: Args) => Effect.Effect, allowPaused = false, ) => (...args: Args) => @@ -531,6 +552,15 @@ function withRateLimitBackoff( const wrapped = { kind: api.kind, capabilities: api.capabilities, + ...(api.getCapabilities === undefined + ? {} + : { + getCapabilities: wrap( + "getCapabilities", + api.getCapabilities, + options?.preflightAllowsPause === true, + ), + }), // Refused during a pause like any other read, except for the caller that asks for the // bypass: a lookup that failed is not held, so letting every background read through would // spawn this host's CLI on each of them and re-extend the pause it was already in. @@ -553,7 +583,11 @@ function withRateLimitBackoff( : { listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), - getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + getChangeRequest: wrap( + "getChangeRequest", + api.getChangeRequest, + options?.preflightAllowsPause === true, + ), ...(api.getChangeRequestPreview === undefined ? {} : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), @@ -592,6 +626,12 @@ function withRateLimitBackoff( updateChangeRequest: interactive("updateChangeRequest", api.updateChangeRequest), }), comment: interactive("comment", api.comment), + ...(api.readAttachment === undefined + ? {} + : { readAttachment: wrap("readAttachment", api.readAttachment) }), + ...(api.uploadAttachment === undefined + ? {} + : { uploadAttachment: interactive("uploadAttachment", api.uploadAttachment) }), ...(api.updateComment === undefined ? {} : { updateComment: interactive("updateComment", api.updateComment) }), @@ -700,6 +740,7 @@ export const make = Effect.gen(function* () { const listWorkspaceProjects = ( filter: Pick, + options?: { readonly preflightAllowsPause: boolean }, ): Effect.Effect => (filter.projectId === undefined ? projections.getProjectShells(filter.projectIds) @@ -775,7 +816,7 @@ export const make = Effect.gen(function* () { supported.push({ cursorKey: key, project, - api: withRateLimitBackoff(api, host, rateLimits), + api: withRateLimitBackoff(api, host, rateLimits, options), repository, host, remote: @@ -795,8 +836,11 @@ export const make = Effect.gen(function* () { * targeting can fall back to another checkout on the host. Azure derives its organization * from the checkout, so it requires a matching repository. */ - const requireProject = (ref: PullRequestRef): Effect.Effect => - listWorkspaceProjects({ projectId: ref.projectId }).pipe( + const requireProject = ( + ref: PullRequestRef, + options?: { readonly preflightAllowsPause: boolean }, + ): Effect.Effect => + listWorkspaceProjects({ projectId: ref.projectId }, options).pipe( Effect.flatMap(({ supported }): Effect.Effect => { const own = supported[0]; const repository = ref.repository.trim(); @@ -824,6 +868,7 @@ export const make = Effect.gen(function* () { // the complete repository identity before narrowing those checkouts by host. return listWorkspaceProjects( repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + options, ).pipe( Effect.flatMap(({ supported }) => { const onHost = supported.filter((candidate) => candidate.host === host); @@ -897,6 +942,33 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(toPullRequestError(operation))); + const requireOpenForReview = ( + project: SupportedProject, + ref: PullRequestRef, + operation: string, + ) => + project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: ref.number, + }) + .pipe( + Effect.mapError(toPullRequestError(operation)), + Effect.flatMap((changeRequest) => + changeRequest.state === "open" + ? Effect.void + : Effect.fail( + new PullRequestOperationError({ + operation, + detail: + "Only open change requests can receive approvals, change requests, or review requests.", + }), + ), + ), + ); + /** * The cursors the page sent back, read once before any host is asked anything. Null where the * page sent none, which is the listing read from its newest row. @@ -1636,12 +1708,23 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(toPullRequestError("detail"))), viewerOf(project), + project.api + .getCapabilities?.({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + }) + .pipe(Effect.mapError(toPullRequestError("detail"))) ?? + Effect.succeed(project.api.capabilities), ], { concurrency: 2 }, ).pipe( - Effect.map(([changeRequest, viewer]): PullRequestDetail => ({ + Effect.map(([changeRequest, viewer, capabilities]): PullRequestDetail => ({ provider: project.api.kind, - capabilities: project.api.capabilities, + capabilities: + changeRequest.attachments === undefined + ? capabilities + : { ...capabilities, attachments: changeRequest.attachments }, projectId: project.project.id, projectTitle: project.project.title, workspaceRoot: project.project.workspaceRoot, @@ -1858,11 +1941,37 @@ export const make = Effect.gen(function* () { ); const runAction = (input: PullRequestActionInput): Effect.Effect => - requireProject(input).pipe( - Effect.flatMap((project): Effect.Effect => { + requireProject(input, { preflightAllowsPause: true }).pipe( + Effect.bindTo("project"), + Effect.bind( + "capabilities", + ({ project }) => + project.api + .getCapabilities?.({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + }) + .pipe(Effect.mapError(toPullRequestError("runAction"))) ?? + Effect.succeed(project.api.capabilities), + ), + Effect.flatMap(({ project, capabilities }): Effect.Effect => { + if ( + input.bypassMergeChecks === true && + (capabilities.bypassMergeChecks !== true || + input.action !== "merge" || + input.stackNumber !== undefined) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "This host cannot bypass checks for this action.", + }), + ); + } if ( input.stackNumber !== undefined && - (project.api.capabilities.stackActions !== true || + (capabilities.stackActions !== true || !["merge", "update-branch"].includes(input.action) || input.expectedStackHeads === undefined || (input.action === "update-branch" && input.updateMethod !== "rebase")) @@ -1876,7 +1985,7 @@ export const make = Effect.gen(function* () { } // The surface hides what a host cannot do, and this refuses it as well: a request that // reached here anyway must not be handed to a provider that never claimed the action. - if (!project.api.capabilities.actions.includes(input.action)) { + if (!capabilities.actions.includes(input.action)) { return Effect.fail( new PullRequestOperationError({ operation: "runAction", @@ -1889,7 +1998,7 @@ export const make = Effect.gen(function* () { // rebase would quietly merge instead of failing. if ( input.mergeMethod !== undefined && - !project.api.capabilities.mergeMethods.includes(input.mergeMethod) + !capabilities.mergeMethods.includes(input.mergeMethod) ) { return Effect.fail( new PullRequestOperationError({ @@ -1902,7 +2011,7 @@ export const make = Effect.gen(function* () { // must not be asked to rebase and left to pick something else. if ( input.updateMethod !== undefined && - !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod) + !(capabilities.updateMethods ?? []).includes(input.updateMethod) ) { return Effect.fail( new PullRequestOperationError({ @@ -1921,6 +2030,14 @@ export const make = Effect.gen(function* () { input.action === "update-branch", ).pipe( Effect.flatMap((viewer): Effect.Effect => { + if (input.bypassMergeChecks === true && viewer.bypassMergeChecks !== true) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "You do not have permission to bypass merge checks on this pull request.", + }), + ); + } const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; if ( stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) @@ -1944,35 +2061,72 @@ export const make = Effect.gen(function* () { }), ); } - return project.api - .runAction({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - action: input.action, - ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), - ...(input.expectedStackHeads === undefined - ? {} - : { expectedStackHeads: input.expectedStackHeads }), - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), - }) - .pipe( - // Once the authorized provider action starts, a failure may leave partial - // remote updates. Validation and permission failures above changed nothing. - Effect.ensuring( - input.stackNumber === undefined - ? Effect.void - : refreshAfterTurn(project.project.id), - ), - Effect.mapError(toPullRequestError("runAction")), - Effect.as( - project.api.kind === "azure-devops" - ? input.repository.trim() - : project.repository, - ), - ); + return ( + input.stackNumber === undefined && + ["merge", "enable-auto-merge", "close", "revert"].includes(input.action) + ? project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }) + .pipe( + Effect.mapError(toPullRequestError("runAction")), + Effect.flatMap((changeRequest) => { + const allowed = + input.action === "revert" + ? changeRequest.state === "merged" + : changeRequest.state === "open" && + (input.action === "close" || !changeRequest.isDraft); + return allowed + ? Effect.void + : Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: + "This action is not available in the change request's current state.", + }), + ); + }), + ) + : Effect.void + ).pipe( + Effect.andThen(() => + project.api + .runAction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.bypassMergeChecks === true ? { bypassMergeChecks: true } : {}), + ...(input.updateMethod === undefined + ? {} + : { updateMethod: input.updateMethod }), + }) + .pipe( + // Once the authorized provider action starts, a failure may leave partial + // remote updates. Validation and permission failures above changed nothing. + Effect.ensuring( + input.stackNumber === undefined + ? Effect.void + : refreshAfterTurn(project.project.id), + ), + Effect.mapError(toPullRequestError("runAction")), + Effect.as( + project.api.kind === "azure-devops" + ? input.repository.trim() + : project.repository, + ), + ), + ), + ); }), ); }), @@ -2062,6 +2216,79 @@ export const make = Effect.gen(function* () { }), ); + const readAttachment: PullRequestService["Service"]["readAttachment"] = (input) => + withRoutingCredential( + input, + Effect.gen(function* () { + const project = yield* requireProject(input); + const url = pullRequestMediaUrl({ + ...input, + host: project.host, + repository: project.repository, + }); + if (project.api.kind !== input.provider || !project.api.readAttachment || url === null) { + return yield* new PullRequestOperationError({ + operation: "readAttachment", + detail: "This attachment cannot be read with the selected repository credentials.", + }); + } + return yield* project.api + .readAttachment({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + url, + headers: input.headers, + }) + .pipe(Effect.mapError(toPullRequestError("readAttachment"))); + }), + ); + + const uploadAttachment: PullRequestService["Service"]["uploadAttachment"] = Effect.fn( + "PullRequestService.uploadAttachment", + )(function* (input) { + const project = yield* requireProject(input, { preflightAllowsPause: true }); + const ref = { + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }; + const [capabilities, changeRequest] = yield* Effect.all( + [ + project.api.getCapabilities?.(ref) ?? Effect.succeed(project.api.capabilities), + project.api.getChangeRequest(ref), + ], + { concurrency: 2 }, + ).pipe(Effect.mapError(toPullRequestError("uploadAttachment"))); + const capability = changeRequest.attachments ?? capabilities.attachments; + if (!capability?.supported || !project.api.uploadAttachment) { + return yield* new PullRequestOperationError({ + operation: "uploadAttachment", + detail: capability?.reason ?? "This host does not support attachment uploads.", + }); + } + if (input.data.byteLength === 0 || input.data.byteLength > capability.maxBytes) { + return yield* new PullRequestOperationError({ + operation: "uploadAttachment", + detail: `Attachments must be between 1 and ${capability.maxBytes} bytes.`, + }); + } + return yield* project.api + .uploadAttachment({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + name: input.name, + mimeType: input.mimeType, + data: input.data, + filePath: input.filePath, + }) + .pipe(Effect.mapError(toPullRequestError("uploadAttachment"))); + }); + const updateComment: PullRequestService["Service"]["updateComment"] = (input) => (input.body.trim().length === 0 ? Effect.fail( @@ -2088,6 +2315,7 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, commentId: input.commentId, + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), kind: input.kind, body: input.body, }).pipe(Effect.mapError(toPullRequestError("updateComment"))); @@ -2095,7 +2323,7 @@ export const make = Effect.gen(function* () { ); const submitReview: PullRequestService["Service"]["submitReview"] = (input) => - requireProject(input).pipe( + requireProject(input, { preflightAllowsPause: true }).pipe( Effect.flatMap((project): Effect.Effect => { const review = project.api.capabilities.review; const refuse = (detail: string) => @@ -2131,17 +2359,25 @@ export const make = Effect.gen(function* () { "You need write access on this repository to comment on a line of a change request.", ); } - return project.api - .submitReview({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - verdict: input.verdict, - body: input.body, - comments: input.comments, - }) - .pipe(Effect.mapError(toPullRequestError("submitReview"))); + return ( + input.verdict === "comment" + ? Effect.void + : requireOpenForReview(project, input, "submitReview") + ).pipe( + Effect.andThen(() => + project.api + .submitReview({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(toPullRequestError("submitReview"))), + ), + ); }), ); }), @@ -2155,10 +2391,22 @@ export const make = Effect.gen(function* () { detail: "A reply cannot be empty.", }), ) - : requireProject(input) + : requireProject(input, { preflightAllowsPause: true }) ).pipe( - Effect.flatMap((project): Effect.Effect => { - if (!project.api.capabilities.review.reply) { + Effect.flatMap((project) => + ( + project.api.getCapabilities?.({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + }) ?? Effect.succeed(project.api.capabilities) + ).pipe( + Effect.mapError(toPullRequestError("replyToThread")), + Effect.map((capabilities) => ({ project, capabilities })), + ), + ), + Effect.flatMap(({ project, capabilities }): Effect.Effect => { + if (!capabilities.review.reply) { return Effect.fail( new PullRequestOperationError({ operation: "replyToThread", @@ -2193,9 +2441,21 @@ export const make = Effect.gen(function* () { ); const setThreadResolution: PullRequestService["Service"]["setThreadResolution"] = (input) => - requireProject(input).pipe( - Effect.flatMap((project): Effect.Effect => { - if (!project.api.capabilities.review.resolve) { + requireProject(input, { preflightAllowsPause: true }).pipe( + Effect.flatMap((project) => + ( + project.api.getCapabilities?.({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + }) ?? Effect.succeed(project.api.capabilities) + ).pipe( + Effect.mapError(toPullRequestError("setThreadResolution")), + Effect.map((capabilities) => ({ project, capabilities })), + ), + ), + Effect.flatMap(({ project, capabilities }): Effect.Effect => { + if (!capabilities.review.resolve) { return Effect.fail( new PullRequestOperationError({ operation: "setThreadResolution", @@ -2301,7 +2561,7 @@ export const make = Effect.gen(function* () { ); const requestReviewers: PullRequestService["Service"]["requestReviewers"] = (input) => - requireProject(input).pipe( + requireProject(input, { preflightAllowsPause: true }).pipe( Effect.flatMap((project): Effect.Effect => { if (!project.api.capabilities.reviewers.request) { return Effect.fail( @@ -2321,16 +2581,24 @@ export const make = Effect.gen(function* () { }), ); } - return project.api - .setReviewerRequest({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - reviewers: input.reviewers, - requested: input.requested, - }) - .pipe(Effect.mapError(toPullRequestError("requestReviewers"))); + return ( + input.requested + ? requireOpenForReview(project, input, "requestReviewers") + : Effect.void + ).pipe( + Effect.andThen(() => + project.api + .setReviewerRequest({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(toPullRequestError("requestReviewers"))), + ), + ); }), ); }), @@ -3220,6 +3488,8 @@ export const make = Effect.gen(function* () { update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), updateComment: invalidatedByMutation(updateComment), + uploadAttachment, + readAttachment, submitReview: invalidatedByMutation(submitReview), replyToThread: invalidatedByMutation(replyToThread), setThreadResolution: invalidatedByMutation(setThreadResolution), diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index 8e863b8751d9..405df8849944 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -238,8 +238,97 @@ describe("decodeViewerJson", () => { }); describe("decodeThreadsJson", () => { + it("keeps native general and line threads with replies and resolution state", () => { + const { comments, reviewThreads } = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 8, + status: "fixed", + threadContext: { + filePath: "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/src/a.ts", + leftFileStart: { line: 12 }, + rightFileStart: null, + }, + comments: [ + { id: 1, content: "Question", publishedDate: "2026-07-02T00:00:00Z" }, + { id: 2, content: "Answer", publishedDate: "2026-07-02T01:00:00Z" }, + ], + }, + { + id: 9, + status: "pending", + comments: [ + { id: 1, content: "General discussion", publishedDate: "2026-07-02T00:00:00Z" }, + ], + }, + ], + }), + ), + ); + expect(comments).toHaveLength(3); + expect(reviewThreads[0]).toMatchObject({ + id: "8", + path: "src/a.ts", + line: 12, + side: "left", + isResolved: true, + }); + expect(reviewThreads[0]?.comments.map((comment) => comment.id)).toEqual(["8:1", "8:2"]); + expect(reviewThreads[1]).toMatchObject({ id: "9", path: null, line: null, isResolved: false }); + }); + + it("keeps comments when the host returned an unusable web URL", () => { + const { comments } = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 5, + comments: [ + { id: 1, content: "Review comment", publishedDate: "2026-07-02T00:00:00Z" }, + ], + }, + ], + }), + "invalid URL", + ), + ); + + expect(comments[0]).toMatchObject({ body: "Review comment", url: null }); + }); + + it.each([ + "https://dev.azure.com/acme/My%20Project/_git/web/pullrequest/42", + "https://acme.visualstudio.com/DefaultCollection/My%20Project/_git/web/pullrequest/42", + ])("links comments to their discussion on %s", (pullRequestUrl) => { + const { comments } = expectSuccess( + decodeThreadsJson( + asJson({ + value: [ + { + id: 5, + comments: [1, 2].map((id) => ({ + id, + content: "Review comment", + publishedDate: "2026-07-02T00:00:00Z", + })), + }, + ], + }), + `${pullRequestUrl}?_a=overview`, + ), + ); + + expect(comments.map((comment) => comment.url)).toEqual([ + `${pullRequestUrl}?_a=overview&discussionId=5`, + `${pullRequestUrl}?_a=overview&discussionId=5`, + ]); + }); + it("takes every real comment of every thread, oldest first", () => { - const comments = expectSuccess( + const { comments } = expectSuccess( decodeThreadsJson( asJson({ value: [ @@ -280,7 +369,7 @@ describe("decodeThreadsJson", () => { }); it("reads a thread pinned to a file as a review comment", () => { - const comments = expectSuccess( + const { comments } = expectSuccess( decodeThreadsJson( asJson({ value: [ @@ -294,11 +383,11 @@ describe("decodeThreadsJson", () => { ), ); - expect(comments[0]).toMatchObject({ kind: "review-comment", path: "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/src/app.ts" }); + expect(comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); }); it("keeps the replies under a thread, which are as much of the conversation", () => { - const comments = expectSuccess( + const { comments } = expectSuccess( decodeThreadsJson( asJson({ value: [ @@ -320,7 +409,7 @@ describe("decodeThreadsJson", () => { }); it("drops deleted threads and threads with nothing to show", () => { - const comments = expectSuccess( + const { comments } = expectSuccess( decodeThreadsJson( asJson({ value: [ @@ -431,6 +520,7 @@ describe("decodeIterationChangesJson", () => { changeEntries: [ { changeType: "rename", + changeTrackingId: 12, sourceServerItem: "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/docs/old.md", item: { path: "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/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, }, @@ -447,6 +537,7 @@ describe("decodeIterationChangesJson", () => { expect(page.changes).toEqual([ { path: "docs/new.md", + changeTrackingId: 12, oldPath: "docs/old.md", changeKind: "rename-pure", objectId: "aaaa", @@ -561,7 +652,8 @@ describe("what az devops invoke answers with", () => { // Set on every JSON body it returns, from a response header these routes do not send, so it // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. expect( - expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), + expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))) + .comments, ).toEqual([]); expect( diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index 2e949e103959..7f80e65981e0 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import type { PullRequestActor, PullRequestComment, + PullRequestReviewThread, PullRequestMergeMethod, PullRequestMergeability, PullRequestState, @@ -61,10 +62,16 @@ const RawPullRequestSchema = Schema.Struct({ repository: Schema.optional( Schema.NullOr( Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.NullOr(Schema.String)), webUrl: Schema.optional(Schema.NullOr(Schema.String)), project: Schema.optional( - Schema.NullOr(Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)) })), + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), ), }), ), @@ -84,8 +91,15 @@ const RawPullRequestSchema = Schema.Struct({ const RawThreadSchema = Schema.Struct({ id: Schema.Int, isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), + status: Schema.optional(Schema.NullOr(Schema.Union([Schema.String, Schema.Int]))), threadContext: Schema.optional( - Schema.NullOr(Schema.Struct({ filePath: Schema.optional(Schema.NullOr(Schema.String)) })), + Schema.NullOr( + Schema.Struct({ + filePath: Schema.optional(Schema.NullOr(Schema.String)), + leftFileStart: Schema.optional(Schema.NullOr(Schema.Struct({ line: Schema.Int }))), + rightFileStart: Schema.optional(Schema.NullOr(Schema.Struct({ line: Schema.Int }))), + }), + ), ), comments: Schema.optional( Schema.NullOr( @@ -122,6 +136,8 @@ const RawViewerSchema = Schema.Struct({ export interface AzureDevOpsRepositoryLocation { readonly project: string; readonly repository: string; + readonly repositoryId?: string; + readonly projectId?: string; } export interface AzureDevOpsPullRequest { @@ -204,7 +220,14 @@ function toLocation( const project = trimmed(raw.repository?.project?.name); const repository = trimmed(raw.repository?.name); if (project === null || repository === null) return null; - return { project, repository }; + const repositoryId = trimmed(raw.repository?.id); + const projectId = trimmed(raw.repository?.project?.id); + return { + project, + repository, + ...(repositoryId === null ? {} : { repositoryId }), + ...(projectId === null ? {} : { projectId }), + }; } function toAutoMergeMethod( @@ -340,18 +363,29 @@ export function decodeViewerJson(raw: string): Result.Result, DecodeFailure> { + pullRequestUrl?: string, +): Result.Result< + { + comments: ReadonlyArray; + reviewThreads: ReadonlyArray; + }, + DecodeFailure +> { const decoded = decodeThreadPage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const comments: PullRequestComment[] = []; + const reviewThreads: PullRequestReviewThread[] = []; for (const entry of decoded.success.value) { const decodedThread = decodeThreadEntry(entry); if (Exit.isFailure(decodedThread)) continue; const thread = decodedThread.value; if (thread.isDeleted === true) continue; - const path = trimmed(thread.threadContext?.filePath); + const path = trimmed(thread.threadContext?.filePath)?.replace(/^\/+/, "") || null; + const threadComments: PullRequestComment[] = []; + const url = pullRequestUrl && URL.canParse(pullRequestUrl) ? new URL(pullRequestUrl) : null; + url?.searchParams.set("discussionId", String(thread.id)); for (const comment of thread.comments ?? []) { const publishedDate = trimmed(comment.publishedDate); if ( @@ -362,21 +396,39 @@ export function decodeThreadsJson( ) { continue; } - comments.push({ + threadComments.push({ id: `${thread.id}:${comment.id ?? 0}`, kind: path === null ? "issue-comment" : "review-comment", author: toActor(comment.author), body: comment.content ?? "", createdAt: publishedDate, - url: null, + url: url?.href ?? null, path, reviewState: null, }); } + if (threadComments.length === 0) continue; + threadComments.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + comments.push(...threadComments); + const rightLine = thread.threadContext?.rightFileStart?.line; + const leftLine = thread.threadContext?.leftFileStart?.line; + const line = rightLine ?? leftLine; + reviewThreads.push({ + id: String(thread.id), + path, + line: line !== undefined && line > 0 ? line : null, + side: rightLine === undefined && leftLine !== undefined ? "left" : "right", + isResolved: ["fixed", "wontFix", "closed", "byDesign", 2, 3, 4, 5].includes( + thread.status ?? "", + ), + isOutdated: false, + comments: threadComments, + }); } - return Result.succeed( - comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - ); + return Result.succeed({ + comments: comments.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + reviewThreads, + }); } /** @@ -397,6 +449,7 @@ const RawIterationSchema = Schema.Struct({ const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknown) }); const RawChangeEntrySchema = Schema.Struct({ + changeTrackingId: Schema.optional(Schema.NullOr(Schema.Int)), changeType: Schema.optional(Schema.NullOr(Schema.String)), sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), /** Where a renamed file came from. Azure states it here on an iteration's changes. */ @@ -441,6 +494,7 @@ export interface AzureDevOpsIteration { * Azure reports by naming the file's previous home rather than as a delete and an add. */ export interface AzureDevOpsChangeEntry { + readonly changeTrackingId?: number; readonly path: string; readonly oldPath: string; readonly changeKind: "new" | "deleted" | "change" | "rename-pure" | "rename-changed"; @@ -543,6 +597,7 @@ export function decodeIterationChangesJson( const oldPath = toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; changes.push({ + ...(change.changeTrackingId == null ? {} : { changeTrackingId: change.changeTrackingId }), path, oldPath, changeKind: toChangeKind(change.changeType, oldPath !== path), diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts index 356c5da96f33..958b7156f39f 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts @@ -2,6 +2,7 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildReviewThreads, decodeCommentsJson, decodeCommitsJson, decodeConflictsJson, @@ -177,6 +178,51 @@ describe("decodeViewerJson", () => { }); describe("decodeCommentsJson", () => { + it("groups general replies under their root and leaves orphan replies in the flat list", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 1, + content: { raw: "Question" }, + created_on: "2026-07-01T00:00:00Z", + resolution: {}, + }, + { + id: 2, + parent: { id: 1 }, + content: { raw: "Reply" }, + created_on: "2026-07-01T01:00:00Z", + }, + { + id: 3, + parent: { id: 2 }, + content: { raw: "Nested" }, + created_on: "2026-07-01T02:00:00Z", + }, + { + id: 4, + parent: { id: 99 }, + content: { raw: "Orphan" }, + created_on: "2026-07-01T03:00:00Z", + }, + { + id: 5, + parent: { id: 5 }, + content: { raw: "Cyclic parent" }, + created_on: "2026-07-01T04:00:00Z", + }, + ]), + ), + ); + const threads = buildReviewThreads(decoded.entries); + + expect(decoded.comments).toHaveLength(5); + expect(threads).toHaveLength(1); + expect(threads[0]).toMatchObject({ id: "1", path: null, line: null, isResolved: true }); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["1", "2", "3"]); + }); + it("keeps a posted comment and drops deleted and unposted ones", () => { const decoded = expectSuccess( decodeCommentsJson( diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts index f0bc8a71a827..81d1cb9c4496 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -417,12 +417,6 @@ export interface BitbucketComments { readonly next: string | null; } -/** - * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a - * line opens a thread, and every reply that leads back to it belongs in it. A reply whose - * parent is on a page that was not read has nowhere to go, and is left out rather than shown - * as a thread of its own — it still stands in the flat conversation, which needs no parent. - */ export function buildReviewThreads( comments: ReadonlyArray, ): ReadonlyArray { @@ -431,24 +425,25 @@ export function buildReviewThreads( // Bounded by the number of comments read, so a parent cycle cannot spin here. let current = comment; for (let step = 0; step < byId.size; step += 1) { - const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); - if (parent === undefined) return current; + if (current.parent == null) return current; + const parent = byId.get(current.parent.id); + if (parent === undefined) return undefined; current = parent; } - return current; + return undefined; }; const threads = new Map(); const replies = new Map>>(); for (const comment of comments) { const root = rootOf(comment); + if (root === undefined) continue; const inline = root.inline; const path = trimmed(inline?.path); - if (path === null) continue; if (root.id === comment.id) { // `to` is the line as the file stands now, `from` the line it replaced; a comment that // carries only `from` was written against the removed side. - const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; + const side = inline?.from != null && inline.to == null ? "left" : "right"; const line = side === "left" ? inline?.from : inline?.to; threads.set(root.id, { id: String(root.id), diff --git a/apps/server/src/pullRequest/forgejoPullRequestJson.test.ts b/apps/server/src/pullRequest/forgejoPullRequestJson.test.ts new file mode 100644 index 000000000000..e3ae672502be --- /dev/null +++ b/apps/server/src/pullRequest/forgejoPullRequestJson.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vite-plus/test"; + +import { forgejoComment, forgejoReview, forgejoReviewThread } from "./forgejoPullRequestJson.ts"; + +it.each([ + "https://forgejo.example/forgejo/team/repo/pulls/7#issuecomment-12", + "https://gitea.example/team/repo/pulls/7#issuecomment-12", + undefined, +])("preserves the comment link supplied by the host: %s", (url) => { + const comment = { + id: 12, + body: "Please update this line.", + user: null, + created_at: "2026-09-13T10:00:00Z", + ...(url ? { html_url: url } : {}), + }; + expect(forgejoComment(comment).url).toBe(url ?? null); + expect( + forgejoReview({ + ...comment, + state: "COMMENT", + submitted_at: comment.created_at, + comments_count: 1, + }).url, + ).toBe(url ?? null); + expect( + forgejoReviewThread({ + ...comment, + path: "file.ts", + position: 4, + original_position: 4, + commit_id: "head", + original_commit_id: "head", + resolver: null, + }).comments[0]?.url, + ).toBe(url ?? null); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 4591ac0437bf..2e7e84cf2480 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -855,6 +855,30 @@ describe("repository access decoding", () => { }); describe("viewer permission decoding", () => { + it("only exposes admin merge when the host explicitly permits it", () => { + for (const allowed of [true, false, undefined]) { + const access = expectSuccess( + decodeViewerPermissionsJson( + JSON.stringify({ + data: { + repository: { + viewerPermission: "ADMIN", + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + pullRequest: { + viewerCanUpdate: true, + viewerDidAuthor: false, + viewerCanMergeAsAdmin: allowed, + }, + }, + }, + }), + ), + ); + expect(access.canBypassMergeChecks === true).toBe(allowed === true); + } + }); const viewerJson = (repository: Record) => JSON.stringify({ data: { @@ -1076,6 +1100,30 @@ describe("review thread decoding", () => { ]); }); + it.each([false, true])( + "uses native permissions for the next resolution action: resolved=%s", + (isResolved) => { + const { threads } = expectSuccess( + decodeReviewThreadsJson( + threadsJson([ + { + id: "PRRT_1", + path: "src/a.ts", + isResolved, + viewerCanResolve: true, + viewerCanReply: false, + viewerCanUnresolve: false, + comments: { nodes: [comment("c1", "Check this")] }, + }, + ]), + ), + ); + + expect(threads[0]?.thread.canResolve).toBe(!isResolved); + expect(threads[0]?.thread.canReply).toBe(false); + }, + ); + it("leaves an outdated thread without a line rather than pinning it to a stale one", () => { const reviewThreads = expectSuccess( decodeReviewThreadsJson( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index a011702d685e..aa972c054774 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -459,6 +459,9 @@ const RawReviewThreadsSchema = Schema.Struct({ Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), isResolved: Schema.optional(Schema.Boolean), + viewerCanResolve: Schema.optional(Schema.Boolean), + viewerCanReply: Schema.optional(Schema.Boolean), + viewerCanUnresolve: Schema.optional(Schema.Boolean), isOutdated: Schema.optional(Schema.Boolean), path: Schema.optional(Schema.NullOr(Schema.String)), /** Null once the thread's line has left the diff, which `isOutdated` reports. */ @@ -605,6 +608,7 @@ const RawCoreSchema = Schema.Struct({ ...RawDetailSchema.fields, ...RawViewerFieldsSchema.fields, viewerCanUpdateBranch: Schema.Boolean, + viewerCanMergeAsAdmin: Schema.optional(Schema.Boolean), baseRef: Schema.NullOr( Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), @@ -713,7 +717,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St headRepositoryOwner { login } author { login avatarUrl ... on User { id name } } autoMergeRequest { mergeMethod } - viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch + viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch viewerCanMergeAsAdmin baseRef { compare(headRef: $headRef) { behindBy } } reviewRequests(first: 100) { nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } @@ -863,6 +867,9 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin nodes { id isResolved + viewerCanResolve + viewerCanReply + viewerCanUnresolve isOutdated path line @@ -1840,6 +1847,7 @@ export function decodePullRequestCoreJson( canWrite: toCanWrite(repository.viewerPermission), canTriage: toCanTriage(repository.viewerPermission), ...toPullRequestViewerFields(pr), + ...(pr.viewerCanMergeAsAdmin === true ? { canBypassMergeChecks: true } : {}), mergeCapabilities: { merge: repository.mergeCommitAllowed, squash: repository.squashMergeAllowed, @@ -2078,6 +2086,7 @@ export function decodeReviewThreadsJson( const path = trimmed(thread.path); const id = trimmed(thread.id); if (path === null || id === null || thread.comments.nodes.length === 0) return []; + const canResolve = thread.isResolved ? thread.viewerCanUnresolve : thread.viewerCanResolve; return [ { thread: { @@ -2091,6 +2100,8 @@ export function decodeReviewThreadsJson( : null, side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right", isResolved: thread.isResolved === true, + ...(canResolve === undefined ? {} : { canResolve }), + ...(thread.viewerCanReply === undefined ? {} : { canReply: thread.viewerCanReply }), isOutdated: thread.isOutdated === true, comments: thread.comments.nodes.map((comment) => ({ id: comment.id, @@ -2563,6 +2574,7 @@ export function buildLabelRequestJson(labels: ReadonlyArray): string { * only read access can still be told apart from a passer-by. */ export interface GitHubViewerAccess { + readonly canBypassMergeChecks?: boolean; readonly canWrite: boolean; /** * The viewer's role reaches triage, which is the least that may label. Everyone who can write @@ -2584,7 +2596,7 @@ export interface GitHubViewerAccess { export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission - pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } + pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor viewerCanMergeAsAdmin } } }`; @@ -2593,7 +2605,12 @@ const RawViewerPermissionsSchema = Schema.Struct({ repository: Schema.Struct({ ...RawRepositoryAccessSchema.fields, /** Null for a number that names no pull request the viewer can see. */ - pullRequest: Schema.NullOr(RawViewerFieldsSchema), + pullRequest: Schema.NullOr( + Schema.Struct({ + ...RawViewerFieldsSchema.fields, + viewerCanMergeAsAdmin: Schema.optional(Schema.Boolean), + }), + ), }), }), }); @@ -2617,6 +2634,9 @@ export function decodeViewerPermissionsJson( canWrite: toCanWrite(repository.viewerPermission), canTriage: toCanTriage(repository.viewerPermission), ...toPullRequestViewerFields(repository.pullRequest), + ...(repository.pullRequest?.viewerCanMergeAsAdmin === true + ? { canBypassMergeChecks: true } + : {}), }); } diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index c9bc12743756..9033d353139e 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -5,6 +5,7 @@ import { decodeAwardEmojiJson, decodeRepositoryBlobsJson, decodeCommitsJson, + decodeDiscussionsJson, decodeMergeRequestDetailJson, decodeMergeRequestDiffsJson, decodeMergeRequestListJson, @@ -48,6 +49,76 @@ function expectSuccess(result: Result.Result): A { return result.success; } +describe("decodeDiscussionsJson", () => { + const note = (id: number, fields: Record = {}) => ({ + id, + body: `note ${id}`, + created_at: "2026-07-01T00:00:00Z", + ...fields, + }); + + it("keeps general discussions and replyable individual notes while excluding system notes", () => { + const decoded = expectSuccess( + decodeDiscussionsJson( + JSON.stringify([ + { id: "general", individual_note: false, notes: [note(1), note(2)] }, + { id: "single", individual_note: true, notes: [note(3)] }, + { id: "system", individual_note: false, notes: [note(4, { system: true })] }, + ]), + ), + ); + + expect(decoded.rawCount).toBe(3); + expect(decoded.threads).toHaveLength(2); + expect(decoded.threads[0]).toMatchObject({ + id: "general", + path: null, + line: null, + canResolve: false, + }); + expect(decoded.threads[0]?.comments.map((comment) => comment.id)).toEqual(["1", "2"]); + expect(decoded.threads[1]).toMatchObject({ id: "single", path: null, canResolve: false }); + }); + + it.each([false, true])("uses every resolvable note to report resolution: %s", (resolved) => { + const decoded = expectSuccess( + decodeDiscussionsJson( + JSON.stringify([ + { + id: "general", + individual_note: false, + notes: [ + note(1, { resolvable: true, resolved: true }), + note(2, { resolvable: true, resolved }), + note(3, { resolvable: false, resolved: false }), + ], + }, + ]), + ), + ); + + expect(decoded.threads[0]).toMatchObject({ canResolve: true, isResolved: resolved }); + }); + + it.each(["image", "file"])( + "keeps %s discussions without assigning a text line", + (position_type) => { + const decoded = expectSuccess( + decodeDiscussionsJson( + JSON.stringify([ + { + id: "file", + notes: [note(1, { position: { position_type, new_path: "image.png", new_line: 1 } })], + }, + ]), + ), + ); + + expect(decoded.threads[0]).toMatchObject({ path: "image.png", line: null }); + }, + ); +}); + describe("decodeMergeRequestListJson", () => { it("reads a merge request as a change request", () => { const batch = expectSuccess( diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index b9991920d0d3..08cd625e3d2d 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -536,11 +536,6 @@ export interface GitLabDiscussions { readonly rawCount: number; } -/** - * Positioned discussions only. GitLab returns the merge request's whole conversation here, - * including the plain notes the timeline already shows, and only a positioned one belongs - * against a line of the diff. - */ export function decodeDiscussionsJson( raw: string, ): Result.Result { @@ -555,19 +550,28 @@ export function decodeDiscussionsJson( const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); const root = notes[0]; const position = root?.position; - if (root === undefined || !position || position.position_type !== "text") continue; + if (root === undefined) continue; // A comment on an added or context line carries `new_line`; one on a removed line carries // only `old_line`, and belongs against the file as it was. - const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; - const path = trimmed(side === "left" ? position.old_path : position.new_path); - const line = side === "left" ? position.old_line : position.new_line; - if (path === null) continue; + const side = position?.old_line != null && position.new_line == null ? "left" : "right"; + const path = trimmed( + side === "left" ? position?.old_path : (position?.new_path ?? position?.old_path), + ); + const line = + position?.position_type === "text" + ? side === "left" + ? position.old_line + : position.new_line + : null; + const resolvableNotes = notes.filter((note) => note.resolvable === true); threads.push({ id: discussion.value.id, path, line: typeof line === "number" && line > 0 ? line : null, side, - isResolved: root.resolved === true, + isResolved: + resolvableNotes.length > 0 && resolvableNotes.every((note) => note.resolved === true), + canResolve: resolvableNotes.length > 0, // GitLab reports no equivalent of "written against a line that has since moved", so a // thread the diff cannot place is worked out from the diff itself rather than claimed // here. diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 01a4692264e0..5b9e16dd80b8 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -90,6 +90,30 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("rejects staging outside workspace roots before accessing Git", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const detectCalls: Array<{ readonly cwd: string }> = []; + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review + .applyPatch({ + cwd: outsideRoot, + sourceKind: "unstaged", + expectedDiffHash: "hash", + fileIndex: 0, + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + assert.equal(error._tag, "VcsRepositoryDetectionError"); + assert.equal(error.operation, "ReviewService.applyPatch"); + assert.deepEqual(detectCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("allows diff preview cwd inside the configured workspace root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index dc13e5650c3c..728bdf9de1ec 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -9,6 +9,7 @@ import { VcsRepositoryDetectionError, VcsUnsupportedOperationError, type ReviewDiffFileContentsInput, + type ReviewApplyPatchInput, type ReviewDiffFileContentsResult, type ReviewDiffPreviewError, type ReviewDiffPreviewInput, @@ -22,6 +23,9 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class ReviewService extends Context.Service< ReviewService, { + readonly applyPatch: ( + input: ReviewApplyPatchInput, + ) => Effect.Effect; readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; @@ -64,7 +68,10 @@ export const make = Effect.gen(function* () { }; const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( - operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", + operation: + | "ReviewService.getDiffPreview" + | "ReviewService.getDiffFileContents" + | "ReviewService.applyPatch", cwd: string, ) { const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([ @@ -83,7 +90,9 @@ export const make = Effect.gen(function* () { detail: operation === "ReviewService.getDiffPreview" ? "Review diff preview cwd must stay within the configured workspace root." - : "Review diff file contents cwd must stay within the configured workspace root.", + : operation === "ReviewService.applyPatch" + ? "Staging cwd must stay within the configured workspace root." + : "Review diff file contents cwd must stay within the configured workspace root.", }); }); @@ -101,6 +110,13 @@ export const make = Effect.gen(function* () { }; } + if (input.workingTreeScope && handle.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getDiffPreview", + kind: handle.kind, + detail: "Staged and unstaged changes require a Git repository.", + }); + } const getDriverDiffPreview = handle.driver.getDiffPreview; if (!getDriverDiffPreview) { if (handle.kind === "git") { @@ -134,6 +150,18 @@ export const make = Effect.gen(function* () { }); return ReviewService.of({ + applyPatch: Effect.fn("ReviewService.applyPatch")(function* (input) { + yield* assertWorkspaceBoundCwd("ReviewService.applyPatch", input.cwd); + const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); + if (handle?.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.applyPatch", + kind: handle?.kind ?? "unknown", + detail: "Staging requires a Git repository.", + }); + } + yield* git.applyReviewPatch(input); + }), getDiffPreview, getDiffFileContents, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2618020d388d..c5979e0a7590 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -16,6 +16,7 @@ import { EnvironmentId, EventId, GitCommandError, + GitManagerError, KeybindingRule, MessageId, ExternalLauncherCommandNotFoundError, @@ -527,6 +528,7 @@ const buildAppUnderTest = (options?: { vcsDriverRegistry?: Partial; gitVcsDriver?: Partial; gitManager?: Partial; + workspaceFileSystem?: Partial; sourceControlRepositoryService?: Partial< SourceControlRepositoryService.SourceControlRepositoryService["Service"] >; @@ -656,7 +658,7 @@ const buildAppUnderTest = (options?: { ? { kind: "git" as const, rootPath: input.cwd, - metadataPath: null, + metadataPath: tempBaseDir, freshness: { source: "live-local" as const, observedAt: TEST_EPOCH, @@ -685,7 +687,7 @@ const buildAppUnderTest = (options?: { kind: input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, rootPath: input.cwd, - metadataPath: null, + metadataPath: tempBaseDir, freshness: { source: "live-local", observedAt: TEST_EPOCH, @@ -709,10 +711,10 @@ const buildAppUnderTest = (options?: { const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePaths.layer, workspaceEntriesLayer, - WorkspaceFileSystem.layer.pipe( - Layer.provide(WorkspacePaths.layer), - Layer.provide(workspaceEntriesLayer), - ), + (options?.layers?.workspaceFileSystem + ? Layer.mock(WorkspaceFileSystem.WorkspaceFileSystem)(options.layers.workspaceFileSystem) + : WorkspaceFileSystem.layer + ).pipe(Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer)), ProjectFaviconResolver.layer.pipe( Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), @@ -7200,6 +7202,215 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serializes ordinary file writes with compare-and-save requests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-save-race-" }); + const readStarted = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + const ordinaryAtLock = yield* Deferred.make(); + const ordinaryWritten = yield* Deferred.make(); + let contents = "original"; + let detections = 0; + yield* buildAppUnderTest({ + layers: { + vcsDriverRegistry: { + detect: (input) => + Effect.gen(function* () { + if (input.cwd === cwd && ++detections === 2) + yield* Deferred.succeed(ordinaryAtLock, undefined); + return null; + }), + }, + workspaceFileSystem: { + readFile: () => + Effect.gen(function* () { + const snapshot = contents; + yield* Deferred.succeed(readStarted, undefined); + yield* Deferred.await(releaseRead); + return { + relativePath: "file.ts", + contents: snapshot, + byteLength: snapshot.length, + truncated: false, + }; + }), + writeFile: (input) => + Effect.gen(function* () { + contents = input.contents; + if (contents === "ordinary") yield* Deferred.succeed(ordinaryWritten, undefined); + return { relativePath: input.relativePath }; + }), + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const input = { cwd, relativePath: "file.ts" }; + const guarded = yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + expectedContents: "original", + contents: "guarded", + }).pipe(Effect.forkChild); + yield* Deferred.await(readStarted); + const ordinary = yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + contents: "ordinary", + }).pipe(Effect.forkChild); + yield* Effect.raceFirst( + Deferred.await(ordinaryAtLock), + Deferred.await(ordinaryWritten), + ); + yield* Deferred.succeed(releaseRead, undefined); + yield* Fiber.join(guarded); + yield* Fiber.join(ordinary); + assert.equal(contents, "ordinary"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects a delayed workspace save after the checkout changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-guarded-write-" }); + let branch: string | null = "review"; + let state: "open" | "closed" | "merged" = "open"; + let invalidated = false; + let lookupFails = false; + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + detectRepository: () => + Effect.succeed({ + kind: "git", + rootPath: cwd, + metadataPath: cwd, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + isInsideWorkTree: () => Effect.succeed(true), + }, + gitManager: { + resolvePullRequest: ({ reference }) => { + assert.equal(reference, "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/acme/web/pull/7"); + if (lookupFails) + return Effect.fail( + new GitManagerError({ + operation: "resolvePullRequest", + cwd, + detail: "Host lookup failed", + }), + ); + return Effect.succeed({ + pullRequest: { + number: 7, + title: "Review", + url: reference, + baseBranch: "main", + headBranch: "review", + state, + }, + }); + }, + invalidateLocalStatus: () => + Effect.sync(() => { + invalidated = true; + }), + localStatus: () => + Effect.sync(() => { + assert.isTrue(invalidated); + invalidated = false; + return { + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: branch, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }; + }), + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const input = { + cwd, + relativePath: "file.ts", + contents: "saved", + expectedBranch: "review", + pullRequestUrl: "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/acme/web/pull/7", + }; + yield* client[WS_METHODS.projectsWriteFile](input); + assert.isTrue(invalidated); + yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + expectedContents: "saved", + contents: "new saved", + }); + const stale = yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + expectedContents: "saved", + contents: "stale edit", + }).pipe(Effect.result); + assert.equal(stale._tag, "Failure"); + assert.equal(yield* fs.readFileString(path.join(cwd, "file.ts")), "new saved"); + yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + expectedContents: "new saved", + }); + for (const nextBranch of ["other", null]) { + branch = nextBranch; + const result = yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + contents: "pending edit", + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.include(result.failure.message, "checkout changed"); + assert.propertyVal(result.failure, "failure", "checkout_changed"); + } + assert.equal(yield* fs.readFileString(path.join(cwd, "file.ts")), "saved"); + } + branch = "review"; + for (const nextState of ["closed", "merged"] as const) { + state = nextState; + const result = yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + contents: "edit after merge", + }).pipe(Effect.flip); + assert.propertyVal(result, "failure", "pull_request_not_open"); + assert.equal(yield* fs.readFileString(path.join(cwd, "file.ts")), "saved"); + } + lookupFails = true; + const unavailable = yield* client[WS_METHODS.projectsWriteFile]({ + ...input, + contents: "pending edit", + }).pipe(Effect.flip); + assert.propertyVal(unavailable, "failure", "pull_request_verification_failed"); + assert.equal(yield* fs.readFileString(path.join(cwd, "file.ts")), "saved"); + yield* client[WS_METHODS.projectsWriteFile]({ + cwd, + relativePath: "file.ts", + contents: "local edit", + }); + assert.equal(yield* fs.readFileString(path.join(cwd, "file.ts")), "local edit"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -7648,6 +7859,172 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("waits for a branch switch before validating and staging review edits", () => + Effect.gen(function* () { + const switchStarted = yield* Deferred.make(); + const releaseSwitch = yield* Deferred.make(); + const stageAtLock = yield* Deferred.make(); + const staged = yield* Deferred.make(); + let branch = "main"; + let stagedOnBranch: string | undefined; + let detections = 0; + const refreshStatus = () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: branch, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + detectRepository: () => + Effect.gen(function* () { + if (++detections === 2) yield* Deferred.succeed(stageAtLock, undefined); + return null; + }), + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + switchRef: ({ refName }) => + Effect.gen(function* () { + yield* Deferred.succeed(switchStarted, undefined); + yield* Deferred.await(releaseSwitch); + branch = refName; + return { refName }; + }), + }, + reviewService: { + applyPatch: () => + Effect.gen(function* () { + stagedOnBranch = branch; + yield* Deferred.succeed(staged, undefined); + }), + }, + vcsStatusBroadcaster: { + refreshStatus, + refreshLocalStatus: refreshStatus, + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const switching = yield* client[WS_METHODS.vcsSwitchRef]({ + cwd: "/tmp/repo", + refName: "other", + }).pipe(Effect.forkChild); + yield* Deferred.await(switchStarted); + const staging = yield* client[WS_METHODS.reviewApplyPatch]({ + cwd: "/tmp/repo", + sourceKind: "unstaged", + expectedDiffHash: "hash", + fileIndex: 0, + }).pipe(Effect.forkChild); + yield* Effect.raceFirst(Deferred.await(stageAtLock), Deferred.await(staged)); + yield* Deferred.succeed(releaseSwitch, undefined); + yield* Fiber.join(switching); + yield* Fiber.join(staging); + assert.equal(stagedOnBranch, "other"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("refreshes staged review status before allowing a branch switch", () => + Effect.gen(function* () { + const refreshStarted = yield* Deferred.make(); + const releaseRefresh = yield* Deferred.make(); + const switchAtLock = yield* Deferred.make(); + const events: string[] = []; + let branch = "main"; + let refreshedBranch: string | undefined; + let detections = 0; + const status = { + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + detectRepository: () => + Effect.gen(function* () { + if (++detections === 2) yield* Deferred.succeed(switchAtLock, undefined); + return null; + }), + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + switchRef: ({ refName }) => + Effect.sync(() => { + events.push("switch"); + branch = refName; + return { refName }; + }), + }, + reviewService: { + applyPatch: () => Effect.sync(() => events.push("stage")).pipe(Effect.asVoid), + }, + vcsStatusBroadcaster: { + refreshLocalStatus: () => + Effect.gen(function* () { + yield* Deferred.succeed(refreshStarted, undefined); + yield* Deferred.await(releaseRefresh); + refreshedBranch = branch; + events.push("refresh"); + return { ...status, refName: branch }; + }), + refreshStatus: () => Effect.succeed({ ...status, refName: branch }), + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const staging = yield* client[WS_METHODS.reviewApplyPatch]({ + cwd: "/tmp/repo", + sourceKind: "unstaged", + expectedDiffHash: "hash", + fileIndex: 0, + }).pipe(Effect.forkChild); + yield* Effect.raceFirst( + Deferred.await(refreshStarted), + Fiber.join(staging).pipe( + Effect.andThen(Effect.die("Staging finished without refreshing local status.")), + ), + ); + const switching = yield* client[WS_METHODS.vcsSwitchRef]({ + cwd: "/tmp/repo", + refName: "other", + }).pipe(Effect.forkChild); + yield* Deferred.await(switchAtLock); + yield* Deferred.succeed(releaseRefresh, undefined); + yield* Fiber.join(staging); + yield* Fiber.join(switching); + assert.deepEqual(events, ["stage", "refresh", "switch"]); + assert.equal(refreshedBranch, "main"); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc git methods", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -7988,6 +8365,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, @@ -8068,6 +8448,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.sync(() => { @@ -8142,6 +8525,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b27d56bef399..08c7ba52f02f 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import { + FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest, @@ -60,6 +61,7 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly fetch?: typeof fetch; readonly requestFailure?: ( request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; @@ -122,10 +124,14 @@ function makeLayer(input: { const layer = BitbucketApi.layer.pipe( Layer.provide( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => execute(request)), - ), + input.fetch + ? FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), + ) + : Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ), ), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ @@ -909,3 +915,111 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); + +it.effect("sends multipart attachment bytes with existing repository credentials", () => { + const fixture = makeLayer({ response: () => new Response("", { status: 201 }) }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const formData = new FormData(); + formData.set("files", new Blob([new Uint8Array([0, 128, 255])]), "log.bin"); + yield* api.request({ method: "POST", url: "/repositories/owner/repo/downloads", formData }); + const request = fixture.execute.mock.calls[0]?.[0]; + assert.strictEqual(request?.body._tag, "FormData"); + assert.strictEqual(request?.headers.authorization, `Basic ${btoa("user@example.com:token")}`); + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/owner/repo/downloads", + ); + }).pipe(Effect.provide(fixture.layer)); +}); + +it.effect( + "reads attachment bytes from the selected repository with authentication and range", + () => { + const fixture = makeLayer({ + response: () => new Response(new Uint8Array([0, 128, 255]), { status: 206 }), + }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const response = yield* api.readAttachment({ + repository: "owner/repo", + url: "https://bitbucket.org/owner/repo/downloads/screen%20one.png", + headers: { + range: "bytes=0-2", + "if-range": "etag", + authorization: "untrusted", + cookie: "untrusted", + }, + }); + assert.deepStrictEqual( + new Uint8Array(yield* response.arrayBuffer), + new Uint8Array([0, 128, 255]), + ); + const request = fixture.execute.mock.calls[0]?.[0]; + assert.strictEqual( + request?.url, + "https://api.test.local/2.0/repositories/owner/repo/downloads/screen%20one.png", + ); + assert.strictEqual(request?.headers.authorization, `Basic ${btoa("user@example.com:token")}`); + assert.strictEqual(request?.headers.range, "bytes=0-2"); + assert.strictEqual(request?.headers["if-range"], "etag"); + assert.strictEqual(request?.headers.cookie, undefined); + }).pipe(Effect.scoped, Effect.provide(fixture.layer)); + }, +); + +it.effect( + "rejects attachment hosts and paths outside the selected repository before sending credentials", + () => { + const fixture = makeLayer({ response: () => new Response("") }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + for (const url of [ + "https://other.example/owner/repo/downloads/image.png", + "http://bitbucket.org/owner/repo/downloads/image.png", + "https://user@bitbucket.org/owner/repo/downloads/image.png", + "https://bitbucket.org/other/repo/downloads/image.png", + "https://bitbucket.org/owner/repo/src/image.png", + "https://bitbucket.org/owner/repo/downloads/%2fsecret", + "https://bitbucket.org/owner/repo/downloads/../secret", + "https://bitbucket.org/owner/repo/downloads/image.png?target=other", + ]) { + const error = yield* api + .readAttachment({ repository: "owner/repo", url, headers: {} }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); + } + assert.strictEqual(fixture.execute.mock.calls.length, 0); + }).pipe(Effect.scoped, Effect.provide(fixture.layer)); + }, +); + +it.effect( + "returns download redirects for the unsigned media fetcher without forwarding credentials", + () => { + const response = () => + new Response(null, { + status: 302, + headers: { location: "https://storage.example/signed-image?signature=test" }, + }); + const fetch = vi.fn(async () => response()); + const fixture = makeLayer({ response, fetch }); + return Effect.gen(function* () { + const api = yield* BitbucketApi.BitbucketApi; + const response = yield* api.readAttachment({ + repository: "owner/repo", + url: "https://bitbucket.org/owner/repo/downloads/image.png", + headers: {}, + }); + assert.strictEqual(response.status, 302); + assert.strictEqual( + response.headers.location, + "https://storage.example/signed-image?signature=test", + ); + assert.strictEqual(fetch.mock.calls.length, 1); + assert.strictEqual(fetch.mock.calls[0]?.[1]?.redirect, "manual"); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + assert.strictEqual(headers.get("authorization"), `Basic ${btoa("user@example.com:token")}`); + }).pipe(Effect.scoped, Effect.provide(fixture.layer)); + }, +); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 202740680be8..a05800a3135c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -6,6 +6,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; import { NonNegativeInt, TrimmedNonEmptyString, @@ -13,7 +14,12 @@ import { type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, } from "@t3tools/contracts"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { sanitizeBranchFragment } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl, @@ -323,6 +329,11 @@ export class BitbucketApi extends Context.Service< BitbucketApi, { readonly probeAuth: Effect.Effect; + readonly readAttachment: (input: { + readonly repository: string; + readonly url: string; + readonly headers: Readonly>; + }) => Effect.Effect; /** * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints @@ -338,6 +349,7 @@ export class BitbucketApi extends Context.Service< readonly url: string; /** A JSON document, for the endpoints that take one. */ readonly body?: string; + readonly formData?: FormData; /** Response bytes to keep; past this the body comes back cut short and marked. */ readonly maxBytes?: number; }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; @@ -826,6 +838,7 @@ export const make = Effect.gen(function* () { readonly method: "GET" | "POST" | "PUT" | "DELETE"; readonly url: string; readonly body?: string; + readonly formData?: FormData; readonly redirects: number; }): Effect.Effect => { const url = trustedUrl(input.url); @@ -844,9 +857,11 @@ export const make = Effect.gen(function* () { : HttpClientRequest.put(url); // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. const withBody = - input.body === undefined - ? base - : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + input.formData !== undefined + ? base.pipe(HttpClientRequest.bodyFormData(input.formData)) + : input.body === undefined + ? base + : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); return httpClient.execute(withAuth(withBody)).pipe( Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), @@ -902,6 +917,46 @@ export const make = Effect.gen(function* () { return BitbucketApi.of({ request, + readAttachment: Effect.fn("BitbucketApi.readAttachment")(function* (input) { + const url = URL.canParse(input.url) ? new URL(input.url) : null; + const segments = input.repository.split("/"); + const prefix = `/${segments.map(encodeURIComponent).join("/")}/downloads/`; + const encodedName = url?.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : ""; + let fileName = ""; + try { + fileName = decodeURIComponent(encodedName); + } catch {} + if ( + !url || + url.origin !== "https://bitbucket.org" || + url.username || + url.password || + url.search || + segments.length !== 2 || + segments.some((segment) => !segment || segment === "." || segment === "..") || + !fileName || + fileName === "." || + fileName === ".." || + /[\\/\p{Cc}]/u.test(fileName) + ) + return yield* new BitbucketUntrustedUrlError({ host: url?.origin ?? "an unreadable url" }); + const headers: Record = { "accept-encoding": "identity" }; + for (const name of ["range", "if-range"]) { + const value = input.headers[name]; + if (value !== undefined) headers[name] = value; + } + const request = HttpClientRequest.get( + apiUrl( + `/repositories/${segments.map(encodeURIComponent).join("/")}/downloads/${encodeURIComponent(fileName)}`, + ), + ).pipe(HttpClientRequest.setHeaders(headers), withAuth); + return yield* HttpClient.withScope(httpClient) + .execute(request) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.mapError((cause) => new BitbucketRequestError({ operation: "request", cause })), + ); + }), probeAuth: executeJson( "probeAuth", HttpClientRequest.get(apiUrl("/user")), diff --git a/apps/server/src/sourceControl/ForgejoCli.test.ts b/apps/server/src/sourceControl/ForgejoCli.test.ts new file mode 100644 index 000000000000..0575e7b5c1d8 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoCli.test.ts @@ -0,0 +1,93 @@ +import * as Option from "effect/Option"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; + +it.effect("sends binary issue attachments using the selected fj login", () => { + const data = new FormData(); + data.set("attachment", new Blob([new Uint8Array([0, 128, 255])]), "image.png"); + const requests: string[] = []; + const httpClient = HttpClient.make((request) => { + requests.push(request.url); + expect(request.headers.authorization).toBe("token test-fj-token"); + if (request.method === "GET") + return Effect.gen(function* () { + expect( + Option.getOrThrow(yield* Effect.serviceOption(FetchHttpClient.RequestInit)).redirect, + ).toBe("manual"); + expect(request.headers.range).toBe("bytes=0-2"); + expect(request.headers.cookie).toBeUndefined(); + return HttpClientResponse.fromWeb( + request, + new Response(new Uint8Array([0, 128, 255]), { status: 206 }), + ); + }); + expect(request.body._tag).toBe("FormData"); + if (request.body._tag === "FormData") expect(request.body.formData).toBe(data); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response('{"browser_download_url":"https://forgejo.example/attachments/id"}', { + status: 201, + }), + ), + ); + }); + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli.api({ + cwd: "/repo", + repository: "owner/repo", + reference: "https://forgejo.example/owner/repo", + host: "forgejo.example", + path: "repos/owner/repo/issues/7/assets", + method: "POST", + formData: data, + }); + expect(result.stdout).toContain("browser_download_url"); + const url = "https://forgejo.example/attachments/12345678-1234-1234-1234-123456789012"; + const response = yield* cli.readAttachment!({ + cwd: "/repo", + repository: "owner/repo", + host: "forgejo.example", + reference: "https://forgejo.example/owner/repo", + number: 7, + url, + headers: { range: "bytes=0-2", cookie: "never-forward" }, + }); + expect(new Uint8Array(yield* response.arrayBuffer)).toEqual(new Uint8Array([0, 128, 255])); + expect(requests).toEqual([ + "https://forgejo.example/api/v1/repos/owner/repo/issues/7/assets", + url, + ]); + }).pipe( + Effect.scoped, + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + '{"hosts":{"forgejo.example":{"type":"Application","token":"test-fj-token"}}}', + ), + }), + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index 0eacba0759cf..e20a2a4ea435 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -1,4 +1,6 @@ import * as Context from "effect/Context"; +import type * as Scope from "effect/Scope"; +import { pullRequestMediaUrl } from "@t3tools/shared/pullRequestMedia"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -9,7 +11,12 @@ import * as Semaphore from "effect/Semaphore"; import * as NodeOS from "node:os"; // @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform. import * as NodePath from "node:path"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -121,6 +128,7 @@ export interface ForgejoApiInput extends ForgejoRepositoryInput { readonly path: string; readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; readonly body?: unknown; + readonly formData?: FormData; } export class ForgejoCli extends Context.Service< @@ -146,6 +154,13 @@ export class ForgejoCli extends Context.Service< readonly resolveRepository: ( input: ForgejoRepositoryInput, ) => Effect.Effect; + readonly readAttachment?: ( + input: ForgejoRepositoryInput & { + readonly number: number; + readonly url: string; + readonly headers: Readonly>; + }, + ) => Effect.Effect; readonly api: ( input: ForgejoApiInput, ) => Effect.Effect; @@ -343,6 +358,7 @@ export const make = Effect.gen(function* () { readonly path: string; readonly method?: ForgejoApiInput["method"]; readonly body?: string; + readonly formData?: FormData; }) { const base = new URL(`${input.baseUrl}/api/v1/`); const url = new URL(input.path, base); @@ -362,6 +378,8 @@ export const make = Effect.gen(function* () { ); if (input.body !== undefined) request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + if (input.formData !== undefined) + request = request.pipe(HttpClientRequest.bodyFormData(input.formData)); const response = yield* httpClient .execute(request) .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); @@ -674,6 +692,15 @@ export const make = Effect.gen(function* () { path, ...(input.method === undefined ? {} : { method: input.method }), ...(stdin === undefined ? {} : { body: stdin }), + ...(input.formData === undefined ? {} : { formData: input.formData }), + }); + } + if (input.formData !== undefined) { + return yield* new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: + "Attachment uploads require fj authentication. tea does not support multipart uploads.", }); } const result = yield* execute({ @@ -718,7 +745,39 @@ export const make = Effect.gen(function* () { }); return result; }); - return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api }); + const readAttachment: NonNullable = Effect.fn( + "ForgejoCli.readAttachment", + )(function* (input) { + const repository = yield* resolveRepository(input); + const invalid = (detail: string) => + new ForgejoCliError({ command: repository.command ?? "fj", cwd: input.cwd, detail }); + const url = pullRequestMediaUrl({ + provider: "forgejo", + host: input.host, + repository: input.repository ?? repository.repository, + number: input.number, + url: input.url, + }); + if (!url || new URL(url).origin !== new URL(repository.baseUrl).origin) + return yield* invalid("The attachment does not belong to this Forgejo server."); + if (repository.command !== "fj") + return yield* invalid("Private attachments require fj authentication on this server."); + const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token; + if (!token) return yield* invalid("fj has no credentials for this server."); + const headers: Record = { + "accept-encoding": "identity", + authorization: `token ${token}`, + }; + for (const name of ["range", "if-range"]) + if (input.headers[name]) headers[name] = input.headers[name]; + return yield* HttpClient.withScope(httpClient) + .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers))) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.mapError(() => invalid("Forgejo attachment download failed.")), + ); + }); + return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api, readAttachment }); }); export const layer = Layer.effect(ForgejoCli, make); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5893c21ff772..b46ba0124383 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -191,6 +191,20 @@ describe("GitHubCli.layer", () => { ["repo", "view", "other.example.test/owner/repo", "--json", "name"], ["api", "https://other.example.test/user", "--hostname", "github.com"], ["api", "user"], + ["api", "https://uploads.github.com/other", "--hostname", "github.com"], + ["api", "http://uploads.github.com/user-attachments/assets", "--hostname", "github.com"], + [ + "api", + "https://user@uploads.github.com/user-attachments/assets", + "--hostname", + "github.com", + ], + [ + "api", + "https://uploads.other.ghe.com/user-attachments/assets", + "--hostname", + "github.com", + ], ]) { const failure = yield* gh.execute({ cwd: "/repo", args }).pipe( Effect.provideService(GitHubCli.PinnedGitHubCredential, { @@ -637,3 +651,35 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); }); + +it.effect("pins official attachment uploads to the same verified GitHub credential", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("{}"))); + const gh = yield* GitHubCli.GitHubCli; + yield* gh + .execute({ + cwd: "/repo", + args: [ + "api", + "https://uploads.github.com/user-attachments/assets?repository_id=42", + "--hostname", + "github.com", + "--method", + "POST", + "--input", + "/tmp/safe-upload", + ], + }) + .pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("test-upload-token"), + credentialFingerprint: "upload-account", + }), + ); + expect(mockRun.mock.calls[0]?.[0].env).toMatchObject({ + GH_TOKEN: "test-upload-token", + GH_DEBUG: "", + }); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index c525740efeae..979128ffabef 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -69,7 +69,27 @@ function commandHosts(args: ReadonlyArray): Array { function targetsVerifiedHost(args: ReadonlyArray, host: string): boolean { const hosts = commandHosts(args); - return hosts.length > 0 && hosts.every((target) => target === host); + const upload = + args[0] === "api" && + args.some((arg) => { + try { + const url = new URL(arg); + return ( + url.protocol === "https:" && + url.host === `uploads.${host}` && + url.pathname === "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/user-attachments/assets" && + !url.username && + !url.password + ); + } catch { + return false; + } + }) && + (host === "github.com" || /^[a-z0-9-]+\.ghe\.com$/.test(host)); + return ( + hosts.length > 0 && + hosts.every((target) => target === host || (upload && target === `uploads.${host}`)) + ); } const gitHubCliFailureFields = { diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9d03e1ab8aa5..83ae93533bfc 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -270,6 +270,7 @@ export class GitLabCli extends Context.Service< /** Piped to the child's stdin, for payloads that must never appear in argv. */ readonly stdin?: string; readonly maxOutputBytes?: number; + readonly env?: Readonly>; }) => Effect.Effect; readonly listMergeRequests: (input: { @@ -425,6 +426,7 @@ export const make = Effect.gen(function* () { timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin === undefined ? {} : { stdin: input.stdin }), ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.env === undefined ? {} : { env: input.env }), }) .pipe(Effect.mapError(mapError)); diff --git a/apps/server/src/sourceControl/forgejoPullRequests.ts b/apps/server/src/sourceControl/forgejoPullRequests.ts index affb9bae74c7..c8b22116399f 100644 --- a/apps/server/src/sourceControl/forgejoPullRequests.ts +++ b/apps/server/src/sourceControl/forgejoPullRequests.ts @@ -38,8 +38,8 @@ export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), isCrossRepository: - raw.head.repo !== null && - raw.base.repo !== null && + raw.head.repo === null || + raw.base.repo === null || raw.head.repo.full_name !== raw.base.repo.full_name, headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null, headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null, diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..28fe29426fdc 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1005,6 +1005,33 @@ it.effect("restores empty checkpoints without changing paths outside the workspa }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); +it.effect("checkpoint capture preserves staged review progress across turns", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-staged-checkpoint-" }); + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* runGit(cwd, ["commit", "--allow-empty", "-m", "initial"]); + yield* fileSystem.writeFileString(`${cwd}/review.txt`, "reviewed\n"); + yield* runGit(cwd, ["add", "review.txt"]); + yield* fileSystem.writeFileString(`${cwd}/review.txt`, "later edit\n"); + yield* driver.checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/review"), + }); + const index = yield* driver.execute({ operation: "test", cwd, args: ["show", ":review.txt"] }); + assert.equal(index.stdout, "reviewed\n"); + const checkpoint = yield* driver.execute({ + operation: "test", + cwd, + args: ["show", "refs/t3/checkpoints/review:review.txt"], + }); + assert.equal(checkpoint.stdout, "later edit\n"); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + it.effect("GitVcsDriver forwards execute env to the VCS process", () => { let observedEnv: NodeJS.ProcessEnv | undefined; let observedAppendTruncationMarker: boolean | undefined; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..bda7bef41689 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -20,6 +20,7 @@ import { type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, type ReviewDiffPreviewInput, + type ReviewApplyPatchInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, type ReviewDiffFileContentsResult, @@ -96,6 +97,8 @@ export interface GitRemoteStatusDetails { export interface GitPreparedCommitContext { stagedSummary: string; stagedPatch: string; + stagedTree?: string; + headCommit?: string | null; } export interface ExecuteGitProgress { @@ -153,6 +156,9 @@ export interface GitCommitProgress { } export interface GitCommitOptions { + readonly stagedTree?: string; + readonly expectedHead?: string | null; + readonly expectedBranch?: string | null; readonly timeoutMs?: number; readonly progress?: GitCommitProgress; } @@ -286,6 +292,7 @@ export class GitVcsDriver extends Context.Service< readonly prepareCommitContext: ( cwd: string, filePaths?: readonly string[], + stagedOnly?: boolean, ) => Effect.Effect; readonly commit: ( cwd: string, @@ -296,7 +303,7 @@ export class GitVcsDriver extends Context.Service< readonly pushCurrentBranch: ( cwd: string, fallbackBranch: string | null, - options?: { readonly remoteName?: string | null }, + options?: { readonly remoteName?: string | null; readonly pushToUpstream?: boolean }, ) => Effect.Effect; readonly readRangeContext: ( cwd: string, @@ -305,6 +312,9 @@ export class GitVcsDriver extends Context.Service< readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; + readonly applyReviewPatch: ( + input: ReviewApplyPatchInput, + ) => Effect.Effect; readonly getReviewDiffFileContents: ( input: ReviewDiffFileContentsInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..dfccc331358f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1091,6 +1091,274 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("review diff previews", () => { + it.effect("returns a truncated staged preview and refuses to unstage it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "large.txt", "large staged line\n".repeat(8_000)); + yield* git(cwd, ["add", "large.txt"]); + const index = yield* git(cwd, ["write-tree"]); + const source = (yield* driver.getReviewDiffPreview({ cwd, workingTreeScope: "staged" })) + .sources[0]!; + assert.isTrue(source.truncated); + assert.include(source.diff, "diff --git a/large.txt b/large.txt"); + assert.deepEqual( + source.files?.map((file) => file.path), + ["large.txt"], + ); + yield* writeTextFile(cwd, "large.txt", "later unstaged edit\n"); + const stagedFile = (yield* driver.getReviewDiffPreview({ + cwd, + workingTreeScope: "staged", + file: { path: "large.txt", previousPath: null, sourceKind: "staged" }, + })).sources[0]!; + assert.isFalse(stagedFile.truncated); + assert.include(stagedFile.diff, "+large staged line"); + assert.notInclude(stagedFile.diff, "later unstaged edit"); + const unstagedFile = (yield* driver.getReviewDiffPreview({ + cwd, + workingTreeScope: "unstaged", + file: { path: "large.txt", previousPath: null, sourceKind: "unstaged" }, + })).sources[0]!; + assert.include(unstagedFile.diff, "-large staged line"); + assert.include(unstagedFile.diff, "+later unstaged edit"); + const error = yield* driver + .applyReviewPatch({ + cwd, + sourceKind: "staged", + expectedDiffHash: source.diffHash, + fileIndex: 0, + }) + .pipe(Effect.flip); + assert.include(error.detail, "Refresh the diff"); + assert.equal(yield* git(cwd, ["write-tree"]), index); + }), + ); + + it.effect( + "stages one hunk, retains the index across later edits, and commits only staged content", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const original = Array.from({ length: 30 }, (_, index) => `line ${index + 1}\n`).join(""); + yield* writeTextFile(cwd, "review.txt", original); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "fixture"]); + const edited = original + .replace("line 2\n", "first edit\n") + .replace("line 28\n", "second edit\n"); + yield* writeTextFile(cwd, "review.txt", edited); + const before = yield* driver.getReviewDiffPreview({ cwd, workingTreeScope: "unstaged" }); + const source = before.sources[0]!; + yield* driver.applyReviewPatch({ + cwd, + sourceKind: "unstaged", + expectedDiffHash: source.diffHash, + fileIndex: 0, + hunkIndex: 0, + }); + assert.include(yield* git(cwd, ["show", ":review.txt"]), "first edit"); + assert.notInclude(yield* git(cwd, ["show", ":review.txt"]), "second edit"); + const stale = yield* driver + .applyReviewPatch({ + cwd, + sourceKind: "unstaged", + expectedDiffHash: source.diffHash, + fileIndex: 0, + hunkIndex: 1, + }) + .pipe(Effect.flip); + assert.include(stale.detail, "Refresh the diff"); + yield* writeTextFile(cwd, "review.txt", edited.replace("first edit", "later edit")); + const next = yield* driver.getReviewDiffPreview({ cwd, workingTreeScope: "unstaged" }); + assert.include(next.sources[0]!.diff, "-first edit"); + assert.include(next.sources[0]!.diff, "+later edit"); + const staged = yield* driver.getReviewDiffPreview({ cwd, workingTreeScope: "staged" }); + const expanded = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "staged", + oldPath: "review.txt", + newPath: "review.txt", + }), + ); + assert.include(expanded.oldContents, "line 2"); + assert.include(expanded.newContents, "first edit"); + assert.notInclude(expanded.newContents, "later edit"); + const context = yield* driver.prepareCommitContext(cwd, undefined, true); + assert.include(context!.stagedPatch, "+first edit"); + assert.notInclude(context!.stagedPatch, "later edit"); + yield* driver.commit(cwd, "Reviewed first change", ""); + assert.include(yield* git(cwd, ["show", "HEAD:review.txt"]), "first edit"); + assert.include(yield* git(cwd, ["diff"]), "+later edit"); + assert.include(staged.sources[0]!.diff, "+first edit"); + }), + ); + + it.effect("stages binary files from a nested workspace and rejects invalid selections", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(`${cwd}/nested`); + yield* fileSystem.writeFile(`${cwd}/nested/binary.bin`, new Uint8Array([0, 1, 2, 3])); + const preview = yield* driver.getReviewDiffPreview({ + cwd: `${cwd}/nested`, + workingTreeScope: "unstaged", + }); + const source = preview.sources[0]!; + assert.include(source.diff, "GIT binary patch"); + const input = { + cwd: `${cwd}/nested`, + sourceKind: "unstaged" as const, + expectedDiffHash: source.diffHash, + fileIndex: 0, + }; + const invalid = yield* driver + .applyReviewPatch({ ...input, hunkIndex: 0 }) + .pipe(Effect.flip); + assert.include(invalid.detail, "hunk is unavailable"); + assert.equal(yield* git(cwd, ["diff", "--cached"]), ""); + yield* driver.applyReviewPatch(input); + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "nested/binary.bin"); + const index = yield* git(cwd, ["write-tree"]); + yield* driver.applyReviewPatch(input).pipe(Effect.flip); + assert.equal(yield* git(cwd, ["write-tree"]), index); + }), + ); + + it.effect( + "stages and unstages a new file before the first commit without changing its contents", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + yield* driver.initRepo({ cwd }); + yield* fileSystem.makeDirectory(`${cwd}/nested`); + yield* writeTextFile(cwd, "nested/new [file].txt", "new file\n\n"); + for (const sourceKind of ["unstaged", "staged"] as const) { + const preview = yield* driver.getReviewDiffPreview({ + cwd: `${cwd}/nested`, + workingTreeScope: sourceKind, + }); + const source = preview.sources[0]!; + assert.include(source.diff, "+new file"); + yield* driver.applyReviewPatch({ + cwd: `${cwd}/nested`, + sourceKind, + expectedDiffHash: source.diffHash, + fileIndex: 0, + }); + assert.equal( + yield* fileSystem.readFileString(`${cwd}/nested/new [file].txt`), + "new file\n\n", + ); + } + assert.equal(yield* git(cwd, ["ls-files"]), ""); + }), + ); + + it.effect("unstages a rename hunk while keeping the rename and other hunks staged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const original = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`).join(""); + yield* writeTextFile(cwd, "before file.txt", original); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "fixture"]); + yield* fileSystem.rename(`${cwd}/before file.txt`, `${cwd}/after file.txt`); + yield* writeTextFile( + cwd, + "after file.txt", + original.replace("line 2\n", "first edit\n").replace("line 48\n", "second edit\n"), + ); + yield* git(cwd, ["add", "-A"]); + yield* git(cwd, ["update-index", "--chmod=+x", "--", "after file.txt"]); + const source = (yield* driver.getReviewDiffPreview({ cwd, workingTreeScope: "staged" })) + .sources[0]!; + yield* driver.applyReviewPatch({ + cwd, + sourceKind: "staged", + expectedDiffHash: source.diffHash, + fileIndex: 0, + hunkIndex: 0, + }); + assert.equal(yield* git(cwd, ["ls-files"]), "README.md\nafter file.txt"); + const contents = yield* git(cwd, ["show", ":after file.txt"]); + assert.notInclude(contents, "first edit"); + assert.include(contents, "second edit"); + assert.match(yield* git(cwd, ["ls-files", "--stage", "--", "after file.txt"]), /^100755 /); + assert.include(yield* git(cwd, ["diff"]), "+first edit"); + }), + ); + + it.effect("ignores relative diff configuration when staging from a nested workspace", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const file of ["file.txt", "nested/file.txt"]) + yield* writeTextFile(cwd, file, "before\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "fixture"]); + yield* git(cwd, ["config", "diff.relative", "true"]); + yield* writeTextFile(cwd, "nested/file.txt", "after\n"); + const source = (yield* driver.getReviewDiffPreview({ + cwd: `${cwd}/nested`, + workingTreeScope: "unstaged", + })).sources[0]!; + assert.include(source.diff, "a/nested/file.txt"); + yield* driver.applyReviewPatch({ + cwd: `${cwd}/nested`, + sourceKind: "unstaged", + expectedDiffHash: source.diffHash, + fileIndex: 0, + }); + assert.equal(yield* git(cwd, ["show", ":file.txt"]), "before"); + assert.equal(yield* git(cwd, ["show", ":nested/file.txt"]), "after"); + yield* writeTextFile(cwd, "file.txt", "root edit\n"); + yield* git(cwd, ["add", "file.txt"]); + const context = yield* driver.prepareCommitContext(`${cwd}/nested`, undefined, true); + assert.include(context!.stagedSummary, "M\tfile.txt"); + assert.include(context!.stagedSummary, "M\tnested/file.txt"); + assert.include(context!.stagedPatch, "+root edit"); + }), + ); + + it.effect("stages and reverses renames and deletions without changing the working tree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + yield* writeTextFile(cwd, "before.txt", "rename me\n"); + yield* writeTextFile(cwd, "deleted.txt", "delete me\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "fixture"]); + yield* fileSystem.rename(`${cwd}/before.txt`, `${cwd}/after.txt`); + yield* fileSystem.remove(`${cwd}/deleted.txt`); + for (const sourceKind of ["unstaged", "unstaged", "staged", "staged"] as const) { + const preview = yield* driver.getReviewDiffPreview({ cwd, workingTreeScope: sourceKind }); + const source = preview.sources[0]!; + yield* driver.applyReviewPatch({ + cwd, + sourceKind, + expectedDiffHash: source.diffHash, + fileIndex: 0, + }); + } + assert.equal(yield* git(cwd, ["diff", "--cached"]), ""); + assert.equal(yield* fileSystem.readFileString(`${cwd}/after.txt`), "rename me\n"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/deleted.txt`)); + }), + ); + it.effect("loads repository-relative files from a nested project directory", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1448,6 +1716,43 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps same-size edits visible when a temporary index is needed", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fs.utimes(path.join(cwd, "tracked.txt"), 1, 1); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "tracked file"]); + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fs.utimes(path.join(cwd, "tracked.txt"), 1, 1); + const indexPath = path.join(cwd, ".git", "index"); + yield* fs.utimes(indexPath, 1, 1); + const index = yield* fs.readFile(indexPath); + yield* writeTextFile(cwd, "untracked.txt", "new file\n"); + for (const workingTreeScope of [undefined, "unstaged"] as const) { + const preview = yield* driver.getReviewDiffPreview({ + cwd, + ...(workingTreeScope ? { workingTreeScope } : {}), + }); + const dirty = preview.sources.find( + (source) => source.kind === (workingTreeScope ?? "working-tree"), + )!; + assert.include(dirty.diff, "-before"); + assert.include(dirty.diff, "+after!"); + assert.deepStrictEqual( + dirty.files?.map((file) => file.path), + ["tracked.txt", "untracked.txt"], + ); + } + assert.deepStrictEqual(yield* fs.readFile(indexPath), index); + }), + ); + it.effect("keeps complete stats for files beyond the combined patch limit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2819,6 +3124,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + const missingTarget = yield* (yield* GitVcsDriver.GitVcsDriver) + .pushCurrentBranch(cwd, null, { pushToUpstream: true }) + .pipe(Effect.result); + assert.equal(missingTarget._tag, "Failure"); const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); assert.deepInclude(pushed, { status: "pushed", diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..0dd0bf69f7e6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput, + type ReviewApplyPatchInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, type ReviewDiffPreviewSource, @@ -58,7 +59,11 @@ const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; // Patches the clients render are parsed against git's default a/ and b/ path // prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would // otherwise leak into the patch and leave every parsed file unnamed. -export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; +export const PATCH_RENDER_PREFIX_ARGS = [ + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-relative", +] as const; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -1979,8 +1984,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = - Effect.fn("prepareCommitContext")(function* (cwd, filePaths) { - if (filePaths && filePaths.length > 0) { + Effect.fn("prepareCommitContext")(function* (cwd, filePaths, stagedOnly) { + if (!stagedOnly && filePaths && filePaths.length > 0) { yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( Effect.catchTags({ GitCommandError: () => Effect.void, @@ -1993,14 +1998,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--", ...filePaths, ]); - } else { + } else if (!stagedOnly) { yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); } + const headCommit = stagedOnly + ? (yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.head", + cwd, + ["rev-parse", "--verify", "HEAD"], + true, + )).trim() || null + : undefined; + const stagedTree = stagedOnly + ? (yield* runGitStdout("GitVcsDriver.prepareCommitContext.tree", cwd, [ + "write-tree", + ])).trim() + : undefined; + const headTree = stagedTree + ? (headCommit ?? + (yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.emptyTree", + cwd, + ["hash-object", "-w", "-t", "tree", "--stdin"], + { stdin: "" }, + )).trim()) + : undefined; + const comparison = stagedTree && headTree ? [headTree, stagedTree] : ["--cached"]; const stagedSummary = yield* runGitStdout( "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, - ["diff", "--cached", "--name-status"], + ["diff", "--no-relative", ...comparison, "--name-status"], ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { return null; @@ -2009,7 +2037,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + ["diff", "--no-relative", "--no-ext-diff", ...comparison, "--patch", "--minimal"], { maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2019,6 +2047,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { stagedSummary, stagedPatch, + ...(stagedTree ? { stagedTree, headCommit: headCommit ?? null } : {}), }; }); @@ -2043,6 +2072,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; + if (options?.stagedTree) { + const [tree, head, branch] = yield* Effect.all( + [ + runGitStdout("GitVcsDriver.commit.tree", cwd, ["write-tree"]), + runGitStdout("GitVcsDriver.commit.head", cwd, ["rev-parse", "--verify", "HEAD"], true), + runGitStdout( + "GitVcsDriver.commit.branch", + cwd, + ["symbolic-ref", "--short", "HEAD"], + true, + ), + ], + { concurrency: 3 }, + ); + if ( + (head.trim() || null) !== options.expectedHead || + (branch.trim() || null) !== options.expectedBranch + ) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit.head", + command: "git commit", + cwd, + detail: + "The branch changed while generating the commit message. Review the changes and try again.", + }); + } + if (tree.trim() !== options.stagedTree) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.commit.tree", + command: "git commit", + cwd, + detail: + "Staged changes changed while generating the commit message. Review the changes and try again.", + }); + } + } yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), @@ -2071,6 +2136,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } + if (options?.pushToUpstream && !details.hasUpstream) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.pushCurrentBranch", cwd, args: ["push"] }), + detail: "The PR push target is unavailable. Refresh the review and try again.", + }); + } const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { const publishBranch = yield* resolvePublishBranchName(cwd, branch); @@ -2156,6 +2227,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( Effect.orElseSucceed(() => null), ); + if (options?.pushToUpstream && !currentUpstream) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.pushCurrentBranch", cwd, args: ["push"] }), + detail: "The PR push target is unavailable. Refresh the review and try again.", + }); + } if (currentUpstream) { // A branch tracking a differently named ref was cut from it, the way // `git checkout -b feature origin/dev` and our own worktree flow leave @@ -2167,6 +2244,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // `effect-atom`: the branch name ends in the upstream head while the // upstream ref ends in the branch name. const isAliasOfUpstreamHead = + options?.pushToUpstream === true || branch === currentUpstream.branchName || (branch.endsWith(`/${currentUpstream.branchName}`) && currentUpstream.upstreamRef.endsWith(`/${branch}`)); @@ -2327,6 +2405,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* ( cwd: string, untrackedPaths: ReadonlyArray, + unstaged = false, ) { const [stagedDeletionsStdout, indexValue] = yield* Effect.all( [ @@ -2344,7 +2423,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: 2 }, ); - const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean)); + const stagedDeletions = new Set( + unstaged ? [] : stagedDeletionsStdout.split("\0").filter(Boolean), + ); const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); if (pathsToAdd.length === 0) return undefined; @@ -2355,7 +2436,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* prefix: `t3code-review-index-${process.pid}-`, }); const indexExists = yield* fileSystem.exists(indexPath); - if (indexExists) yield* fileSystem.copyFile(indexPath, tempIndexPath); + if (indexExists) { + const { mtime } = yield* fileSystem.stat(indexPath); + const timestamp = Option.getOrElse(mtime, () => 1); + yield* fileSystem.copyFile(indexPath, tempIndexPath); + yield* fileSystem.utimes(tempIndexPath, timestamp, timestamp); + } const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; const tempIndexConfig = [ "-c", @@ -2415,14 +2501,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const cwd = repository.worktreeRoot; const branch = repository.currentBranch; - const baseRef = - input.baseRef ?? - (branch - ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(Effect.orElseSucceed(() => null)) - : null); + const baseRef = input.workingTreeScope + ? null + : (input.baseRef ?? + (branch + ? yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ) + : null)); const diffArgs = [ "diff", + ...(input.workingTreeScope === "staged" ? ["--cached"] : []), + ...(input.workingTreeScope ? ["--binary"] : []), "--find-renames", "--no-color", "--no-ext-diff", @@ -2439,7 +2530,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const result = yield* executeGit( "GitVcsDriver.getReviewDiffPreview.stat", cwd, - [...args, ref, "--", ...pathArgs], + [...args, ...(ref ? [ref] : []), "--", ...pathArgs], { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, env }, ); if (result.exitCode === 0) return { ref, files: parseReviewNumstat(result.stdout) }; @@ -2476,12 +2567,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const patch = yield* executeGit( "GitVcsDriver.getReviewDiffPreview.patch", cwd, - [...diffArgs, "--patch", stat.ref, "--", ...pathArgs], + [...diffArgs, "--patch", ...(stat.ref ? [stat.ref] : []), "--", ...pathArgs], { maxOutputBytes: patchLimit, appendTruncationMarker: true, env }, ); return { ...patch, files: stat.files }; }); + const dirtyRef = input.workingTreeScope === "unstaged" ? "" : "HEAD"; const readDirty = Effect.gen(function* () { + if (input.workingTreeScope === "staged") return yield* readTrackedDiff(dirtyRef); if (input.file?.sourceKind === "branch-range") return yield* readTrackedDiff(null); const untracked = yield* executeGit( "GitVcsDriver.review.listUntracked", @@ -2495,14 +2588,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); if (untracked === null) { - const tracked = yield* readTrackedDiff("HEAD"); + const tracked = yield* readTrackedDiff(dirtyRef); return { ...tracked, files: undefined, stdoutTruncated: true }; } const paths = splitNullSeparatedGitStdoutPaths(untracked).filter( (candidate) => !input.file || candidate === input.file.path, ); - if (paths.length === 0) return yield* readTrackedDiff("HEAD"); - const env = yield* prepareReviewIndex(cwd, paths).pipe( + if (paths.length === 0) return yield* readTrackedDiff(dirtyRef); + const env = yield* prepareReviewIndex(cwd, paths, input.workingTreeScope === "unstaged").pipe( Effect.catchTags({ PlatformError: (cause) => Effect.fail( @@ -2516,7 +2609,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), }), ); - return yield* readTrackedDiff("HEAD", env); + return yield* readTrackedDiff(dirtyRef, env); }).pipe(Effect.scoped); const [dirtyTrackedResult, baseResult] = yield* Effect.all( [ @@ -2554,10 +2647,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const sources: ReviewDiffPreviewSource[] = [ { - id: "working-tree", - kind: "working-tree", - title: "Dirty worktree", - baseRef: "HEAD", + id: input.workingTreeScope ?? "working-tree", + kind: input.workingTreeScope ?? "working-tree", + title: + input.workingTreeScope === "staged" + ? "Staged changes" + : input.workingTreeScope === "unstaged" + ? "Unstaged changes" + : "Dirty worktree", + baseRef: input.workingTreeScope === "unstaged" ? null : "HEAD", headRef: null, diff: dirtyDiff, ...(dirtyFiles === undefined ? {} : { files: dirtyFiles }), @@ -2687,10 +2785,82 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return new TextDecoder("utf-8").decode(bytes); }); + const applyReviewPatch = Effect.fn("applyReviewPatch")(function* (input: ReviewApplyPatchInput) { + const preview = yield* getReviewDiffPreview({ + cwd: input.cwd, + workingTreeScope: input.sourceKind, + }); + const source = preview.sources.find((candidate) => candidate.kind === input.sourceKind); + const fail = (detail: string) => + new GitCommandError({ + operation: "GitVcsDriver.applyReviewPatch", + command: "git apply --cached", + cwd: input.cwd, + detail, + }); + if (!source || source.truncated || source.diffHash !== input.expectedDiffHash) { + return yield* fail("The changes have moved. Refresh the diff before staging or unstaging."); + } + let patch = source.diff.split(/(?=^diff --git )/m)[input.fileIndex]; + if (!patch?.startsWith("diff --git ")) return yield* fail("The selected file is unavailable."); + if (input.hunkIndex !== undefined) { + const hunks = patch.split(/(?=^@@ )/m); + const hunk = hunks[input.hunkIndex + 1]; + if (!hunk) return yield* fail("The selected hunk is unavailable."); + let header = hunks[0]!; + if (input.sourceKind === "staged" && /^rename to /m.test(header)) { + const newPath = /^\+\+\+ (.+)$/m.exec(header)?.[1]; + if (!newPath) return yield* fail("The renamed file path is unavailable."); + header = header + .split("\n") + .filter((line) => !/^(rename |similarity index )/.test(line)) + .map((line) => + line.startsWith("diff --git ") + ? `diff --git ${newPath} ${newPath}` + : line.startsWith("--- ") + ? `--- ${newPath}` + : line, + ) + .join("\n"); + } + patch = header.replace(/^(old mode|new mode) .+\n/gm, "") + hunk; + } + const repositoryRoot = yield* runGitStdout("GitVcsDriver.applyReviewPatch.root", input.cwd, [ + "rev-parse", + "--show-toplevel", + ]); + yield* executeGit( + "GitVcsDriver.applyReviewPatch", + repositoryRoot.trim(), + [ + "apply", + "--cached", + "--whitespace=nowarn", + ...(input.sourceKind === "staged" ? ["--reverse"] : []), + "-", + ], + { stdin: patch.endsWith("\n") ? patch : patch + "\n" }, + ); + }); + const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( input: ReviewDiffFileContentsInput, ) { - if (input.sourceKind === "working-tree") { + if (input.sourceKind === "staged") { + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, "HEAD", input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readReviewFileAtRevision(input, "", input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + } + if (input.sourceKind === "working-tree" || input.sourceKind === "unstaged") { const repositoryRoot = yield* runGitStdout( "GitVcsDriver.getReviewDiffFileContents.repositoryRoot", input.cwd, @@ -2703,7 +2873,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [ input.changeType === "new" ? Effect.succeed("") - : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath), + : readReviewFileAtRevision( + input, + input.sourceKind === "unstaged" ? "" : (input.baseRef ?? "HEAD"), + input.oldPath, + ), input.changeType === "deleted" ? Effect.succeed("") : readWorkingTreeReviewFile(input, repositoryRoot), @@ -3636,6 +3810,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), readRangeContext, getReviewDiffPreview, + applyReviewPatch, getReviewDiffFileContents, readConfigValue, listRefs, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d118bd3d012c..c5c831311451 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,6 +124,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import { withPullRequestAttachment } from "./pullRequest/PullRequestAttachments.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -2801,6 +2802,17 @@ const makeWsRpcLayer = ( "rpc.aggregate": "pull-requests", }, ), + [WS_METHODS.pullRequestsUploadAttachment]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsUploadAttachment, + withPullRequestViewer( + input, + withPullRequestAttachment(input, (attachment) => + pullRequests.uploadAttachment({ ...input, ...attachment }), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsUpdateComment]: (input) => observeRpcEffect( WS_METHODS.pullRequestsUpdateComment, @@ -3039,15 +3051,104 @@ const makeWsRpcLayer = ( [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ + Effect.gen(function* () { + if (input.expectedBranch !== undefined) { + yield* gitWorkflow.invalidateLocalStatus(input.cwd); + const status = yield* gitWorkflow.localStatus({ cwd: input.cwd }).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "checkout_verification_failed", + cause, + }), + ), + ); + if (!status.isRepo || status.refName !== input.expectedBranch) { + return yield* new ProjectWriteFileError({ cwd: input.cwd, relativePath: input.relativePath, - ...projectFileFailureContext(cause), - cause, - }), + failure: "checkout_changed", + }); + } + } + if (input.expectedContents !== undefined) { + const file = yield* workspaceFileSystem.readFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "read_before_write_failed", + cause, + }), + ), + ); + if ( + file.truncated || + (file.contents !== input.expectedContents && file.contents !== input.contents) + ) { + return yield* new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "contents_changed", + }); + } + } + if (input.pullRequestUrl !== undefined) { + const { pullRequest } = yield* gitWorkflow + .resolvePullRequest({ + cwd: input.cwd, + reference: input.pullRequestUrl, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "pull_request_verification_failed", + cause, + }), + ), + ); + if (pullRequest.state !== "open") { + return yield* new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "pull_request_not_open", + }); + } + } + return yield* workspaceFileSystem.writeFile(input).pipe( + Effect.tap(() => + input.expectedBranch !== undefined + ? gitWorkflow.invalidateLocalStatus(input.cwd) + : Effect.void, + ), + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), + ); + }).pipe((effect) => + gitWorkflow.withRepositoryLock(input.cwd, effect).pipe( + Effect.mapError((cause) => + cause._tag === "ProjectWriteFileError" + ? cause + : new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "workspace_verification_failed", + cause, + }), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -3117,6 +3218,7 @@ const makeWsRpcLayer = ( input.resource._tag === "native-app-icon" || // GitHub media names the repository it authenticates through itself. input.resource._tag === "github-media" || + input.resource._tag === "pull-request-media" || (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) ) { return yield* issueAssetUrl({ resource: input.resource }); @@ -3338,6 +3440,23 @@ const makeWsRpcLayer = ( review.getDiffFileContents(input), { "rpc.aggregate": "review" }, ), + [WS_METHODS.reviewApplyPatch]: (input) => + observeRpcEffect( + WS_METHODS.reviewApplyPatch, + gitWorkflow.withRepositoryLock( + input.cwd, + review + .applyPatch(input) + .pipe( + Effect.tap(() => + vcsStatusBroadcaster + .refreshLocalStatus(input.cwd) + .pipe(Effect.ignoreCause({ log: true })), + ), + ), + ), + { "rpc.aggregate": "review" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..8b0800cc4f12 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "build": "vp build", "preview": "vp preview", "typecheck": "tsc --noEmit", - "test": "vp test run --passWithNoTests --project unit" + "test": "vp test run --passWithNoTests --project unit", + "test:review-editor": "node scripts/review-editor-smoke.mjs" }, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/apps/web/scripts/review-editor-smoke.mjs b/apps/web/scripts/review-editor-smoke.mjs new file mode 100644 index 000000000000..0c7bac59f197 --- /dev/null +++ b/apps/web/scripts/review-editor-smoke.mjs @@ -0,0 +1,164 @@ +import * as NodeAssert from "node:assert/strict"; +import * as NodeModule from "node:module"; + +const { chromium } = NodeModule.createRequire( + new URL("../../desktop/package.json", import.meta.url), +)("playwright-core"); +const origin = new URL(process.argv[2]); +const browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, +}); +try { + for (const platform of ["Linux x86_64", "MacIntel"]) { + for (const diffStyle of ["unified", "split"]) { + const page = await browser.newPage(); + await page.addInitScript((value) => { + Object.defineProperty(navigator, "platform", { get: () => value }); + }, platform); + await page.route("**/review-editor-smoke", (route) => + route.fulfill({ + contentType: "text/html", + body: `
`, + }), + ); + await page.goto(new URL("/review-editor-smoke", origin).href); + const frame = () => + page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + const contents = (name) => + page.evaluate( + (value) => + window.editors.find((editor) => editor.getFile().name === value).getFile().contents, + name, + ); + const addedLine = (name) => + page.locator(`[aria-label="${name}"] [data-line-type="change-addition"]`).first(); + await addedLine("one.ts").waitFor(); + NodeAssert.equal((await contents("one.ts")).split("\n").length, 4); + await page.evaluate(() => + document.addEventListener( + "pointerdown", + () => { + window.view.setOptions({ ...window.view.options, themeType: "dark" }); + window.view.render(true); + }, + { once: true }, + ), + ); + await addedLine("one.ts").click({ position: { x: 60, y: 8 } }); + await page.keyboard.type("a"); + NodeAssert.match(await contents("one.ts"), /fairst/); + await addedLine("two.ts").click({ position: { x: 60, y: 8 } }); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + await frame(); + await frame(); + await page.keyboard.type("AFTER_ENTER"); + NodeAssert.match(await contents("two.ts"), /;\nAFTER_ENTER\n/); + await page.keyboard.press("Home"); + await page.keyboard.press("Backspace"); + await frame(); + await frame(); + await page.keyboard.type("AFTER_BACKSPACE"); + NodeAssert.match(await contents("two.ts"), /;AFTER_BACKSPACEAFTER_ENTER\n/); + await page.evaluate(() => { + window.editors.find((editor) => editor.getFile().name === "two.ts").focus(); + document.querySelector("input").focus(); + }); + await frame(); + await page.keyboard.type("outside"); + NodeAssert.equal(await page.getByLabel("Outside editor").inputValue(), "outside"); + await addedLine("two.ts").click({ position: { x: 60, y: 8 } }); + await page.evaluate(() => + window.editors + .find((editor) => editor.getFile().name === "two.ts") + .setSelections([ + { start: { line: 1, character: 16 }, end: { line: 1, character: 16 }, direction: 0 }, + ]), + ); + await frame(); + const modifier = platform === "MacIntel" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+d`); + await page.keyboard.press(`${modifier}+d`); + await page.keyboard.type("renamed"); + NodeAssert.match(await contents("two.ts"), /const second = renamed;\nconst third = renamed;/); + await page.keyboard.press(`${modifier}+f`); + await page.getByPlaceholder("Search", { exact: true }).fill("renamed"); + NodeAssert.equal( + await page.getByPlaceholder("Search", { exact: true }).inputValue(), + "renamed", + ); + await page.keyboard.press("Escape"); + NodeAssert.equal(await page.getByPlaceholder("Search", { exact: true }).count(), 0); + NodeAssert.match(await contents("one.ts"), /fairst/); + const reactDom = await page.request.get( + new URL("/node_modules/.vite/deps/react-dom_client.js", origin).href, + ); + const reactUrl = (await reactDom.text()).match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1]; + NodeAssert.ok(reactUrl); + await page.route("**/review-editor-partial", (route) => + route.fulfill({ + contentType: "text/html", + body: `
`, + }), + ); + await page.goto(new URL("/review-editor-partial", origin).href); + await page.waitForFunction(() => typeof window.hydrate === "function"); + await page.evaluate(() => window.hydrate()); + await addedLine("one.ts").click({ position: { x: 60, y: 8 } }); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + await frame(); + await frame(); + await page.keyboard.type("AFTER_ENTER"); + NodeAssert.match(await contents("one.ts"), /;\nAFTER_ENTER\n/); + await page.keyboard.press("Home"); + await page.keyboard.press("Backspace"); + await frame(); + await frame(); + await page.keyboard.type("AFTER_BACKSPACE"); + NodeAssert.match(await contents("one.ts"), /;AFTER_BACKSPACEAFTER_ENTER\n/); + await page.getByLabel("Outside editor").click(); + await page.keyboard.type("outside"); + NodeAssert.equal(await page.getByLabel("Outside editor").inputValue(), "outside"); + console.log( + `${platform} ${diffStyle}: typing, line edits, focus, find, multi-selection, and controlled partial diffs passed`, + ); + await page.close(); + } + } +} finally { + await browser.close(); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index abebe4841cbd..6aba97156d9c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -225,6 +225,11 @@ interface ChatMarkdownProps { /** Loads GitHub-hosted media through `cwd`'s GitHub credential, which a private repository's uploads need; without it those images and videos load unauthenticated and 404. */ githubMedia?: boolean | undefined; + resolveMediaResource?: + | (( + url: string, + ) => Extract | null) + | undefined; /** Levels added to each markdown heading in the accessibility tree so the text nests under the heading that introduces it, such as a chat message's author. Rendered tags and their styling are unchanged. */ @@ -1578,7 +1583,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, - { readonly _tag: "attachment" | "workspace-file" | "media-file" | "github-media" } + { + readonly _tag: + | "attachment" + | "workspace-file" + | "media-file" + | "github-media" + | "pull-request-media"; + } >; readonly kind?: "image" | "video"; readonly alt: string; @@ -2262,6 +2274,7 @@ function useChatMarkdownState({ renderContextReference, headingLevelOffset = 0, githubMedia = false, + resolveMediaResource, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); @@ -2660,6 +2673,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, githubMedia, + resolveMediaResource, renderContextReference, headingLevelOffset, imageBaseDir, @@ -2690,6 +2704,7 @@ function useChatMarkdownState({ expandMedia, fileLinkChip, githubMedia, + resolveMediaResource, renderContextReference, headingLevelOffset, imageBaseDir, @@ -2854,6 +2869,7 @@ const CHAT_MARKDOWN_COMPONENTS = { updateThreadPullRequestLink, fileLinkChip, renderContextReference, + resolveMediaResource, } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; @@ -2866,6 +2882,25 @@ const CHAT_MARKDOWN_COMPONENTS = { {label} ); } + const nativeVideo = + href && + (mediaKindFromPath(href) === "video" || + mediaKindFromPath(hastPlainTextDeep(node)) === "video") + ? resolveMediaResource?.(href) + : null; + if (environmentId !== null && nativeVideo) { + return ( + + ); + } const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? @@ -3125,6 +3160,7 @@ const CHAT_MARKDOWN_COMPONENTS = { cwd, environmentId, githubMedia, + resolveMediaResource, imageBaseDir, threadRef, renderContextReference, @@ -3157,17 +3193,16 @@ const CHAT_MARKDOWN_COMPONENTS = { const directUri = imageSource._tag === "Direct" ? imageSource.uri : null; const githubMediaUrl = directUri === null ? null : githubMediaFetchUrl(resolveProtocolRelativeMediaUrl(directUri)); - if ( - githubMedia && - cwd !== undefined && - environmentId !== null && - directUri !== null && - githubMediaUrl !== null - ) { + const hostedMediaResource = resolveMediaResource + ? resolveMediaResource(classifiedSrc) + : githubMedia && cwd !== undefined && githubMediaUrl !== null + ? { _tag: "github-media" as const, cwd, url: githubMediaUrl } + : null; + if (environmentId !== null && hostedMediaResource !== null) { return ( ); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 344eca250ce9..060299bb181d 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -1,4 +1,6 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId, type AssetResource } from "@t3tools/contracts"; +import { act, type ReactNode, type ComponentProps } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -22,6 +24,29 @@ vi.mock("../assets/assetUrls", () => ({ }; }, })); +vi.mock("./ui/tooltip", async () => { + const { cloneElement, isValidElement } = await import("react"); + return { + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger({ + render, + children, + }: ComponentProps) { + if (!isValidElement(render)) return <>{children}; + return children === undefined ? render : cloneElement(render, undefined, children); + }, + TooltipPopup: () => null, + }; +}); +vi.mock("../hooks/useSettings", async (importOriginal) => { + const actual = await importOriginal(); + const settings = actual.getClientSettings(); + return { + ...actual, + useClientSettings: (select?: (value: typeof settings) => unknown) => + select ? select(settings) : settings, + }; +}); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); @@ -49,6 +74,8 @@ vi.mock("~/lib/openPullRequestLink", () => ({ import ChatMarkdown, { ChatMarkdownAssetImage } from "./ChatMarkdown"; import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; +import { PullRequestMarkdown } from "./pullRequest/PullRequestMarkdown"; +import { PullRequestAttachmentContext } from "./pullRequest/PullRequestAttachmentContext"; const threadRef = { environmentId: EnvironmentId.make("env-windows"), @@ -405,3 +432,199 @@ describe("ChatMarkdown workspace images", () => { expect(html).not.toContain("Image unavailable"); }); }); + +it("loads native PR media through signed URLs and keeps ordinary external media direct", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const resolveMediaResource = ( + url: string, + ): Extract | null => + url.startsWith("/uploads/") || url.startsWith("https://gitlab.com/owner/repo/-/uploads/") + ? { + _tag: "pull-request-media", + provider: "gitlab", + reference: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + url: url.startsWith("/") ? `https://gitlab.com/owner/repo/-${url}` : url, + } + : null; + const text = + "![private](/uploads/hash/shot.png)\n\n[Private document](https://gitlab.com/owner/repo/-/uploads/hash/report.pdf)\n\n[clip.mp4](/uploads/hash/clip.mp4)\n\n![public](https://public.example/shot.png)"; + try { + testState.assetState = "loading"; + await act(async () => { + renderer = create( + , + ); + }); + expect(testState.resources).toContainEqual( + expect.objectContaining({ + _tag: "pull-request-media", + url: "https://gitlab.com/owner/repo/-/uploads/hash/shot.png", + }), + ); + expect(testState.resources).toContainEqual( + expect.objectContaining({ + _tag: "pull-request-media", + url: "https://gitlab.com/owner/repo/-/uploads/hash/clip.mp4", + }), + ); + expect( + renderer!.root.findAllByType("img").some((node) => node.props.src?.includes("/uploads/")), + ).toBe(false); + testState.assetState = "success"; + await act(async () => { + renderer!.update( + resolveMediaResource(url)} + />, + ); + }); + expect( + renderer!.root + .findAllByType("img") + .some((node) => node.props.src?.startsWith("https://signed.test/")), + ).toBe(true); + expect( + renderer!.root + .findAllByType("a") + .some( + (node) => node.props.href === "https://gitlab.com/owner/repo/-/uploads/hash/report.pdf", + ), + ).toBe(true); + + expect( + renderer!.root + .findAllByType("img") + .some((node) => node.props.src === "https://public.example/shot.png"), + ).toBe(true); + } finally { + if (renderer) await act(async () => renderer!.unmount()); + vi.unstubAllGlobals(); + } +}); + +it.each([ + [ + "github", + "github.com", + "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/user-attachments/assets/12345678-1234-1234-1234-123456789012", + ], + ["github", "github.com", "https://raw.githubusercontent.com/other/repo/main/image.png"], + ["bitbucket", "bitbucket.org", "https://bitbucket.org/owner/repo/downloads/shot.png"], + [ + "gitlab", + "gitlab.com", + "https://gitlab.com/owner/repo/uploads/0123456789abcdef0123456789abcdef/icons.svg#icon", + ], + [ + "gitlab", + "gitlab.com", + "https://gitlab.com/owner/repo/uploads/0123456789abcdef0123456789abcdef/shot.png", + ], + [ + "forgejo", + "code.example.com", + "https://code.example.com/attachments/12345678-1234-1234-1234-123456789012", + ], + [ + "azure-devops", + "dev.azure.com", + "https://dev.azure.com/org/project/_apis/git/repositories/repo/pullRequests/1/attachments/shot.png?api-version=7.1-preview.1", + ], +] as const)( + "uses the PR context to show private %s attachments in saved comments and editor previews", + async (provider, host, url) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + testState.assetState = "success"; + testState.resources.length = 0; + try { + await act(async () => { + renderer = create( + "", + }} + > + + , + ); + }); + expect(testState.resources).toContainEqual( + expect.objectContaining({ + _tag: "pull-request-media", + provider, + reference: expect.objectContaining({ expectedAccountId: "account" }), + url: url.split("#", 1)[0], + }), + ); + expect( + renderer!.root + .findAllByType("img") + .some((node) => node.props.src?.startsWith("https://signed.test/")), + ).toBe(true); + if (url.includes("#icon")) + expect( + renderer!.root.findAllByType("img").some((node) => node.props.src?.endsWith("#icon")), + ).toBe(true); + } finally { + if (renderer) await act(async () => renderer!.unmount()); + vi.unstubAllGlobals(); + } + }, +); + +it("does not use the legacy GitHub credential when the PR media resolver rejects a URL", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + testState.resources.length = 0; + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + null} + />, + ); + }); + expect(testState.resources).toEqual([]); + expect( + renderer!.root + .findAllByType("img") + .some( + (node) => + node.props.src === "https://raw.githubusercontent.com/other/repo/main/image.png", + ), + ).toBe(true); + } finally { + if (renderer) await act(async () => renderer!.unmount()); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 667eb0e11f6c..5ec0c4b6a5f1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,5 @@ +import { useReviewEdits, useReviewPanelLeaveGuard } from "./diffs/ReviewEdits"; +import { isDiffSearchShortcut } from "./diffs/DiffSearch.logic"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; @@ -1463,6 +1465,7 @@ function releaseChatTimelineAnchor void) => { + const active = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (active?.kind === "diff" || active?.kind === "pull-request") requestLeave(action); + else action(); + }, + [activeThreadRef, requestLeave], + ); const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -4527,25 +4542,27 @@ export default function ChatView(props: ChatViewProps) { const createBrowserSurface = useCallback( (profileId?: string) => { if (!activeThreadRef) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }).then((result) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - if (error instanceof BrowserSettingsReadError) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open browser", - description: error.message, - }), - ); - } + leaveReviewPanel(() => { + void addBrowserSurface({ + threadRef: activeThreadRef, + openPreview, + ...(profileId === undefined ? {} : { profileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } + }); }); }, - [activeThreadRef, openPreview], + [activeThreadRef, openPreview, leaveReviewPanel], ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; @@ -4857,10 +4874,18 @@ export default function ChatView(props: ChatViewProps) { .getState() .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); } - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); + const close = () => { + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); + }; + if ( + activeRightPanelSurface?.kind === "diff" || + activeRightPanelSurface?.kind === "pull-request" + ) + requestLeave(close); + else close(); } - }, [activeRightPanelSurface, activeThreadRef]); + }, [activeRightPanelSurface, activeThreadRef, requestLeave]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4882,22 +4907,24 @@ export default function ChatView(props: ChatViewProps) { ]); const addTerminalSurface = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) return; - const cwd = gitCwd ?? activeProject.workspaceRoot; - const terminalId = nextTerminalId(allocatableActiveTerminalIds); - useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - void openTerminal({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadId, - terminalId, - cwd, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, - }), - }, + leaveReviewPanel(() => { + const cwd = gitCwd ?? activeProject.workspaceRoot; + const terminalId = nextTerminalId(allocatableActiveTerminalIds); + useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); + setTerminalFocusRequestId((value) => value + 1); + void openTerminal({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }), + }, + }); }); }, [ activeProject, @@ -4907,6 +4934,7 @@ export default function ChatView(props: ChatViewProps) { allocatableActiveTerminalIds, gitCwd, openTerminal, + leaveReviewPanel, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { @@ -4999,18 +5027,22 @@ export default function ChatView(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); - } - if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); - } - if (surface.kind === "diff" && !diffOpen) { - onDiffPanelOpen?.(); - } + const activate = () => { + useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + setActivePreviewTab(activeThreadRef, surface.resourceId); + } + if (surface.kind === "terminal") { + setTerminalFocusRequestId((value) => value + 1); + } + if (surface.kind === "diff" && !diffOpen) { + onDiffPanelOpen?.(); + } + }; + if (activeRightPanelSurface?.id === surface.id) activate(); + else leaveReviewPanel(activate); }, - [activeThreadRef, diffOpen, onDiffPanelOpen], + [activeRightPanelSurface?.id, activeThreadRef, diffOpen, leaveReviewPanel, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; @@ -5091,14 +5123,29 @@ export default function ChatView(props: ChatViewProps) { const finishRightPanelSurfaceClose = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - cleanupRightPanelSurfaces(surfaces); - const store = useRightPanelStore.getState(); - for (const surface of surfaces) { - store.closeSurface(activeThreadRef, surface.id); - } - syncActivePreviewSurface(); + const close = () => { + cleanupRightPanelSurfaces(surfaces); + const store = useRightPanelStore.getState(); + for (const surface of surfaces) { + store.closeSurface(activeThreadRef, surface.id); + } + syncActivePreviewSurface(); + }; + if ( + (activeRightPanelSurface?.kind === "diff" || + activeRightPanelSurface?.kind === "pull-request") && + surfaces.some((surface) => surface.id === activeRightPanelSurface.id) + ) + requestLeave(close); + else close(); }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + [ + activeThreadRef, + activeRightPanelSurface, + cleanupRightPanelSurfaces, + syncActivePreviewSurface, + requestLeave, + ], ); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { @@ -6672,7 +6719,7 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); return; } - if (!activeThreadId || isCommandPaletteOpen()) { + if (!activeThreadId || isCommandPaletteOpen() || isDiffSearchShortcut(event)) { return; } const terminalFocusOwner = getTerminalFocusOwner(); @@ -9471,12 +9518,17 @@ export default function ChatView(props: ChatViewProps) { const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; - useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); + const open = () => { + explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; + useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }; + const selected = useDiffPanelStore.getState().byThreadKey[scopedThreadKey(activeThreadRef)]; + if (selected?.kind === "turn" && selected.turnId === turnId) open(); + else requestLeave(open); }, - [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen], + [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen, requestLeave], ); // The revert handler is read from a ref at call-time so the callback // reference is fully stable and never busts TimelineRowCtx identity. diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fd29acbff3f7..26209dddb253 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -7,6 +7,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { invalidateReviewDiffPreviews } from "@t3tools/client-runtime/state/review"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { ArrowRightIcon, @@ -28,9 +29,12 @@ import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { useFileContextMenuHandler } from "../fileContextMenu"; import { type DraftId } from "../composerDraftStore"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; +import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { cn } from "~/lib/utils"; +import { cn, randomUUID } from "~/lib/utils"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { toastManager } from "./ui/toast"; +import { useReviewEdits } from "./diffs/ReviewEdits"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useTheme } from "../hooks/useTheme"; @@ -39,6 +43,7 @@ import { buildFileDiffIdentityKey, getDiffCollapseIconClassName, getDiffLineStat, + getDiffPatchFileIndexes, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -55,7 +60,8 @@ import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; +import { DiffFileTree, type DiffFileTreeHandle } from "./diffs/DiffFileTree"; +import type { ReviewEditTargetResolver } from "./diffs/EditableDiffCodeView"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -79,11 +85,11 @@ import { DropdownMenuTrigger, } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useEnvironmentQuery } from "../state/query"; +import { formatEnvironmentQueryError, useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { serverEnvironment } from "../state/server"; import { reviewEnvironment } from "../state/review"; -import { vcsEnvironment } from "../state/vcs"; +import { vcsActionManager, vcsEnvironment } from "../state/vcs"; import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; @@ -146,6 +152,8 @@ export default function DiffPanel({ fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const [codeViewRevision, setCodeViewRevision] = useState(0); + const [applyingPatch, setApplyingPatch] = useState(false); + const edits = useReviewEdits(); const [codeView, setCodeView] = useState(null); const routeThreadRef = useParams({ @@ -214,7 +222,10 @@ export default function DiffPanel({ }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); const selectedTurnId = diffSelection.kind === "turn" ? diffSelection.turnId : null; - const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; + const selectedGitScope = + diffSelection.kind === "unstaged" || diffSelection.kind === "staged" + ? diffSelection.kind + : "branch"; const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; const selectedFileRevealRequestId = @@ -231,8 +242,10 @@ export default function DiffPanel({ const selectedScopeLabel = selectedTurnId === null ? selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes" + ? "Unstaged changes" + : selectedGitScope === "staged" + ? "Staged changes" + : "Branch changes" : selectedTurn?.turnId === latestTurn?.turnId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; @@ -244,8 +257,10 @@ export default function DiffPanel({ const reviewSectionTitle = selectedTurn ? `Turn ${selectedCheckpointTurnCount ?? "?"}` : selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes"; + ? "Unstaged changes" + : selectedGitScope === "staged" + ? "Staged changes" + : "Branch changes"; const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -275,6 +290,7 @@ export default function DiffPanel({ cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), ignoreWhitespace: diffIgnoreWhitespace, + ...(selectedGitScope !== "branch" ? { workingTreeScope: selectedGitScope } : {}), }, }) : null, @@ -292,6 +308,7 @@ export default function DiffPanel({ cwd: serverConfig.cwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), ignoreWhitespace: diffIgnoreWhitespace, + ...(selectedGitScope !== "branch" ? { workingTreeScope: selectedGitScope } : {}), }, }) : null, @@ -299,6 +316,16 @@ export default function DiffPanel({ const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd ? fallbackBranchDiffPreview : primaryBranchDiffPreview; + const previewEnvironmentId = activeThread?.environmentId; + const previewCwd = shouldRetryBranchDiffAtEnvironmentCwd ? serverConfig?.cwd : activeCwd; + const refreshBranchDiffPreview = useCallback(() => { + if (previewEnvironmentId && previewCwd) { + invalidateReviewDiffPreviews(appAtomRegistry, { + environmentId: previewEnvironmentId, + cwd: previewCwd, + }); + } + }, [previewEnvironmentId, previewCwd]); const canRefreshGitDiff = isGitRepo && selectedTurnId === null && activeThread != null && activeCwd != null; const activeThreadRefreshKey = routeThreadRef @@ -306,11 +333,116 @@ export default function DiffPanel({ : null; const selectedGitSource = branchDiffPreview.data?.sources.find( - (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + (source) => source.kind === (selectedGitScope === "branch" ? "branch-range" : selectedGitScope), ); - const refreshPreviewQuery = branchDiffPreview.refresh; - const refreshDiffFromUserAction = refreshPreviewQuery; - + const sourceControlAction = useAtomValue( + vcsActionManager.stateAtom({ + environmentId: activeThread?.environmentId ?? null, + cwd: activeCwd ?? null, + }), + ); + const localChangeScope = selectedTurnId === null && selectedGitScope !== "branch"; + const hasUnsavedChanges = [...(edits?.drafts.values() ?? [])].some( + (draft) => + draft.environmentId === activeThread?.environmentId && + draft.cwd === activeCwd && + !draft.pullRequestUrl && + draft.contents !== draft.savedContents, + ); + const stagingDisabled = + applyingPatch || + sourceControlAction.isRunning || + hasUnsavedChanges || + diffIgnoreWhitespace || + selectedGitSource?.truncated === true || + shouldRetryBranchDiffAtEnvironmentCwd; + const stagingHelp = hasUnsavedChanges + ? "Save your edits with Cmd/Ctrl+S first." + : diffIgnoreWhitespace + ? "Show whitespace changes before staging." + : undefined; + const patchFileIndexes = useMemo( + () => + getDiffPatchFileIndexes( + selectedGitSource?.kind === "staged" || selectedGitSource?.kind === "unstaged" + ? selectedGitSource.diff + : "", + ), + [selectedGitSource], + ); + const applyPatch = useCallback( + async (fileDiff: FileDiffMetadata, hunkIndex?: number) => { + const preview = branchDiffPreview.data; + if ( + stagingDisabled || + !activeThread || + !preview || + !selectedGitSource || + (selectedGitScope !== "staged" && selectedGitScope !== "unstaged") + ) + return; + const fileIndex = patchFileIndexes.get(buildFileDiffIdentityKey(fileDiff)); + if (fileIndex === undefined) return; + setApplyingPatch(true); + try { + const result = await reviewEnvironment.applyPatch.run(appAtomRegistry, { + environmentId: activeThread.environmentId, + input: { + cwd: preview.cwd, + sourceKind: selectedGitScope, + expectedDiffHash: selectedGitSource.diffHash, + fileIndex, + ...(hunkIndex === undefined ? {} : { hunkIndex }), + }, + }); + if (result._tag === "Failure") throw new Error(formatEnvironmentQueryError(result.cause)); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not update staged changes", + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setApplyingPatch(false); + } + }, + [ + stagingDisabled, + activeThread, + selectedGitSource, + patchFileIndexes, + branchDiffPreview.data, + selectedGitScope, + ], + ); + const commitStaged = async () => { + if (stagingDisabled || !activeThread || !activeCwd) return; + const result = await vcsActionManager + .runStackedAction({ environmentId: activeThread.environmentId, cwd: activeCwd }) + .run(appAtomRegistry, { + actionId: randomUUID(), + action: "commit", + stagedOnly: true, + threadId: activeThread.id, + ...(activeProjectId ? { projectId: activeProjectId } : {}), + ...(gitStatusQuery.data?.refName ? { expectedBranch: gitStatusQuery.data.refName } : {}), + }); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "Could not commit staged changes", + description: formatEnvironmentQueryError(result.cause), + }); + } else { + toastManager.add({ + type: "success", + title: + result.value.commit.status === "skipped_no_changes" + ? "No staged changes to commit" + : "Staged changes committed", + }); + } + }; const currentLoadDiffFiles = useMemo(() => { const preview = branchDiffPreview.data; if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) { @@ -435,8 +567,9 @@ export default function DiffPanel({ ? DateTime.formatIso(branchDiffPreview.data.generatedAt) : undefined, preview: renderablePatch, + fileTreeOpen, }); - const refreshBranchDiffPreview = refreshPreviewQuery; + const refreshDiffFromUserAction = refreshBranchDiffPreview; useEffect(() => { if (!canRefreshGitDiff) return; @@ -603,6 +736,78 @@ export default function DiffPanel({ }, [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); + const editEnvironmentId = activeThread?.environmentId; + const resolveEditTarget = useCallback( + (filePath) => { + const relativePath = resolveDiffPathForWorkspace({ + filePath, + workspaceRoot: activeCwd, + repositoryRoot: activeRepositoryRoot, + }); + if ( + shouldRetryBranchDiffAtEnvironmentCwd || + !gitStatusQuery.data || + !editEnvironmentId || + !activeCwd || + !relativePath + ) + return null; + return { + environmentId: editEnvironmentId, + cwd: activeCwd, + filePath: relativePath, + expectedBranch: gitStatusQuery.data.refName, + ...(canRefreshGitDiff ? { onSaved: refreshBranchDiffPreview } : {}), + }; + }, + [ + activeCwd, + activeRepositoryRoot, + editEnvironmentId, + gitStatusQuery.data, + canRefreshGitDiff, + refreshBranchDiffPreview, + shouldRetryBranchDiffAtEnvironmentCwd, + ], + ); + const renderHeaderFilenameSuffix = useCallback( + (fileDiff: FileDiffMetadata) => ( + <> + + {fileStats.has(resolveFileDiffPath(fileDiff)) ? ( + retry(resolveFileDiffPath(fileDiff))} + /> + ) : null} + {localChangeScope && ( + + )} + + ), + [ + localChangeScope, + stagingDisabled, + stagingHelp, + applyPatch, + selectedGitScope, + fileStats, + fileStates, + retry, + ], + ); + const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { setCollapsedDiffFiles((current) => { @@ -620,7 +825,9 @@ export default function DiffPanel({ [collapseScopeKey, defaultCollapsedDiffFileKeys], ); + const treeRef = useRef(null); const toggleDiffFileCollapse = useCallback(() => { + treeRef.current?.setExpanded(allDiffFilesCollapsed); setCodeViewRevision((current) => current + 1); setCollapsedDiffFiles((current) => { const currentKeys = @@ -631,19 +838,26 @@ export default function DiffPanel({ fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys), }; }); - }, [collapseScopeKey, defaultCollapsedDiffFileKeys, diffFileKeys]); + }, [allDiffFilesCollapsed, collapseScopeKey, defaultCollapsedDiffFileKeys, diffFileKeys]); + const requestSelectionChange = edits?.requestLeave ?? ((action: () => void) => action()); const selectTurn = (turnId: TurnId) => { - if (!routeThreadRef) return; - useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId); + if (!routeThreadRef || (diffSelection.kind === "turn" && diffSelection.turnId === turnId)) + return; + requestSelectionChange(() => useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId)); }; - const selectGitScope = (scope: "branch" | "unstaged") => { - if (!routeThreadRef) return; - useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope); + const selectGitScope = (scope: "branch" | "unstaged" | "staged") => { + if (!routeThreadRef || diffSelection.kind === scope) return; + requestSelectionChange(() => + useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope), + ); }; const selectBranchBaseRef = (baseRef: string | null) => { - if (!routeThreadRef) return; - useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); + if (!routeThreadRef || (diffSelection.kind === "branch" && diffSelection.baseRef === baseRef)) + return; + requestSelectionChange(() => + useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef), + ); }; const headerRow = ( @@ -666,7 +880,17 @@ export default function DiffPanel({ } onClick={() => selectGitScope("unstaged")} > - Working tree + Unstaged changes + + selectGitScope("staged")} + > + Staged changes
+ {localChangeScope && ( +
+ { + const scope = values[0]; + if (scope === "staged" || scope === "unstaged") selectGitScope(scope); + }} + > + Unstaged + Staged + + {selectedGitScope === "staged" && ( + + )} + {(applyingPatch || + stagingHelp || + (sourceControlAction.isRunning && sourceControlAction.currentPhaseLabel)) && ( + + {applyingPatch + ? "Updating staged changes..." + : (stagingHelp ?? sourceControlAction.currentPhaseLabel)} + + )} +
+ )} {isSelectedPatchTruncated && !lazySource && (

This preview exceeds the size limit. Changes shown are incomplete. @@ -1007,8 +1266,10 @@ export default function DiffPanel({ selectedTurn ? "Loading checkpoint diff..." : selectedGitScope === "unstaged" - ? "Loading working tree diff..." - : "Loading branch diff..." + ? "Loading unstaged changes..." + : selectedGitScope === "staged" + ? "Loading staged changes..." + : "Loading branch diff..." } /> ) : ( @@ -1038,7 +1299,7 @@ export default function DiffPanel({ (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-title"), ); - const filePath = title?.textContent; + const filePath = title?.getAttribute("data-file-path") ?? title?.textContent; // The filename remains the explicit "open in editor" affordance. if (filePath) { openDiffFile(filePath); @@ -1061,7 +1322,8 @@ export default function DiffPanel({ (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-title"), ); - const filePath = title?.textContent?.trim(); + const filePath = + title?.getAttribute("data-file-path") ?? title?.textContent?.trim(); if (!filePath) return; event.preventDefault(); onFileContextMenu( @@ -1081,22 +1343,31 @@ export default function DiffPanel({ codeViewKey={`${codeViewMountKey}:${lazySource ? filePatchScope : "preview"}`} className="h-full min-h-0 overflow-auto" files={codeViewFiles} + {...(selectedTurnId === null && selectedGitScope !== "staged" + ? { editing: resolveEditTarget } + : {})} renderCodeViewFooter={renderLoadingBoundary} sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} composerDraftTarget={composerDraftTarget} - renderHeaderFilenameSuffix={(fileDiff) => { - const path = resolveFileDiffPath(fileDiff); - const stat = fileStats.get(path); - return ( - <> - - {stat ? ( - retry(path)} /> - ) : null} - - ); - }} + renderHeaderFilenameSuffix={renderHeaderFilenameSuffix} + {...(localChangeScope + ? { + renderHunkAction: (fileDiff: FileDiffMetadata, hunkIndex: number) => ( +

+ +
+ ), + } + : {})} {...(lazySource ? { unsafeCSSExtra: @@ -1164,15 +1435,15 @@ export default function DiffPanel({ />
{fileTreeOpen ? ( - + ) : null} ) : ( diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 253a0f3d215e..6d0fc8f14fe6 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -20,7 +20,8 @@ import { import { nextFileCommentId } from "../files/fileCommentAnnotations"; import { DiffCommentAnnotation } from "./DiffCommentAnnotation"; -import { StyledDiffCodeView, type StyledDiffCodeViewOptions } from "./StyledDiffCodeView"; +import type { StyledDiffCodeViewOptions } from "./StyledDiffCodeView"; +import { EditableDiffCodeView, type ReviewEditTargetResolver } from "./EditableDiffCodeView"; interface DiffCommentAnnotationEntry { id: string; @@ -32,6 +33,7 @@ interface DiffCommentAnnotationEntry { interface DiffCommentAnnotationGroup { entries: DiffCommentAnnotationEntry[]; + hunk?: { fileDiff: FileDiffMetadata; index: number }; } type DiffCommentLineAnnotation = DiffLineAnnotation; @@ -86,10 +88,12 @@ interface AnnotatableCodeViewProps { options: StyledDiffCodeViewOptions; viewerRef?: Ref; className?: string; + editing?: ReviewEditTargetResolver; renderCodeViewFooter?: () => ReactNode; unsafeCSSExtra?: string; renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode; renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode; + renderHunkAction?: (fileDiff: FileDiffMetadata, hunkIndex: number) => ReactNode; renderHeaderPrefix: ( fileDiff: FileDiffMetadata, fileKey: string, @@ -110,11 +114,13 @@ export function AnnotatableCodeView({ options, viewerRef, className, + editing, renderCodeViewFooter, unsafeCSSExtra, renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, + renderHunkAction, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -153,8 +159,18 @@ export function AnnotatableCodeView({ text: comment.text, }); }, []); - const annotations = - draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; + const hunkActions: DiffCommentLineAnnotation[] = renderHunkAction + ? fileDiff.hunks.map((hunk, index) => ({ + side: hunk.additionCount > 0 ? "additions" : "deletions", + lineNumber: hunk.additionCount > 0 ? hunk.additionStart : hunk.deletionStart, + metadata: { entries: [], hunk: { fileDiff, index } }, + })) + : []; + const annotations = [ + ...hunkActions, + ...persisted, + ...(draft?.fileKey === fileKey ? [draft.annotation] : []), + ]; return { id: fileKey, type: "diff", @@ -172,7 +188,7 @@ export function AnnotatableCodeView({ ), }; }), - [draft, files, reviewComments, sectionId], + [draft, files, reviewComments, sectionId, renderHunkAction], ); const removeEntry = useCallback( @@ -247,8 +263,9 @@ export function AnnotatableCodeView({ const hasOpenComment = draft !== null; return ( - - key={codeViewKey} + + viewerKey={codeViewKey} + {...(editing ? { editing } : {})} {...(viewerRef ? { viewerRef } : {})} {...(className ? { className } : {})} {...(unsafeCSSExtra ? { unsafeCSSExtra } : {})} @@ -277,6 +294,11 @@ export function AnnotatableCodeView({ : null } renderAnnotation={(annotation) => { + if (annotation.metadata.hunk) + return renderHunkAction?.( + annotation.metadata.hunk.fileDiff, + annotation.metadata.hunk.index, + ); const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft"); return (
({ useTheme: () => ({ resolvedTheme: "dark" }) })); // Tooltip positioning is unrelated to the tree's actual model and activation path. @@ -36,6 +36,7 @@ class TreeRow { describe("diff tree file activation", () => { let renderer: ReactTestRenderer | undefined; const targets: CodeViewScrollTarget[] = []; + const treeRef = createRef(); const viewer = { getInstance: () => viewer, scrollTo: (target: CodeViewScrollTarget) => targets.push(target), @@ -44,13 +45,17 @@ describe("diff tree file activation", () => { function Panel({ files = entries, selectedPath = null, + widthStorageKey = "t3code.diffFileTreeWidth", }: { files?: DiffFileTreeEntry[]; selectedPath?: string | null; + widthStorageKey?: string; }) { const reveal = useCodeViewFileReveal(viewer, "working-tree"); return ( { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("HTMLElement", TreeRow); + vi.stubGlobal("window", new EventTarget()); }); afterEach(async () => { @@ -115,6 +121,46 @@ describe("diff tree file activation", () => { vi.unstubAllGlobals(); }); + it("stores resized widths independently for each review surface", async () => { + const widths = new Map(); + Object.assign(window, { + localStorage: { + getItem: (key: string) => widths.get(key) ?? null, + setItem: (key: string, value: string) => widths.set(key, value), + }, + }); + vi.stubGlobal("document", { body: { style: { removeProperty() {} } } }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const pointer = { + button: 0, + pointerId: 1, + clientX: 100, + preventDefault() {}, + stopPropagation() {}, + currentTarget: { + setPointerCapture() {}, + hasPointerCapture: () => true, + releasePointerCapture() {}, + }, + } as unknown as PointerEvent; + for (const widthStorageKey of ["t3code.diffFileTreeWidth", "t3code.pullRequestFileTreeWidth"]) { + await mount({ widthStorageKey }); + const { handlers } = renderer!.root.findByType(RightPanelResizeHandle).props; + await act(async () => { + handlers.onPointerDown(pointer); + handlers.onPointerMove({ ...pointer, clientX: 60 }); + handlers.onPointerUp({ ...pointer, clientX: 60 }); + }); + await act(async () => renderer!.unmount()); + renderer = undefined; + } + expect([...widths]).toEqual([ + ["t3code.diffFileTreeWidth", "296"], + ["t3code.pullRequestFileTreeWidth", "296"], + ]); + }); + it("mounts a file-to-symlink type change as one tree row", async () => { // Git carries a type change as a deletion and an addition of the same path. const typeChange = diffFileTreeEntries([ @@ -197,6 +243,20 @@ describe("diff tree file activation", () => { expect(targets).toEqual([]); }); + it("lets the panel control all folders without a second collapse button", async () => { + await mount({ files: [{ path: "src/nested/app.ts", status: "modified" }] }); + await act(async () => treeRef.current!.setExpanded(false)); + for (const path of ["src/", "src/nested/"]) { + const directory = model().getItem(path)!; + expect("isExpanded" in directory && directory.isExpanded()).toBe(false); + } + await act(async () => treeRef.current!.setExpanded(true)); + for (const path of ["src/", "src/nested/"]) { + const directory = model().getItem(path)!; + expect("isExpanded" in directory && directory.isExpanded()).toBe(true); + } + }); + it("reorders refreshed files without reopening a collapsed folder", async () => { const files: DiffFileTreeEntry[] = [ { path: "src/state/shell.ts", status: "modified" }, @@ -212,6 +272,8 @@ describe("diff tree file activation", () => { const folder = model().getItem("src/features/")!; if (!("isExpanded" in folder)) throw new Error("Expected the directory handle"); expect(folder.isExpanded()).toBe(false); + await act(async () => model().setSearch(".ts")); + expect(model().getSearchMatchingPaths()).toEqual([files[1]!.path, files[0]!.path]); }); it("does not echo controlled selection, but lets the reader activate it", async () => { diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 9108c0556cc9..23657abbb84c 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -1,30 +1,50 @@ import type { GitStatusEntry } from "@pierre/trees"; -import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { FileTree, useFileTree } from "@pierre/trees/react"; +import { + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, + type SyntheticEvent, +} from "react"; import { useTheme } from "~/hooks/useTheme"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; -import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; +import { setAllDirectoriesExpanded } from "../files/fileTreeExpansion"; +import { RightPanelResizeHandle } from "../preview/RightPanelResizeHandle"; import { Button } from "../ui/button"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup } from "../ui/tooltip"; +import { DiffRenameDetails } from "./DiffRename"; import { buildDiffFileTreeUpdates, compareDiffFileTreeEntries, collectDirectoryPaths, diffFileTreePositions, + diffFileTreeViewedCounts, type DiffFileTreeEntry, } from "./diffFileTree.logic"; export type { DiffFileTreeEntry } from "./diffFileTree.logic"; +export interface DiffFileTreeHandle { + setExpanded: (expanded: boolean) => void; +} + interface DiffFileTreeProps { + readonly ref?: Ref; readonly entries: ReadonlyArray; /** Called with the file's path when the reader picks a file row. */ readonly onSelectFile: (path: string) => void; + readonly onSetViewed?: (path: string, viewed: boolean) => void; + readonly viewedPending?: boolean; /** * The file the diff is currently showing, kept selected in the tree. Bump `revealRequestId` to * scroll the tree to the same path again. @@ -32,11 +52,13 @@ interface DiffFileTreeProps { readonly selectedPath?: string | null; readonly revealRequestId?: number; readonly ariaLabel: string; + readonly widthStorageKey: string; /** Right-aligned content in the header row, after the file count. */ readonly headerAccessory?: ReactNode; /** Rendered under the tree, for a host that still has files to fetch. */ readonly footer?: ReactNode; readonly className?: string; + readonly defaultWidth?: number; } /** @@ -44,17 +66,61 @@ interface DiffFileTreeProps { * compared to a workspace, and the reader came for the files, not the folders. */ export function DiffFileTree({ + ref, entries, onSelectFile, + onSetViewed, + viewedPending = false, selectedPath = null, revealRequestId = 0, ariaLabel, + widthStorageKey, headerAccessory, footer, className, + defaultWidth = 256, }: DiffFileTreeProps) { const { resolvedTheme } = useTheme(); + const containerRef = useRef(null); + const [maxWidth, setMaxWidth] = useState(640); + useLayoutEffect(() => { + const parent = containerRef.current?.parentElement; + if (!parent) return; + const resize = () => setMaxWidth(Math.max(160, Math.min(640, parent.clientWidth * 0.6))); + resize(); + const observer = new ResizeObserver(resize); + observer.observe(parent); + return () => observer.disconnect(); + }, []); + const { width, handlers } = useResizableWidth({ + storageKey: widthStorageKey, + defaultWidth, + minWidth: 160, + maxWidth, + edge: "left", + }); const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); + const entriesByPath = useMemo( + () => new Map(entries.map((entry) => [entry.path, entry])), + [entries], + ); + const viewedCounts = useMemo(() => diffFileTreeViewedCounts(entries), [entries]); + const viewedFileCount = useMemo( + () => entries.filter((entry) => entry.viewed === true).length, + [entries], + ); + const viewedRef = useRef({ counts: viewedCounts, onSetViewed, pending: viewedPending }); + const [hoveredRow, setHoveredRow] = useState(null); + const hoveredEntry = entriesByPath.get(hoveredRow?.getAttribute("data-item-path") ?? ""); + const showPathTooltip = (event: SyntheticEvent) => { + const row = event.nativeEvent + .composedPath() + .find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-item-path"), + ); + setHoveredRow(row ?? null); + }; const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); const positions = useMemo(() => diffFileTreePositions(paths), [paths]); const [ordering] = useState(() => { @@ -84,6 +150,18 @@ export function DiffFileTree({ }, [onSelectFile, paths]); const { model } = useFileTree({ + renderRowDecoration: ({ item }) => { + const { counts, onSetViewed: setViewed, pending } = viewedRef.current; + const count = counts.get(item.path); + if (!setViewed || !count) return null; + return { + checked: count.viewed === count.total ? true : count.viewed > 0 ? "mixed" : false, + label: `Viewed: ${item.path}`, + title: `${count.stale > 0 ? "Changed since viewed. " : ""}${count.viewed} of ${count.total} viewed`, + disabled: pending, + onChange: (viewed) => setViewed(item.path, viewed), + }; + }, density: "compact", flattenEmptyDirectories: true, initialExpansion: "open", @@ -96,10 +174,78 @@ export function DiffFileTree({ paths: [], search: false, sort: ordering.sort, - unsafeCSS: PIERRE_TREE_UNSAFE_CSS, + unsafeCSS: `${PIERRE_TREE_UNSAFE_CSS} + :host { + --trees-accent-override: var(--primary); + --trees-bg-muted-override: color-mix(in srgb, currentColor 7%, transparent); + } + [data-type='item']:has([data-tree-checkbox]) { + [data-item-section='spacing'] { order: -3; } + &[data-item-type='folder'] > [data-item-section='icon'] { order: -2; } + [data-item-section='decoration'] { + order: -1; + flex: 0 0 20px; + justify-content: center; + overflow: visible; + } + [data-item-section='content'] { flex: 1 1 auto; } + } + [data-tree-checkbox] { + appearance: none; + display: grid; + place-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + margin: 0; + border: 1px solid color-mix(in srgb, var(--muted-foreground) 60%, transparent); + border-radius: 4px; + background: var(--background); + color: var(--primary-foreground); + cursor: pointer; + } + [data-tree-checkbox]:hover:not(:disabled) { border-color: var(--foreground); } + [data-tree-checkbox]:checked, + [data-tree-checkbox]:indeterminate { + border-color: var(--primary); + background: var(--primary); + } + [data-tree-checkbox]:checked::after { + content: ''; + width: 9px; + height: 6px; + border: solid currentColor; + border-width: 0 0 2px 2px; + transform: translateY(-1px) rotate(-45deg); + } + [data-tree-checkbox]:indeterminate::after { + content: ''; + width: 8px; + height: 2px; + background: currentColor; + } + [data-tree-checkbox]:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } + [data-tree-checkbox]:disabled { cursor: wait; opacity: 0.5; } + @media (forced-colors: active) { + [data-tree-checkbox] { appearance: auto; } + [data-tree-checkbox]::after { display: none; } + } + [data-type='item'] { border-radius: 5px; } + [data-file-tree-virtualized-scroll='true'] { + overflow-x: hidden; + } + `, }); - const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => - areAllDirectoriesExpanded(currentModel, directoryPaths), + useImperativeHandle( + ref, + () => ({ + setExpanded: (expanded: boolean) => + setAllDirectoriesExpanded(model, directoryPaths, expanded), + }), + [model, directoryPaths], ); useEffect(() => { @@ -126,8 +272,12 @@ export function DiffFileTree({ if (directory !== null && "collapse" in directory) directory.collapse(); } } + }, [directoryPaths, model, ordering, paths, positions]); + + useEffect(() => { + viewedRef.current = { counts: viewedCounts, onSetViewed, pending: viewedPending }; model.setGitStatus(gitStatus); - }, [directoryPaths, gitStatus, model, ordering, paths, positions]); + }, [gitStatus, model, onSetViewed, viewedCounts, viewedPending]); useEffect(() => { if (selectedPath === null) { @@ -164,48 +314,76 @@ export function DiffFileTree({ }, [model, paths, revealRequestId, selectedPath]); return ( -
+
+
Files - {entries.length} + + {onSetViewed ? `${viewedFileCount} / ${entries.length} viewed` : entries.length} + {headerAccessory} - {directoryPaths.length > 0 ? ( - - - setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded) - } - /> - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} - - - ) : null}
{ + const count = viewedCounts.get(item.path); + if (!count) return null; + const viewed = count.viewed === count.total; + return ( +
+ +
+ ); + }, + } + : {})} + onPointerMoveCapture={showPathTooltip} + onPointerLeave={() => setHoveredRow(null)} + onFocusCapture={showPathTooltip} + onKeyUpCapture={(event) => { + if (event.key === "Escape") setHoveredRow(null); + else showPathTooltip(event); + }} + onBlurCapture={() => setHoveredRow(null)} + onScrollCapture={() => setHoveredRow(null)} + onWheelCapture={() => setHoveredRow(null)} onClickCapture={(event) => { if ( + event.nativeEvent + .composedPath() + .some( + (node) => + node instanceof HTMLElement && node.getAttribute("data-tree-checkbox") !== null, + ) || event.defaultPrevented || event.button !== 0 || event.ctrlKey || @@ -230,6 +408,24 @@ export function DiffFileTree({ className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} /> + !open && setHoveredRow(null)} + > + + {hoveredEntry?.previousPath ? ( + + ) : ( + + {hoveredEntry?.path} + + )} + + {footer}
); diff --git a/apps/web/src/components/diffs/DiffRename.tsx b/apps/web/src/components/diffs/DiffRename.tsx new file mode 100644 index 000000000000..d0ec5ec61f63 --- /dev/null +++ b/apps/web/src/components/diffs/DiffRename.tsx @@ -0,0 +1,78 @@ +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { changedPathParts } from "./diffFileTree.logic"; + +interface DiffRenameProps { + readonly previousPath: string; + readonly path: string; + readonly withChanges?: boolean; +} + +export function DiffRenameDetails({ previousPath, path, withChanges }: DiffRenameProps) { + const parts = changedPathParts(previousPath, path); + return ( +
+
{withChanges ? "Renamed and modified" : "Renamed"}
+
+
+ + + Previous path: + {parts.prefix} + {parts.before} + {parts.suffix} + +
+
+ + + New path: + {parts.prefix} + {parts.after} + {parts.suffix} + +
+
+
+ ); +} + +export function DiffRenameHeader(props: DiffRenameProps) { + const parts = changedPathParts(props.previousPath, props.path); + const directory = parts.prefix.slice(0, parts.prefix.lastIndexOf("/") + 1); + const prefix = parts.prefix.slice(directory.length); + return ( + + } + className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" + aria-label={`${props.withChanges ? "Renamed and modified" : "Renamed"}: ${props.previousPath} → ${props.path}`} + > + + {prefix} + {parts.before} + {parts.suffix} + + + + {prefix} + {parts.after} + {parts.suffix} + + {directory ? ( + + {directory.slice(0, -1)} + + ) : null} + + + + + + ); +} diff --git a/apps/web/src/components/diffs/DiffSearch.logic.ts b/apps/web/src/components/diffs/DiffSearch.logic.ts new file mode 100644 index 000000000000..7f50762d2deb --- /dev/null +++ b/apps/web/src/components/diffs/DiffSearch.logic.ts @@ -0,0 +1,111 @@ +import type { CodeViewItem, SelectionSide } from "@pierre/diffs"; + +export function isDiffSearchShortcut(event: KeyboardEvent) { + return ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + (event.key.toLowerCase() === "f" || event.key.toLowerCase() === "d") && + event + .composedPath() + .some((node) => node instanceof HTMLElement && node.hasAttribute("data-diff-search-scope")) + ); +} + +export interface DiffSearchMatch { + id: string; + lineNumber: number; + side: SelectionSide; + character: number; + length: number; +} + +const MAX_SEARCH_MATCHES = 10_000; + +export function findDiffSearchMatches( + items: readonly CodeViewItem[], + query: string, +): { matches: DiffSearchMatch[]; limited: boolean } { + if (!query) return { matches: [], limited: false }; + const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu"); + const matches: DiffSearchMatch[] = []; + for (const item of items) { + const search = (text: string, lineNumber: number, side: SelectionSide) => { + for (const match of text.matchAll(pattern)) { + if (matches.length === MAX_SEARCH_MATCHES) return true; + matches.push({ + id: item.id, + lineNumber, + side, + character: match.index, + length: match[0].length, + }); + } + return false; + }; + if (item.type === "file") { + let lineNumber = 0; + for (const line of item.file.contents.matchAll(/[^\n]*(?:\n|$)/g)) { + if (search(line[0], ++lineNumber, "additions")) return { matches, limited: true }; + } + continue; + } + const diff = item.fileDiff; + let nextAddition = 0; + for (const hunk of diff.hunks) { + if (!diff.isPartial) { + while (nextAddition < hunk.additionLineIndex) { + if (search(diff.additionLines[nextAddition]!, nextAddition + 1, "additions")) + return { matches, limited: true }; + nextAddition++; + } + } + for (const content of hunk.hunkContent) { + if (content.type === "change") { + for (let index = 0; index < content.deletions; index++) { + const lineIndex = content.deletionLineIndex + index; + if ( + search( + diff.deletionLines[lineIndex]!, + hunk.deletionStart + lineIndex - hunk.deletionLineIndex, + "deletions", + ) + ) + return { matches, limited: true }; + } + } + const count = content.type === "context" ? content.lines : content.additions; + for (let index = 0; index < count; index++) { + const lineIndex = content.additionLineIndex + index; + if ( + search( + diff.additionLines[lineIndex]!, + hunk.additionStart + lineIndex - hunk.additionLineIndex, + "additions", + ) + ) + return { matches, limited: true }; + } + } + nextAddition = Math.max(0, hunk.additionLineIndex + hunk.additionCount); + } + if (!diff.isPartial) { + while (nextAddition < diff.additionLines.length) { + if (search(diff.additionLines[nextAddition]!, nextAddition + 1, "additions")) + return { matches, limited: true }; + nextAddition++; + } + } + } + return { matches, limited: false }; +} + +export function revealDiffSearchMatch( + items: readonly CodeViewItem[], + match: DiffSearchMatch | undefined, +): readonly CodeViewItem[] { + if (!match) return items; + return items.map((item) => + item.id === match.id && item.collapsed ? { ...item, collapsed: false } : item, + ); +} diff --git a/apps/web/src/components/diffs/DiffSearch.test.tsx b/apps/web/src/components/diffs/DiffSearch.test.tsx new file mode 100644 index 000000000000..c7cf80b3df58 --- /dev/null +++ b/apps/web/src/components/diffs/DiffSearch.test.tsx @@ -0,0 +1,338 @@ +import { parseDiffFromFile, parsePatchFiles, type CodeViewItem } from "@pierre/diffs"; +import type { CodeViewHandle } from "@pierre/diffs/react"; +import { act, useLayoutEffect, type KeyboardEvent, type ComponentProps } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useDiffSearch } from "./DiffSearch"; +import { findDiffSearchMatches, isDiffSearchShortcut } from "./DiffSearch.logic"; + +vi.mock("../ui/input", () => ({ Input: (props: ComponentProps<"input">) => })); +vi.mock("../ui/button", () => ({ + Button: (props: ComponentProps<"button">) => + + +
+ ) : null; + return { + searchBar, + onKeyDown, + items: visibleItems, + decorate, + active: match !== undefined, + highlightCSS: `::highlight(${highlightName}) { background-color: #facc15; color: #171717; }`, + }; +} diff --git a/apps/web/src/components/diffs/EditableDiffCodeView.tsx b/apps/web/src/components/diffs/EditableDiffCodeView.tsx new file mode 100644 index 000000000000..c733113733b0 --- /dev/null +++ b/apps/web/src/components/diffs/EditableDiffCodeView.tsx @@ -0,0 +1,303 @@ +import { + cloneFileDiffMetadata, + hydratePartialDiff, + parseDiffFromFile, + type CodeViewCreateEditorOptions, + type CodeViewItem, + type FileDiffMetadata, +} from "@pierre/diffs"; +import { Editor } from "@pierre/diffs/editor"; +import { + isWorkspaceAudioPreviewPath, + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { resolveFileDiffPath } from "~/lib/diffRendering"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + readReviewDraft, + reviewEditKey, + reviewPublishKey, + useReviewEdits, + type ReviewEditTarget, +} from "./ReviewEdits"; +import { StyledDiffCodeView, type StyledDiffCodeViewProps } from "./StyledDiffCodeView"; + +export type ReviewEditTargetResolver = (filePath: string) => ReviewEditTarget | null; + +interface PreparedFile { + key: string; + fileDiff: FileDiffMetadata; + pullRequestUrl: string | undefined; + version: number | undefined; +} + +export function EditableDiffCodeView({ + items, + options, + editing, + viewerKey, + renderHeaderFilenameSuffix, + renderHeaderMetadata, + ...props +}: Omit, "items" | "initialItems"> & { + items: readonly CodeViewItem[]; + editing?: ReviewEditTargetResolver; + viewerKey?: string; +}) { + const edits = useReviewEdits(); + const [prepared, setPrepared] = useState>(new Map()); + const preparing = useRef(new Map>()); + const mounted = useRef(true); + const focusRequest = useRef<{ key: string; line: number; character: number } | null>(null); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const editItems = useMemo[]>( + () => + items.map((item) => { + if (item.type !== "diff" || !edits) return item; + const target = editing?.(resolveFileDiffPath(item.fileDiff)); + if (!target) return item; + const key = reviewEditKey(target); + const draft = edits.drafts.get(key); + if (!draft) return item; + const file = prepared.get(item.id); + if (target.readOnly) { + const source = file?.key === key ? file.fileDiff : item.fileDiff; + const readonlyDiff = source.isPartial + ? null + : parseDiffFromFile( + source.type === "new" + ? null + : { + name: source.prevName ?? source.name, + contents: source.deletionLines.join(""), + }, + { name: source.name, contents: draft.contents }, + ); + if (!readonlyDiff?.hunks.length) { + return { + id: item.id, + type: "file", + file: { name: source.name, contents: draft.contents }, + ...(item.collapsed === undefined ? {} : { collapsed: item.collapsed }), + edit: false, + version: (item.version ?? 0) + 1, + }; + } + return { + ...item, + fileDiff: readonlyDiff, + edit: false, + version: (item.version ?? 0) + 1, + }; + } + if ( + !file || + key !== file.key || + target.pullRequestUrl !== file.pullRequestUrl || + item.version !== file.version + ) + return item; + const publishing = + target.pullRequestUrl && + edits.publishing.has(reviewPublishKey(target.environmentId, target.pullRequestUrl)); + return { + ...item, + fileDiff: file.fileDiff, + edit: !publishing, + version: (item.version ?? 0) + 1, + }; + }), + [editing, edits, items, prepared], + ); + const focus = edits?.focus; + const createEditor = useCallback( + (editorOptions: CodeViewCreateEditorOptions) => { + let key: string | null = null; + return new Editor({ + ...editorOptions, + onAttach: (editor) => { + const file = editor.getFile(); + const target = file && editing?.(resolveFileDiffPath(file)); + if (!target || target.readOnly) return; + key = reviewEditKey(target); + const request = focusRequest.current; + if (request?.key !== key) return; + focusRequest.current = null; + const position = { line: request.line, character: request.character }; + editor.setSelections([{ start: position, end: position, direction: "none" }]); + editor.focus({ preventScroll: true }); + }, + onFocus: () => { + if (key) focus?.(key); + }, + onBlur: () => focus?.(null), + }); + }, + [editing, focus], + ); + + const prepare = (item: CodeViewItem, source?: FileDiffMetadata) => { + if (item.type !== "diff" || !editing || !edits || item.fileDiff.type === "deleted") return; + const filePath = resolveFileDiffPath(item.fileDiff); + const target = editing(filePath); + if ( + !target || + target.readOnly || + isWorkspaceAudioPreviewPath(filePath) || + isWorkspaceImagePreviewPath(filePath) || + isWorkspaceVideoPreviewPath(filePath) + ) + return; + const key = reviewEditKey(target); + const cached = prepared.get(item.id); + if (cached?.key === key && cached.version === item.version) return; + const requestKey = JSON.stringify([key, item.id, item.version]); + const pending = preparing.current.get(requestKey); + if (pending) return pending; + const promise = (async () => { + const diff = source ?? item.fileDiff; + let fileDiff = + diff.isPartial && options?.loadDiffFiles + ? hydratePartialDiff("clone", diff, await options.loadDiffFiles(diff)) + : cloneFileDiffMetadata(diff); + if (fileDiff.isPartial) throw new Error("The full file must be available before editing."); + const fresh = await readReviewDraft( + target, + target.pullRequestUrl ? fileDiff.additionLines.join("") : undefined, + ); + if (!mounted.current) return; + const draft = edits.begin(fresh); + if (fileDiff.additionLines.join("") !== draft.contents) { + fileDiff = parseDiffFromFile( + fileDiff.type === "new" + ? null + : { + name: fileDiff.prevName ?? fileDiff.name, + contents: fileDiff.deletionLines.join(""), + }, + { name: fileDiff.name, contents: draft.contents }, + ); + focusRequest.current = null; + } + setPrepared((previous) => + new Map(previous).set(item.id, { + key, + fileDiff, + pullRequestUrl: target.pullRequestUrl, + version: item.version, + }), + ); + })().finally(() => preparing.current.delete(requestKey)); + preparing.current.set(requestKey, promise); + return promise; + }; + + return ( + + {...props} + key={viewerKey} + items={editItems} + createEditor={createEditor} + renderHeaderMetadata={(item) => + renderHeaderMetadata?.( + item.type === "file" ? (items.find((source) => source.id === item.id) ?? item) : item, + ) + } + onItemEditChange={(item, file) => { + const entry = prepared.get(item.id); + if (entry) edits?.change(entry.key, file.contents); + }} + options={{ + ...options, + onPostRender: (_node, _instance, _phase, context) => { + if (context.type !== "diff" || context.item.edit || edits?.publishing.size) return; + void prepare(context.item, context.instance.fileDiff)?.catch(() => {}); + }, + onLineClick: (line, context) => { + if ( + !edits || + !editing || + context.type !== "diff" || + line.type !== "diff-line" || + line.numberColumn || + line.annotationSide === "deletions" || + context.item.fileDiff.type === "deleted" || + context.item.edit + ) + return; + const event = line.event; + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return; + const filePath = resolveFileDiffPath(context.item.fileDiff); + const target = editing(filePath); + if ( + !target || + target.readOnly || + isWorkspaceAudioPreviewPath(filePath) || + isWorkspaceImagePreviewPath(filePath) || + isWorkspaceVideoPreviewPath(filePath) + ) + return; + const key = reviewEditKey(target); + const root = line.lineElement.getRootNode(); + const caret = document.caretPositionFromPoint?.( + event.clientX, + event.clientY, + root instanceof ShadowRoot ? { shadowRoots: [root] } : undefined, + ); + let character = 0; + if (caret && line.lineElement.contains(caret.offsetNode)) { + const range = document.createRange(); + range.selectNodeContents(line.lineElement); + range.setEnd(caret.offsetNode, caret.offset); + character = range.toString().length; + } + focusRequest.current = { key, line: line.lineNumber - 1, character }; + void prepare(context.item, context.instance.fileDiff)?.catch((cause) => { + toastManager.add({ + type: "error", + title: "Cannot edit this file", + description: cause instanceof Error ? cause.message : "The file could not be loaded.", + }); + }); + }, + }} + renderHeaderFilenameSuffix={(item) => { + const target = editing?.( + item.type === "diff" ? resolveFileDiffPath(item.fileDiff) : item.file.name, + ); + const draft = target && edits?.drafts.get(reviewEditKey(target)); + return ( + <> + {renderHeaderFilenameSuffix?.(item)} + {draft && draft.contents !== draft.savedContents && ( + + + } + /> + + {target.readOnly + ? "Unsaved changes · Pull request is read-only" + : "Unsaved changes · Cmd/Ctrl+S to save"} + + + )} + + ); + }} + /> + ); +} diff --git a/apps/web/src/components/diffs/ReviewEdits.test.tsx b/apps/web/src/components/diffs/ReviewEdits.test.tsx new file mode 100644 index 000000000000..9029d4b3376a --- /dev/null +++ b/apps/web/src/components/diffs/ReviewEdits.test.tsx @@ -0,0 +1,920 @@ +import { FileRenderer, parseDiffFromFile } from "@pierre/diffs"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import { act, useEffect, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const { + read, + write, + refresh, + blockOptions, + blocker, + toast, + refreshStatus, + editorOptions, + prepareWorkspace, + publish, + formatError, +} = vi.hoisted(() => ({ + editorOptions: vi.fn(), + prepareWorkspace: vi.fn(), + publish: vi.fn(), + formatError: vi.fn(), + read: vi.fn(), + refreshStatus: vi.fn(), + write: vi.fn(), + refresh: vi.fn(), + blockOptions: vi.fn(), + toast: vi.fn(), + blocker: { status: "idle", proceed: vi.fn(), reset: vi.fn() }, +})); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ executeAtomQuery: read })); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { refresh } })); +vi.mock("~/state/vcs", () => ({ + vcsEnvironment: { refreshStatus: { run: refreshStatus } }, + vcsActionManager: { runStackedAction: () => ({ run: publish }) }, +})); +vi.mock("~/state/git", () => ({ + gitEnvironment: { preparePullRequestThread: { run: prepareWorkspace } }, +})); +vi.mock("@pierre/diffs/editor", () => ({ Editor: editorOptions })); +vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => write })); +vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: formatError })); +vi.mock("../files/projectFilesQueryState", () => ({ getProjectFileQueryAtom: () => "file" })); +vi.mock("./StyledDiffCodeView", () => ({ StyledDiffCodeView: () => null })); +vi.mock("../ui/toast", () => ({ toastManager: { add: toast } })); +vi.mock("@tanstack/react-router", () => ({ + useBlocker: (options: unknown) => { + blockOptions(options); + return blocker; + }, +})); +vi.mock("../ui/button", () => ({ Button: (props: object) => + + + + + + + ); +} diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index 64bc88aab2fd..f12e448a8353 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -7,12 +7,23 @@ import { type UncontrolledCodeViewProps, } from "@pierre/diffs/react"; /* oxlint-enable eslint/no-restricted-imports */ -import type { Ref } from "react"; +import { useCallback, useImperativeHandle, useState, type Ref } from "react"; -import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; +import { + DIFF_SURFACE_THEME_UNSAFE_CSS, + resolveFileDiffPath, + resolveFileDiffPreviousPath, +} from "~/lib/diffRendering"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { DiffRenameHeader } from "./DiffRename"; +import { useDiffSearch } from "./DiffSearch"; const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} +[data-search-match] { + background-color: color-mix(in srgb, var(--diffs-modified-base) 24%, var(--code-background)) !important; + box-shadow: inset 3px 0 var(--diffs-modified-base); +} + :is( [data-line], [data-line-annotation], @@ -200,6 +211,16 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} line-height: 1 !important; } +[data-diffs-header]:is([data-change-type="rename-pure"], [data-change-type="rename-changed"]) + :is([data-prev-name], [data-title], [data-rename-icon]) { + display: none !important; +} + +[data-diffs-header]:is([data-change-type="rename-pure"], [data-change-type="rename-changed"]) + ::slotted([slot="header-filename-suffix"]) { + min-width: 0; +} + [data-diffs-header] [data-metadata] { align-items: center !important; line-height: 1 !important; @@ -264,7 +285,7 @@ export type StyledDiffCodeViewOptions = Omit< "unsafeCSS" | "itemMetrics" | "layout" >; -type StyledDiffCodeViewProps = ( +export type StyledDiffCodeViewProps = ( | Omit, "options"> | Omit, "options"> ) & { @@ -283,43 +304,85 @@ export function StyledDiffCodeView({ viewerRef, className, unsafeCSSExtra, + renderHeaderFilenameSuffix, + items, + initialItems, ...props }: StyledDiffCodeViewProps) { + const [viewer, setViewer] = useState | null>(null); + const search = useDiffSearch(items ?? initialItems ?? [], viewer); + useImperativeHandle(viewerRef, () => viewer!, [viewer]); + const renderFilenameSuffix = useCallback< + NonNullable["renderHeaderFilenameSuffix"]> + >( + (item) => { + if ( + item.type !== "diff" || + (item.fileDiff.type !== "rename-pure" && item.fileDiff.type !== "rename-changed") + ) + return renderHeaderFilenameSuffix?.(item); + return ( +
+ + {renderHeaderFilenameSuffix?.(item)} +
+ ); + }, + [renderHeaderFilenameSuffix], + ); return ( - - {...props} - {...(viewerRef ? { ref: viewerRef } : {})} - // The custom element itself is focusable for keyboard scrolling. Its native outline sits - // outside the panel clipping boundary; actual controls inside retain their own indicators. - className={ - className - ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}` - : "diff-render-surface [--code-background:var(--background)] outline-none" - } - options={{ - ...options, - unsafeCSS: unsafeCSSExtra - ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}` - : DIFF_VIEW_UNSAFE_CSS, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - // Pierre uses its general file spacing as a fallback in expanded-file layout paths. - // Keep it zero alongside the explicit paddingTop or expanding the first file can - // reintroduce the library's default 8px gap above its header. - spacing: 0, - paddingTop: 0, - // Unlike the gap above, the 8px under a file's last line is painted - // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has - // to count it: at zero every expanded file's virtual height ran 8px short of its - // rendered height, and the end of the list sat past the reachable scroll range — - // one clipped file row per expanded file above it. - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }} - /> +
+ {search.searchBar} + + {...props} + {...(items ? { items: search.items } : { initialItems: initialItems ?? [] })} + renderHeaderFilenameSuffix={renderFilenameSuffix} + ref={setViewer} + // The custom element itself is focusable for keyboard scrolling. Its native outline sits + // outside the panel clipping boundary; actual controls inside retain their own indicators. + className={ + className + ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}` + : "diff-render-surface [--code-background:var(--background)] outline-none" + } + options={{ + ...options, + ...(search.active ? { expandUnchanged: true } : {}), + onPostRender: (node, _instance, phase, context) => { + if (context.type === "file") + options?.onPostRender?.(node, context.instance, phase, context); + else options?.onPostRender?.(node, context.instance, phase, context); + if (phase !== "unmount") search.decorate(node, context.item.id); + }, + unsafeCSS: `${DIFF_VIEW_UNSAFE_CSS}\n${search.highlightCSS}\n${unsafeCSSExtra ?? ""}`, + itemMetrics: { + diffHeaderHeight: 32, + hunkSeparatorHeight: 24, + // Pierre uses its general file spacing as a fallback in expanded-file layout paths. + // Keep it zero alongside the explicit paddingTop or expanding the first file can + // reintroduce the library's default 8px gap above its header. + spacing: 0, + paddingTop: 0, + // Unlike the gap above, the 8px under a file's last line is painted + // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has + // to count it: at zero every expanded file's virtual height ran 8px short of its + // rendered height, and the end of the list sat past the reachable scroll range — + // one clipped file row per expanded file above it. + paddingBottom: 8, + }, + layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, + }} + /> +
); } diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts index 170e84308069..af4858d4d841 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -6,14 +6,30 @@ import { buildDiffFileTreeUpdates, compareDiffFileTreeEntries, collectDirectoryPaths, + changedPathParts, diffFileTreePositions, + diffFileTreeViewedCounts, diffFileTreeEntries, + orderFilesByTree, } from "./diffFileTree.logic"; function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata { return { type, name, prevName } as FileDiffMetadata; } +it("counts nested viewed files without including similar folder names", () => { + const counts = diffFileTreeViewedCounts([ + { path: "src/a.ts", status: "modified", viewed: true }, + { path: "src/deep/b.ts", status: "added", viewedStale: true }, + { path: "src-other/c.ts", status: "deleted", viewed: true }, + { path: "README.md", status: "modified" }, + ]); + expect(counts.get("src/")).toEqual({ total: 2, viewed: 1, stale: 1 }); + expect(counts.get("src/deep/")).toEqual({ total: 1, viewed: 0, stale: 1 }); + expect(counts.get("src-other/")).toEqual({ total: 1, viewed: 1, stale: 0 }); + expect(counts.get("README.md")).toEqual({ total: 1, viewed: 0, stale: 0 }); +}); + describe("diffFileTreeEntries", () => { it("maps each change type to its git status under the file's current path", () => { expect( @@ -27,13 +43,51 @@ describe("diffFileTreeEntries", () => { ).toEqual([ { path: "a/src/a.ts", status: "added" }, { path: "src/b.ts", status: "deleted" }, - { path: "src/c.ts", status: "renamed" }, - { path: "src/d.ts", status: "renamed" }, + { + path: "src/c.ts", + status: "renamed", + previousPath: "src/old-c.ts", + renamedWithChanges: false, + }, + { + path: "src/d.ts", + status: "renamed", + previousPath: "src/old-d.ts", + renamedWithChanges: true, + }, { path: "README.md", status: "modified" }, ]); }); }); +describe("changedPathParts", () => { + it.each([ + [ + "src/quality-control.ts", + "src/issues.ts", + { prefix: "src/", before: "quality-control", after: "issues", suffix: ".ts" }, + ], + ["old/file.ts", "new/file.ts", { prefix: "", before: "old", after: "new", suffix: "/file.ts" }], + [ + "src/file.ts", + "src/new-file.ts", + { prefix: "src/", before: "", after: "new-", suffix: "file.ts" }, + ], + [ + "src/new-file.ts", + "src/file.ts", + { prefix: "src/", before: "new-", after: "", suffix: "file.ts" }, + ], + ["src/😀.ts", "src/😁.ts", { prefix: "src/", before: "😀", after: "😁", suffix: ".ts" }], + ["src/file.ts", "src/file.ts", { prefix: "src/file.ts", before: "", after: "", suffix: "" }], + ])("isolates the changed part of %s → %s", (previousPath, path, expected) => { + const parts = changedPathParts(previousPath, path); + expect(parts).toEqual(expected); + expect(parts.prefix + parts.before + parts.suffix).toBe(previousPath); + expect(parts.prefix + parts.after + parts.suffix).toBe(path); + }); +}); + describe("diffFileTreeEntries", () => { it("folds a file-to-symlink type change into one modified entry", () => { expect( @@ -62,6 +116,35 @@ describe("collectDirectoryPaths", () => { }); describe("diff tree reading order", () => { + it("keeps nested folders contiguous using the first file in review order", () => { + const paths = ["lib/b.ts", "ui/a.ts", "lib/c.ts", "ui/a.test.ts", "lib/b.test.ts", "lock"]; + expect(orderFilesByTree(paths, (path) => path)).toEqual([ + "lib/b.ts", + "lib/c.ts", + "lib/b.test.ts", + "ui/a.ts", + "ui/a.test.ts", + "lock", + ]); + expect(orderFilesByTree(paths.toReversed(), (path) => path)).toEqual([ + "lock", + "lib/b.test.ts", + "lib/c.ts", + "lib/b.ts", + "ui/a.test.ts", + "ui/a.ts", + ]); + }); + + it("keeps both sides of a type change and handles empty input", () => { + const removed = file("deleted", "src/app.ts"); + const added = file("new", "src/app.ts"); + expect( + orderFilesByTree([removed, file("change", "README.md"), added], (file) => file.name), + ).toEqual([removed, added, file("change", "README.md")]); + expect(orderFilesByTree([], String)).toEqual([]); + }); + it("places folders and files where their first diff appears", () => { const paths = [ "apps/mobile/src/state/shell.ts", diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index b727c3ee3ffb..a52254bd6a2d 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -1,12 +1,31 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import type { FileTreeBatchOperation, FileTreeSortComparator, GitStatus } from "@pierre/trees"; -import { resolveFileDiffPath } from "~/lib/diffRendering"; +import { resolveFileDiffPath, resolveFileDiffPreviousPath } from "~/lib/diffRendering"; /** One changed file as the tree shows it: its current path and how it changed. */ export interface DiffFileTreeEntry { readonly path: string; readonly status: GitStatus; + readonly previousPath?: string; + readonly renamedWithChanges?: boolean; + readonly viewed?: boolean; + readonly viewedStale?: boolean; +} + +export function diffFileTreeViewedCounts(entries: ReadonlyArray) { + const counts = new Map(); + for (const entry of entries) { + const paths = [entry.path, ...collectDirectoryPaths([entry.path])]; + for (const path of paths) { + const count = counts.get(path) ?? { total: 0, viewed: 0, stale: 0 }; + count.total++; + if (entry.viewed) count.viewed++; + if (entry.viewedStale) count.stale++; + counts.set(path, count); + } + } + return counts; } function toGitStatus(file: FileDiffMetadata): GitStatus { @@ -31,14 +50,46 @@ function toGitStatus(file: FileDiffMetadata): GitStatus { export function diffFileTreeEntries( files: ReadonlyArray, ): ReadonlyArray { - const statusByPath = new Map(); + const entries = new Map(); for (const file of files) { - const path = resolveFileDiffPath(file); - const status = toGitStatus(file); - const previous = statusByPath.get(path); - statusByPath.set(path, previous === undefined || previous === status ? status : "modified"); + const entry: DiffFileTreeEntry = { + path: resolveFileDiffPath(file), + status: toGitStatus(file), + ...(file.type === "rename-pure" || file.type === "rename-changed" + ? { + previousPath: resolveFileDiffPreviousPath(file), + renamedWithChanges: file.type === "rename-changed", + } + : {}), + }; + const previous = entries.get(entry.path); + entries.set( + entry.path, + previous + ? { ...previous, status: previous.status === entry.status ? entry.status : "modified" } + : entry, + ); } - return [...statusByPath].map(([path, status]) => ({ path, status })); + return [...entries.values()]; +} + +export function changedPathParts(previousPath: string, path: string) { + const before = Array.from(previousPath); + const after = Array.from(path); + let start = 0; + while (start < Math.min(before.length, after.length) && before[start] === after[start]) start++; + let end = 0; + while ( + end < Math.min(before.length, after.length) - start && + before[before.length - end - 1] === after[after.length - end - 1] + ) + end++; + return { + prefix: before.slice(0, start).join(""), + before: before.slice(start, before.length - end).join(""), + after: after.slice(start, after.length - end).join(""), + suffix: before.slice(before.length - end).join(""), + }; } /** @@ -72,6 +123,31 @@ export function diffFileTreePositions(paths: ReadonlyArray): ReadonlyMap return positions; } +export function orderFilesByTree( + files: ReadonlyArray, + getPath: (file: T) => string, +): ReadonlyArray { + const positions = diffFileTreePositions(files.map(getPath)); + return files + .map((file) => { + let path = ""; + const segments = getPath(file).split("/"); + const ranks = segments.map((segment, index) => { + path += segment + (index < segments.length - 1 ? "/" : ""); + return positions.get(path)!; + }); + return { file, ranks }; + }) + .sort((left, right) => { + for (let index = 0; index < Math.min(left.ranks.length, right.ranks.length); index++) { + const difference = left.ranks[index]! - right.ranks[index]!; + if (difference !== 0) return difference; + } + return left.ranks.length - right.ranks.length; + }) + .map(({ file }) => file); +} + export function compareDiffFileTreeEntries( getPositions: () => ReadonlyMap, ): FileTreeSortComparator { diff --git a/apps/web/src/components/diffs/reviewEditorHydration.test.ts b/apps/web/src/components/diffs/reviewEditorHydration.test.ts new file mode 100644 index 000000000000..21f222ce3bf8 --- /dev/null +++ b/apps/web/src/components/diffs/reviewEditorHydration.test.ts @@ -0,0 +1,59 @@ +import { DiffHunksRenderer, hydratePartialDiff, parsePatchFiles } from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { describe, expect, it } from "vite-plus/test"; + +describe.each(["clone", "merge"] as const)("review editor %s hydration", (mode) => { + it.each(["unified", "split"] as const)( + "keeps hunks valid after %s line edits", + async (diffStyle) => { + const contents = Array.from( + { length: 30 }, + (_, index) => `const line${index + 1} = ${index + 1};\n`, + ).join(""); + const partial = parsePatchFiles( + [ + "diff --git a/file.ts b/file.ts", + "--- a/file.ts", + "+++ b/file.ts", + "@@ -10,3 +10,3 @@", + " const line10 = 10;", + "-const line11 = 11;", + "+const line11 = 42;", + " const line12 = 12;", + "", + ].join("\n"), + )[0]!.files[0]!; + const renderer = new DiffHunksRenderer({ diffStyle }); + renderer.beginEditSession(partial); + const diff = hydratePartialDiff(mode, partial, { + oldFile: { name: "file.ts", contents }, + newFile: { name: "file.ts", contents: contents.replace("line11 = 11", "line11 = 42") }, + }); + await renderer.initializeHighlighter(); + renderer.renderDiff(diff); + const document = new TextDocument("file.ts", diff.additionLines.join("")); + document.applyEdits([ + { + range: { start: { line: 10, character: 18 }, end: { line: 10, character: 18 } }, + newText: "\n", + }, + ]); + renderer.applyDocumentChange(document); + expect(() => renderer.renderDiff(diff)).not.toThrow(); + expect(diff.additionLines.join("")).toBe(document.getText()); + expect(diff.hunks).toHaveLength(1); + expect(diff.hunks[0]).toMatchObject({ additionCount: 4, deletionCount: 3 }); + document.applyEdits([ + { + range: { start: { line: 10, character: 18 }, end: { line: 11, character: 0 } }, + newText: "", + }, + ]); + renderer.applyDocumentChange(document); + expect(() => renderer.renderDiff(diff)).not.toThrow(); + expect(diff.additionLines.join("")).toBe(contents.replace("line11 = 11", "line11 = 42")); + expect(diff.hunks[0]).toMatchObject({ additionCount: 3, deletionCount: 3 }); + renderer.cleanUp(); + }, + ); +}); diff --git a/apps/web/src/components/diffs/useReviewFilePatches.test.tsx b/apps/web/src/components/diffs/useReviewFilePatches.test.tsx new file mode 100644 index 000000000000..3a1ff9fbc8d0 --- /dev/null +++ b/apps/web/src/components/diffs/useReviewFilePatches.test.tsx @@ -0,0 +1,91 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, type ReviewDiffPreviewSource } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { act, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { expect, it, vi } from "vite-plus/test"; +import { reviewEnvironment } from "~/state/review"; +import { useReviewFilePatches } from "./useReviewFilePatches"; + +vi.mock("~/state/review", () => ({ + reviewEnvironment: { diffFilePatch: vi.fn() }, +})); + +it.each(["staged", "unstaged"] as const)( + "loads %s files by their visible tree position", + async (kind) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const pending = Atom.make(AsyncResult.initial()); + vi.mocked(reviewEnvironment.diffFilePatch).mockReturnValue(pending); + const registry = AtomRegistry.make(); + const source: ReviewDiffPreviewSource = { + id: kind, + kind, + title: kind, + baseRef: null, + headRef: null, + diff: "", + diffHash: "hash", + truncated: true, + files: ["a/z.ts", "A/b.ts", "a/a.ts", "b/a.ts", "b/b.ts", "b/c.ts"].map((path) => ({ + path, + previousPath: null, + additions: 1, + deletions: 0, + })), + }; + let result: ReturnType; + function Probe({ fileTreeOpen }: { fileTreeOpen: boolean }) { + const value = useReviewFilePatches({ + environmentId: EnvironmentId.make("test"), + cwd: "/repo", + source, + baseRef: null, + ignoreWhitespace: false, + theme: "dark", + revision: "1", + preview: null, + fileTreeOpen, + }); + useEffect(() => { + result = value; + }, [value]); + return null; + } + let renderer: ReactTestRenderer | undefined; + const render = (fileTreeOpen: boolean) => ( + + + + ); + try { + await act(async () => { + renderer = create(render(true)); + }); + const paths = result!.renderableFiles.map((file) => file.name); + expect(paths.indexOf("a/z.ts") - paths.indexOf("a/a.ts")).toBe(1); + await act(async () => result!.requestFile(5)); + expect( + vi.mocked(reviewEnvironment.diffFilePatch).mock.lastCall?.[0].input.request, + ).toMatchObject({ + workingTreeScope: kind, + file: { path: paths[5], sourceKind: kind }, + }); + const treeScope = result!.scope; + await act(async () => renderer!.update(render(false))); + expect(result!.scope).not.toBe(treeScope); + expect(result!.renderableFiles.map((file) => file.name)).toEqual( + source + .files!.map((file) => file.path) + .toSorted((a, b) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }), + ), + ); + } finally { + await act(async () => renderer?.unmount()); + registry.dispose(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + } + }, +); diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index 8ac353e86be8..b58cda6615c4 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -6,6 +6,7 @@ import * as Atom from "effect/unstable/reactivity/Atom"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering"; import { reviewEnvironment } from "~/state/review"; +import { orderFilesByTree } from "./diffFileTree.logic"; export function useReviewFilePatches({ environmentId, @@ -16,6 +17,7 @@ export function useReviewFilePatches({ theme, revision, preview, + fileTreeOpen, }: { environmentId: EnvironmentId | undefined; cwd: string | undefined; @@ -25,6 +27,7 @@ export function useReviewFilePatches({ theme: "light" | "dark"; revision: string | undefined; preview: RenderablePatch | null; + fileTreeOpen: boolean; }) { const registry = useContext(RegistryContext); const scope = JSON.stringify([ @@ -34,19 +37,20 @@ export function useReviewFilePatches({ source?.diffHash, baseRef, ignoreWhitespace, + fileTreeOpen, ]); const [requested, setRequested] = useState({ scope, indices: [0, 1, 2, 3] }); const indices = useMemo( () => (requested.scope === scope ? requested.indices : [0, 1, 2, 3]), [requested, scope], ); - const files = useMemo( - () => + const files = useMemo(() => { + const sorted = source?.files?.toSorted((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" }), - ) ?? [], - [source?.files], - ); + ) ?? []; + return fileTreeOpen ? orderFilesByTree(sorted, (file) => file.path) : sorted; + }, [source?.files, fileTreeOpen]); const queries = useMemo( () => !environmentId || !cwd || !source @@ -65,6 +69,9 @@ export function useReviewFilePatches({ cwd, ...(baseRef ? { baseRef } : {}), ignoreWhitespace, + ...(source.kind === "staged" || source.kind === "unstaged" + ? { workingTreeScope: source.kind } + : {}), file: { path: file.path, previousPath: file.previousPath, @@ -153,7 +160,7 @@ export function useReviewFilePatches({ }, [queries, files, registry], ); - const renderableFiles = useMemo( + const parsedFiles = useMemo( () => source ? files.map((file, index): FileDiffMetadata => { @@ -185,6 +192,11 @@ export function useReviewFilePatches({ ), [source, files, patches, scope, preview], ); + const renderableFiles = useMemo( + () => + fileTreeOpen && !source ? orderFilesByTree(parsedFiles, resolveFileDiffPath) : parsedFiles, + [fileTreeOpen, parsedFiles, source], + ); const fileStates = new Map( files.map((file, index) => { const patch = patches.get(index); diff --git a/apps/web/src/components/pullRequest/PullRequestAttachmentContext.ts b/apps/web/src/components/pullRequest/PullRequestAttachmentContext.ts new file mode 100644 index 000000000000..b30b6fdacb54 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestAttachmentContext.ts @@ -0,0 +1,12 @@ +import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; +import { createContext } from "react"; + +export const PullRequestAttachmentContext = createContext<{ + environmentId: EnvironmentId; + reference: PullRequestRef; + provider: PullRequestDetailView["provider"] | undefined; + cwd: string; + url: string | undefined; + capabilities: PullRequestDetailView["capabilities"]["attachments"]; + upload: (attachmentId: string, file: File) => Promise; +} | null>(null); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.test.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.test.tsx new file mode 100644 index 000000000000..fdecaf54dbfb --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.test.tsx @@ -0,0 +1,663 @@ +import type { CodeViewDiffItem } from "@pierre/diffs/react"; +import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; +import { act, type ComponentProps, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +const { query, command, refresh, settings } = vi.hoisted(() => ({ + query: vi.fn(), + command: vi.fn(), + refresh: vi.fn(), + settings: { diffLayout: "unified", diffFilesCollapsed: false, wordWrap: false }, +})); +vi.mock("~/state/query", () => ({ useEnvironmentQuery: query })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => command })); +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { diff: (request: unknown) => request, filesViewed: () => "viewed" }, +})); +vi.mock("@effect/atom-react", () => ({ useAtomRefresh: () => refresh })); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: () => settings, + useUpdateClientSettings: () => command, +})); +vi.mock("~/hooks/useLocalStorage", async () => { + const { useState } = await import("react"); + return { + useLocalStorage: (_key: string, initial: unknown) => useState(initial), + getLocalStorageItem: () => null, + }; +}); +vi.mock("../diffs/EditableDiffCodeView", () => ({ + EditableDiffCodeView: ({ + items, + renderHeaderPrefix, + renderCodeViewFooter, + }: { + items: readonly CodeViewDiffItem[]; + renderHeaderPrefix: (item: CodeViewDiffItem) => ReactNode; + renderCodeViewFooter: () => ReactNode; + }) => ( + <> + {items.map((item) => ( +
+ {renderHeaderPrefix(item)} + {item.collapsed ? null :
{item.fileDiff.additionLines.join("")}
} +
+ ))} + {renderCodeViewFooter()} + + ), +})); +vi.mock("../ui/button", () => ({ + Button: (props: ComponentProps<"button">) =>
), [ + actionPending, + detail.workspaceRoot, + environmentId, + updatePendingComment, addComment, draft, finishSelection, @@ -1328,6 +1449,25 @@ function PullRequestCodeTab({ ) : null} + + { + setGuided(Boolean(pressed)); + if (pressed && guideItem?.collapsed) toggleFile(guideItem.id); + }} + variant="ghost" + size="sm" + > + + + } + /> + Guided review + !placedThreadIds.has(thread.id)); // A file carrying five stranded conversations should read as that file once rather than as // five copies of its path. - const orphanFiles = new Map(); + const orphanFiles = new Map(); for (const thread of orphanThreads) { const existing = orphanFiles.get(thread.path); if (existing) existing.push(thread); @@ -1476,7 +1616,7 @@ function PullRequestCodeTab({ ); return ( -
+
{toolbar} {/* Above the code, closed, and counted: these belong to the change rather than to any line of it, and in the stream they read as cards dropped into the patch. */} @@ -1484,7 +1624,10 @@ function PullRequestCodeTab({ { + setOrphansOpen(open); + if (open) setOrphansVisited(true); + }} > {/* Still a heading, so the section keeps its place in a screen reader's outline; the count is spelled out there rather than left as a bare number. */} @@ -1511,102 +1654,165 @@ function PullRequestCodeTab({ - + {/* Capped: opened on a change with dozens of them, this would otherwise leave no room for the diff it sits above. */} -
- {[...orphanFiles].map(([path, threads]) => ( -
- - {path}

} - /> - {path} -
-
- {threads.map((thread) => ( -
- {thread.line === null ? null : ( -

Line {thread.line}

- )} - {renderThreadCard(thread)} -
- ))} + {orphansVisited ? ( +
+ {[...orphanFiles].map(([path, threads]) => ( +
+ + + {path ?? "General discussion"} +

+ } + /> + {path ?? "General discussion"} +
+
+ {threads.map((thread) => ( +
+ {thread.line === null ? null : ( +

Line {thread.line}

+ )} + {renderThreadCard(thread)} +
+ ))} +
-
- ))} -
+ ))} +
+ ) : null} ) : null} -
- {/* Relative wrapper so the review overlay floats over the diff rather than pushing it +
+ {guided && guideItem ? ( + { + setFileViewed(guideItem.id, resolveFileDiffPath(guideItem.fileDiff), viewed); + if (viewed && guideIndex + 1 < items.length) selectGuideFile(guideIndex + 1); + }, + } + : {})} + onPrevious={() => selectGuideFile(guideIndex - 1)} + onNext={() => selectGuideFile(guideIndex + 1)} + {...(onExplainFile + ? { onExplain: () => onExplainFile(resolveFileDiffPath(guideItem.fileDiff)) } + : {})} + onSelectHunk={(index) => { + const hunk = guideItem.fileDiff.hunks[index]; + if (!hunk) return; + if (guideItem.collapsed) toggleFile(guideItem.id); + setGuideLine({ + scope: scopeKey, + target: { + type: "line", + id: guideItem.id, + lineNumber: hunk.additionCount === 0 ? hunk.deletionStart : hunk.additionStart, + side: hunk.additionCount === 0 ? "deletions" : "additions", + align: "start", + }, + }); + }} + /> + ) : null} +
+ {/* Relative wrapper so the review overlay floats over the diff rather than pushing it up; the viewer inside still owns its own scrolling. */} -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // A control inside the header — the collapse chevron — handles itself, and - // this capture listener fires before its own click does. Leave it alone or - // the two toggles cancel out. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { - return; - } - // A label answers for the control it names and this listener runs before it hears - // anything, so stopping the press keeps the header from folding what the tick folds. - if (node.hasAttribute("data-viewed-toggle")) return; - if (node.hasAttribute("data-diffs-header")) { - const filePath = node.querySelector("[data-title]")?.textContent?.trim(); - if (filePath === undefined || filePath === "") return; - const item = items.find( - (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, - ); - if (item !== undefined) toggleFile(item.id); - return; +
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // A control inside the header — the collapse chevron — handles itself, and + // this capture listener fires before its own click does. Leave it alone or + // the two toggles cancel out. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + if (node.hasAttribute("data-viewed-toggle")) return; + if (node.hasAttribute("data-diffs-header")) { + const filePath = node.querySelector("[data-title]")?.textContent?.trim(); + if (filePath === undefined || filePath === "") return; + const item = items.find( + (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, + ); + if (item !== undefined) toggleFile(item.id); + return; + } } - } - }} - > - {/* The viewer virtualizes against the element it is told is scrolling and places its + }} + > + {/* The viewer virtualizes against the element it is told is scrolling and places its rows absolutely, so it has to own that element — the thread diff panel hands it the same one. Scrolling from a parent instead leaves it painting over its neighbours. */} - - // Keep scrollbar space stable so file metadata and line numbers do not shift as a - // diff crosses the overflow boundary. The viewer is itself focusable for keyboard - // interaction, but its native host outline clips and competes with the focus - // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" - viewerRef={setViewer} - items={items} - selectedLines={selectedLines} - onSelectedLinesChange={setSelectedLines} - options={diffViewOptions} - // The viewer owns the scroll container, so the sentinel that asks for the next slice - // has to live inside it — at the end of the files, where reaching it means the reader - // is running out of diff. - renderCodeViewFooter={renderCodeViewFooter} - renderHeaderPrefix={renderHeaderPrefix} - renderHeaderMetadata={renderHeaderMetadata} - renderAnnotation={renderAnnotation} - unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} - /> - {reviewOverlay} -
- {fileTreeOpen ? ( -
+ {fileTreeOpen ? ( + Loading all files in this folder… +

+ ) : nextCursor === null ? null : (
{unstructured}
diff --git a/apps/web/src/components/pullRequest/PullRequestCommentActions.tsx b/apps/web/src/components/pullRequest/PullRequestCommentActions.tsx new file mode 100644 index 000000000000..ec58f869f728 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCommentActions.tsx @@ -0,0 +1,85 @@ +import type { PullRequestReviewThread } from "@t3tools/contracts"; +import { + CheckIcon, + LinkIcon, + MessageSquarePlusIcon, + MoreHorizontalIcon, + PencilIcon, +} from "lucide-react"; +import { createContext, useContext } from "react"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; + +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/menu"; +import { toastManager } from "../ui/toast"; +import type { PullRequestCommentReference } from "./pullRequestDetail.logic"; + +export const PullRequestCommentActionsContext = createContext<{ + add: (comment: PullRequestCommentReference) => void; + disabled: boolean; + threads: ReadonlyMap; + resolve?: (thread: PullRequestReviewThread) => void; +} | null>(null); + +export function PullRequestCommentActions({ + comment, + showResolution = false, + onEdit, + disabled = false, +}: { + comment: PullRequestCommentReference; + showResolution?: boolean; + onEdit?: () => void; + disabled?: boolean; +}) { + const actions = useContext(PullRequestCommentActionsContext); + const thread = showResolution ? actions?.threads.get(comment.id) : undefined; + const busy = disabled || actions?.disabled === true; + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "comment link", + onError: () => toastManager.add({ type: "error", title: "Could not copy the comment link" }), + }); + if (!comment.url && !actions && !onEdit) return null; + return ( + + {thread && thread.canResolve !== false && actions?.resolve ? ( + + ) : null} + + } + > + + + + {onEdit ? ( + + + Edit comment + + ) : null} + {comment.url ? ( + copyToClipboard(comment.url!)}> + {isCopied ? : } + {isCopied ? "Link copied" : "Copy comment link"} + + ) : null} + {actions ? ( + actions.add(comment)}> + + Add to composer + + ) : null} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx b/apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx index acf45c57bdeb..0364f14e081e 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx @@ -7,7 +7,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { Button } from "../ui/button"; import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; -import { Textarea } from "../ui/textarea"; +import { PullRequestMarkdownField } from "./PullRequestMarkdownField"; import { toastManager } from "../ui/toast"; import { PullRequestGlyph } from "./pullRequestIcons"; @@ -31,6 +31,7 @@ export function PullRequestCommentComposer({ }) { const [open, setOpen] = useState(false); const [body, setBody] = useState(""); + const [uploadPending, setUploadPending] = useState(false); const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); const textareaRef = useRef(null); const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); @@ -47,7 +48,7 @@ export function PullRequestCommentComposer({ const submit = async (action: "comment" | "close" | "reopen") => { const trimmed = body.trim(); - if (trimmed.length === 0 || submitting !== null || actionPending) return; + if (trimmed.length === 0 || submitting !== null || actionPending || uploadPending) return; setSubmitting(action); if (action !== "comment") { const result = await onCommentAction(trimmed, action); @@ -79,19 +80,14 @@ export function PullRequestCommentComposer({ return ( - } + render={
-