diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md new file mode 100644 index 0000000000..ae781be604 --- /dev/null +++ b/apps/gittensory-miner-extension/README.md @@ -0,0 +1,13 @@ +# Gittensory Miner Extension + +Contributor-facing browser extension scaffold for GitHub **issue** pages. It is intentionally separate from +[`apps/gittensory-extension/`](../gittensory-extension/) (the **Maintainer Overlay**), which injects private PR/issue +context for maintainers. + +This package is Phase 6 scaffolding only: + +- Manifest V3 with issue-page `content_scripts` +- `background.js` service worker + `content.js` message-passing shell +- Options page for local watched-repo configuration + +The read-only issue-page opportunity badge is implemented in a follow-up issue after this scaffold lands. diff --git a/apps/gittensory-miner-extension/background.js b/apps/gittensory-miner-extension/background.js new file mode 100644 index 0000000000..328458d75f --- /dev/null +++ b/apps/gittensory-miner-extension/background.js @@ -0,0 +1,48 @@ +const PING_MESSAGE = "gittensory-miner:ping"; +const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context"; + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (!message || typeof message.type !== "string") return false; + if (message.type === PING_MESSAGE) { + sendResponse({ ok: true, payload: { ready: true } }); + return false; + } + if (message.type === ISSUE_CONTEXT_MESSAGE) { + const task = loadIssueContextShell(message); + void task.then((payload) => sendResponse({ ok: true, payload })).catch((error) => + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }), + ); + return true; + } + return false; +}); + +async function loadIssueContextShell(message) { + const settings = await loadMinerExtensionSettings(); + const repoFullName = `${message.owner}/${message.repo}`; + const watched = settings.watchedRepos.includes(repoFullName); + return { + watched, + issueNumber: message.issueNumber, + repoFullName, + badge: null, + status: watched ? "shell-ready" : "repo-not-watched", + }; +} + +async function loadMinerExtensionSettings() { + const stored = await chrome.storage.sync.get({ watchedRepos: [] }); + const watchedRepos = Array.isArray(stored.watchedRepos) + ? stored.watchedRepos.map((value) => String(value).trim()).filter(Boolean) + : []; + return { watchedRepos }; +} + +if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { + globalThis.__gittensoryMinerBackgroundInternals = { + PING_MESSAGE, + ISSUE_CONTEXT_MESSAGE, + loadIssueContextShell, + loadMinerExtensionSettings, + }; +} diff --git a/apps/gittensory-miner-extension/content.js b/apps/gittensory-miner-extension/content.js new file mode 100644 index 0000000000..80db3ffa50 --- /dev/null +++ b/apps/gittensory-miner-extension/content.js @@ -0,0 +1,56 @@ +const target = matchGitHubIssueTarget(location.pathname); + +if (target?.kind === "issue") { + mountIssueShell(target); +} + +function matchGitHubIssueTarget(pathname) { + const match = String(pathname ?? "").match(/^\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:\/|$)/); + if (!match) return null; + const [, owner, repo, number] = match; + return { kind: "issue", owner, repo, issueNumber: Number(number) }; +} + +function mountIssueShell(target) { + if (document.querySelector("[data-gittensory-miner-issue-shell]")) return; + const container = document.createElement("aside"); + container.className = "gittensory-miner-issue-shell"; + container.dataset.gittensoryMinerIssueShell = "true"; + container.hidden = true; + document.body.appendChild(container); + void loadIssueShell(container, target); +} + +async function loadIssueShell(container, target) { + const response = await chrome.runtime.sendMessage({ + type: "gittensory-miner:issue-context", + owner: target.owner, + repo: target.repo, + issueNumber: target.issueNumber, + }); + if (!response?.ok) { + container.hidden = true; + return; + } + renderIssueShell(container, response.payload); +} + +function renderIssueShell(container, payload) { + if (!payload?.watched) { + container.hidden = true; + return; + } + container.hidden = false; + container.textContent = ""; + const label = document.createElement("span"); + label.className = "gittensory-miner-issue-shell__label"; + label.textContent = "Gittensory miner opportunity shell"; + container.appendChild(label); +} + +if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { + globalThis.__gittensoryMinerContentInternals = { + matchGitHubIssueTarget, + renderIssueShell, + }; +} diff --git a/apps/gittensory-miner-extension/manifest.json b/apps/gittensory-miner-extension/manifest.json new file mode 100644 index 0000000000..5bb4bada21 --- /dev/null +++ b/apps/gittensory-miner-extension/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "Gittensory Miner Opportunity", + "description": "Contributor-facing GitHub issue opportunity signals from a locally configured miner plane.", + "version": "0.1.0", + "permissions": ["storage"], + "host_permissions": ["https://github.com/*"], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://github.com/*/*/issues/*"], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "options_page": "options.html", + "action": { + "default_title": "Gittensory Miner" + } +} diff --git a/apps/gittensory-miner-extension/options.html b/apps/gittensory-miner-extension/options.html new file mode 100644 index 0000000000..d575743f7b --- /dev/null +++ b/apps/gittensory-miner-extension/options.html @@ -0,0 +1,70 @@ + + + + + + Gittensory Miner Extension + + + +
+

