Public: anyone can read this; the crew can change it.
A shipped change can be reverted: the revert commit, headless #198
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/189-a-shipped-change-can-be-reverted-the-rev → main 1 commit fork
7 files
changed +429
−10
src/actions/pulls.ts +4
| @@ -74,6 +74,9 @@ export interface OpenPullInput { | ||
| 74 | 74 | headBranch: string; |
| 75 | 75 | /** For a fork proposing upstream: the fork the head branch lives in. */ |
| 76 | 76 | headRepo?: Repo; |
| 77 | /** The issue id of the pull this one reverts ("Take it back"). Native | |
| 78 | * repos only — a mirror's pull is the origin's, which has no such field. */ | |
| 79 | revertsIssueId?: string; | |
| 77 | 80 | } |
| 78 | 81 | |
| 79 | 82 | export async function openPull( |
| @@ -171,6 +174,7 @@ export async function openPull( | ||
| 171 | 174 | headDir: repoDir(app.reposRoot, head.ownerName, head.name), |
| 172 | 175 | } |
| 173 | 176 | : {}), |
| 177 | ...(input.revertsIssueId ? { revertsIssueId: input.revertsIssueId } : {}), | |
| 174 | 178 | }, |
| 175 | 179 | ); |
| 176 | 180 | if (!created.ok) return created; |
src/actions/revert.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | /** | |
| 2 | * Take it back: land a change that undoes exactly what a shipped pull | |
| 3 | * merged. It never writes to the default branch itself — a branch, a | |
| 4 | * revert commit built as plumbing (`git/revert.ts`), and a pull whose title | |
| 5 | * says what it undoes, exactly like any other change a person opens. Only | |
| 6 | * a fresh branch and a fresh pull commit if the revert applies cleanly; | |
| 7 | * anything that does not land cleanly leaves nothing behind. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { revertOf, type Pull } from "../core/pulls.ts"; | |
| 12 | import type { Repo } from "../core/repos.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { resolveOid } from "../git/merge.ts"; | |
| 15 | import { repoDir } from "../git/repos.ts"; | |
| 16 | import { revertCommit, type RevertError } from "../git/revert.ts"; | |
| 17 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | import { requireWriter, type AccessError } from "./access.ts"; | |
| 19 | import { createBranch, deleteBranch } from "./branches.ts"; | |
| 20 | import { openPull } from "./pulls.ts"; | |
| 21 | ||
| 22 | export type RevertPullError = | |
| 23 | | AccessError | |
| 24 | | RevertError | |
| 25 | | "not_shipped" | |
| 26 | | "mirrored" | |
| 27 | | "already_reverted" | |
| 28 | | { kind: "branch_failed"; reason: string } | |
| 29 | | { kind: "pull_failed"; reason: string }; | |
| 30 | ||
| 31 | export async function revertPull( | |
| 32 | app: App, | |
| 33 | actor: User | undefined, | |
| 34 | repo: Repo, | |
| 35 | pull: Pull, | |
| 36 | ): Promise<Result<Pull, RevertPullError>> { | |
| 37 | const writer = requireWriter(app, actor, repo); | |
| 38 | if (!writer.ok) return writer; | |
| 39 | if (pull.repoId !== repo.id) return err("not_found"); | |
| 40 | // Rule 1 in docs/APPS.md: the crew proposes, a person lands. Taking a | |
| 41 | // change back is a landing verb, not a proposing one. | |
| 42 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 43 | if (pull.mergedAt === null || pull.mergedOid === null) | |
| 44 | return err("not_shipped"); | |
| 45 | // On a mirror the origin owns history; a local revert here would be | |
| 46 | // overwritten by the next sync, exactly as a local merge would. | |
| 47 | if (repo.origin !== null || pull.originHeadRepo !== null) | |
| 48 | return err("mirrored"); | |
| 49 | if (revertOf(app.db, repo.id, pull.id) !== undefined) | |
| 50 | return err("already_reverted"); | |
| 51 | ||
| 52 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 53 | const onto = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 54 | if (onto === undefined) | |
| 55 | return err({ kind: "git", message: "the default branch has no tip" }); | |
| 56 | ||
| 57 | const built = await revertCommit(dir, { | |
| 58 | commitOid: pull.mergedOid, | |
| 59 | ontoOid: onto, | |
| 60 | message: `Revert "${pull.title}" (#${pull.number})`, | |
| 61 | author: { | |
| 62 | name: writer.value.displayName || writer.value.username, | |
| 63 | email: writer.value.email, | |
| 64 | }, | |
| 65 | }); | |
| 66 | if (!built.ok) return built; | |
| 67 | ||
| 68 | // The branch name is the server's own — never taken from the request — | |
| 69 | // so it always says exactly what it reverts. | |
| 70 | const branchName = `revert-${pull.number}`; | |
| 71 | const branch = await createBranch(app, writer.value, repo, { | |
| 72 | name: branchName, | |
| 73 | from: built.value.commitOid, | |
| 74 | }); | |
| 75 | if (!branch.ok) { | |
| 76 | const reason = typeof branch.error === "string" ? branch.error : "git"; | |
| 77 | return err({ kind: "branch_failed", reason }); | |
| 78 | } | |
| 79 | ||
| 80 | const opened = await openPull(app, writer.value, repo, { | |
| 81 | title: `Revert "${pull.title}"`, | |
| 82 | body: `Reverts #${pull.number}.`, | |
| 83 | baseBranch: repo.defaultBranch, | |
| 84 | headBranch: branchName, | |
| 85 | revertsIssueId: pull.id, | |
| 86 | }); | |
| 87 | if (!opened.ok) { | |
| 88 | // The branch was made for this pull and the pull did not open: take it | |
| 89 | // back so a refused revert leaves nothing half-made behind, the same | |
| 90 | // rule `editFiles` (`actions/contents.ts`) follows for the same reason. | |
| 91 | await deleteBranch(app, writer.value, repo, branchName); | |
| 92 | const reason = typeof opened.error === "string" ? opened.error : "git"; | |
| 93 | return err({ kind: "pull_failed", reason }); | |
| 94 | } | |
| 95 | return ok(opened.value); | |
| 96 | } |
src/core/pulls.ts +32 −2
| @@ -51,6 +51,8 @@ export interface Pull extends Issue { | ||
| 51 | 51 | * not hold): `owner/name` there. Null for a native pull. |
| 52 | 52 | */ |
| 53 | 53 | readonly originHeadRepo: string | null; |
| 54 | /** The pull this one reverts, when it was opened by "Take it back". */ | |
| 55 | readonly revertsPullId: string | null; | |
| 54 | 56 | } |
| 55 | 57 | |
| 56 | 58 | interface PullRow { |
| @@ -62,6 +64,7 @@ interface PullRow { | ||
| 62 | 64 | merged_oid: string | null; |
| 63 | 65 | head_repo_id: string | null; |
| 64 | 66 | origin_head_repo: string | null; |
| 67 | reverts_issue_id: string | null; | |
| 65 | 68 | } |
| 66 | 69 | |
| 67 | 70 | function fromRow(issue: Issue, row: PullRow): Pull { |
| @@ -77,6 +80,7 @@ function fromRow(issue: Issue, row: PullRow): Pull { | ||
| 77 | 80 | headRepoId, |
| 78 | 81 | crossRepo: headRepoId !== issue.repoId, |
| 79 | 82 | originHeadRepo: row.origin_head_repo, |
| 83 | revertsPullId: row.reverts_issue_id, | |
| 80 | 84 | }; |
| 81 | 85 | } |
| 82 | 86 | |
| @@ -110,6 +114,8 @@ export interface CreatePullInput { | ||
| 110 | 114 | headRepoId?: string; |
| 111 | 115 | /** The fork's on-disk dir, required when headRepoId differs from the base. */ |
| 112 | 116 | headDir?: string; |
| 117 | /** The issue id of the pull this one reverts ("Take it back"). */ | |
| 118 | revertsIssueId?: string; | |
| 113 | 119 | } |
| 114 | 120 | |
| 115 | 121 | export async function createPull( |
| @@ -148,8 +154,8 @@ export async function createPull( | ||
| 148 | 154 | |
| 149 | 155 | const headRepoId = input.headRepoId ?? input.repoId; |
| 150 | 156 | db.query( |
| 151 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) | |
| 152 | VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, | |
| 157 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, reverts_issue_id) | |
| 158 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, | |
| 153 | 159 | ).run( |
| 154 | 160 | issue.value.id, |
| 155 | 161 | input.baseBranch, |
| @@ -157,6 +163,7 @@ export async function createPull( | ||
| 157 | 163 | baseOid, |
| 158 | 164 | headOid, |
| 159 | 165 | headRepoId, |
| 166 | input.revertsIssueId ?? null, | |
| 160 | 167 | ); |
| 161 | 168 | |
| 162 | 169 | // A durable, fetchable ref for the PR head — the way tooling addresses it, |
| @@ -199,6 +206,29 @@ export function pullForBranch( | ||
| 199 | 206 | return row ? getPull(db, repoId, row.number) : undefined; |
| 200 | 207 | } |
| 201 | 208 | |
| 209 | /** | |
| 210 | * The pull that already reverts this one, if taking it back has already | |
| 211 | * been done — open, or merged, either one counts as "already taken back". | |
| 212 | * A revert pull that was closed *without* merging is an abandoned attempt, | |
| 213 | * not a standing answer, so it does not block a second try. | |
| 214 | */ | |
| 215 | export function revertOf( | |
| 216 | db: Database, | |
| 217 | repoId: string, | |
| 218 | pullId: string, | |
| 219 | ): Pull | undefined { | |
| 220 | const row = db | |
| 221 | .query< | |
| 222 | { number: number }, | |
| 223 | [string, string] | |
| 224 | >(`SELECT issue.number AS number FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 225 | WHERE issue.repo_id = ?1 AND pull.reverts_issue_id = ?2 | |
| 226 | AND (issue.state = 'open' OR pull.merged_at IS NOT NULL) | |
| 227 | ORDER BY issue.created_at DESC LIMIT 1`) | |
| 228 | .get(repoId, pullId); | |
| 229 | return row ? getPull(db, repoId, row.number) : undefined; | |
| 230 | } | |
| 231 | ||
| 202 | 232 | /** |
| 203 | 233 | * Open pulls, in any repository, whose head is this branch of this one: |
| 204 | 234 | * what a push to a fork moves. |
src/core/schema.ts +9
| @@ -783,4 +783,13 @@ export const migrations: readonly string[] = [ | ||
| 783 | 783 | DROP TABLE look; |
| 784 | 784 | ALTER TABLE look_new RENAME TO look; |
| 785 | 785 | `, |
| 786 | ||
| 787 | // 27 — take it back: a pull that reverts another, named explicitly rather | |
| 788 | // than sniffed from its title. `reverts_issue_id` points at the issue the | |
| 789 | // shipped pull was (a pull is its own issue), so "was this already taken | |
| 790 | // back" is one lookup rather than a guess from wording. | |
| 791 | ` | |
| 792 | ALTER TABLE pull ADD COLUMN reverts_issue_id TEXT REFERENCES issue(id) ON DELETE SET NULL; | |
| 793 | CREATE INDEX pull_reverts ON pull(reverts_issue_id) WHERE reverts_issue_id IS NOT NULL; | |
| 794 | `, | |
| 786 | 795 | ]; |
src/git/merge.ts +36 −8
| @@ -30,23 +30,38 @@ export interface MergeResult { | ||
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | * Can head be merged into base, and if so what would the tree be? Pure | |
| 34 | * computation — writes nothing. Returns the merged tree oid or the conflicted | |
| 35 | * paths. Used both to show mergeability and as the first half of a merge. | |
| 33 | * The plumbing both `mergeable` and a revert's three-way merge share: run | |
| 34 | * `merge-tree --write-tree` over two sides and report the merged tree oid or | |
| 35 | * the conflicted paths. `base` overrides the merge base git would otherwise | |
| 36 | * compute from history — a revert needs the commit being undone as the base, | |
| 37 | * not whatever `ours`/`theirs` share an ancestor with. | |
| 36 | 38 | */ |
| 37 | export async function mergeable( | |
| 39 | export async function writeMergeTree( | |
| 38 | 40 | dir: string, |
| 39 | baseOid: string, | |
| 40 | headOid: string, | |
| 41 | ours: string, | |
| 42 | theirs: string, | |
| 43 | base?: string, | |
| 41 | 44 | ): Promise<Result<{ treeOid: string }, MergeError>> { |
| 42 | if (!isSafeOid(baseOid) || !isSafeOid(headOid)) | |
| 45 | if ( | |
| 46 | !isSafeOid(ours) || | |
| 47 | !isSafeOid(theirs) || | |
| 48 | (base !== undefined && !isSafeOid(base)) | |
| 49 | ) { | |
| 43 | 50 | return err({ kind: "unsafe" }); |
| 51 | } | |
| 44 | 52 | |
| 45 | 53 | // merge-tree exits 0 on a clean merge (stdout = tree oid) and 1 on conflict |
| 46 | 54 | // (stdout = tree oid, blank line, then conflicted file names). Both outcomes |
| 47 | 55 | // are data on stdout, so capture regardless of exit code. |
| 48 | 56 | const out = await runGitCapture( |
| 49 | ["merge-tree", "--write-tree", "--name-only", baseOid, headOid], | |
| 57 | [ | |
| 58 | "merge-tree", | |
| 59 | "--write-tree", | |
| 60 | "--name-only", | |
| 61 | ...(base === undefined ? [] : [`--merge-base=${base}`]), | |
| 62 | ours, | |
| 63 | theirs, | |
| 64 | ], | |
| 50 | 65 | { cwd: dir, timeoutMs: 60_000 }, |
| 51 | 66 | ); |
| 52 | 67 | |
| @@ -70,6 +85,19 @@ export async function mergeable( | ||
| 70 | 85 | }); |
| 71 | 86 | } |
| 72 | 87 | |
| 88 | /** | |
| 89 | * Can head be merged into base, and if so what would the tree be? Pure | |
| 90 | * computation — writes nothing. Returns the merged tree oid or the conflicted | |
| 91 | * paths. Used both to show mergeability and as the first half of a merge. | |
| 92 | */ | |
| 93 | export async function mergeable( | |
| 94 | dir: string, | |
| 95 | baseOid: string, | |
| 96 | headOid: string, | |
| 97 | ): Promise<Result<{ treeOid: string }, MergeError>> { | |
| 98 | return writeMergeTree(dir, baseOid, headOid); | |
| 99 | } | |
| 100 | ||
| 73 | 101 | /** Land head onto base with the chosen strategy, only if base is unmoved. */ |
| 74 | 102 | export async function merge( |
| 75 | 103 | dir: string, |
src/git/revert.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * Reverting a shipped commit as plumbing — the same three-way merge `git | |
| 3 | * revert` runs, computed with `merge-tree` instead of a checkout: the commit | |
| 4 | * being undone is the merge base, the branch it landed on is one side, and | |
| 5 | * the commit's own parent (the state right before it) is the other. Any | |
| 6 | * change made since keeps its place; only what this one commit did is | |
| 7 | * undone, and a real conflict is reported rather than guessed at. | |
| 8 | */ | |
| 9 | ||
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | import { runGit } from "./env.ts"; | |
| 12 | import { isSafeOid } from "./guard.ts"; | |
| 13 | import { writeMergeTree, type MergeError, type MergeIdentity } from "./merge.ts"; | |
| 14 | ||
| 15 | export type RevertError = | |
| 16 | | MergeError | |
| 17 | /** More than two parents: an octopus merge the forge never makes itself, | |
| 18 | * so reverting one would be a guess about which side is "the" undo. */ | |
| 19 | | { kind: "octopus"; parents: number }; | |
| 20 | ||
| 21 | export interface RevertResult { | |
| 22 | readonly commitOid: string; | |
| 23 | } | |
| 24 | ||
| 25 | /** A commit's parent oids, in order; empty for a root commit. */ | |
| 26 | export async function commitParents( | |
| 27 | dir: string, | |
| 28 | oid: string, | |
| 29 | ): Promise<string[] | undefined> { | |
| 30 | if (!isSafeOid(oid)) return undefined; | |
| 31 | const result = await runGit(["rev-parse", `${oid}^@`], { cwd: dir }); | |
| 32 | if (!result.ok) return undefined; | |
| 33 | return result.value | |
| 34 | .split("\n") | |
| 35 | .map((line) => line.trim()) | |
| 36 | .filter((line) => line !== ""); | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Build the commit that undoes `commitOid`, staged on top of `ontoOid`. A | |
| 41 | * squash merge (the forge's own default) has one parent, which is exactly | |
| 42 | * the state to restore; an ordinary merge's first parent is the base | |
| 43 | * branch's own side — `git revert -m 1`'s rule, and the only mainline the | |
| 44 | * forge's own merges ever produce, so that is the one used without asking. | |
| 45 | */ | |
| 46 | export async function revertCommit( | |
| 47 | dir: string, | |
| 48 | input: { | |
| 49 | commitOid: string; | |
| 50 | ontoOid: string; | |
| 51 | message: string; | |
| 52 | author: MergeIdentity; | |
| 53 | }, | |
| 54 | ): Promise<Result<RevertResult, RevertError>> { | |
| 55 | if (!isSafeOid(input.commitOid) || !isSafeOid(input.ontoOid)) | |
| 56 | return err({ kind: "unsafe" }); | |
| 57 | ||
| 58 | const parents = await commitParents(dir, input.commitOid); | |
| 59 | if (parents === undefined || parents.length === 0) | |
| 60 | return err({ kind: "git", message: "could not read the change's parent" }); | |
| 61 | if (parents.length > 2) return err({ kind: "octopus", parents: parents.length }); | |
| 62 | const mainline = parents[0]!; | |
| 63 | ||
| 64 | const tree = await writeMergeTree(dir, input.ontoOid, mainline, input.commitOid); | |
| 65 | if (!tree.ok) return tree; | |
| 66 | ||
| 67 | const env = { | |
| 68 | GIT_AUTHOR_NAME: input.author.name, | |
| 69 | GIT_AUTHOR_EMAIL: input.author.email, | |
| 70 | GIT_COMMITTER_NAME: input.author.name, | |
| 71 | GIT_COMMITTER_EMAIL: input.author.email, | |
| 72 | }; | |
| 73 | const commit = await runGit( | |
| 74 | ["commit-tree", tree.value.treeOid, "-p", input.ontoOid, "-m", input.message], | |
| 75 | { cwd: dir, env }, | |
| 76 | ); | |
| 77 | if (!commit.ok) return err({ kind: "git", message: commit.error.stderr }); | |
| 78 | return ok({ commitOid: commit.value.trim() }); | |
| 79 | } |
test/revert.test.ts added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | /** | |
| 2 | * Take it back, headless: `revertPull` turns a shipped change into a new | |
| 3 | * branch and pull that undoes it, without touching the default branch and | |
| 4 | * without a page in front of it. Everything here is driven through actions | |
| 5 | * directly, the way `test/crew.test.ts` builds its repositories — no | |
| 6 | * `git clone`/`git push` over HTTP, which does not run in this sandbox. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { revertPull } from "../src/actions/revert.ts"; | |
| 17 | import { getPull, revertOf, type Pull } from "../src/core/pulls.ts"; | |
| 18 | import type { Repo } from "../src/core/repos.ts"; | |
| 19 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { resolveOid } from "../src/git/merge.ts"; | |
| 22 | import { repoDir } from "../src/git/repos.ts"; | |
| 23 | ||
| 24 | describe("take it back: reverting a shipped change, headless", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | let repo: Repo; | |
| 29 | let dir: string; | |
| 30 | ||
| 31 | const contentAt = async (ref: string, path: string): Promise<string> => { | |
| 32 | const p = Bun.spawn(["git", "show", `${ref}:${path}`], { | |
| 33 | cwd: dir, | |
| 34 | stdout: "pipe", | |
| 35 | stderr: "ignore", | |
| 36 | }); | |
| 37 | return new Response(p.stdout).text(); | |
| 38 | }; | |
| 39 | ||
| 40 | const branchExists = async (name: string): Promise<boolean> => | |
| 41 | (await resolveOid(dir, `refs/heads/${name}`)) !== undefined; | |
| 42 | ||
| 43 | const shipChange = async ( | |
| 44 | branch: string, | |
| 45 | title: string, | |
| 46 | content: string, | |
| 47 | ): Promise<Pull> => { | |
| 48 | await commitToBranch(app, alice, repo, { | |
| 49 | branch, | |
| 50 | base: "main", | |
| 51 | message: title, | |
| 52 | files: [{ path: "code.txt", content }], | |
| 53 | }); | |
| 54 | const opened = await openPull(app, alice, repo, { | |
| 55 | title, | |
| 56 | baseBranch: "main", | |
| 57 | headBranch: branch, | |
| 58 | }); | |
| 59 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 60 | const merged = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 61 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 62 | return getPull(app.db, repo.id, opened.value.number)!; | |
| 63 | }; | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-revert-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | ||
| 69 | const created = await createUser(app.db, { | |
| 70 | username: "alice", | |
| 71 | email: "[email protected]", | |
| 72 | password: "a-good-passphrase", | |
| 73 | adminIfFirst: true, | |
| 74 | }); | |
| 75 | if (!created.ok) throw new Error(created.error); | |
| 76 | alice = created.value; | |
| 77 | ||
| 78 | const madeRepo = await createRepo(app, alice, { | |
| 79 | name: "app", | |
| 80 | visibility: "public", | |
| 81 | }); | |
| 82 | if (!madeRepo.ok) throw new Error(String(madeRepo.error)); | |
| 83 | repo = madeRepo.value; | |
| 84 | dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 85 | ||
| 86 | await commitToBranch(app, alice, repo, { | |
| 87 | branch: "main", | |
| 88 | message: "base", | |
| 89 | files: [{ path: "code.txt", content: "alpha\n" }], | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | afterAll(async () => { | |
| 94 | app.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a test fails on today's code: ship it, take it back, main only changes once the revert lands", async () => { | |
| 99 | const shipped = await shipChange("loud", "Make it loud", "alpha\nBETA\n"); | |
| 100 | expect(await contentAt("main", "code.txt")).toBe("alpha\nBETA\n"); | |
| 101 | ||
| 102 | const reverted = await revertPull(app, alice, repo, shipped); | |
| 103 | expect(reverted.ok).toBe(true); | |
| 104 | if (!reverted.ok) return; | |
| 105 | const revertPullRow = reverted.value; | |
| 106 | expect(revertPullRow.title).toBe('Revert "Make it loud"'); | |
| 107 | expect(revertPullRow.body).toContain(`Reverts #${shipped.number}`); | |
| 108 | expect(revertPullRow.revertsPullId).toBe(shipped.id); | |
| 109 | expect(await branchExists(`revert-${shipped.number}`)).toBe(true); | |
| 110 | ||
| 111 | // main is untouched until a person merges the revert pull. | |
| 112 | expect(await contentAt("main", "code.txt")).toBe("alpha\nBETA\n"); | |
| 113 | expect( | |
| 114 | await contentAt(`revert-${shipped.number}`, "code.txt"), | |
| 115 | ).toBe("alpha\n"); | |
| 116 | ||
| 117 | const landed = await mergePull(app, alice, repo, revertPullRow, "squash"); | |
| 118 | expect(landed.ok).toBe(true); | |
| 119 | expect(await contentAt("main", "code.txt")).toBe("alpha\n"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a change already taken back is refused rather than reverted twice", async () => { | |
| 123 | const shipped = getPull(app.db, repo.id, 1)!; | |
| 124 | const again = await revertPull(app, alice, repo, shipped); | |
| 125 | expect(again).toEqual({ ok: false, error: "already_reverted" }); | |
| 126 | expect(revertOf(app.db, repo.id, shipped.id)).toBeDefined(); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a revert that would conflict changes nothing and leaves no branch behind", async () => { | |
| 130 | const gamma = await shipChange("gamma", "Add gamma", "alpha\nGAMMA\n"); | |
| 131 | // Change the very same line again, so undoing "Add gamma" can no | |
| 132 | // longer apply without a conflict. | |
| 133 | await shipChange("delta", "Change it again", "alpha\nDELTA\n"); | |
| 134 | expect(await contentAt("main", "code.txt")).toBe("alpha\nDELTA\n"); | |
| 135 | ||
| 136 | const refused = await revertPull(app, alice, repo, gamma); | |
| 137 | expect(refused).toMatchObject({ ok: false, error: { kind: "conflict" } }); | |
| 138 | expect(await contentAt("main", "code.txt")).toBe("alpha\nDELTA\n"); | |
| 139 | expect(await branchExists(`revert-${gamma.number}`)).toBe(false); | |
| 140 | expect(revertOf(app.db, repo.id, gamma.id)).toBeUndefined(); | |
| 141 | }); | |
| 142 | ||
| 143 | test("an agent may not take a change back — landing is a person's verb", async () => { | |
| 144 | const agent = await createAgent(app, alice, { username: "bot" }); | |
| 145 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 146 | const added = addToCrew(app, alice, repo, "bot"); | |
| 147 | if (!added.ok) throw new Error(String(added.error)); | |
| 148 | const shipped = await shipChange( | |
| 149 | "epsilon", | |
| 150 | "Add epsilon", | |
| 151 | "alpha\nDELTA\nEPSILON\n", | |
| 152 | ); | |
| 153 | const refused = await revertPull(app, agent.value.user, repo, shipped); | |
| 154 | expect(refused).toEqual({ ok: false, error: "forbidden" }); | |
| 155 | }); | |
| 156 | ||
| 157 | test("reverting a pull that never shipped is refused", async () => { | |
| 158 | await commitToBranch(app, alice, repo, { | |
| 159 | branch: "zeta", | |
| 160 | base: "main", | |
| 161 | message: "not merged", | |
| 162 | files: [{ path: "code.txt", content: "alpha\nDELTA\nZETA\n" }], | |
| 163 | }); | |
| 164 | const opened = await openPull(app, alice, repo, { | |
| 165 | title: "Add zeta", | |
| 166 | baseBranch: "main", | |
| 167 | headBranch: "zeta", | |
| 168 | }); | |
| 169 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 170 | const refused = await revertPull(app, alice, repo, opened.value); | |
| 171 | expect(refused).toEqual({ ok: false, error: "not_shipped" }); | |
| 172 | }); | |
| 173 | }); |
new above
offline