diff --git a/.github/composite/build-image/action.yml b/.github/composite/build-image/action.yml new file mode 100644 index 000000000..cf82223c3 --- /dev/null +++ b/.github/composite/build-image/action.yml @@ -0,0 +1,77 @@ +name: "Build & Upload Docker Image" +description: "Build & (optionally) upload Docker Image to Docker Registry" + +inputs: + GPG_PRIVATE_KEY: + description: "GPG Private Key" + required: true + GPG_PASSPHRASE: + description: "GPG Passphrase" + required: true + DOCKER_UPLOAD: + description: "Boolean indicating whether the image should be uploaded to Docker registry or not." + required: false + default: true + TAG_PREFIX: + description: "Docker tags prefix" + required: false + SERVER_PROFILES: + description: "Profile(s) to apply to Codebloom instance." + required: false + default: prod + +runs: + using: "composite" + steps: + - name: Disable man-db + uses: ./.github/composite/disable-mandb + + - name: Set up pnpm + uses: pnpm/action-setup@master + with: + version: 10.24.0 + cache: true + cache_dependency_path: js/pnpm-lock.yaml + package_json_file: js/package.json + + - name: Set up OpenJDK 25 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "25" + cache: "maven" + + - name: Set up bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('.github/scripts/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install deps + shell: bash + run: bun install --cwd .github/scripts --frozen-lockfile + + - name: Load secrets + uses: ./.github/composite/load-secrets + with: + GPG_PRIVATE_KEY: ${{ inputs.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ inputs.GPG_PASSPHRASE }} + UNLOAD_ENVIRONMENTS: ci,ci-app + + - name: Expose GitHub Runtime + uses: crazy-max/ghaction-github-runtime@v3 + + - name: Run script + shell: bash + run: bun .github/scripts/build-image.ts + env: + DOCKER_UPLOAD: ${{ inputs.DOCKER_UPLOAD }} + TAG_PREFIX: ${{ inputs.TAG_PREFIX }} + SERVER_PROFILES: ${{ inputs.SERVER_PROFILES }} diff --git a/.github/composite/load-secrets/action.yml b/.github/composite/load-secrets/action.yml index 64392f6da..25df02b5b 100644 --- a/.github/composite/load-secrets/action.yml +++ b/.github/composite/load-secrets/action.yml @@ -37,8 +37,24 @@ runs: shell: bash run: git-crypt --version - - name: Run load secrets script + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install deps + shell: bash + run: bun install --cwd .github/scripts --frozen-lockfile + + - name: Run script shell: bash - run: bash .github/scripts/load-secrets.sh + run: bun .github/scripts/load-secrets.ts env: UNLOAD_ENVIRONMENTS: ${{ inputs.UNLOAD_ENVIRONMENTS }} diff --git a/.github/composite/test/backend-pre-test/action.yml b/.github/composite/test/backend-pre-test/action.yml index e58004745..2cfa4c0b6 100644 --- a/.github/composite/test/backend-pre-test/action.yml +++ b/.github/composite/test/backend-pre-test/action.yml @@ -20,6 +20,22 @@ runs: javac -version echo "JAVA_HOME=$JAVA_HOME" + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('.github/scripts/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install deps + shell: bash + run: bun install --cwd .github/scripts --frozen-lockfile + - name: Run script shell: bash - run: bash .github/scripts/run-backend-compile-tests.sh + run: bun .github/scripts/run-backend-compile-tests.ts diff --git a/.github/composite/test/backend-test/action.yml b/.github/composite/test/backend-test/action.yml new file mode 100644 index 000000000..a240ac154 --- /dev/null +++ b/.github/composite/test/backend-test/action.yml @@ -0,0 +1,68 @@ +name: "Backend test" +description: "Run backend tests" + +inputs: + GPG_PRIVATE_KEY: + description: "GPG Private Key" + required: true + GPG_PASSPHRASE: + description: "GPG Passphrase" + required: true + UPLOAD_TEST_COV: + description: "Boolean indicating whether tests should be uploaded to Codecov or not." + required: false + default: true + +runs: + using: "composite" + steps: + - name: Disable man-db + uses: ./.github/composite/disable-mandb + + - name: Set up OpenJDK 25 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "25" + cache: "maven" + + - name: Set up bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('.github/scripts/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install deps + shell: bash + run: bun install --cwd .github/scripts --frozen-lockfile + + - name: Load secrets + uses: ./.github/composite/load-secrets + with: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + UNLOAD_ENVIRONMENTS: ci-app + + - name: Run script + shell: bash + run: bun .github/scripts/run-backend-tests.ts + + - name: Upload JaCoCo HTML report + uses: actions/upload-artifact@v4 + if: ${{ inputs.UPLOAD_TEST_COV == true }} + with: + name: jacoco-report + path: target/site/jacoco/ + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + if: ${{ inputs.UPLOAD_TEST_COV == true }} + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/composite/test/frontend-test/action.yml b/.github/composite/test/frontend-test/action.yml new file mode 100644 index 000000000..b2a1bb37d --- /dev/null +++ b/.github/composite/test/frontend-test/action.yml @@ -0,0 +1,59 @@ +name: "Frontend Test" +description: "Run frontend tests" + +inputs: + GPG_PRIVATE_KEY: + description: "GPG Private Key" + required: true + GPG_PASSPHRASE: + description: "GPG Passphrase" + required: true + +runs: + using: "composite" + steps: + - name: Disable man-db + uses: ./.github/composite/disable-mandb + + - name: Set up pnpm + uses: pnpm/action-setup@master + with: + version: 10.24.0 + cache: true + cache_dependency_path: js/pnpm-lock.yaml + package_json_file: js/package.json + + - name: Set up OpenJDK 25 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "25" + cache: "maven" + + - name: Set up bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('.github/scripts/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install deps + shell: bash + run: bun install --cwd .github/scripts --frozen-lockfile + + - name: Load secrets + uses: ./.github/composite/load-secrets + with: + GPG_PRIVATE_KEY: ${{ inputs.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ inputs.GPG_PASSPHRASE }} + UNLOAD_ENVIRONMENTS: ci-app + + - name: Run script + shell: bash + run: bun .github/scripts/run-frontend-tests.ts diff --git a/.github/scripts/build-image.ts b/.github/scripts/build-image.ts new file mode 100644 index 000000000..7918c768a --- /dev/null +++ b/.github/scripts/build-image.ts @@ -0,0 +1,95 @@ +import { $ } from "bun"; +import { db } from "./fn/run-local-db"; +import { backend } from "./fn/run-backend-instance"; + +process.env.TZ = "America/New_York"; + +const tagPrefix = process.env.TAG_PREFIX || ""; +const shouldDockerUpload = Boolean(process.env.DOCKER_UPLOAD) || false; +const serverProfiles = process.env.SERVER_PROFILES || "prod"; + +const dockerHubPat = process.env.DOCKER_HUB_PAT; +if (!dockerHubPat) { + throw new Error("DOCKER_HUB_PAT is required."); +} + +async function main() { + try { + await db.start(); + await backend.start(); + + await $`corepack enable pnpm`; + await $`pnpm --dir js i -D --frozen-lockfile`; + await $`pnpm --dir js run generate`; + + // copy old tz format from build-image.sh + const timestamp = new Date() + .toLocaleString("en-US", { + timeZone: process.env.TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + .replace(/(\d+)\/(\d+)\/(\d+),\s(\d+):(\d+):(\d+)/, "$3.$1.$2-$4.$5.$6"); + + const gitSha = (await $`git rev-parse --short HEAD`.text()).trim(); + + const tags = [ + `tahminator/codebloom:${tagPrefix}latest`, + `tahminator/codebloom:${tagPrefix}${timestamp}`, + `tahminator/codebloom:${tagPrefix}${gitSha}`, + ]; + + console.log("Building image with following tags:"); + tags.forEach((tag) => console.log(tag)); + + if (dockerHubPat) { + console.log("DOCKER_HUB_PAT found"); + } else { + console.log("DOCKER_HUB_PAT missing or empty"); + } + + await $`echo ${dockerHubPat} | docker login -u tahminator --password-stdin`; + + try { + await $`docker buildx create --use --name codebloom-builder`; + } catch { + await $`docker buildx use codebloom-builder`; + } + + const buildMode = shouldDockerUpload ? "--push" : "--load"; + + const viteStagingArg = + serverProfiles === "stg" ? ["--build-arg", "VITE_STAGING=true"] : []; + + const tagArgs = tags.flatMap((tag) => ["--tag", tag]); + + await $`docker buildx build ${buildMode} \ + --file infra/Dockerfile \ + --build-arg SERVER_PROFILES=${serverProfiles} \ + --build-arg COMMIT_SHA=${gitSha} \ + --cache-from=type=gha \ + --cache-to=type=gha,mode=max \ + ${viteStagingArg} \ + ${tagArgs} \ + .`.quiet(); + + console.log("Image pushed successfully."); + } finally { + await backend.end(); + await db.end(); + } +} + +main() + .then(() => { + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/.github/scripts/bun.lock b/.github/scripts/bun.lock new file mode 100644 index 000000000..6b3eea5d0 --- /dev/null +++ b/.github/scripts/bun.lock @@ -0,0 +1,48 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "scripts", + "dependencies": { + "bun": "^1.3.6", + }, + "devDependencies": { + "@types/bun": "^1.3.6", + }, + }, + }, + "packages": { + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA=="], + + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "bun": ["bun@1.3.6", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.6", "@oven/bun-darwin-x64": "1.3.6", "@oven/bun-darwin-x64-baseline": "1.3.6", "@oven/bun-linux-aarch64": "1.3.6", "@oven/bun-linux-aarch64-musl": "1.3.6", "@oven/bun-linux-x64": "1.3.6", "@oven/bun-linux-x64-baseline": "1.3.6", "@oven/bun-linux-x64-musl": "1.3.6", "@oven/bun-linux-x64-musl-baseline": "1.3.6", "@oven/bun-windows-x64": "1.3.6", "@oven/bun-windows-x64-baseline": "1.3.6" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-Tn98GlZVN2WM7+lg/uGn5DzUao37Yc0PUz7yzYHdeF5hd+SmHQGbCUIKE4Sspdgtxn49LunK3mDNBC2Qn6GJjw=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/.github/scripts/fn/colors.ts b/.github/scripts/fn/colors.ts new file mode 100644 index 000000000..aebe42cbd --- /dev/null +++ b/.github/scripts/fn/colors.ts @@ -0,0 +1,103 @@ +export function black(s: string) { + return `${BLACK}${s}${RESET}`; +} + +export function red(s: string) { + return `${RED}${s}${RESET}`; +} + +export function green(s: string) { + return `${GREEN}${s}${RESET}`; +} + +export function yellow(s: string) { + return `${YELLOW}${s}${RESET}`; +} + +export function blue(s: string) { + return `${BLUE}${s}${RESET}`; +} + +export function magenta(s: string) { + return `${MAGENTA}${s}${RESET}`; +} + +export function cyan(s: string) { + return `${CYAN}${s}${RESET}`; +} + +export function white(s: string) { + return `${WHITE}${s}${RESET}`; +} + +export function gray(s: string) { + return `${GRAY}${s}${RESET}`; +} + +export function brightRed(s: string) { + return `${BRIGHT_RED}${s}${RESET}`; +} + +export function brightGreen(s: string) { + return `${BRIGHT_GREEN}${s}${RESET}`; +} + +export function brightYellow(s: string) { + return `${BRIGHT_YELLOW}${s}${RESET}`; +} + +export function brightBlue(s: string) { + return `${BRIGHT_BLUE}${s}${RESET}`; +} + +export function brightMagenta(s: string) { + return `${BRIGHT_MAGENTA}${s}${RESET}`; +} + +export function brightCyan(s: string) { + return `${BRIGHT_CYAN}${s}${RESET}`; +} + +export function brightWhite(s: string) { + return `${BRIGHT_WHITE}${s}${RESET}`; +} + +export function bold(s: string) { + return `${BOLD}${s}${RESET}`; +} + +export function dim(s: string) { + return `${DIM}${s}${RESET}`; +} + +export function italic(s: string) { + return `${ITALIC}${s}${RESET}`; +} + +export function underline(s: string) { + return `${UNDERLINE}${s}${RESET}`; +} + +const BLACK = "\x1b[30m"; +const RED = "\x1b[31m"; +const GREEN = "\x1b[32m"; +const YELLOW = "\x1b[33m"; +const BLUE = "\x1b[34m"; +const MAGENTA = "\x1b[35m"; +const CYAN = "\x1b[36m"; +const WHITE = "\x1b[37m"; +const GRAY = "\x1b[90m"; + +const BRIGHT_RED = "\x1b[91m"; +const BRIGHT_GREEN = "\x1b[92m"; +const BRIGHT_YELLOW = "\x1b[93m"; +const BRIGHT_BLUE = "\x1b[94m"; +const BRIGHT_MAGENTA = "\x1b[95m"; +const BRIGHT_CYAN = "\x1b[96m"; +const BRIGHT_WHITE = "\x1b[97m"; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const ITALIC = "\x1b[3m"; +const UNDERLINE = "\x1b[4m"; diff --git a/.github/scripts/fn/run-backend-instance.ts b/.github/scripts/fn/run-backend-instance.ts new file mode 100644 index 000000000..c35ae98b4 --- /dev/null +++ b/.github/scripts/fn/run-backend-instance.ts @@ -0,0 +1,74 @@ +import { $ } from "bun"; +import { cyan } from "./colors"; + +let be: Bun.Subprocess<"ignore", Bun.BunFile, "inherit"> | undefined; + +async function start() { + try { + console.log("Starting backend instance..."); + + await $`java -version`; + await $`javac -version`; + console.log(`JAVA_HOME=${process.env.JAVA_HOME}`); + + const logFile = Bun.file("backend.log"); + be = Bun.spawn( + ["./mvnw", "-Dspring-boot.run.profiles=ci", "spring-boot:run"], + { + stdout: logFile, + }, + ); + + console.log("Waiting for backend to become ready."); + + let ready = false; + const attempts = 30; + + for (let i = 1; i <= attempts; i++) { + try { + const response = await fetch("http://localhost:8080/api"); + const data = (await response.json()) as { success: boolean }; + + if (data.success === true) { + console.log("Backend is up!"); + ready = true; + break; + } + } catch (_) {} + + console.log(`Waiting for backend... (${i}/${attempts})`); + await Bun.sleep(2000); + } + + if (!ready) { + console.error("Backend failed to start in time."); + await end(); + process.exit(1); + } + + console.log("backend ready"); + } catch (e) { + console.error(e); + end(); + } +} + +async function end() { + if (be) { + if (!be.killed) { + be.kill(); + } + console.log(cyan("=== BACKEND LOGS ===")); + const logs = await Bun.file("backend.log").text(); + logs + .split("\n") + .filter((s) => s.length > 0) + .forEach((line) => console.log(cyan(line))); + console.log(cyan("=== BACKEND LOGS END ===")); + } +} + +export const backend = { + start, + end, +}; diff --git a/.github/scripts/fn/run-local-db.ts b/.github/scripts/fn/run-local-db.ts new file mode 100644 index 000000000..97298a6ba --- /dev/null +++ b/.github/scripts/fn/run-local-db.ts @@ -0,0 +1,85 @@ +import { $ } from "bun"; +import { brightGreen, brightMagenta } from "./colors"; + +async function start() { + try { + console.log("Starting postgres container..."); + + await $`docker rm -f codebloom-db`; + + await $`docker run -d \ + --name codebloom-db \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=codebloom \ + -p 5440:5432 \ + postgres:16`; + + console.log("Waiting for postgres to become ready."); + + let ready = false; + const attempts = 30; + + for (let i = 1; i <= attempts; i++) { + const check = await $`docker exec codebloom-db pg_isready -U postgres` + .quiet() + .nothrow(); + + if (check.exitCode === 0) { + console.log("postgres is ready!"); + ready = true; + break; + } + + console.log(`Waiting for backend... (${i}/${attempts})`); + await Bun.sleep(2000); + } + + if (!ready) { + console.error("postgres failed to start in time."); + await end(); + process.exit(1); + } + + process.env.DATABASE_HOST = "localhost"; + process.env.DATABASE_PORT = "5440"; + process.env.DATABASE_NAME = "codebloom"; + process.env.DATABASE_USER = "postgres"; + process.env.DATABASE_PASSWORD = "postgres"; + + console.log("postres started, running migrations..."); + + await $`./mvnw flyway:migrate -Dflyway.locations=filesystem:./db`; + + console.log("postgres ready"); + } catch (e) { + console.error(e); + end(); + } +} + +async function end() { + console.log("Stopping and removing postgres container..."); + + console.log(brightMagenta("=== DB LOGS ===")); + const logs = await $`docker logs codebloom-db`.text(); + logs + .split("\n") + .filter((s) => s.length > 0) + .forEach((line) => console.log(brightMagenta(line))); + console.log(brightMagenta("=== DB LOGS END ===")); + + await $`docker stop codebloom-db`.quiet().nothrow(); + await $`docker rm codebloom-db`.quiet().nothrow(); + + delete process.env.DATABASE_HOST; + delete process.env.DATABASE_PORT; + delete process.env.DATABASE_NAME; + delete process.env.DATABASE_USER; + delete process.env.DATABASE_PASSWORD; +} + +export const db = { + start, + end, +}; diff --git a/.github/scripts/load-secrets.sh b/.github/scripts/load-secrets.sh deleted file mode 100644 index 860495abf..000000000 --- a/.github/scripts/load-secrets.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -git-crypt unlock - -# UNLOAD_ENVIRONMENTS="prod,staging,dev" -IFS=',' read -ra ENVS <<<"${UNLOAD_ENVIRONMENTS:-}" - -declare -A LOADED - -for v in "${ENVS[@]}"; do - ENV_FILE=".env.${v}" - if [[ -f "$ENV_FILE" ]]; then - echo "Loading $ENV_FILE" - declare -A BEFORE - for VAR in $(compgen -v); do - BEFORE["$VAR"]=1 - done - - source "$ENV_FILE" - - for VAR in $(compgen -v); do - if [[ -z "${BEFORE["$VAR"]:-}" ]]; then - LOADED["$VAR"]=1 - fi - done - else - echo "Warning: $ENV_FILE not found" - fi -done - -EXCLUDED_VARS=( - "PATH" - "HOME" - "PWD" - "SHELL" - "USER" - "DEBUG" - "LOG_LEVEL" - "CI" - "JAVA_HOME" -) - -for VAR in "${!LOADED[@]}"; do - VALUE="${!VAR-}" - - if [[ "$VAR" == "VAR" ]]; then # weird bug - continue - fi - - echo "$VAR=$VALUE" >>"$GITHUB_ENV" - - for EX in "${EXCLUDED_VARS[@]}"; do - if [[ "$VAR" == "$EX" ]]; then - echo "Not masking $VAR: Excluded" - continue 2 - fi - done - - if [[ "$VALUE" == "true" || "$VALUE" == "false" || -z "$VALUE" ]]; then - echo "Not masking $VAR: true/false/empty value" - continue - fi - - echo "Masking $VAR" - echo "::add-mask::$VALUE" -done diff --git a/.github/scripts/load-secrets.ts b/.github/scripts/load-secrets.ts new file mode 100644 index 000000000..3da1aba85 --- /dev/null +++ b/.github/scripts/load-secrets.ts @@ -0,0 +1,96 @@ +import { $ } from "bun"; + +// UNLOAD_ENVIRONMENTS="prod,staging,dev" +const unloadEnvironments = process.env.UNLOAD_ENVIRONMENTS || ""; + +const excludedVars = [ + "PATH", + "HOME", + "PWD", + "SHELL", + "USER", + "DEBUG", + "LOG_LEVEL", + "CI", + "JAVA_HOME", +]; + +async function main() { + await $`git-crypt unlock`; + + const envs = unloadEnvironments + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + + const loaded = new Map(); + + for (const env of envs) { + const envFile = Bun.file(`.env.${env}`); + if (await envFile.exists()) { + console.log(`Loading ${envFile.name}`); + + const content = await envFile.text(); + const lines = content.split("\n").filter((s) => s.length > 0); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const match = trimmed.split("=").filter((s) => s.length > 0); + if (match.length === 2) { + const [key, value] = match; + const cleanKey = key.trim(); + let cleanValue = value.trim(); + if ( + (cleanValue.startsWith('"') && cleanValue.endsWith('"')) || + (cleanValue.startsWith("'") && cleanValue.endsWith("'")) + ) { + cleanValue = cleanValue.slice(1, -1); + } + + if (!loaded.has(cleanKey)) { + loaded.set(cleanKey, cleanValue); + } + } + } + } else { + console.log(`Warning: ${envFile} not found`); + } + } + + const githubEnv = process.env.GITHUB_ENV; + if (!githubEnv) { + console.log("Warning: GITHUB_ENV not set, skipping variable export"); + return; + } + + const githubEnvFileWriter = Bun.file(githubEnv).writer(); + + for (const [varName, value] of loaded.entries()) { + githubEnvFileWriter.write(`${varName}=${value}\n`); + if (excludedVars.includes(varName)) { + console.log(`Not masking ${varName}: Excluded`); + continue; + } + + if (value === "true" || value === "false" || value === "") { + console.log(`Not masking ${varName}: true/false/empty value`); + continue; + } + + console.log(`Masking ${varName}`); + console.log(`::add-mask::${value}`); + } + + githubEnvFileWriter.flush(); +} + +main() + .then(() => { + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/.github/scripts/local-db.sh b/.github/scripts/local-db.sh deleted file mode 100644 index 2decab01d..000000000 --- a/.github/scripts/local-db.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash - -db_cleanup() { - echo "Stopping and removing postgres container..." - docker stop codebloom-db >/dev/null 2>&1 || true - docker rm codebloom-db >/dev/null 2>&1 || true - - unset DATABASE_HOST - unset DATABASE_PORT - unset DATABASE_NAME - unset DATABASE_USER - unset DATABASE_PASSWORD -} - -db_startup() { - echo "Starting postgres container..." - docker rm -f codebloom-db >/dev/null 2>&1 || true - docker run -d \ - --name codebloom-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=codebloom \ - -p 5440:5432 \ - postgres:16 - - echo "Waiting for postgres to become ready." - for i in {1..30}; do - if docker exec codebloom-db pg_isready -U postgres >/dev/null 2>&1; then - echo "postgres is ready!" - break - fi - echo "Waiting for postgres, sleep 2... ($i/30)" - sleep 2 - done - - if ! docker exec codebloom-db pg_isready -U postgres >/dev/null 2>&1; then - echo "postgres failed to start in time." - docker logs codebloom-db || true - exit 1 - fi - - export DATABASE_HOST=localhost - export DATABASE_PORT=5440 - export DATABASE_NAME=codebloom - export DATABASE_USER=postgres - export DATABASE_PASSWORD=postgres - - echo "postgres ready. migrating now..." - ./mvnw flyway:migrate -Dflyway.locations=filesystem:./db - echo "postgres migration complete" - -} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 000000000..c5c7a8a5a --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,15 @@ +{ + "name": "scripts", + "version": "1.0.0", + "description": "CodeBloom CI Scripts", + "scripts": {}, + "keywords": [], + "author": "Tahmid Ahmed", + "license": "MIT", + "dependencies": { + "bun": "^1.3.6" + }, + "devDependencies": { + "@types/bun": "^1.3.6" + } +} diff --git a/.github/scripts/run-backend-compile-tests.sh b/.github/scripts/run-backend-compile-tests.sh deleted file mode 100644 index 1f9e29aa7..000000000 --- a/.github/scripts/run-backend-compile-tests.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -RED='\033[0;31m' - -# fmt -./mvnw spotless:check - -# lint -./mvnw checkstyle:check - -# compile -./mvnw -B verify -Dmaven.test.skip=true --no-transfer-progress diff --git a/.github/scripts/run-backend-compile-tests.ts b/.github/scripts/run-backend-compile-tests.ts new file mode 100644 index 000000000..a2aad8a60 --- /dev/null +++ b/.github/scripts/run-backend-compile-tests.ts @@ -0,0 +1,21 @@ +import { $ } from "bun"; + +async function main() { + // fmt + await $`./mvnw spotless:check`; + + // lint + await $`./mvnw checkstyle:check`; + + // compile + await $`./mvnw -B verify -Dmaven.test.skip=true --no-transfer-progress`; +} + +main() + .then(() => { + process.exit(); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/.github/scripts/run-backend-instance.sh b/.github/scripts/run-backend-instance.sh deleted file mode 100644 index 5c9a5549a..000000000 --- a/.github/scripts/run-backend-instance.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -backend_cleanup() { - echo "[INFO] ===== BACKEND LOGS START =====" - cat backend.log || echo "[WARN] backend.log not found" - echo "[INFO] ===== BACKEND LOGS END =====" - if kill $(cat spring_pid.txt) >/dev/null 2>&1; then - echo "Backend process killed successfully." - else - echo "Backend was not running or already stopped." - fi -} - -backend_startup() { - java -version - javac -version - echo "JAVA_HOME=$JAVA_HOME" - - ./mvnw -Dspring-boot.run.profiles=ci spring-boot:run >backend.log 2>&1 & - echo $! >spring_pid.txt - - backend_started=false - for i in {1..30}; do - if curl -s http://localhost:8080/api | grep -q '"success":true'; then - echo "Backend is up!" - backend_started=true - break - fi - echo "Waiting for backend... ($i/30)" - sleep 5 - done - - if [ "$backend_started" = false ]; then - echo "Backend failed to start in time." - exit 1 - fi - -} diff --git a/.github/scripts/run-backend-tests.sh b/.github/scripts/run-backend-tests.sh deleted file mode 100644 index cd828e5c8..000000000 --- a/.github/scripts/run-backend-tests.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$DIR/local-db.sh" - -trap db_cleanup EXIT - -java -version -javac -version -echo "JAVA_HOME=$JAVA_HOME" - -./mvnw -B install -D skipTests --no-transfer-progress - -./mvnw -B exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install-deps" -./mvnw -B exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install firefox" - -corepack enable pnpm -cd email -pnpm i --frozen-lockfile -./email.sh -cd .. - -db_startup - -./mvnw clean verify -Dspring.profiles.active=ci diff --git a/.github/scripts/run-backend-tests.ts b/.github/scripts/run-backend-tests.ts new file mode 100644 index 000000000..0c2389d98 --- /dev/null +++ b/.github/scripts/run-backend-tests.ts @@ -0,0 +1,29 @@ +import { $ } from "bun"; +import { db } from "./fn/run-local-db"; + +async function main() { + try { + await db.start(); + + await $`./mvnw -B install -D skipTests --no-transfer-progress`; + + await $`./mvnw -B exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install-deps"`; + await $`./mvnw -B exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install firefox"`; + + await $`corepack enable pnpm`; + await $`cd email && pnpm i --frozen-lockfile && ./email.sh && cd ..`; + + await $`./mvnw clean verify -Dspring.profiles.active=ci`; + } finally { + await db.end(); + } +} + +main() + .then(() => { + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/.github/scripts/run-frontend-tests.sh b/.github/scripts/run-frontend-tests.sh deleted file mode 100644 index 84723f160..000000000 --- a/.github/scripts/run-frontend-tests.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$DIR/local-db.sh" -source "$DIR/run-backend-instance.sh" -trap 'backend_cleanup; db_cleanup' EXIT - -db_startup -backend_startup - -corepack enable pnpm -pnpm --dir js i --frozen-lockfile -pnpm --dir js run generate -pnpm --dir js run test diff --git a/.github/scripts/run-frontend-tests.ts b/.github/scripts/run-frontend-tests.ts new file mode 100644 index 000000000..fee5175f4 --- /dev/null +++ b/.github/scripts/run-frontend-tests.ts @@ -0,0 +1,27 @@ +import { $ } from "bun"; +import { db } from "./fn/run-local-db"; +import { backend } from "./fn/run-backend-instance"; + +async function main() { + try { + await db.start(); + await backend.start(); + + await $`corepack enable pnpm`; + await $`pnpm --dir js i --frozen-lockfile`; + await $`pnpm --dir js run generate`; + await $`pnpm --dir js run test`; + } finally { + await backend.end(); + await db.end(); + } +} + +main() + .then(() => { + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/.github/scripts/tsconfig.json b/.github/scripts/tsconfig.json new file mode 100644 index 000000000..c3c57e20d --- /dev/null +++ b/.github/scripts/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 800956041..06bf9e4b3 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -103,6 +103,9 @@ jobs: pr_reviewer.enable_review_labels_effort: "false" pr_reviewer.final_update_message: "false" pr_description.publish_description_as_comment: "true" + pr_description.extra_instructions: | + Below is some additional context for the PR from the connected Notion task: + ${{ steps.notion_check.outputs.context }} pr_description.publish_description_as_comment_persistent: "false" pr_description.publish_labels: "false" pr_description.add_original_user_description: "false" @@ -112,8 +115,23 @@ jobs: pr_code_suggestions.enable_help_text: "false" pr_code_suggestions.enable_chat_text: "false" pr_code_suggestions.persistent_comment: "false" - pr_code_suggestions.max_history_len: "4" + pr_code_suggestions.max_history_len: "10" pr_code_suggestions.publish_output_no_suggestions: "true" + pr_code_suggestions.extra_instructions: | + Scrutinize this PR in the context of the following Notion task: + ${{ steps.notion_check.outputs.context }} + + Additional review guidelines: + - Verify that the implementation matches the acceptance criteria from the Notion task + - Check for proper error handling and edge cases + - Ensure code follows existing patterns and architecture in the codebase + - Look for potential security vulnerabilities or data validation issues + - Verify that database migrations (if any) are reversible and safe + - Check for proper logging and monitoring considerations + - Ensure API endpoints follow RESTful conventions and existing API patterns + - Verify that environment variables and secrets are handled securely + - Check for potential performance bottlenecks or N+1 query issues + - Ensure proper test coverage for critical paths pr_code_suggestions.apply_suggestions_checkbox: "true" pr_code_suggestions.suggestions_score_threshold: "0" pr_code_suggestions.new_score_mechanism: "true" diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 20c2280f8..44e73da23 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -25,7 +25,7 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - - name: Run backend pre test + - name: Run workflow uses: ./.github/composite/test/backend-pre-test backendTests: @@ -37,47 +37,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - - name: Disable man-db - uses: ./.github/composite/disable-mandb - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Set up OpenJDK 25 - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "25" - cache: "maven" - - - name: Verify Java version - run: | - java -version - javac -version - echo "JAVA_HOME=$JAVA_HOME" - - - name: Load secrets - uses: ./.github/composite/load-secrets + - name: Run workflow + uses: ./.github/composite/test/backend-test with: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - UNLOAD_ENVIRONMENTS: ci,ci-app - - - name: Run script - run: bash .github/scripts/run-backend-tests.sh - - - name: Upload JaCoCo HTML report - uses: actions/upload-artifact@v4 - with: - name: jacoco-report - path: target/site/jacoco/ - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} frontendTests: name: Frontend Tests @@ -88,40 +52,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - - name: Disable man-db - uses: ./.github/composite/disable-mandb - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Set up OpenJDK 25 - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "25" - cache: "maven" - - - name: Verify Java version - run: | - java -version - javac -version - echo "JAVA_HOME=$JAVA_HOME" - - - name: Fix a bug with corepack by installing corepack globally - run: npm i -g corepack@latest - working-directory: js - - - name: Load secrets - uses: ./.github/composite/load-secrets + - name: Run workflow + uses: ./.github/composite/test/frontend-test with: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - UNLOAD_ENVIRONMENTS: ci-app - - - name: Run script - run: bash .github/scripts/run-frontend-tests.sh testBuildImage: name: Build Test Docker Image @@ -134,35 +69,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Disable man-db - uses: ./.github/composite/disable-mandb - - - name: Set up OpenJDK 25 - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "25" - cache: "maven" - - - name: Verify Java version - run: | - java -version - javac -version - echo "JAVA_HOME=$JAVA_HOME" - - - name: Expose GitHub Runtime - uses: crazy-max/ghaction-github-runtime@v3 - - - name: Load secrets - uses: ./.github/composite/load-secrets + - name: Run workflow + uses: ./.github/composite/build-image with: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - UNLOAD_ENVIRONMENTS: ci,ci-app - - - name: Run script - run: bash .github/scripts/build-image.sh - env: + TAG_PREFIX: test- DOCKER_UPLOAD: false validateDBSchema: @@ -211,34 +123,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Disable man-db - uses: ./.github/composite/disable-mandb - - - name: Set up OpenJDK 25 - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "25" - cache: "maven" - - - name: Verify Java version - run: | - java -version - javac -version - echo "JAVA_HOME=$JAVA_HOME" - - - name: Load secrets - uses: ./.github/composite/load-secrets + - name: Run workflow + uses: ./.github/composite/build-image with: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - UNLOAD_ENVIRONMENTS: ci,ci-app - - - name: Expose GitHub Runtime - uses: crazy-max/ghaction-github-runtime@v3 - - name: Run script - run: bash .github/scripts/build-image.sh redeploy: name: Redeploy on DigitalOcean diff --git a/src/main/java/org/patinanetwork/codebloom/scheduled/auth/LeetcodeAuthStealer.java b/src/main/java/org/patinanetwork/codebloom/scheduled/auth/LeetcodeAuthStealer.java index 0a0178936..6ecb3a20b 100644 --- a/src/main/java/org/patinanetwork/codebloom/scheduled/auth/LeetcodeAuthStealer.java +++ b/src/main/java/org/patinanetwork/codebloom/scheduled/auth/LeetcodeAuthStealer.java @@ -163,7 +163,6 @@ public String getCsrf() { } String stealCookieImpl() { - LOCK.writeLock().lock(); try (Playwright playwright = Playwright.create(); Browser browser = playwright .firefox() @@ -273,10 +272,7 @@ String stealCookieImpl() { } else { log.info("Should be authenticated but not authenticated."); } - } finally { - LOCK.writeLock().unlock(); } - return null; } }