Gittensory miner extension

+

+ Configure which repositories this contributor-facing extension should watch on GitHub issue pages. This is a + separate product surface from the maintainer overlay at apps/gittensory-extension/. +

+
+ + +
+

+
+ + + diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js new file mode 100644 index 0000000000..9d929b2455 --- /dev/null +++ b/apps/gittensory-miner-extension/options.js @@ -0,0 +1,47 @@ +function parseWatchedRepos(text) { + return String(text ?? "") + .split(/\r?\n|,/) + .map((line) => line.trim()) + .filter(Boolean); +} + +if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { + globalThis.__gittensoryMinerOptionsInternals = { + parseWatchedRepos, + }; +} + +const form = document.querySelector("#settings"); +const status = document.querySelector("#status"); +const watchedRepos = document.querySelector("#watchedRepos"); + +if (!form || !status || !watchedRepos) { + // options.html is not mounted (unit-test harness or partial load). +} else { +void refreshSettings(); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const repos = parseWatchedRepos(watchedRepos.value); + await chrome.storage.sync.set({ watchedRepos: repos }); + await refreshSettings(); + showStatus(repos.length > 0 ? `Watching ${repos.length} repository(ies).` : "Cleared watched repositories."); + } catch (error) { + showStatus(error instanceof Error ? error.message : String(error)); + } +}); +} + +async function refreshSettings() { + const stored = await chrome.storage.sync.get({ watchedRepos: [] }); + const repos = Array.isArray(stored.watchedRepos) ? stored.watchedRepos : []; + watchedRepos.value = repos.join("\n"); +} + +function showStatus(message) { + status.textContent = message; + window.setTimeout(() => { + status.textContent = ""; + }, 2600); +} diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts new file mode 100644 index 0000000000..d552c8b126 --- /dev/null +++ b/test/unit/miner-extension-content.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from "node:fs"; +import { Script, createContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +const contentScript = readFileSync("apps/gittensory-miner-extension/content.js", "utf8"); +const backgroundScript = readFileSync("apps/gittensory-miner-extension/background.js", "utf8"); +const optionsScript = readFileSync("apps/gittensory-miner-extension/options.js", "utf8"); +const manifest = JSON.parse(readFileSync("apps/gittensory-miner-extension/manifest.json", "utf8")); + +describe("miner extension scaffold", () => { + it("ships a Manifest V3 issue-page-only content script surface", () => { + expect(manifest.manifest_version).toBe(3); + expect(manifest.name).toContain("Miner"); + expect(manifest.content_scripts).toHaveLength(1); + expect(manifest.content_scripts[0].matches).toEqual(["https://github.com/*/*/issues/*"]); + expect(manifest.background.service_worker).toBe("background.js"); + expect(manifest.options_page).toBe("options.html"); + }); + + it("detects GitHub issue routes without matching pull requests", () => { + const internals = loadContentInternals(); + + expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues/145")).toEqual({ + kind: "issue", + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/pull/146")).toBeNull(); + expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues")).toBeNull(); + }); + + it("renders the watched-repo shell placeholder without a badge payload", () => { + const internals = loadContentInternals(); + const container = createMockContainer(); + + internals.renderIssueShell(container, { + watched: true, + issueNumber: 145, + repoFullName: "JSONbored/gittensory", + badge: null, + status: "shell-ready", + }); + + expect(container.hidden).toBe(false); + expect(container.textContent).toContain("opportunity shell"); + }); + + it("keeps the shell hidden for unwatched repositories", () => { + const internals = loadContentInternals(); + const container = createMockContainer(); + + internals.renderIssueShell(container, { + watched: false, + issueNumber: 145, + repoFullName: "JSONbored/gittensory", + badge: null, + status: "repo-not-watched", + }); + + expect(container.hidden).toBe(true); + }); + + it("returns shell-ready issue context for watched repositories", async () => { + const internals = loadBackgroundInternals({ + watchedRepos: ["JSONbored/gittensory"], + }); + + const payload = await internals.loadIssueContextShell({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + + expect(payload).toEqual({ + watched: true, + issueNumber: 145, + repoFullName: "JSONbored/gittensory", + badge: null, + status: "shell-ready", + }); + }); + + it("parses watched repositories from newline or comma separated options input", () => { + const internals = loadOptionsInternals(); + expect(internals.parseWatchedRepos("JSONbored/gittensory\nowner/repo")).toEqual([ + "JSONbored/gittensory", + "owner/repo", + ]); + expect(internals.parseWatchedRepos("JSONbored/gittensory, owner/repo")).toEqual([ + "JSONbored/gittensory", + "owner/repo", + ]); + }); +}); + +function createMockContainer() { + const container = { + hidden: false, + textContent: "", + dataset: {} as Record, + appendChild(node: { textContent?: string }) { + if (node.textContent) { + container.textContent += node.textContent; + } + }, + }; + return container as { + hidden: boolean; + textContent: string; + dataset: Record; + appendChild: (node: { textContent?: string }) => void; + }; +} + +function loadContentInternals() { + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + location: { pathname: "/JSONbored/gittensory/pull/146" }, + document: { + querySelector: () => null, + createElement: () => createMockContainer(), + body: { appendChild: () => {} }, + }, + chrome: { runtime: { sendMessage: async () => ({ ok: true, payload: { watched: true } }) } }, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(contentScript).runInContext(vmContext); + return vmContext.__gittensoryMinerContentInternals as { + matchGitHubIssueTarget: ( + pathname: string, + ) => { kind: "issue"; owner: string; repo: string; issueNumber: number } | null; + renderIssueShell: (container: { hidden: boolean; textContent: string }, payload: unknown) => void; + }; +} + +function loadBackgroundInternals({ watchedRepos = [] as string[] } = {}) { + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + chrome: { + storage: { sync: { get: async () => ({ watchedRepos }) } }, + runtime: { onMessage: { addListener: () => {} } }, + }, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(backgroundScript).runInContext(vmContext); + return vmContext.__gittensoryMinerBackgroundInternals as { + loadIssueContextShell: (message: { + owner: string; + repo: string; + issueNumber: number; + }) => Promise>; + }; +} + +function loadOptionsInternals() { + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + document: { + querySelector: () => null, + }, + chrome: { + storage: { sync: { get: async () => ({ watchedRepos: [] }), set: async () => {} } }, + }, + setTimeout: () => 0, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(optionsScript).runInContext(vmContext); + return vmContext.__gittensoryMinerOptionsInternals as { + parseWatchedRepos: (text: string) => string[]; + }; +}