From 9d9a5e1d71908f5c656eb6fb4c5fdaa9567b69ee Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 8 Apr 2026 08:45:07 -0400 Subject: [PATCH 1/6] Updated release messaging behavior --- .dockerignore | 2 + .github/workflows/ci.yml | 10 +- .github/workflows/deploy.yml | 4 + README.md | 28 +- package-lock.json | 542 +++++++++++++++++- packages/backend/Dockerfile | 8 +- packages/backend/package.json | 1 + .../backend/scripts/write-release-metadata.js | 55 ++ packages/backend/src/ai/ai.service.spec.ts | 19 +- packages/backend/src/ai/ai.service.ts | 180 +++++- .../src/shared/services/web/web.service.ts | 25 + 11 files changed, 836 insertions(+), 38 deletions(-) create mode 100644 packages/backend/scripts/write-release-metadata.js diff --git a/.dockerignore b/.dockerignore index 80da067e..dd6e8c69 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ node_modules npm-debug.log images .git +!.git +!.git/** .github coverage packages/**/coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b89a3b..340364ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,5 +157,13 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Build Docker image - run: docker build -f packages/backend/Dockerfile -t muzzle:ci . + run: | + PREVIOUS_RELEASE_SHA=$(git rev-parse HEAD^ 2>/dev/null || true) + docker build \ + --build-arg PREVIOUS_RELEASE_SHA="$PREVIOUS_RELEASE_SHA" \ + -f packages/backend/Dockerfile \ + -t muzzle:ci \ + . diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e3ba0974..1fd854c8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,6 +24,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -38,6 +40,8 @@ jobs: context: . file: packages/backend/Dockerfile push: true + build-args: | + PREVIOUS_RELEASE_SHA=${{ github.event.before }} # Always push :latest so the Linode deploy script can reference a stable tag. # The SHA tag gives you an immutable rollback point. tags: | diff --git a/README.md b/README.md index 32e2b660..9bdaabe8 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,11 @@ npm run build:frontend ```bash # Build backend Docker image -docker build -f packages/backend/Dockerfile -t mocker-backend:latest . +docker build \ + --build-arg PREVIOUS_RELEASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || true)" \ + -f packages/backend/Dockerfile \ + -t mocker-backend:latest \ + . # Run Docker container docker run -p 3000:3000 \ @@ -290,17 +294,17 @@ The script requires `bash`, `curl`, `grep`, `mktemp`, and `tr`. It reads environ From the root directory, you can run: -| Command | Description | -| --------------------------------------------------------- | ------------------------------------ | -| `npm run start` | Start the backend development server | -| `npm run start:prod` | Start the backend in production mode | -| `npm run build` | Build all workspaces | -| `npm run build:backend` | Build only the backend | -| `npm run test` | Run tests across all workspaces | -| `npm run test:backend` | Run tests for the backend only | -| `npm run lint` | Lint all packages | -| `npm run lint:fix` | Lint and auto-fix issues | -| `docker build -f packages/backend/Dockerfile -t muzzle .` | Build the backend Docker image | +| Command | Description | +| --------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------- | ------------------------------ | +| `npm run start` | Start the backend development server | +| `npm run start:prod` | Start the backend in production mode | +| `npm run build` | Build all workspaces | +| `npm run build:backend` | Build only the backend | +| `npm run test` | Run tests across all workspaces | +| `npm run test:backend` | Run tests for the backend only | +| `npm run lint` | Lint all packages | +| `npm run lint:fix` | Lint and auto-fix issues | +| `docker build --build-arg PREVIOUS_RELEASE_SHA="$(git rev-parse HEAD^ 2>/dev/null | | true)" -f packages/backend/Dockerfile -t muzzle .` | Build the backend Docker image | You can also run workspace-specific commands using: diff --git a/package-lock.json b/package-lock.json index 03a55800..dbd7be31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -862,6 +862,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1512,6 +1522,471 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@ioredis/commands": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", @@ -5479,6 +5954,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -10730,7 +11214,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10855,6 +11338,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11897,6 +12424,12 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12098,12 +12631,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/typeorm/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/typeorm/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -13081,6 +13608,7 @@ "openai": "^4.103.0", "sentence-splitter": "^5.0.0", "sentiment": "^5.0.2", + "sharp": "^0.34.5", "typeorm": "^0.3.22", "uuid": "^9.0.1", "winston": "^3.17.0" diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index d87f8b23..4fbd08c3 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,17 +1,22 @@ # Build from repo root: docker build -f packages/backend/Dockerfile . FROM node:20-alpine AS build WORKDIR /usr/src/app +ARG PREVIOUS_RELEASE_SHA # Copy workspace and backend files required for compilation and minification. COPY package.json package-lock.json ./ COPY tsconfig.base.json ./ +COPY .git ./.git COPY packages/backend/package.json ./packages/backend/ +COPY packages/backend/scripts ./packages/backend/scripts COPY packages/backend/tsconfig.json ./packages/backend/ COPY packages/backend/tsconfig.prod.json ./packages/backend/ COPY packages/backend/src ./packages/backend/src # Build backend artifact inside Docker. -RUN npm ci \ +RUN apk add --no-cache git \ + && npm ci \ + && PREVIOUS_RELEASE_SHA="$PREVIOUS_RELEASE_SHA" node ./packages/backend/scripts/write-release-metadata.js ./packages/backend/release-metadata.json \ && npm run build:prod -w @mocker/backend \ && npm prune --omit=dev @@ -24,6 +29,7 @@ WORKDIR /usr/src/app # Copy backend build artifacts. Runtime-generated images are written under /tmp. COPY --from=build --chown=65532:65532 /usr/src/app/packages/backend/dist ./dist +COPY --from=build --chown=65532:65532 /usr/src/app/packages/backend/release-metadata.json ./release-metadata.json COPY --from=build --chown=65532:65532 /usr/src/app/node_modules ./node_modules EXPOSE 80 diff --git a/packages/backend/package.json b/packages/backend/package.json index e976aa74..2ed16bbd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -38,6 +38,7 @@ "openai": "^4.103.0", "sentence-splitter": "^5.0.0", "sentiment": "^5.0.2", + "sharp": "^0.34.5", "typeorm": "^0.3.22", "uuid": "^9.0.1", "winston": "^3.17.0" diff --git a/packages/backend/scripts/write-release-metadata.js b/packages/backend/scripts/write-release-metadata.js new file mode 100644 index 00000000..a3282736 --- /dev/null +++ b/packages/backend/scripts/write-release-metadata.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const outputPath = process.argv[2] || path.join(process.cwd(), 'release-metadata.json'); + +const runGit = (args) => { + try { + return execFileSync('git', args, { + cwd: process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +}; + +const normalizeSha = (value) => { + if (!value) { + return null; + } + + const trimmed = value.trim(); + if (!trimmed || /^0+$/.test(trimmed)) { + return null; + } + + return trimmed; +}; + +const currentSha = normalizeSha(runGit(['rev-parse', 'HEAD'])); +const previousSha = normalizeSha(process.env.PREVIOUS_RELEASE_SHA) || normalizeSha(runGit(['rev-parse', 'HEAD^'])); +const rawLog = previousSha + ? runGit(['log', '--format=%H\t%s', `${previousSha}..HEAD`]) + : runGit(['log', '--format=%H\t%s', '-n', '8']); + +const commits = rawLog + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha, subject] = line.split('\t'); + return sha && subject ? { sha, subject } : null; + }) + .filter(Boolean); + +const metadata = { + currentSha, + previousSha, + commits, +}; + +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 64bff781..9a466567 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -49,6 +49,7 @@ const buildAiService = (): AIService => { ai.webService = { sendMessage: jest.fn().mockResolvedValue({ ok: true }), + setProfilePhoto: jest.fn().mockResolvedValue({ ok: true }), } as unknown as AIService['webService']; ai.slackService = { @@ -193,21 +194,33 @@ describe('AIService', () => { }); describe('redeployMoonbeam', () => { - it('publishes deployment message with quote and image', async () => { + it('publishes deployment message with quote, changelog, and profile photo update', async () => { (aiService.openAi.responses.create as jest.Mock).mockResolvedValue({ output: [{ type: 'message', content: [{ type: 'output_text', text: 'A quote' }] }], }); (aiService.gemini.models.generateContent as jest.Mock).mockResolvedValue({ candidates: [{ content: { parts: [{ inlineData: { data: Buffer.from('image').toString('base64') } }] } }], }); - jest.spyOn(aiService, 'writeToDiskAndReturnUrl').mockResolvedValue('https://muzzle.lol/deploy.png'); + jest + .spyOn(aiService as never, 'getMoonbeamReleaseChangelog' as never) + .mockResolvedValue('*Release changelog*\n- tightened auth'); + + const diskSpy = jest + .spyOn(aiService as never, 'writeImageBufferToDiskAndReturnUrl' as never) + .mockResolvedValue('https://muzzle.lol/deploy.png'); await aiService.redeployMoonbeam(); + expect(diskSpy).toHaveBeenCalled(); + expect(aiService.webService.setProfilePhoto).toHaveBeenCalledWith(expect.any(Buffer)); expect(aiService.webService.sendMessage).toHaveBeenCalledWith( '#muzzlefeedback', 'Moonbeam has been deployed.', - expect.any(Array), + expect.arrayContaining([ + expect.objectContaining({ type: 'image', image_url: 'https://muzzle.lol/deploy.png' }), + expect.objectContaining({ type: 'markdown', text: '"A quote"' }), + expect.objectContaining({ type: 'markdown', text: '*Release changelog*\n- tightened auth' }), + ]), ); }); }); diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index c033f25f..8a32c575 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -1,5 +1,7 @@ import path from 'path'; import fs from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { v4 as uuidv4 } from 'uuid'; import type { MessageWithName } from '../shared/models/message/message-with-name'; import { HistoryPersistenceService } from '../shared/services/history.persistence.service'; @@ -37,6 +39,7 @@ import type { } from 'openai/resources/responses/responses'; import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; +import sharp from 'sharp'; interface ExtractionResult { slackId: string; @@ -45,6 +48,19 @@ interface ExtractionResult { existingMemoryId: number | null; } +interface ReleaseCommit { + sha: string; + subject: string; +} + +interface ReleaseMetadata { + currentSha: string | null; + previousSha: string | null; + commits: ReleaseCommit[]; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + const isResponseOutputMessage = (block: ResponseOutputItem): block is ResponseOutputMessage => block.type === 'message'; const isResponseOutputText = (block: ResponseOutputText | ResponseOutputRefusal): block is ResponseOutputText => @@ -57,6 +73,25 @@ const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): str }; const DEFAULT_IMAGE_DIR = path.join('/tmp', 'mocker-images'); +const execFileAsync = promisify(execFile); +const RELEASE_METADATA_PATHS = [ + path.resolve(process.cwd(), 'release-metadata.json'), + path.resolve(process.cwd(), 'packages/backend/release-metadata.json'), + path.resolve(__dirname, '..', 'release-metadata.json'), + path.resolve(__dirname, '..', '..', 'release-metadata.json'), +]; +const normalizeReleaseSha = (value?: string): string | null => { + if (!value) { + return null; + } + + const trimmed = value.trim(); + if (!trimmed || /^0+$/.test(trimmed)) { + return null; + } + + return trimmed; +}; export class AIService { redis = new AIPersistenceService(); @@ -132,14 +167,18 @@ export class AIService { } public async writeToDiskAndReturnUrl(base64Image: string): Promise { + const base64Data = base64Image.replace(/^data:image\/png;base64,/, ''); + return this.writeImageBufferToDiskAndReturnUrl(Buffer.from(base64Data, 'base64')); + } + + private async writeImageBufferToDiskAndReturnUrl(imageBytes: Buffer): Promise { const dir = process.env.IMAGE_DIR ?? DEFAULT_IMAGE_DIR; const filename = `${uuidv4()}.png`; const filePath = path.join(dir, filename); - const base64Data = base64Image.replace(/^data:image\/png;base64,/, ''); try { await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(filePath, base64Data, 'base64'); + await fs.promises.writeFile(filePath, imageBytes); return `https://muzzle.lol/images/${filename}`; } catch (error) { logError(this.aiServiceLogger, 'Failed to write AI image to disk', error, { @@ -176,13 +215,13 @@ export class AIService { let imageBytes = Buffer.from([]); if (!response.candidates || response.candidates.length === 0) { this.aiServiceLogger.warn('No candidates in Gemini response'); - return ''; + return Buffer.from([]); } const parts = response.candidates[0].content?.parts; if (!parts || parts.length === 0) { this.aiServiceLogger.warn('No parts in first candidate'); - return ''; + return Buffer.from([]); } parts.forEach((part: Part) => { @@ -193,23 +232,27 @@ export class AIService { } }); - return imageBytes.toString('base64'); - }) - .then(async (x) => { - if (x) { - return this.writeToDiskAndReturnUrl(x); - } else { + if (imageBytes.length === 0) { const error = new Error(`No b64_json was returned for prompt: ${REDPLOY_MOONBEAM_IMAGE_PROMPT}`); logError(this.aiServiceLogger, 'Gemini redeploy image generation returned no image data', error, { prompt: REDPLOY_MOONBEAM_IMAGE_PROMPT, }); throw error; } + + return imageBytes; }); - return Promise.all([aiImage, aiQuote]) - .then((results) => { - const [imageUrl, quote] = results; + const releaseChangelog = this.getMoonbeamReleaseChangelog(); + + return Promise.all([aiImage, aiQuote, releaseChangelog]) + .then(async (results) => { + const [imageBytes, quote, changelog] = results; + const [imageUrl] = await Promise.all([ + this.writeImageBufferToDiskAndReturnUrl(imageBytes), + this.updateMoonbeamProfilePhoto(imageBytes), + ]); + this.aiServiceLogger.info('Redeploy Moonbeam - generated quote and image successfully'); this.aiServiceLogger.info('Redeploy Moonbeam - quote:', quote); this.aiServiceLogger.info('Redeploy Moonbeam - image URL:', imageUrl); @@ -220,6 +263,13 @@ export class AIService { alt_text: 'Moonbeam has been deployed.', }, { type: 'markdown', text: quote ? `"${quote}"` : '' }, + { + type: 'divider', + }, + { + type: 'markdown', + text: changelog, + }, ]; void this.webService.sendMessage('#muzzlefeedback', 'Moonbeam has been deployed.', blocks); }) @@ -594,12 +644,114 @@ export class AIService { const participantSlackIds = this.extractParticipantSlackIds(historyMessages, { excludeSlackIds: [MOONBEAM_SLACK_ID], }); - if (participantSlackIds.length === 0) return; await this.extractMemories(teamId, channelId, history, participantSlackIds); } + private async updateMoonbeamProfilePhoto(imageBytes: Buffer): Promise { + const profileImage = await sharp(imageBytes) + .resize(512, 512, { fit: 'cover', position: 'centre' }) + .png() + .toBuffer(); + await this.webService.setProfilePhoto(profileImage); + } + + private async getMoonbeamReleaseChangelog(): Promise { + const metadata = (await this.readReleaseMetadataFromDisk()) ?? (await this.readReleaseMetadataFromGit()); + if (!metadata || metadata.commits.length === 0) { + return '*Release changelog*\n- Changelog unavailable for this deployment.'; + } + + const comparison = metadata.previousSha + ? `Changes since ${metadata.previousSha.slice(0, 7)}:` + : 'Recent shipped changes:'; + const lines = metadata.commits.slice(0, 8).map((commit) => `- ${commit.subject}`); + return ['*Release changelog*', comparison, ...lines].join('\n'); + } + + private async readReleaseMetadataFromDisk(): Promise { + for (const candidatePath of RELEASE_METADATA_PATHS) { + try { + const raw = await fs.promises.readFile(candidatePath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + const metadata = this.parseReleaseMetadata(parsed); + if (metadata) { + return metadata; + } + } catch { + continue; + } + } + + return null; + } + + private async readReleaseMetadataFromGit(): Promise { + try { + const currentSha = normalizeReleaseSha( + (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd() })).stdout, + ); + if (!currentSha) { + return null; + } + + const previousSha = + normalizeReleaseSha(process.env.PREVIOUS_RELEASE_SHA) ?? + (await execFileAsync('git', ['rev-parse', 'HEAD^'], { cwd: process.cwd() }) + .then(({ stdout }) => normalizeReleaseSha(stdout)) + .catch(() => null)); + + const logArgs = previousSha + ? ['log', '--format=%H\t%s', `${previousSha}..HEAD`] + : ['log', '--format=%H\t%s', '-n', '8']; + const rawLog = (await execFileAsync('git', logArgs, { cwd: process.cwd() })).stdout; + const commits = rawLog + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha, subject] = line.split('\t'); + return sha && subject ? { sha, subject } : null; + }) + .filter((commit): commit is ReleaseCommit => commit !== null); + + return { + currentSha, + previousSha, + commits, + }; + } catch { + return null; + } + } + + private parseReleaseMetadata(metadata: unknown): ReleaseMetadata | null { + if (!isRecord(metadata)) { + return null; + } + + const commits = Array.isArray(metadata.commits) + ? metadata.commits + .map((commit) => { + if (!isRecord(commit)) { + return null; + } + + return typeof commit.sha === 'string' && typeof commit.subject === 'string' + ? { sha: commit.sha, subject: commit.subject } + : null; + }) + .filter((commit): commit is ReleaseCommit => commit !== null) + : []; + + return { + currentSha: typeof metadata.currentSha === 'string' ? metadata.currentSha : null, + previousSha: typeof metadata.previousSha === 'string' ? metadata.previousSha : null, + commits, + }; + } + private async extractMemories( teamId: string, channelId: string, diff --git a/packages/backend/src/shared/services/web/web.service.ts b/packages/backend/src/shared/services/web/web.service.ts index 020f857d..df01bb64 100644 --- a/packages/backend/src/shared/services/web/web.service.ts +++ b/packages/backend/src/shared/services/web/web.service.ts @@ -9,6 +9,7 @@ import type { Block, ConversationsListResponse, UsersListResponse, + UsersSetPhotoArguments, } from '@slack/web-api'; import { WebClient } from '@slack/web-api'; import { logError } from '../../logger/error-logging'; @@ -127,6 +128,30 @@ export class WebService { }); } + public setProfilePhoto(image: Buffer): Promise { + const token: string | undefined = process.env.MUZZLE_BOT_USER_TOKEN; + const photoRequest: UsersSetPhotoArguments = { + token, + image, + }; + + return this.web.users + .setPhoto(photoRequest) + .then((result) => { + if (result.ok === false) { + throw new Error(result.error || 'unknown_slack_error'); + } + + return result; + }) + .catch((e) => { + logError(this.logger, 'Failed to set Slack profile photo', e, { + errorCode: getSlackErrorCode(e), + }); + throw e; + }); + } + public editMessage(channel: string, text: string, ts: string): void { const token = process.env.MUZZLE_BOT_USER_TOKEN; const update: ChatUpdateArguments = { From 9db6b74dcfce43cb941171c9a9c5eecf16e571bd Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 8 Apr 2026 08:47:03 -0400 Subject: [PATCH 2/6] updated tests --- packages/backend/src/ai/ai.service.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 9a466567..a950c07c 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -195,11 +195,16 @@ describe('AIService', () => { describe('redeployMoonbeam', () => { it('publishes deployment message with quote, changelog, and profile photo update', async () => { + const validPngBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9kAAAAASUVORK5CYII=', + 'base64', + ); + (aiService.openAi.responses.create as jest.Mock).mockResolvedValue({ output: [{ type: 'message', content: [{ type: 'output_text', text: 'A quote' }] }], }); (aiService.gemini.models.generateContent as jest.Mock).mockResolvedValue({ - candidates: [{ content: { parts: [{ inlineData: { data: Buffer.from('image').toString('base64') } }] } }], + candidates: [{ content: { parts: [{ inlineData: { data: validPngBuffer.toString('base64') } }] } }], }); jest .spyOn(aiService as never, 'getMoonbeamReleaseChangelog' as never) From bb84b0097281dfde2022f97ae95040feaf1b9a06 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Wed, 8 Apr 2026 08:53:43 -0400 Subject: [PATCH 3/6] Updated tests to meet coverage requirements --- packages/backend/src/ai/ai.service.spec.ts | 60 +++++++++++++++++++ .../shared/services/web/web.service.spec.ts | 31 +++++++++- .../src/test/mocks/slack-web-api.mock.ts | 1 + 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index a950c07c..07da64b0 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -228,6 +228,66 @@ describe('AIService', () => { ]), ); }); + + it('returns an unavailable changelog when no metadata source is available', async () => { + jest.spyOn(aiService as never, 'readReleaseMetadataFromDisk' as never).mockResolvedValue(null); + jest.spyOn(aiService as never, 'readReleaseMetadataFromGit' as never).mockResolvedValue(null); + + await expect((aiService as never).getMoonbeamReleaseChangelog()).resolves.toBe( + '*Release changelog*\n- Changelog unavailable for this deployment.', + ); + }); + + it('formats changelog entries without a previous sha', async () => { + jest.spyOn(aiService as never, 'readReleaseMetadataFromDisk' as never).mockResolvedValue({ + currentSha: 'current', + previousSha: null, + commits: [ + { sha: '1', subject: 'first change' }, + { sha: '2', subject: 'second change' }, + ], + }); + + await expect((aiService as never).getMoonbeamReleaseChangelog()).resolves.toBe( + '*Release changelog*\nRecent shipped changes:\n- first change\n- second change', + ); + }); + + it('reads release metadata from disk after skipping invalid candidates', async () => { + const readFileSpy = jest + .spyOn(fs.promises, 'readFile') + .mockRejectedValueOnce(new Error('missing')) + .mockResolvedValueOnce('not json' as never) + .mockResolvedValueOnce( + JSON.stringify({ + currentSha: 'abc1234', + previousSha: 'def5678', + commits: [{ sha: 'abc1234', subject: 'ship it' }], + }) as never, + ); + + await expect((aiService as never).readReleaseMetadataFromDisk()).resolves.toEqual({ + currentSha: 'abc1234', + previousSha: 'def5678', + commits: [{ sha: 'abc1234', subject: 'ship it' }], + }); + expect(readFileSpy).toHaveBeenCalledTimes(3); + }); + + it('parses release metadata and filters invalid commits', () => { + expect((aiService as never).parseReleaseMetadata(null)).toBeNull(); + expect( + (aiService as never).parseReleaseMetadata({ + currentSha: 'abc1234', + previousSha: 'def5678', + commits: [{ sha: 'good', subject: 'usable' }, { nope: true }, null], + }), + ).toEqual({ + currentSha: 'abc1234', + previousSha: 'def5678', + commits: [{ sha: 'good', subject: 'usable' }], + }); + }); }); describe('promptWithHistory', () => { diff --git a/packages/backend/src/shared/services/web/web.service.spec.ts b/packages/backend/src/shared/services/web/web.service.spec.ts index b117145b..357b9f2f 100644 --- a/packages/backend/src/shared/services/web/web.service.spec.ts +++ b/packages/backend/src/shared/services/web/web.service.spec.ts @@ -7,7 +7,7 @@ type MockWebClient = { postEphemeral: jest.Mock; update: jest.Mock; }; - users: { list: jest.Mock }; + users: { list: jest.Mock; setPhoto: jest.Mock }; conversations: { list: jest.Mock }; files: { upload: jest.Mock }; }; @@ -133,6 +133,35 @@ describe('WebService', () => { }); }); + describe('setProfilePhoto', () => { + it('uploads the profile photo successfully', async () => { + const result = { ok: true }; + const image = Buffer.from('png-bytes'); + mockWebClient.users.setPhoto.mockResolvedValue(result); + + await expect(webService.setProfilePhoto(image)).resolves.toEqual(result); + expect(mockWebClient.users.setPhoto).toHaveBeenCalledWith(expect.objectContaining({ image })); + }); + + it('throws and logs when Slack responds with ok false', async () => { + const loggerSpy = jest.spyOn(webService.logger, 'error'); + mockWebClient.users.setPhoto.mockResolvedValue({ ok: false, error: 'bad_image' }); + + await expect(webService.setProfilePhoto(Buffer.from('png-bytes'))).rejects.toThrow('bad_image'); + expect(loggerSpy).toHaveBeenCalled(); + }); + + it('throws and logs when the upload rejects', async () => { + const loggerSpy = jest.spyOn(webService.logger, 'error'); + const error = new Error('upload failed'); + (error as SlackApiError).data = { error: 'ratelimited' }; + mockWebClient.users.setPhoto.mockRejectedValue(error); + + await expect(webService.setProfilePhoto(Buffer.from('png-bytes'))).rejects.toThrow('upload failed'); + expect(loggerSpy).toHaveBeenCalled(); + }); + }); + describe('getAllUsers/getAllChannels', () => { it('returns users and channels', async () => { mockWebClient.users.list.mockResolvedValue({ ok: true, members: [{ id: 'U1' }] }); diff --git a/packages/backend/src/test/mocks/slack-web-api.mock.ts b/packages/backend/src/test/mocks/slack-web-api.mock.ts index baae8622..c5cf796d 100644 --- a/packages/backend/src/test/mocks/slack-web-api.mock.ts +++ b/packages/backend/src/test/mocks/slack-web-api.mock.ts @@ -12,6 +12,7 @@ export class WebClient { users = { list: jest.fn().mockResolvedValue({ ok: true, members: [] }), + setPhoto: jest.fn().mockResolvedValue(okResult()), }; conversations = { From 7472ddfa799e728ee266212fe6960a690ffcf194 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:03:22 +0000 Subject: [PATCH 4/6] fix: address PR review feedback - best-effort photo update, ENOENT handling, README table, and move git metadata generation out of Docker Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/6876c937-2062-4c4c-82c7-8d8b8cf882ae Co-authored-by: sfreeman422 <16405652+sfreeman422@users.noreply.github.com> --- .dockerignore | 2 -- .github/workflows/deploy.yml | 12 +++++++++-- .gitignore | 1 + README.md | 29 +++++++++++++++++---------- packages/backend/Dockerfile | 12 ++++++----- packages/backend/src/ai/ai.service.ts | 15 +++++++++++--- 6 files changed, 48 insertions(+), 23 deletions(-) diff --git a/.dockerignore b/.dockerignore index dd6e8c69..80da067e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,8 +2,6 @@ node_modules npm-debug.log images .git -!.git -!.git/** .github coverage packages/**/coverage diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1fd854c8..b4755905 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,6 +27,16 @@ jobs: with: fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Generate release metadata + run: | + PREVIOUS_RELEASE_SHA="${{ github.event.before }}" \ + node packages/backend/scripts/write-release-metadata.js packages/backend/release-metadata.json + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: @@ -40,8 +50,6 @@ jobs: context: . file: packages/backend/Dockerfile push: true - build-args: | - PREVIOUS_RELEASE_SHA=${{ github.event.before }} # Always push :latest so the Linode deploy script can reference a stable tag. # The SHA tag gives you an immutable rollback point. tags: | diff --git a/.gitignore b/.gitignore index 2a1be96f..06113e51 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage # Backend specific packages/backend/src/ormconfig.ts +packages/backend/release-metadata.json # Legacy history-upload-job/data diff --git a/README.md b/README.md index 9bdaabe8..7fd5fb28 100644 --- a/README.md +++ b/README.md @@ -294,17 +294,24 @@ The script requires `bash`, `curl`, `grep`, `mktemp`, and `tr`. It reads environ From the root directory, you can run: -| Command | Description | -| --------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------- | ------------------------------ | -| `npm run start` | Start the backend development server | -| `npm run start:prod` | Start the backend in production mode | -| `npm run build` | Build all workspaces | -| `npm run build:backend` | Build only the backend | -| `npm run test` | Run tests across all workspaces | -| `npm run test:backend` | Run tests for the backend only | -| `npm run lint` | Lint all packages | -| `npm run lint:fix` | Lint and auto-fix issues | -| `docker build --build-arg PREVIOUS_RELEASE_SHA="$(git rev-parse HEAD^ 2>/dev/null | | true)" -f packages/backend/Dockerfile -t muzzle .` | Build the backend Docker image | +| Command | Description | +| ------------------------- | ------------------------------------ | +| `npm run start` | Start the backend development server | +| `npm run start:prod` | Start the backend in production mode | +| `npm run build` | Build all workspaces | +| `npm run build:backend` | Build only the backend | +| `npm run test` | Run tests across all workspaces | +| `npm run test:backend` | Run tests for the backend only | +| `npm run lint` | Lint all packages | +| `npm run lint:fix` | Lint and auto-fix issues | + +To build the backend Docker image locally, first generate the release metadata, then build: + +```bash +PREVIOUS_RELEASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || true)" \ + node packages/backend/scripts/write-release-metadata.js packages/backend/release-metadata.json +docker build -f packages/backend/Dockerfile -t muzzle . +``` You can also run workspace-specific commands using: diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 4fbd08c3..456dde6a 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,22 +1,24 @@ # Build from repo root: docker build -f packages/backend/Dockerfile . +# Before building, generate release metadata: +# PREVIOUS_RELEASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || true)" \ +# node packages/backend/scripts/write-release-metadata.js packages/backend/release-metadata.json FROM node:20-alpine AS build WORKDIR /usr/src/app -ARG PREVIOUS_RELEASE_SHA # Copy workspace and backend files required for compilation and minification. COPY package.json package-lock.json ./ COPY tsconfig.base.json ./ -COPY .git ./.git COPY packages/backend/package.json ./packages/backend/ COPY packages/backend/scripts ./packages/backend/scripts COPY packages/backend/tsconfig.json ./packages/backend/ COPY packages/backend/tsconfig.prod.json ./packages/backend/ COPY packages/backend/src ./packages/backend/src +# Copy pre-generated release metadata (produced by write-release-metadata.js before docker build). +COPY packages/backend/release-metadata.json ./packages/backend/ + # Build backend artifact inside Docker. -RUN apk add --no-cache git \ - && npm ci \ - && PREVIOUS_RELEASE_SHA="$PREVIOUS_RELEASE_SHA" node ./packages/backend/scripts/write-release-metadata.js ./packages/backend/release-metadata.json \ +RUN npm ci \ && npm run build:prod -w @mocker/backend \ && npm prune --omit=dev diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 8a32c575..89de56f1 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -250,7 +250,9 @@ export class AIService { const [imageBytes, quote, changelog] = results; const [imageUrl] = await Promise.all([ this.writeImageBufferToDiskAndReturnUrl(imageBytes), - this.updateMoonbeamProfilePhoto(imageBytes), + this.updateMoonbeamProfilePhoto(imageBytes).catch((error) => { + logError(this.aiServiceLogger, 'Failed to update Moonbeam profile photo during redeploy', error); + }), ]); this.aiServiceLogger.info('Redeploy Moonbeam - generated quote and image successfully'); @@ -679,8 +681,15 @@ export class AIService { if (metadata) { return metadata; } - } catch { - continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + continue; + } + + this.aiServiceLogger.debug('Failed to read release metadata from disk', { + candidatePath, + error, + }); } } From e7eea4c467e91d09a1ce5eb8c69ad9bef8556915 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:09:30 +0000 Subject: [PATCH 5/6] fix: use info log level instead of debug for release metadata read failures Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/1653283a-306b-499e-96a0-d672ef72a512 Co-authored-by: sfreeman422 <16405652+sfreeman422@users.noreply.github.com> --- packages/backend/src/ai/ai.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 89de56f1..bc1b7335 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -686,7 +686,7 @@ export class AIService { continue; } - this.aiServiceLogger.debug('Failed to read release metadata from disk', { + this.aiServiceLogger.info('Failed to read release metadata from disk', { candidatePath, error, }); From da2a745f6a4742b6f1c8749d69f1f9af97d76ec6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:24:56 +0000 Subject: [PATCH 6/6] fix: resolve CI failures - metadata generation in docker-build job, ENOENT narrowing, Prettier formatting Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/b862833b-7f36-431d-bb98-ad866eb92ac9 --- .github/workflows/ci.yml | 10 ++++++++-- README.md | 20 ++++++++++---------- packages/backend/src/ai/ai.service.ts | 2 +- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 340364ee..104288a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,11 +159,17 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Generate release metadata + run: | + PREVIOUS_RELEASE_SHA=$(git rev-parse HEAD^ 2>/dev/null || true) \ + node packages/backend/scripts/write-release-metadata.js packages/backend/release-metadata.json - name: Build Docker image run: | - PREVIOUS_RELEASE_SHA=$(git rev-parse HEAD^ 2>/dev/null || true) docker build \ - --build-arg PREVIOUS_RELEASE_SHA="$PREVIOUS_RELEASE_SHA" \ -f packages/backend/Dockerfile \ -t muzzle:ci \ . diff --git a/README.md b/README.md index 7fd5fb28..5f7e9ef3 100644 --- a/README.md +++ b/README.md @@ -294,16 +294,16 @@ The script requires `bash`, `curl`, `grep`, `mktemp`, and `tr`. It reads environ From the root directory, you can run: -| Command | Description | -| ------------------------- | ------------------------------------ | -| `npm run start` | Start the backend development server | -| `npm run start:prod` | Start the backend in production mode | -| `npm run build` | Build all workspaces | -| `npm run build:backend` | Build only the backend | -| `npm run test` | Run tests across all workspaces | -| `npm run test:backend` | Run tests for the backend only | -| `npm run lint` | Lint all packages | -| `npm run lint:fix` | Lint and auto-fix issues | +| Command | Description | +| ----------------------- | ------------------------------------ | +| `npm run start` | Start the backend development server | +| `npm run start:prod` | Start the backend in production mode | +| `npm run build` | Build all workspaces | +| `npm run build:backend` | Build only the backend | +| `npm run test` | Run tests across all workspaces | +| `npm run test:backend` | Run tests for the backend only | +| `npm run lint` | Lint all packages | +| `npm run lint:fix` | Lint and auto-fix issues | To build the backend Docker image locally, first generate the release metadata, then build: diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index bc1b7335..8a3f7cc1 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -682,7 +682,7 @@ export class AIService { return metadata; } } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { continue; }