From 223e29286fcb48a847b120fa8eea6c5bc612817e Mon Sep 17 00:00:00 2001 From: mikais13 Date: Sun, 5 Apr 2026 17:27:18 -1000 Subject: [PATCH 1/8] feat: add test scripts --- package.json | 2 ++ packages/core/package.json | 1 + turbo.json | 2 ++ 3 files changed, 5 insertions(+) diff --git a/package.json b/package.json index a85cac3..8232793 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "build": "turbo run build", "build:api": "turbo run build --filter=api", "types:check": "turbo run types:check", + "test": "turbo run test", + "test:core": "turbo run test --filter=@pr-stack/core", "lint:check": "turbo run lint:check", "lint:fix": "turbo run lint:fix", "prepare:lefthook": "lefthook install && bun -e \"const fs=require('node:fs'); fs.writeFileSync('node_modules/lefthook/bin/index.js', fs.readFileSync('node_modules/lefthook/bin/index.js', 'utf8').replace(/^#!\\/usr\\/bin\\/env\\s+node/gm, '#!\\/usr\\/bin\\/env bun'))\"", diff --git a/packages/core/package.json b/packages/core/package.json index f6f8b8d..2cc9e64 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "bun build src/index.ts --outdir=dist --target=bun", "types:check": "tsc --noEmit --skipLibCheck", + "test": "bun test", "lint:check": "biome check .", "lint:fix": "biome check . --write" }, diff --git a/turbo.json b/turbo.json index b2e595b..f5dffd6 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,8 @@ "types:check": { "dependsOn": ["^types:check"] }, + "test": {}, + "test:core": {}, "lint:check": {}, "lint:fix": {} }, From 2c694a2c1562d2eed7f52548300882b937ae1140 Mon Sep 17 00:00:00 2001 From: mikais13 Date: Sun, 5 Apr 2026 17:38:40 -1000 Subject: [PATCH 2/8] feat(ci): add tests step --- .github/workflows/ci-pipeline.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/ci-pipeline.yaml b/.github/workflows/ci-pipeline.yaml index a1582f6..52faffe 100644 --- a/.github/workflows/ci-pipeline.yaml +++ b/.github/workflows/ci-pipeline.yaml @@ -92,6 +92,36 @@ jobs: - name: Run type check run: bun types:check + test: + name: Run Tests + needs: [setup] + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Restore node_modules cache + id: cache-check + uses: actions/cache@v4 + with: + path: | + node_modules + */node_modules + packages/*/node_modules + apps/*/node_modules + key: ${{ runner.os }}-node_modules-${{ hashFiles('bun.lock') }} + restore-keys: ${{ runner.os }}-node_modules + + - name: Install dependencies if cache was not hit + if: steps.cache-check.outputs.cache-hit != 'true' + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run Tests + run: bun run test + build: name: Build API needs: [setup] From 86a8da1d71a7e164198f297b72b846830d93fc96 Mon Sep 17 00:00:00 2001 From: mikais13 Date: Sun, 5 Apr 2026 23:10:51 -1000 Subject: [PATCH 3/8] feat(bun, tests): add preload bun test config, for github app.ts module mocking --- packages/core/bunfig.toml | 2 ++ packages/core/src/test-config/preload.ts | 11 +++++++++++ 2 files changed, 13 insertions(+) create mode 100644 packages/core/bunfig.toml create mode 100644 packages/core/src/test-config/preload.ts diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 0000000..b7887f0 --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/test-config/preload.ts"] diff --git a/packages/core/src/test-config/preload.ts b/packages/core/src/test-config/preload.ts new file mode 100644 index 0000000..d3b5b91 --- /dev/null +++ b/packages/core/src/test-config/preload.ts @@ -0,0 +1,11 @@ +import { mock } from "bun:test"; + +// Mock app.ts globally as it throws at eval time when env vars are missing, +// so it must be intercepted here before any test file imports it. +mock.module("../github/app", () => ({ + githubApp: { + webhooks: { on: () => {} }, + octokit: { request: async () => ({}) }, + getInstallationOctokit: async () => ({}), + }, +})); From 485bef34d9effb66b64da4f15684e59159a4798e Mon Sep 17 00:00:00 2001 From: mikais13 Date: Sun, 5 Apr 2026 23:37:40 -1000 Subject: [PATCH 4/8] fix(turbo): remove redundant test:core task --- turbo.json | 1 - 1 file changed, 1 deletion(-) diff --git a/turbo.json b/turbo.json index f5dffd6..dcf86d0 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,6 @@ "dependsOn": ["^types:check"] }, "test": {}, - "test:core": {}, "lint:check": {}, "lint:fix": {} }, From 53ad36f947f34da1ff5040652865eea70e0b93de Mon Sep 17 00:00:00 2001 From: mikais13 Date: Mon, 6 Apr 2026 00:03:20 -1000 Subject: [PATCH 5/8] feat(core): randomise test order --- packages/core/bunfig.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml index b7887f0..780e1ed 100644 --- a/packages/core/bunfig.toml +++ b/packages/core/bunfig.toml @@ -1,2 +1,3 @@ [test] preload = ["./src/test-config/preload.ts"] +randomize = true From 18dbdba2cc2671f030355252d3d873e59cf320ea Mon Sep 17 00:00:00 2001 From: mikais13 Date: Wed, 8 Apr 2026 11:56:12 +1200 Subject: [PATCH 6/8] feat(core): add tests for ci-check, rebase, and octokit service --- .../core/src/application/ci-check.test.ts | 160 ++++++++ packages/core/src/application/rebase.test.ts | 343 ++++++++++++++++++ .../core/src/services/octokit.service.test.ts | 170 +++++++++ 3 files changed, 673 insertions(+) create mode 100644 packages/core/src/application/ci-check.test.ts create mode 100644 packages/core/src/application/rebase.test.ts create mode 100644 packages/core/src/services/octokit.service.test.ts diff --git a/packages/core/src/application/ci-check.test.ts b/packages/core/src/application/ci-check.test.ts new file mode 100644 index 0000000..6202a1e --- /dev/null +++ b/packages/core/src/application/ci-check.test.ts @@ -0,0 +1,160 @@ +import { mock } from "bun:test"; + +// Mock auth here — it is test-specific behaviour. +mock.module("../github/auth", () => ({ + getInstallationArtifacts: async () => ({ octokit: {}, token: "fake-token" }), +})); + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { Commit } from "../models/commit.model"; +import { GitService } from "../services/git.service"; +import { OctokitService } from "../services/octokit.service"; +import { shouldSkipCI } from "./ci-check"; + +function makeCommit(sha: string, treeSHA: string) { + return new Commit(sha, treeSHA); +} + +function makeParams( + overrides: { + before?: string; + after?: string; + headSha?: string; + baseSha?: string; + } = {}, +) { + const headSha = overrides.headSha ?? "head-sha"; + return { + before: overrides.before ?? "before-sha", + after: overrides.after ?? headSha, + repository: { + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + }, + pull_request: { + number: 1, + state: "open", + title: "My PR", + head: { label: "owner:feat", ref: "feat", sha: headSha }, + base: { + label: "owner:main", + ref: "main", + sha: overrides.baseSha ?? "base-sha", + }, + }, + }; +} + +describe("shouldSkipCI", () => { + let getCommitSpy: ReturnType>; + let cloneRepoSpy: ReturnType>; + let traverseToSHASpy: ReturnType>; + + beforeEach(() => { + // Default: two commits with the same tree SHA (skip CI scenario) + getCommitSpy = spyOn( + OctokitService.prototype, + "getCommit", + ).mockImplementation(async (sha: string) => + sha === "before-sha" + ? makeCommit("before-sha", "same-tree") + : makeCommit("head-sha", "same-tree"), + ); + cloneRepoSpy = spyOn(GitService.prototype, "cloneRepo").mockResolvedValue( + undefined, + ); + traverseToSHASpy = spyOn( + GitService.prototype, + "traverseToSHA", + ).mockResolvedValue([makeCommit("head-sha", "same-tree")]); + }); + + afterEach(() => { + getCommitSpy.mockRestore(); + cloneRepoSpy.mockRestore(); + traverseToSHASpy.mockRestore(); + }); + + it("returns skipCI: false early when after !== head.sha", async () => { + const params = makeParams({ after: "different-sha", headSha: "head-sha" }); + + const result = await shouldSkipCI(params); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/does not match head SHA/); + expect(cloneRepoSpy).not.toHaveBeenCalled(); + }); + + it("returns skipCI: false when before commit is not found", async () => { + getCommitSpy.mockImplementation(async (sha: string) => + sha === "before-sha" ? null : makeCommit("head-sha", "tree-a"), + ); + + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/Could not retrieve commits/); + }); + + it("returns skipCI: false when after commit is not found", async () => { + getCommitSpy.mockImplementation(async (sha: string) => + sha === "before-sha" ? makeCommit("before-sha", "tree-a") : null, + ); + + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/Could not retrieve commits/); + }); + + it("returns skipCI: false when clone fails", async () => { + cloneRepoSpy.mockRejectedValue(new Error("clone error")); + + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/Failed to clone/); + }); + + it("returns skipCI: false when traverseToSHA returns null", async () => { + traverseToSHASpy.mockResolvedValue(null); + + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/Failed to traverse/); + }); + + it("returns skipCI: false when before SHA is an ancestor of head", async () => { + traverseToSHASpy.mockResolvedValue([ + makeCommit("head-sha", "same-tree"), + makeCommit("before-sha", "same-tree"), + ]); + + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/is an ancestor/); + }); + + it("returns skipCI: false when tree SHAs differ", async () => { + getCommitSpy.mockImplementation(async (sha: string) => + sha === "before-sha" + ? makeCommit("before-sha", "tree-old") + : makeCommit("head-sha", "tree-new"), + ); + + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(false); + expect(result.message).toMatch(/does not match after commit tree SHA/); + }); + + it("returns skipCI: true when tree SHAs match and before is not an ancestor", async () => { + const result = await shouldSkipCI(makeParams()); + + expect(result.skipCI).toBe(true); + expect(result.message).toMatch(/No changes detected/); + }); +}); diff --git a/packages/core/src/application/rebase.test.ts b/packages/core/src/application/rebase.test.ts new file mode 100644 index 0000000..f69c6df --- /dev/null +++ b/packages/core/src/application/rebase.test.ts @@ -0,0 +1,343 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { Deque } from "@datastructures-js/deque"; +import { $ } from "bun"; +import type { Octokit } from "octokit"; +import { PullRequest } from "../models/pull-request.model"; +import type { GitService } from "../services/git.service"; +import type { OctokitService } from "../services/octokit.service"; +import { cascadeRebase } from "./rebase"; + +const LABEL = "pr-stack:auto-rebase"; + +function makeWorkItem( + sourceRef: string, + rebaseOnto: string, + sourceRefSHA = "old-sha", +) { + return { sourceRef, sourceRefSHA, rebaseOnto }; +} + +function makePR( + number: number, + head: string, + base: string, + labels: string[] = [LABEL], +) { + return new PullRequest(number, base, head, labels); +} + +function makeMockGitService(overrides: Partial = {}): GitService { + return { + fetchAndGetSHA: mock(async () => "old-head-sha"), + rebase: mock(async () => ""), + push: mock(async () => {}), + abortRebase: mock(async () => {}), + cloneRepo: mock(async () => {}), + ...overrides, + } as unknown as GitService; +} + +function makeShellError(stderr: string) { + const err = Object.create($.ShellError.prototype); + Object.defineProperty(err, "stderr", { value: Buffer.from(stderr) }); + Object.defineProperty(err, "message", { value: stderr }); + return err as InstanceType; +} + +function makeMockGithubService( + getPRs: (base: string) => PullRequest[], +): OctokitService { + return { + getPullRequestsByBase: mock(async (base: string) => getPRs(base)), + } as unknown as OctokitService; +} + +function makeMockOctokit(updateFn?: () => Promise): Octokit { + return { + rest: { + pulls: { + update: mock(updateFn ?? (async () => {})), + }, + }, + } as unknown as Octokit; +} + +describe("cascadeRebase", () => { + let gitService: GitService; + let octokit: Octokit; + + beforeEach(() => { + gitService = makeMockGitService(); + octokit = makeMockOctokit(); + }); + + it("rebases a single eligible PR", async () => { + const pr = makePR(1, "feat", "main"); + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).toHaveBeenCalledTimes(1); + expect(gitService.push).toHaveBeenCalledTimes(1); + expect(octokit.rest.pulls.update).toHaveBeenCalledTimes(1); + }); + + it("skips PRs without the opt-in label", async () => { + const pr = makePR(1, "feat", "main", []); // no label + const githubService = makeMockGithubService(() => [pr]); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).not.toHaveBeenCalled(); + expect(gitService.push).not.toHaveBeenCalled(); + }); + + it("rebases only labeled PRs when mixed", async () => { + const prs = [ + makePR(1, "feat-a", "main", [LABEL]), + makePR(2, "feat-b", "main", []), + makePR(3, "feat-c", "main", [LABEL]), + ]; + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? prs : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).toHaveBeenCalledTimes(2); + }); + + it("queues dependents of a successfully rebased PR", async () => { + const prA = makePR(1, "feat-a", "main"); + const prB = makePR(2, "feat-b", "feat-a"); + + const githubService = makeMockGithubService((base) => { + if (base === "merged-branch") return [prA]; + if (base === "feat-a") return [prB]; + return []; + }); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).toHaveBeenCalledTimes(2); + }); + + it("does not queue dependents of a failed PR", async () => { + const prA = makePR(1, "feat-a", "main"); + const prB = makePR(2, "feat-b", "feat-a"); + + (gitService.rebase as ReturnType).mockImplementation( + async () => { + throw new Error("conflict"); + }, + ); + + const githubService = makeMockGithubService((base) => { + if (base === "merged-branch") return [prA]; + if (base === "feat-a") return [prB]; + return []; + }); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("#1"); + + // PR B should never be rebased since PR A failed + expect(gitService.rebase).toHaveBeenCalledTimes(1); + // plain Error — not a ShellError — so abortRebase must not be called + expect(gitService.abortRebase).not.toHaveBeenCalled(); + }); + + it("throws with all failed PR numbers after processing all items", async () => { + const pr1 = makePR(1, "feat-a", "main"); + const pr2 = makePR(2, "feat-b", "main"); + + (gitService.rebase as ReturnType).mockImplementation( + async () => { + throw new Error("conflict"); + }, + ); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr1, pr2] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("2 PR(s) failed"); + }); + + it("fails when push throws", async () => { + const pr = makePR(1, "feat", "main"); + (gitService.push as ReturnType).mockImplementation( + async () => { + throw new Error("push rejected"); + }, + ); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("1 PR(s) failed"); + }); + + it("fails when GitHub base update throws", async () => { + const pr = makePR(1, "feat", "main"); + octokit = makeMockOctokit(async () => { + throw new Error("API error"); + }); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("1 PR(s) failed"); + }); + + it("returns immediately for an empty queue", async () => { + const githubService = makeMockGithubService(() => []); + const queue = new Deque<{ + sourceRef: string; + sourceRefSHA: string; + rebaseOnto: string; + }>([]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).not.toHaveBeenCalled(); + }); + + it("drains queue when no dependent PRs are found", async () => { + const githubService = makeMockGithubService(() => []); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).not.toHaveBeenCalled(); + }); + + it("calls abortRebase when rebase throws a ShellError", async () => { + const pr = makePR(1, "feat", "main"); + (gitService.rebase as ReturnType).mockImplementation( + async () => { + throw makeShellError( + "CONFLICT (content): Merge conflict in src/index.ts", + ); + }, + ); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("1 PR(s) failed"); + + expect(gitService.abortRebase).toHaveBeenCalledTimes(1); + }); + + it("throws immediately when fetchAndGetSHA fails (not accumulated)", async () => { + const pr = makePR(1, "feat", "main"); + (gitService.fetchAndGetSHA as ReturnType).mockImplementation( + async () => { + throw new Error("fetch failed"); + }, + ); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("fetch failed"); + + expect(gitService.rebase).not.toHaveBeenCalled(); + }); + + it("cascades rebase through three levels (A → B → C)", async () => { + const prA = makePR(1, "feat-a", "main"); + const prB = makePR(2, "feat-b", "feat-a"); + const prC = makePR(3, "feat-c", "feat-b"); + + const githubService = makeMockGithubService((base) => { + if (base === "merged-branch") return [prA]; + if (base === "feat-a") return [prB]; + if (base === "feat-b") return [prC]; + return []; + }); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await cascadeRebase( + queue, + gitService, + githubService, + octokit, + "owner", + "repo", + ); + + expect(gitService.rebase).toHaveBeenCalledTimes(3); + expect(octokit.rest.pulls.update).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/core/src/services/octokit.service.test.ts b/packages/core/src/services/octokit.service.test.ts new file mode 100644 index 0000000..f09ded1 --- /dev/null +++ b/packages/core/src/services/octokit.service.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { Octokit } from "octokit"; +import { Commit } from "../models/commit.model"; +import { PullRequest } from "../models/pull-request.model"; +import { OctokitService } from "./octokit.service"; + +function makeMockOctokit(overrides: { + getCommit?: ReturnType; + listPulls?: ReturnType; +}): Octokit { + return { + rest: { + git: { + getCommit: + overrides.getCommit ?? + mock(async () => ({ + status: 200, + data: { tree: { sha: "tree-abc" } }, + })), + }, + pulls: { + list: + overrides.listPulls ?? mock(async () => ({ status: 200, data: [] })), + }, + }, + } as unknown as Octokit; +} + +describe("OctokitService.getCommit", () => { + it("returns a Commit with correct SHA and tree SHA on 200", async () => { + const octokit = makeMockOctokit({ + getCommit: mock(async () => ({ + status: 200, + data: { tree: { sha: "tree-abc" } }, + })), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + const result = await service.getCommit("sha-123"); + + expect(result).toBeInstanceOf(Commit); + expect(result?.getSHA()).toBe("sha-123"); + expect(result?.getTreeSHA()).toBe("tree-abc"); + }); + + it("returns null on non-200 status", async () => { + const octokit = makeMockOctokit({ + getCommit: mock(async () => ({ status: 404 })), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + const result = await service.getCommit("sha-404"); + + expect(result).toBeNull(); + }); + + it("returns null when the API throws", async () => { + const octokit = makeMockOctokit({ + getCommit: mock(async () => { + throw new Error("network error"); + }), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + const result = await service.getCommit("sha-err"); + + expect(result).toBeNull(); + }); +}); + +describe("OctokitService.getPullRequestsByBase", () => { + it("maps API response to PullRequest models", async () => { + const octokit = makeMockOctokit({ + listPulls: mock(async () => ({ + status: 200, + data: [ + { + number: 1, + base: { ref: "main" }, + head: { ref: "feat" }, + labels: [{ name: "bug" }], + }, + ], + })), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + const result = await service.getPullRequestsByBase("main", "open"); + expect(result).not.toBeEmpty(); + const first = result[0]; + + expect(first).toBeDefined(); + if (!first) return; // Narrow the type + expect(result).toHaveLength(1); + expect(first).toBeInstanceOf(PullRequest); + expect(first.getNumber()).toBe(1); + expect(first.getBase()).toBe("main"); + expect(first.getHead()).toBe("feat"); + expect(first.getLabels()).toEqual(["bug"]); + }); + + it("returns empty array when no PRs exist", async () => { + const octokit = makeMockOctokit({ + listPulls: mock(async () => ({ status: 200, data: [] })), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + const result = await service.getPullRequestsByBase("main", "open"); + + expect(result).toEqual([]); + }); + + it("maps multiple PRs correctly", async () => { + const octokit = makeMockOctokit({ + listPulls: mock(async () => ({ + status: 200, + data: [ + { + number: 1, + base: { ref: "main" }, + head: { ref: "feat-a" }, + labels: [], + }, + { + number: 2, + base: { ref: "main" }, + head: { ref: "feat-b" }, + labels: [{ name: "pr-stack:auto-rebase" }], + }, + { + number: 3, + base: { ref: "main" }, + head: { ref: "feat-c" }, + labels: [], + }, + ], + })), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + const result = await service.getPullRequestsByBase("main", "open"); + + expect(result).toHaveLength(3); + expect(result.map((pr) => pr.getNumber())).toEqual([1, 2, 3]); + }); + + it("throws when API returns non-200", async () => { + const octokit = makeMockOctokit({ + listPulls: mock(async () => ({ status: 500, data: [] })), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + await expect(service.getPullRequestsByBase("main", "open")).rejects.toThrow( + "500", + ); + }); + + it("throws when pulls.list rejects with a network error", async () => { + const octokit = makeMockOctokit({ + listPulls: mock(async () => { + throw new Error("network error"); + }), + }); + const service = new OctokitService(octokit, "owner", "repo"); + + await expect(service.getPullRequestsByBase("main", "open")).rejects.toThrow( + "network error", + ); + }); +}); From 47a398e9ce7dac2eb695ad9bfe5d09e2ff3bc808 Mon Sep 17 00:00:00 2001 From: mikais13 Date: Wed, 8 Apr 2026 11:56:25 +1200 Subject: [PATCH 7/8] test(core): assert conflict file extraction and failure messages in rebase tests Pin the operator-facing error messages (conflict files, push failure, GitHub API failure) via console.error spy assertions so refactors cannot silently drop them. --- packages/core/src/application/rebase.test.ts | 118 ++++++++++++++++++- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/packages/core/src/application/rebase.test.ts b/packages/core/src/application/rebase.test.ts index f69c6df..9dd87e6 100644 --- a/packages/core/src/application/rebase.test.ts +++ b/packages/core/src/application/rebase.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { Deque } from "@datastructures-js/deque"; import { $ } from "bun"; import type { Octokit } from "octokit"; @@ -203,7 +203,8 @@ describe("cascadeRebase", () => { ).rejects.toThrow("2 PR(s) failed"); }); - it("fails when push throws", async () => { + it("fails when push throws and logs that remote is unchanged", async () => { + const consoleErrorSpy = spyOn(console, "error"); const pr = makePR(1, "feat", "main"); (gitService.push as ReturnType).mockImplementation( async () => { @@ -219,9 +220,18 @@ describe("cascadeRebase", () => { await expect( cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), ).rejects.toThrow("1 PR(s) failed"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringMatching(/git push failed.*Remote is unchanged/), + }), + ); + consoleErrorSpy.mockRestore(); }); - it("fails when GitHub base update throws", async () => { + it("fails when GitHub base update throws and logs that manual update is required", async () => { + const consoleErrorSpy = spyOn(console, "error"); const pr = makePR(1, "feat", "main"); octokit = makeMockOctokit(async () => { throw new Error("API error"); @@ -235,6 +245,16 @@ describe("cascadeRebase", () => { await expect( cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), ).rejects.toThrow("1 PR(s) failed"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringMatching( + /GitHub base update FAILED.*Manual update/s, + ), + }), + ); + consoleErrorSpy.mockRestore(); }); it("returns immediately for an empty queue", async () => { @@ -273,12 +293,16 @@ describe("cascadeRebase", () => { expect(gitService.rebase).not.toHaveBeenCalled(); }); - it("calls abortRebase when rebase throws a ShellError", async () => { + it("calls abortRebase and extracts conflict files (Merge conflict in pattern)", async () => { + const consoleErrorSpy = spyOn(console, "error"); const pr = makePR(1, "feat", "main"); (gitService.rebase as ReturnType).mockImplementation( async () => { throw makeShellError( - "CONFLICT (content): Merge conflict in src/index.ts", + "Auto-merging src/index.ts\n" + + "CONFLICT (content): Merge conflict in src/index.ts\n" + + "CONFLICT (content): Merge conflict in lib/utils.ts\n" + + "Automatic merge failed; fix conflicts and then commit the result.", ); }, ); @@ -293,6 +317,90 @@ describe("cascadeRebase", () => { ).rejects.toThrow("1 PR(s) failed"); expect(gitService.abortRebase).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringContaining("2 file(s)"), + }), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringContaining("src/index.ts"), + }), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringContaining("lib/utils.ts"), + }), + ); + consoleErrorSpy.mockRestore(); + }); + + it("extracts conflict files using modify/delete pattern", async () => { + const consoleErrorSpy = spyOn(console, "error"); + const pr = makePR(1, "feat", "main"); + (gitService.rebase as ReturnType).mockImplementation( + async () => { + throw makeShellError( + "CONFLICT (modify/delete): README.md deleted in HEAD and modified in feat", + ); + }, + ); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("1 PR(s) failed"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringContaining("README.md"), + }), + ); + consoleErrorSpy.mockRestore(); + }); + + it("extracts conflict files from mixed CONFLICT patterns", async () => { + const consoleErrorSpy = spyOn(console, "error"); + const pr = makePR(1, "feat", "main"); + (gitService.rebase as ReturnType).mockImplementation( + async () => { + throw makeShellError( + "CONFLICT (content): Merge conflict in src/app.ts\n" + + "CONFLICT (modify/delete): docs/guide.md deleted in HEAD and modified in feat", + ); + }, + ); + + const githubService = makeMockGithubService((base) => + base === "merged-branch" ? [pr] : [], + ); + const queue = new Deque([makeWorkItem("merged-branch", "main")]); + + await expect( + cascadeRebase(queue, gitService, githubService, octokit, "owner", "repo"), + ).rejects.toThrow("1 PR(s) failed"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringContaining("src/app.ts"), + }), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("FAILED"), + expect.objectContaining({ + message: expect.stringContaining("docs/guide.md"), + }), + ); + consoleErrorSpy.mockRestore(); }); it("throws immediately when fetchAndGetSHA fails (not accumulated)", async () => { From f5b12df7c1f954e1636d1b6ed8ff5146e317ebfe Mon Sep 17 00:00:00 2001 From: mikais13 Date: Wed, 8 Apr 2026 11:56:29 +1200 Subject: [PATCH 8/8] test(core): add startRebases tests Cover the webhook entry point: happy path, clone failure, and cascadeRebase failure. Uses mock.module for getInstallationArtifacts and spyOn for GitService/OctokitService prototype methods, following the ci-check.test.ts pattern. --- packages/core/src/application/rebase.test.ts | 140 ++++++++++++++++++- 1 file changed, 136 insertions(+), 4 deletions(-) diff --git a/packages/core/src/application/rebase.test.ts b/packages/core/src/application/rebase.test.ts index 9dd87e6..9dc2516 100644 --- a/packages/core/src/application/rebase.test.ts +++ b/packages/core/src/application/rebase.test.ts @@ -1,14 +1,33 @@ -import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mock } from "bun:test"; + +// Mock auth before importing the module under test — getInstallationArtifacts +// reads env vars at call time, which are unavailable in tests. +mock.module("../github/auth", () => ({ + getInstallationArtifacts: async () => ({ + octokit: mockOctokit, + token: "fake-token", + }), +})); + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { Deque } from "@datastructures-js/deque"; import { $ } from "bun"; import type { Octokit } from "octokit"; import { PullRequest } from "../models/pull-request.model"; -import type { GitService } from "../services/git.service"; -import type { OctokitService } from "../services/octokit.service"; -import { cascadeRebase } from "./rebase"; +import { GitService } from "../services/git.service"; +import { OctokitService } from "../services/octokit.service"; +import { cascadeRebase, startRebases } from "./rebase"; const LABEL = "pr-stack:auto-rebase"; +const mockOctokit = { + rest: { + pulls: { + update: mock(async () => {}), + }, + }, +}; + function makeWorkItem( sourceRef: string, rebaseOnto: string, @@ -62,6 +81,119 @@ function makeMockOctokit(updateFn?: () => Promise): Octokit { } as unknown as Octokit; } +function makeInput() { + return { + repository: { + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + }, + pull_request: { + number: 1, + state: "open", + title: "My PR", + head: { label: "owner:feat", ref: "feat", sha: "head-sha" }, + base: { label: "owner:main", ref: "main", sha: "base-sha" }, + }, + }; +} + +describe("startRebases", () => { + let cloneRepoSpy: ReturnType>; + let fetchAndGetSHASpy: ReturnType>; + let rebaseSpy: ReturnType>; + let pushSpy: ReturnType>; + let abortRebaseSpy: ReturnType>; + let getPRsByBaseSpy: ReturnType< + typeof spyOn + >; + + afterEach(() => { + cloneRepoSpy.mockRestore(); + fetchAndGetSHASpy.mockRestore(); + rebaseSpy.mockRestore(); + pushSpy.mockRestore(); + abortRebaseSpy.mockRestore(); + getPRsByBaseSpy.mockRestore(); + (mockOctokit.rest.pulls.update as ReturnType).mockReset(); + }); + + it("clones the repo and completes when no dependent PRs are found", async () => { + cloneRepoSpy = spyOn(GitService.prototype, "cloneRepo").mockResolvedValue( + undefined, + ); + fetchAndGetSHASpy = spyOn( + GitService.prototype, + "fetchAndGetSHA", + ).mockResolvedValue("old-sha"); + rebaseSpy = spyOn(GitService.prototype, "rebase").mockResolvedValue(""); + pushSpy = spyOn(GitService.prototype, "push").mockResolvedValue(undefined); + abortRebaseSpy = spyOn( + GitService.prototype, + "abortRebase", + ).mockResolvedValue(undefined); + getPRsByBaseSpy = spyOn( + OctokitService.prototype, + "getPullRequestsByBase", + ).mockResolvedValue([]); + + await startRebases(makeInput()); + + expect(cloneRepoSpy).toHaveBeenCalledTimes(1); + expect(cloneRepoSpy).toHaveBeenCalledWith( + "https://github.com/owner/repo.git", + { bare: false }, + ); + }); + + it("throws when cloneRepo fails", async () => { + cloneRepoSpy = spyOn(GitService.prototype, "cloneRepo").mockRejectedValue( + new Error("clone error"), + ); + fetchAndGetSHASpy = spyOn( + GitService.prototype, + "fetchAndGetSHA", + ).mockResolvedValue("old-sha"); + rebaseSpy = spyOn(GitService.prototype, "rebase").mockResolvedValue(""); + pushSpy = spyOn(GitService.prototype, "push").mockResolvedValue(undefined); + abortRebaseSpy = spyOn( + GitService.prototype, + "abortRebase", + ).mockResolvedValue(undefined); + getPRsByBaseSpy = spyOn( + OctokitService.prototype, + "getPullRequestsByBase", + ).mockResolvedValue([]); + + expect(startRebases(makeInput())).rejects.toThrow("clone error"); + }); + + it("throws when cascadeRebase fails due to a rebase conflict", async () => { + const pr = new PullRequest(2, "feat", "head-sha-pr", [LABEL]); + cloneRepoSpy = spyOn(GitService.prototype, "cloneRepo").mockResolvedValue( + undefined, + ); + fetchAndGetSHASpy = spyOn( + GitService.prototype, + "fetchAndGetSHA", + ).mockResolvedValue("old-sha"); + rebaseSpy = spyOn(GitService.prototype, "rebase").mockRejectedValue( + new Error("conflict"), + ); + pushSpy = spyOn(GitService.prototype, "push").mockResolvedValue(undefined); + abortRebaseSpy = spyOn( + GitService.prototype, + "abortRebase", + ).mockResolvedValue(undefined); + getPRsByBaseSpy = spyOn( + OctokitService.prototype, + "getPullRequestsByBase", + ).mockResolvedValue([pr]); + + expect(startRebases(makeInput())).rejects.toThrow("PR(s) failed"); + }); +}); + describe("cascadeRebase", () => { let gitService: GitService; let octokit: Octokit;