From ac21fc81b5d7f7d14441106655aa785936f8ea65 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Mon, 13 Jul 2026 01:22:05 -0500 Subject: [PATCH] feat(ams): wire miner extension as workspace package with CI Add package.json for gittensory-miner-extension and gittensory-extension, register both as npm workspaces, add build/lint/typecheck scripts and CI steps matching the maintainer extension, and extend lockfile with workspace entries only so npm ci stays in sync. Closes #4866 Co-authored-by: Cursor --- .github/workflows/ci.yml | 10 +- .github/workflows/ui-deploy.yml | 2 +- apps/gittensory-extension/.gitignore | 1 + apps/gittensory-extension/package.json | 12 ++ apps/gittensory-miner-extension/.gitignore | 1 + apps/gittensory-miner-extension/package.json | 12 ++ package-lock.json | 16 +++ package.json | 7 +- scripts/build-miner-extension.mjs | 118 +++++++++++++++++++ scripts/mcp-release-core.mjs | 2 +- test/unit/ci-extension-packages.test.ts | 37 ++++++ test/unit/ci-ui-build-openapi.test.ts | 6 +- 12 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 apps/gittensory-extension/.gitignore create mode 100644 apps/gittensory-extension/package.json create mode 100644 apps/gittensory-miner-extension/.gitignore create mode 100644 apps/gittensory-miner-extension/package.json create mode 100644 scripts/build-miner-extension.mjs create mode 100644 test/unit/ci-extension-packages.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa8f20fd07..1357315227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,10 @@ jobs: - 'apps/gittensory-ui/**' - 'apps/gittensory-miner-ui/**' - 'apps/gittensory-extension/**' + - 'apps/gittensory-miner-extension/**' - 'packages/gittensory-ui-kit/**' - 'scripts/build-extension.mjs' + - 'scripts/build-miner-extension.mjs' - 'package.json' - 'package-lock.json' # Backend changes that can drift the OpenAPI contract the UI type-checks against, but say @@ -381,6 +383,12 @@ jobs: - name: UI tests if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} run: npm run ui:test + - name: Extension lint + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run extension:lint && npm run miner-extension:lint + - name: Extension typecheck + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run extension:typecheck && npm run miner-extension:typecheck # `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a # standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift # check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this @@ -389,7 +397,7 @@ jobs: # steps directly instead of the aggregate script, skipping that redundant regen. - name: UI build if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} - run: npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build + run: npm run extension:build && npm run miner-extension:build && npm --workspace @jsonbored/gittensory-ui run build # The full-suite coverage run, sharded (#ci-shard-coverage). This alone was ~9-10 of the ~11 minutes a # typical backend PR spent in validate-code, because vitest schedules whole test FILES atomically to diff --git a/.github/workflows/ui-deploy.yml b/.github/workflows/ui-deploy.yml index d1785dec15..0d0edfa776 100644 --- a/.github/workflows/ui-deploy.yml +++ b/.github/workflows/ui-deploy.yml @@ -37,4 +37,4 @@ jobs: - name: Validate frontend env: VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev - run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build + run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run extension:lint && npm run miner-extension:lint && npm run extension:typecheck && npm run miner-extension:typecheck && npm run extension:build && npm run miner-extension:build && npm --workspace @jsonbored/gittensory-ui run build diff --git a/apps/gittensory-extension/.gitignore b/apps/gittensory-extension/.gitignore new file mode 100644 index 0000000000..849ddff3b7 --- /dev/null +++ b/apps/gittensory-extension/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/apps/gittensory-extension/package.json b/apps/gittensory-extension/package.json new file mode 100644 index 0000000000..5c42258106 --- /dev/null +++ b/apps/gittensory-extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "@jsonbored/gittensory-extension", + "version": "0.1.0", + "private": true, + "description": "Maintainer-facing browser extension overlay for GitHub pull requests and issues.", + "type": "module", + "scripts": { + "build": "node ../../scripts/build-extension.mjs", + "lint": "node --check auth.js && node --check background.js && node --check content.js && node --check options.js", + "typecheck": "npm run lint" + } +} diff --git a/apps/gittensory-miner-extension/.gitignore b/apps/gittensory-miner-extension/.gitignore new file mode 100644 index 0000000000..849ddff3b7 --- /dev/null +++ b/apps/gittensory-miner-extension/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/apps/gittensory-miner-extension/package.json b/apps/gittensory-miner-extension/package.json new file mode 100644 index 0000000000..cb6e9488fc --- /dev/null +++ b/apps/gittensory-miner-extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "@jsonbored/gittensory-miner-extension", + "version": "0.1.0", + "private": true, + "description": "Contributor-facing browser extension for GitHub issue opportunity signals.", + "type": "module", + "scripts": { + "build": "node ../../scripts/build-miner-extension.mjs", + "lint": "node --check background.js && node --check content.js && node --check opportunity-badge.js && node --check options.js && node --check toolbar-badge.js", + "typecheck": "npm run lint" + } +} diff --git a/package-lock.json b/package-lock.json index 8a0df1122f..320cfef40a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,14 @@ "node": ">=22.0.0" } }, + "apps/gittensory-extension": { + "name": "@jsonbored/gittensory-extension", + "version": "0.1.0" + }, + "apps/gittensory-miner-extension": { + "name": "@jsonbored/gittensory-miner-extension", + "version": "0.1.0" + }, "apps/gittensory-miner-ui": { "name": "@jsonbored/gittensory-miner-ui", "version": "0.0.0", @@ -3106,6 +3114,14 @@ "resolved": "packages/gittensory-miner", "link": true }, + "node_modules/@jsonbored/gittensory-extension": { + "resolved": "apps/gittensory-extension", + "link": true + }, + "node_modules/@jsonbored/gittensory-miner-extension": { + "resolved": "apps/gittensory-miner-extension", + "link": true + }, "node_modules/@jsonbored/gittensory-miner-ui": { "resolved": "apps/gittensory-miner-ui", "link": true diff --git a/package.json b/package.json index cbc5c8457d..732a7f2242 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,13 @@ "actionlint": "node scripts/actionlint.mjs", "ui:dev": "npm run ui:preview", "extension:build": "node scripts/build-extension.mjs", + "extension:lint": "npm --workspace @jsonbored/gittensory-extension run lint", + "extension:typecheck": "npm --workspace @jsonbored/gittensory-extension run typecheck", + "miner-extension:build": "npm --workspace @jsonbored/gittensory-miner-extension run build", + "miner-extension:lint": "npm --workspace @jsonbored/gittensory-miner-extension run lint", + "miner-extension:typecheck": "npm --workspace @jsonbored/gittensory-miner-extension run typecheck", "ui:kit:build": "npm run build --workspace @jsonbored/gittensory-ui-kit", - "ui:build": "npm run ui:kit:build && npm run ui:openapi && npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build && npm --workspace @jsonbored/gittensory-miner-ui run build", + "ui:build": "npm run ui:kit:build && npm run ui:openapi && npm run extension:build && npm run miner-extension:build && npm --workspace @jsonbored/gittensory-ui run build && npm --workspace @jsonbored/gittensory-miner-ui run build", "ui:preview": "npm run ui:build && wrangler dev --config apps/gittensory-ui/dist/server/wrangler.json --ip 127.0.0.1 --port 4173 --local", "ui:lint": "npm run ui:kit:build && npm --workspace @jsonbored/gittensory-ui run lint && npm --workspace @jsonbored/gittensory-miner-ui run lint", "ui:typecheck": "npm run ui:kit:build && npm --workspace @jsonbored/gittensory-ui run typecheck && npm --workspace @jsonbored/gittensory-miner-ui run typecheck", diff --git a/scripts/build-miner-extension.mjs b/scripts/build-miner-extension.mjs new file mode 100644 index 0000000000..67d4a4fd4d --- /dev/null +++ b/scripts/build-miner-extension.mjs @@ -0,0 +1,118 @@ +import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(root, "apps/gittensory-miner-extension"); +const outDir = resolve(source, "dist/package"); +const crcTable = Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; +}); + +const PACKAGE_FILES = [ + "manifest.json", + "background.js", + "content.js", + "opportunity-badge.js", + "options.html", + "options.js", + "styles.css", + "toolbar-badge.js", +]; + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +for (const file of PACKAGE_FILES) { + cpSync(resolve(source, file), resolve(outDir, file)); +} + +const zipPath = resolve(source, "dist/loopover-miner-extension.zip"); +rmSync(zipPath, { force: true }); +writeFileSync(zipPath, createStoredZip(outDir)); + +console.log(`wrote ${relative(root, zipPath)}`); + +function createStoredZip(directory) { + const files = listFiles(directory); + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const file of files) { + const name = relative(directory, file).replaceAll("\\", "/"); + const nameBuffer = Buffer.from(name); + const data = readFileSync(file); + const crc = crc32(data); + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(10, 4); + localHeader.writeUInt16LE(0, 6); + localHeader.writeUInt16LE(0, 8); + localHeader.writeUInt16LE(0, 10); + localHeader.writeUInt16LE(33, 12); + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(data.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(nameBuffer.length, 26); + localHeader.writeUInt16LE(0, 28); + localParts.push(localHeader, nameBuffer, data); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(10, 6); + centralHeader.writeUInt16LE(0, 8); + centralHeader.writeUInt16LE(0, 10); + centralHeader.writeUInt16LE(0, 12); + centralHeader.writeUInt16LE(33, 14); + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(data.length, 20); + centralHeader.writeUInt32LE(data.length, 24); + centralHeader.writeUInt16LE(nameBuffer.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + centralParts.push(centralHeader, nameBuffer); + + offset += localHeader.length + nameBuffer.length + data.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralDirectory, end]); +} + +function listFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }) + .filter((path) => statSync(path).isFile()) + .sort(); +} + +function crc32(buffer) { + let value = 0xffffffff; + for (const byte of buffer) { + value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8); + } + return (value ^ 0xffffffff) >>> 0; +} diff --git a/scripts/mcp-release-core.mjs b/scripts/mcp-release-core.mjs index 695960ba43..cbd5288325 100644 --- a/scripts/mcp-release-core.mjs +++ b/scripts/mcp-release-core.mjs @@ -24,7 +24,7 @@ const CLIENT_VISIBLE_PATHS = [ const SUPPORTING_VISIBLE_PATHS = ["src/openapi/schemas.ts", "src/openapi/spec.ts"]; const GENERATED_OPENAPI_PATHS = ["apps/gittensory-ui/public/openapi.json", "src/openapi/spec.ts"]; -const UI_ONLY_PREFIXES = ["apps/gittensory-ui/", "apps/gittensory-extension/"]; +const UI_ONLY_PREFIXES = ["apps/gittensory-ui/", "apps/gittensory-extension/", "apps/gittensory-miner-extension/"]; const RELEASE_SCOPES = new Set(["release", "changelog"]); const EXCLUDED_SCOPES = new Set(["pwa", "ui", "extension", "github-agent", "sync", "upstream"]); const GROUP_ORDER = ["Features", "Fixes", "Security", "CI", "Build", "Docs", "Tests", "Refactors", "Dependencies", "Chores", "Reverts"]; diff --git a/test/unit/ci-extension-packages.test.ts b/test/unit/ci-extension-packages.test.ts new file mode 100644 index 0000000000..9402927cae --- /dev/null +++ b/test/unit/ci-extension-packages.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (path: string) => readFileSync(path, "utf8"); + +describe("browser extension workspace packages (#4866)", () => { + it("declares workspace package.json files for both extensions", () => { + const maintainer = JSON.parse(read("apps/gittensory-extension/package.json")); + const miner = JSON.parse(read("apps/gittensory-miner-extension/package.json")); + + expect(maintainer.name).toBe("@jsonbored/gittensory-extension"); + expect(miner.name).toBe("@jsonbored/gittensory-miner-extension"); + expect(maintainer.scripts.build).toContain("build-extension.mjs"); + expect(miner.scripts.build).toContain("build-miner-extension.mjs"); + expect(miner.scripts.lint).toContain("node --check"); + expect(miner.scripts.typecheck).toBe("npm run lint"); + }); + + it("wires extension lint/typecheck/build scripts into root package.json", () => { + const pkg = JSON.parse(read("package.json")); + expect(pkg.scripts["extension:lint"]).toContain("@jsonbored/gittensory-extension"); + expect(pkg.scripts["miner-extension:build"]).toContain("@jsonbored/gittensory-miner-extension"); + expect(pkg.scripts["ui:build"]).toContain("miner-extension:build"); + }); + + it("includes both extensions in ci.yml's ui path filter and validate-code steps", () => { + const workflow = read(".github/workflows/ci.yml"); + expect(workflow).toContain("apps/gittensory-miner-extension/**"); + expect(workflow).toContain("scripts/build-miner-extension.mjs"); + expect(workflow).toContain("name: Extension lint"); + expect(workflow).toContain("npm run extension:lint && npm run miner-extension:lint"); + expect(workflow).toContain("npm run extension:typecheck && npm run miner-extension:typecheck"); + expect(workflow).toContain( + "npm run extension:build && npm run miner-extension:build && npm --workspace @jsonbored/gittensory-ui run build", + ); + }); +}); diff --git a/test/unit/ci-ui-build-openapi.test.ts b/test/unit/ci-ui-build-openapi.test.ts index cc99a06796..d05a6f1e7c 100644 --- a/test/unit/ci-ui-build-openapi.test.ts +++ b/test/unit/ci-ui-build-openapi.test.ts @@ -15,7 +15,9 @@ describe("UI build steps skip the redundant OpenAPI regen", () => { const stepEnd = workflow.indexOf("\n\n", stepStart); const step = workflow.slice(stepStart, stepEnd === -1 ? undefined : stepEnd); - expect(step).toContain("run: npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build"); + expect(step).toContain( + "run: npm run extension:build && npm run miner-extension:build && npm --workspace @jsonbored/gittensory-ui run build", + ); expect(step).not.toContain("npm run ui:build"); }); @@ -23,7 +25,7 @@ describe("UI build steps skip the redundant OpenAPI regen", () => { const workflow = read(".github/workflows/ui-deploy.yml"); expect(workflow).toContain( - "run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build", + "run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run extension:lint && npm run miner-extension:lint && npm run extension:typecheck && npm run miner-extension:typecheck && npm run extension:build && npm run miner-extension:build && npm --workspace @jsonbored/gittensory-ui run build", ); expect(workflow).not.toContain("&& npm run ui:build"); });