Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'))\"",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[test]
preload = ["./src/test-config/preload.ts"]
randomize = true
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
160 changes: 160 additions & 0 deletions packages/core/src/application/ci-check.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOn<OctokitService, "getCommit">>;
let cloneRepoSpy: ReturnType<typeof spyOn<GitService, "cloneRepo">>;
let traverseToSHASpy: ReturnType<typeof spyOn<GitService, "traverseToSHA">>;

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/);
});
});
Loading
Loading