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] 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/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 0000000..780e1ed --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,3 @@ +[test] +preload = ["./src/test-config/preload.ts"] +randomize = true 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/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..9dc2516 --- /dev/null +++ b/packages/core/src/application/rebase.test.ts @@ -0,0 +1,583 @@ +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 { 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, + 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; +} + +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; + + 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 and logs that remote is unchanged", async () => { + const consoleErrorSpy = spyOn(console, "error"); + 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"); + + 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 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"); + }); + + 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.stringMatching( + /GitHub base update FAILED.*Manual update/s, + ), + }), + ); + consoleErrorSpy.mockRestore(); + }); + + 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 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( + "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.", + ); + }, + ); + + 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); + 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 () => { + 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", + ); + }); +}); 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 () => ({}), + }, +})); diff --git a/turbo.json b/turbo.json index b2e595b..dcf86d0 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,7 @@ "types:check": { "dependsOn": ["^types:check"] }, + "test": {}, "lint:check": {}, "lint:fix": {} },