diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index 98fc82b5398227ca614aea382f3cdabd5474b6a5..c4a688b1aa7a91c36819af5b0b78b1d377c47afc 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -73,7 +73,11 @@ const measuredApi = Layer.effect( Layer.provide(NodeServices.layer), ); -const services = GitHubPullRequestCli.layer.pipe(Layer.provideMerge(measuredApi)); +const services = GitHubPullRequestCli.layer.pipe( + Layer.provideMerge(measuredApi), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index d9579f317d95b13c315545b2cf8ea21f75871a99..7e6146834db84670fd057ba6447547b4ba4f4ab4 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -16,6 +17,7 @@ import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; @@ -98,6 +100,7 @@ const layer = it.layer( Layer.provideMerge(mockApi), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), ); @@ -492,6 +495,8 @@ it.effect( Layer.provide(Layer.mergeAll(credentials, http)), Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), + Layer.merge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 9e98286dc82125c7d6f2502185e972dcb24cf902..ce944b931aa86acae146524d0f99596075435bbc 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -6,6 +6,7 @@ import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; @@ -43,6 +44,7 @@ import { import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { ACTOR_AVATARS_GRAPHQL_QUERY, @@ -1113,6 +1115,8 @@ const SIMPLE_ACTION_MUTATIONS = { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const api = yield* GitHubApi.GitHubApi; + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; const revalidateChecks = yield* makeChecksRevalidator; const routingIdentities = new Map< string, @@ -2495,6 +2499,8 @@ export const make = Effect.gen(function* () { if (input.stackNumber !== undefined) return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( Effect.provideService(GitHubApi.GitHubApi, api), + Effect.provideService(VcsProcess.VcsProcess, vcsProcess), + Effect.provideService(FileSystem.FileSystem, fileSystem), ); if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts index af6794616f7c08d831bd92904d91bf855d168f9b..7600b9dc9a26e84908c299215ed6b665a76667bf 100644 --- a/apps/server/src/pullRequest/githubStackActions.test.ts +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -4,7 +4,11 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import * as FileSystem from "effect/FileSystem"; +import * as Redacted from "effect/Redacted"; +import { ChildProcessSpawner } from "effect/process"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; /** @@ -17,32 +21,57 @@ type Send = ( kind: "graphql" | "rest", ) => Effect.Effect; +/** Every git command the cascade ran, in order; none actually runs. */ +let gitCalls: Array> = []; +const fakeGit = Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + gitCalls.push(input.args); + const stdout = + input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : ""; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), +); + const runGitHubStackAction = (send: Send, input: Parameters[0]) => runStackAction(input).pipe( Effect.provide( - Layer.mock(GitHubApi.GitHubApi)({ - graphql: (request) => { - // GitHub refuses a document that declares a variable it never uses. - const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); - const unused = declared.filter( - (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), - ); - if (unused.length > 0) { - return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); - } - return send(words(request.query, request.variables), "graphql"); - }, - rest: (request) => - send(words(request.path, request.body), "rest").pipe( - Effect.map((body) => ({ - status: 200, - headers: {}, - body, - truncated: false, - invalidUtf8: false, - })), - ), - }), + Layer.mergeAll( + fakeGit, + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + graphql: (request) => { + // GitHub refuses a document that declares a variable it never uses. + const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); + const unused = declared.filter( + (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), + ); + if (unused.length > 0) { + return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); + } + return send(words(request.query, request.variables), "graphql"); + }, + rest: (request) => + send(words(request.path, request.body), "rest").pipe( + Effect.map((body) => ({ + status: 200, + headers: {}, + body, + truncated: false, + invalidUtf8: false, + })), + ), + }), + ), ), ); @@ -250,41 +279,6 @@ it.effect("refuses a changed stack before performing any mutation", () => }), ); -it.effect("rebases unmerged layers bottom to top without local git commands", () => - Effect.gen(function* () { - const api = fake([stack, access, ...rebaseResponses]); - yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - const mutations = api.calls.filter(isMutation); - expect(mutations).toHaveLength(2); - expect(mutations[0]).toContain("id=PR_2"); - expect(mutations[0]).toContain("sha=bbb"); - expect(mutations[1]).toContain("id=PR_3"); - expect(mutations[1]).toContain("sha=ccc"); - }), -); - -it.effect("does not update later layers after a rebase failure", () => - Effect.gen(function* () { - const api = fake([stack, access, branch(2, "bbb")]); - const execute: Send = (call, kind) => - !isMutation(call) - ? api.execute(call, kind) - : Effect.fail( - new GitHubApi.GitHubApiAuthenticationError({ - host: "github.com", - operation: "runGitHubStackAction", - }), - ); - const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( - Effect.result, - ); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, - }); - }), -); - it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => Effect.gen(function* () { const api = fake([ @@ -325,7 +319,7 @@ it.effect("allows a fork that explicitly permits maintainer updates", () => ...rebaseResponses, ]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - expect(api.calls.at(-1)).toContain("id=PR_3"); + expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2); }), ); @@ -348,92 +342,52 @@ it.effect("bounds polling and reports a still-running merge without claiming suc }), ); -it.effect("rejects a push after preflight without rebasing the new revision", () => +it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () => Effect.gen(function* () { - const api = fake([stack, access, branch(2, "new-head")]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, - }); - expect(api.calls).toHaveLength(3); - }), -); - -it.effect("skips current layers without submitting a rebase mutation", () => - Effect.gen(function* () { - const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + gitCalls = []; + const api = fake([stack, access]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - expect(api.calls.some((args) => isMutation(args))).toBe(false); - }), -); - -it.effect("keeps earlier progress and stops after a later layer fails", () => - Effect.gen(function* () { - const api = fake([ - stack, - access, - branch(2, "bbb"), - rebased, - branch(3, "ccc", 1, ["rebased-sha"]), - { data: { updatePullRequestBranch: null } }, + // GitHub is only read; every change is a git push with a lease on the reviewed head. + expect(api.calls.some(isMutation)).toBe(false); + const rebases = gitCalls.filter((args) => args[0] === "rebase"); + expect(rebases).toEqual([ + ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], + ["rebase", "--quiet", "--onto", "new-sha", "bbb"], ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, - }); - }), -); - -it.effect("reports partial progress when a later head changes during the rebase", () => - Effect.gen(function* () { - const api = fake([ - stack, - access, - branch(2, "bbb"), - rebased, - branch(3, "concurrent-head", 1, ["rebased-sha"]), + expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ + [ + "push", + "--quiet", + "--force-with-lease=refs/heads/middle:bbb", + "origin", + "new-sha:refs/heads/middle", + ], + [ + "push", + "--quiet", + "--force-with-lease=refs/heads/top:ccc", + "origin", + "new-sha:refs/heads/top", + ], ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, - }); - if (result._tag === "Failure") { - expect(result.failure.message).toContain("Earlier updates remain on GitHub"); - } - expect(api.calls.filter((args) => isMutation(args))).toHaveLength(1); }), ); -it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => +it.effect("checks every layer's write access before any git runs", () => Effect.gen(function* () { + gitCalls = []; const api = fake([ stack, - access, - branch(2, "bbb", rebasedParent ? 1 : 0), - ...(rebasedParent ? [rebased] : []), - branch(3, "ccc", 1, ["concurrent-parent-head"]), + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, - }); - expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true); - expect(api.calls.filter((args) => isMutation(args))).toHaveLength(rebasedParent ? 1 : 0); + yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" })); + expect(gitCalls).toEqual([]); }), ); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts index 0ddb57a274bc9372dd5849a6150152c16a3a6c81..59d29627b81ba335b350e49329aa1078212afcdf 100644 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -10,6 +10,7 @@ import * as Schema from "effect/Schema"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; +import { cascadeRebaseStack } from "./githubStackRebase.ts"; const stackErrorIdentity = { repository: Schema.String, @@ -78,7 +79,10 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError = []; - for (const [index, layer] of open.entries()) { - yield* Effect.gen(function* () { - const read = yield* api.graphql({ - host: input.host, - operation: "runGitHubStackAction", - allowReserve: true, - variables: { - owner, - name, - number: layer.number, - sha: layer.headSha, - ids: processed.map((head) => head.id), - }, - // GitHub rejects a declared variable the document never uses, so `$ids` is always - // selected; an empty list asks for nothing. - query: `query($owner:String!,$name:String!,$number:Int!,$sha:String!,$ids:[ID!]!){processed:nodes(ids:$ids){... on PullRequest{headRefOid}} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, - }); - const { - data: { - processed: observed, - repository: { pullRequest: pr }, - }, - } = yield* decodeRebaseBranch(read); - // A push to an earlier layer must not silently become the next layer's new base. - const changed = processed.find( - (head, index) => observed?.[index]?.headRefOid !== head.headSha, - ); - if (changed !== undefined) - return yield* new GitHubStackChangedError({ - ...identity, - number: changed.number, - completed: index, - }); - if (pr.headRefOid !== layer.headSha) - return yield* new GitHubStackChangedError({ - ...identity, - number: layer.number, - completed: index, - }); - if (pr.baseRef.compare.behindBy === 0) { - processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); - return; - } - // Pass the reviewed revision to GitHub, including when a push races this read. - const updated = yield* api.graphql({ - host: input.host, - operation: "runGitHubStackAction", - variables: { id: pr.id, sha: layer.headSha }, - query: - "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", - }); - const response = yield* decodeRebaseResponse(updated); - processed.push({ - id: pr.id, - number: layer.number, - headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, - }); - }).pipe( - Effect.mapError((cause) => - cause._tag === "GitHubStackChangedError" - ? cause - : new GitHubStackRebaseFailedError({ - ...identity, - number: layer.number, - completed: index, - cause, - }), - ), - ); - } + // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old + // copy of every lower layer into the one above it. The cascade moves each layer's own commits. + yield* cascadeRebaseStack({ + host: input.host, + repository: input.repository, + base: stack.base, + layers: open.map((layer) => ({ + number: layer.number, + headBranch: layer.headBranch, + headSha: layer.headSha!, + })), + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackRebaseConflictError" || + cause._tag === "GitHubStackRebaseGitError" + ? new GitHubStackRebaseFailedError({ + ...identity, + number: cause.number, + completed: cause.completed, + cause, + }) + : cause, + ), + ); return; } if (open.some((layer) => layer.isDraft)) diff --git a/apps/server/src/pullRequest/githubStackRebase.test.ts b/apps/server/src/pullRequest/githubStackRebase.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..26b801d13bd8f6cab9b0153299418b2321a9f399 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackRebase.test.ts @@ -0,0 +1,162 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { cascadeRebaseStack } from "./githubStackRebase.ts"; + +const layer = Layer.mergeAll( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + }), + VcsProcess.layer, +).pipe(Layer.provideMerge(NodeServices.layer)); + +/** + * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a + * second layer sits on the first. Returns each branch's head. + */ +const setup = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" }); + const remote = `${root}/remote.git`; + const work = `${root}/work`; + const git = (cwd: string, ...args: string[]) => + process + .run({ + operation: "test", + command: "git", + args, + cwd, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }) + .pipe(Effect.map((out) => out.stdout.trim())); + const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text); + yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote); + yield* git(root, "clone", "--quiet", remote, work); + yield* write("index.ts", "export const answer = 41;\n"); + yield* git(work, "add", "-A"); + yield* git(work, "commit", "--quiet", "-m", "Initial"); + yield* git(work, "switch", "--quiet", "-c", "feat/answer-42"); + yield* write("index.ts", "export const answer = 42;\n"); + yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42"); + yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc"); + yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n"); + yield* git(work, "commit", "--quiet", "-am", "B: document the answer"); + yield* git(work, "switch", "--quiet", "main"); + yield* write("README.md", "main moved ahead\n"); + yield* git(work, "add", "-A"); + yield* git(work, "commit", "--quiet", "-m", "Docs"); + yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc"); + const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`); + return { + remote, + head, + git: (...args: string[]) => git(remote, ...args), + layers: [ + { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") }, + { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") }, + ], + }; +}); + +it.layer(layer)("cascadeRebaseStack", (it) => { + it.effect("moves each layer's own commits onto the rebased layer below it", () => + Effect.gen(function* () { + const repo = yield* setup; + const completed = yield* cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: repo.layers, + remote: repo.remote, + }); + assert.strictEqual(completed, 2); + + const main = yield* repo.head("main"); + const bottom = yield* repo.head("feat/answer-42"); + const top = yield* repo.head("feat/answer-doc"); + // Bottom sits on the new main; top sits on the new bottom with only its own commit. + expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main); + expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom); + expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe( + "B: document the answer", + ); + }).pipe(Effect.scoped), + ); + + it.effect("refuses to overwrite a layer pushed after it was reviewed", () => + Effect.gen(function* () { + const repo = yield* setup; + const stale = [ + repo.layers[0]!, + // Reviewed at the bottom layer's head, but the branch is really at its own commit. + { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha }, + ]; + const error = yield* Effect.flip( + cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: stale, + remote: repo.remote, + }), + ); + expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 }); + expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha); + }).pipe(Effect.scoped), + ); + + it.effect("stops at a conflicting layer and leaves it untouched", () => + Effect.gen(function* () { + const repo = yield* setup; + // Make main conflict with the bottom layer's change to the same line. + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" }); + const run = (...args: string[]) => + process.run({ + operation: "test", + command: "git", + args, + cwd: scratch, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }); + yield* run("clone", "--quiet", repo.remote, "."); + yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n"); + yield* run("commit", "--quiet", "-am", "Conflict"); + yield* run("push", "--quiet", "origin", "main"); + + const error = yield* Effect.flip( + cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: repo.layers, + remote: repo.remote, + }), + ); + expect(error).toMatchObject({ + _tag: "GitHubStackRebaseConflictError", + number: 1, + completed: 0, + }); + expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/pullRequest/githubStackRebase.ts b/apps/server/src/pullRequest/githubStackRebase.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cd2b5f88bb7ebe4cfa09f5e8702e42b3ae84c32 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackRebase.ts @@ -0,0 +1,155 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Base64 from "effect/encoding/Base64"; + +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +/** One open layer of a stack, bottom to top, with the head the reader reviewed. */ +export interface CascadeLayer { + readonly number: number; + readonly headBranch: string; + readonly headSha: string; +} + +/** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ +export class GitHubStackRebaseConflictError extends Schema.TaggedError()( + "GitHubStackRebaseConflictError", + { number: Schema.Int, completed: Schema.Int }, +) { + override get message(): string { + return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; + } +} + +/** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ +export class GitHubStackRebaseGitError extends Schema.TaggedError()( + "GitHubStackRebaseGitError", + { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, +) { + override get message(): string { + return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; + } +} + +/** The git URL of a repository on a GitHub host, which is also where its token is good. */ +function remoteUrl(host: string, repository: string): string { + return `https://${host.trim().toLowerCase()}/${repository}.git`; +} + +/** + * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer + * below it (the bottom one onto the stack's base). Each layer moves only its own commits — + * `git rebase --onto ` — so a rebased lower layer is never replayed into + * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. + * + * It works in a throwaway clone, so the environment's checkout never moves, and pushes each + * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused + * rather than overwritten. The stacks API has no rebase endpoint to call instead. + */ +export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { + readonly host: string; + readonly repository: string; + readonly base: string; + readonly layers: ReadonlyArray; + /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ + readonly remote?: string; +}) { + const api = yield* GitHubApi.GitHubApi; + const process = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const { token } = yield* api.credential(input.host); + const first = input.layers[0]; + if (first === undefined) return 0; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( + Effect.mapError( + (cause) => + new GitHubStackRebaseGitError({ + step: "preparing", + number: first.number, + completed: 0, + cause, + }), + ), + ); + // The token rides in a header for this process only: never in the URL, argv, or git config. + const remote = input.remote ?? remoteUrl(input.host, input.repository); + const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; + const env = { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, + GIT_CONFIG_VALUE_0: authorization, + GIT_CONFIG_KEY_1: "user.name", + GIT_CONFIG_VALUE_1: "T3 Code", + GIT_CONFIG_KEY_2: "user.email", + GIT_CONFIG_VALUE_2: "noreply@t3.codes", + }; + const git = (args: ReadonlyArray, allowNonZeroExit = false) => + process.run({ + operation: "cascadeRebaseStack", + command: "git", + args, + cwd: directory, + env, + timeoutMs: 120_000, + ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), + }); + const failed = (step: string, number: number, completed: number) => (cause: unknown) => + new GitHubStackRebaseGitError({ step, number, completed, cause }); + + yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); + yield* git(["remote", "add", "origin", remote]).pipe( + Effect.mapError(failed("preparing", first.number, 0)), + ); + yield* git([ + "fetch", + "--quiet", + "--no-tags", + "origin", + `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, + ...input.layers.map( + (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, + ), + ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); + + let parentOld = `origin/${input.base}`; + let parentNew = `origin/${input.base}`; + for (const [index, layer] of input.layers.entries()) { + // The bottom layer's old parent is where it forked from the base; above that it is the + // reviewed head of the layer below, which is exactly the commits this layer must not replay. + const upstream = + index === 0 + ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( + Effect.mapError(failed("reading the fork point", layer.number, index)), + )).stdout.trim() + : parentOld; + yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( + Effect.mapError(failed("checking out", layer.number, index)), + ); + const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( + Effect.mapError(failed("rebasing", layer.number, index)), + ); + if (rebase.exitCode !== 0) { + yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); + return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); + } + const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( + Effect.mapError(failed("rebasing", layer.number, index)), + )).stdout.trim(); + if (rebased !== layer.headSha) { + yield* git([ + "push", + "--quiet", + `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, + "origin", + `${rebased}:refs/heads/${layer.headBranch}`, + ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); + } + parentOld = layer.headSha; + parentNew = rebased; + } + return input.layers.length; +}, Effect.scoped);