From 85acaf5d48236ab4c6e5ab251b2da83726a4b788 Mon Sep 17 00:00:00 2001 From: realDiligent Date: Fri, 10 Jul 2026 10:25:28 +0800 Subject: [PATCH 1/3] feat(miner-extension): add read-only issue-page opportunity badge Surface pre-ranked miner opportunity signals on GitHub issue pages via cached ranked candidates, with tier/score/why formatting and graceful omission when no signal is available. Builds on the miner extension scaffold. Co-authored-by: Cursor --- apps/gittensory-miner-extension/README.md | 20 +- apps/gittensory-miner-extension/background.js | 55 ++++- apps/gittensory-miner-extension/content.js | 58 +++-- apps/gittensory-miner-extension/manifest.json | 3 +- .../opportunity-badge.js | 91 ++++++++ apps/gittensory-miner-extension/options.html | 17 +- apps/gittensory-miner-extension/options.js | 35 +++- apps/gittensory-miner-extension/styles.css | 64 ++++++ test/unit/miner-extension-content.test.ts | 198 ++++++++++++------ 9 files changed, 441 insertions(+), 100 deletions(-) create mode 100644 apps/gittensory-miner-extension/opportunity-badge.js create mode 100644 apps/gittensory-miner-extension/styles.css diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index ae781be604..2a7a79f458 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -1,13 +1,23 @@ # Gittensory Miner Extension -Contributor-facing browser extension scaffold for GitHub **issue** pages. It is intentionally separate from +Contributor-facing browser extension 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: +## What it does - Manifest V3 with issue-page `content_scripts` -- `background.js` service worker + `content.js` message-passing shell -- Options page for local watched-repo configuration +- `background.js` service worker + `content.js` message-passing +- Read-only opportunity badge (score/tier + short why) for watched repositories +- Options page for watched repos and a local ranked-candidate cache -The read-only issue-page opportunity badge is implemented in a follow-up issue after this scaffold lands. +The badge surfaces the same ranked signal as `packages/gittensory-miner/lib/opportunity-ranker.js` by reading +pre-ranked candidates from browser local storage. It never writes to GitHub and omits itself when no ranked signal is +available for the current issue. + +## Local ranked cache + +Laptop-mode installs can paste JSON from a miner `discover` run into the options page. The extension stores that list in +`chrome.storage.local.rankedCandidates` and looks up the current issue there. A hosted discovery-index URL can be saved +for future client support; when only unranked hosted metadata is available, the badge degrades gracefully by staying +hidden. diff --git a/apps/gittensory-miner-extension/background.js b/apps/gittensory-miner-extension/background.js index 328458d75f..0d74501d08 100644 --- a/apps/gittensory-miner-extension/background.js +++ b/apps/gittensory-miner-extension/background.js @@ -1,3 +1,8 @@ +import { + formatOpportunityBadge, + lookupRankedOpportunity, +} from "./opportunity-badge.js"; + const PING_MESSAGE = "gittensory-miner:ping"; const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context"; @@ -8,7 +13,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return false; } if (message.type === ISSUE_CONTEXT_MESSAGE) { - const task = loadIssueContextShell(message); + const task = loadIssueOpportunityContext(message); void task.then((payload) => sendResponse({ ok: true, payload })).catch((error) => sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }), ); @@ -17,32 +22,64 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return false; }); -async function loadIssueContextShell(message) { +async function loadIssueOpportunityContext(message) { const settings = await loadMinerExtensionSettings(); const repoFullName = `${message.owner}/${message.repo}`; - const watched = settings.watchedRepos.includes(repoFullName); + const watched = settings.watchedRepos.some( + (repo) => repo.trim().toLowerCase() === repoFullName.toLowerCase(), + ); + if (!watched) { + return { + watched: false, + issueNumber: message.issueNumber, + repoFullName, + badge: null, + status: "repo-not-watched", + }; + } + + const rankedCandidates = await loadRankedCandidates(); + const rankedEntry = lookupRankedOpportunity(rankedCandidates, repoFullName, message.issueNumber); + if (!rankedEntry) { + return { + watched: true, + issueNumber: message.issueNumber, + repoFullName, + badge: null, + status: "no-signal", + }; + } + return { - watched, + watched: true, issueNumber: message.issueNumber, repoFullName, - badge: null, - status: watched ? "shell-ready" : "repo-not-watched", + badge: formatOpportunityBadge(rankedEntry), + status: "ready", }; } async function loadMinerExtensionSettings() { - const stored = await chrome.storage.sync.get({ watchedRepos: [] }); + const stored = await chrome.storage.sync.get({ watchedRepos: [], discoveryIndexUrl: "" }); const watchedRepos = Array.isArray(stored.watchedRepos) ? stored.watchedRepos.map((value) => String(value).trim()).filter(Boolean) : []; - return { watchedRepos }; + const discoveryIndexUrl = + typeof stored.discoveryIndexUrl === "string" ? stored.discoveryIndexUrl.trim() : ""; + return { watchedRepos, discoveryIndexUrl }; +} + +async function loadRankedCandidates() { + const stored = await chrome.storage.local.get({ rankedCandidates: [] }); + return Array.isArray(stored.rankedCandidates) ? stored.rankedCandidates : []; } if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { globalThis.__gittensoryMinerBackgroundInternals = { PING_MESSAGE, ISSUE_CONTEXT_MESSAGE, - loadIssueContextShell, + loadIssueOpportunityContext, loadMinerExtensionSettings, + loadRankedCandidates, }; } diff --git a/apps/gittensory-miner-extension/content.js b/apps/gittensory-miner-extension/content.js index 80db3ffa50..3822fb5e7d 100644 --- a/apps/gittensory-miner-extension/content.js +++ b/apps/gittensory-miner-extension/content.js @@ -1,7 +1,9 @@ +const badgeApi = globalThis.__gittensoryMinerOpportunityBadge; + const target = matchGitHubIssueTarget(location.pathname); if (target?.kind === "issue") { - mountIssueShell(target); + mountOpportunityBadge(target); } function matchGitHubIssueTarget(pathname) { @@ -11,17 +13,33 @@ function matchGitHubIssueTarget(pathname) { return { kind: "issue", owner, repo, issueNumber: Number(number) }; } -function mountIssueShell(target) { - if (document.querySelector("[data-gittensory-miner-issue-shell]")) return; +function mountOpportunityBadge(target) { + if (document.querySelector("[data-gittensory-miner-opportunity-badge]")) return; + const host = findIssueSidebar(); const container = document.createElement("aside"); - container.className = "gittensory-miner-issue-shell"; - container.dataset.gittensoryMinerIssueShell = "true"; + container.className = host + ? "gittensory-miner-opportunity-badge" + : "gittensory-miner-opportunity-badge gittensory-miner-opportunity-badge--floating"; + container.dataset.gittensoryMinerOpportunityBadge = "true"; container.hidden = true; - document.body.appendChild(container); - void loadIssueShell(container, target); + if (host) { + host.prepend(container); + } else { + document.body.appendChild(container); + } + void loadOpportunityBadge(container, target); +} + +function findIssueSidebar() { + return ( + document.querySelector("#partial-discussion-sidebar") || + document.querySelector("[data-testid='issue-sidebar']") || + document.querySelector(".Layout-sidebar") || + document.querySelector(".discussion-sidebar") + ); } -async function loadIssueShell(container, target) { +async function loadOpportunityBadge(container, target) { const response = await chrome.runtime.sendMessage({ type: "gittensory-miner:issue-context", owner: target.owner, @@ -29,28 +47,30 @@ async function loadIssueShell(container, target) { issueNumber: target.issueNumber, }); if (!response?.ok) { - container.hidden = true; + container.remove(); return; } - renderIssueShell(container, response.payload); + renderOpportunityBadge(container, response.payload); } -function renderIssueShell(container, payload) { - if (!payload?.watched) { - container.hidden = true; +function renderOpportunityBadge(container, payload) { + if (!payload?.watched || !payload?.badge) { + container.remove(); + return; + } + const markup = badgeApi?.renderOpportunityBadgeMarkup?.(payload.badge); + if (!markup) { + container.remove(); 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); + container.innerHTML = markup; } if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { globalThis.__gittensoryMinerContentInternals = { matchGitHubIssueTarget, - renderIssueShell, + findIssueSidebar, + renderOpportunityBadge, }; } diff --git a/apps/gittensory-miner-extension/manifest.json b/apps/gittensory-miner-extension/manifest.json index 5bb4bada21..5dca73f687 100644 --- a/apps/gittensory-miner-extension/manifest.json +++ b/apps/gittensory-miner-extension/manifest.json @@ -12,7 +12,8 @@ "content_scripts": [ { "matches": ["https://github.com/*/*/issues/*"], - "js": ["content.js"], + "js": ["opportunity-badge.js", "content.js"], + "css": ["styles.css"], "run_at": "document_idle" } ], diff --git a/apps/gittensory-miner-extension/opportunity-badge.js b/apps/gittensory-miner-extension/opportunity-badge.js new file mode 100644 index 0000000000..9303f81c80 --- /dev/null +++ b/apps/gittensory-miner-extension/opportunity-badge.js @@ -0,0 +1,91 @@ +export function issueLookupKey(repoFullName, issueNumber) { + const repo = String(repoFullName ?? "").trim().toLowerCase(); + const number = Number(issueNumber); + if (!repo || !Number.isInteger(number) || number <= 0) return null; + return `${repo}#${number}`; +} + +export function lookupRankedOpportunity(rankedIssues, repoFullName, issueNumber) { + const targetKey = issueLookupKey(repoFullName, issueNumber); + if (!targetKey || !Array.isArray(rankedIssues)) return null; + for (const entry of rankedIssues) { + if (!entry || typeof entry !== "object") continue; + const key = issueLookupKey(entry.repoFullName, entry.issueNumber); + if (key === targetKey) return entry; + } + return null; +} + +export function scoreToTier(rankScore) { + const score = Number(rankScore); + if (!Number.isFinite(score)) return "Unknown"; + if (score >= 0.75) return "High"; + if (score >= 0.5) return "Medium"; + return "Low"; +} + +export function buildOpportunityWhy(entry) { + const reasons = []; + if (Number(entry.laneFit) >= 0.7) reasons.push("Strong lane fit"); + if (Number(entry.freshness) >= 0.7) reasons.push("Fresh issue"); + if (Number(entry.potential) >= 0.7) reasons.push("High reward potential"); + if (Number(entry.feasibility) >= 0.7) reasons.push("Feasible scope"); + if (Number(entry.dupRisk) <= 0.3) reasons.push("Low duplicate risk"); + if (reasons.length === 0) reasons.push("Balanced opportunity signals"); + return reasons.slice(0, 2).join("; "); +} + +export function formatOpportunityBadge(entry) { + const rankScore = Number(entry.rankScore); + return { + tier: scoreToTier(rankScore), + score: Number.isFinite(rankScore) ? rankScore.toFixed(2) : "—", + why: buildOpportunityWhy(entry), + rankScore: Number.isFinite(rankScore) ? rankScore : null, + }; +} + +export function escapeOpportunityHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +export function renderOpportunityBadgeMarkup(badge) { + if (!badge || typeof badge !== "object") return ""; + return ` +
+ G + Gittensory opportunity + Read-only +
+
+ ${escapeOpportunityHtml(badge.tier)} + ${escapeOpportunityHtml(badge.score)} +
+

${escapeOpportunityHtml(badge.why)}

+ `; +} + +if (typeof globalThis !== "undefined") { + globalThis.__gittensoryMinerOpportunityBadge = { + issueLookupKey, + lookupRankedOpportunity, + scoreToTier, + buildOpportunityWhy, + formatOpportunityBadge, + escapeOpportunityHtml, + renderOpportunityBadgeMarkup, + }; +} diff --git a/apps/gittensory-miner-extension/options.html b/apps/gittensory-miner-extension/options.html index d575743f7b..0bf82ea7af 100644 --- a/apps/gittensory-miner-extension/options.html +++ b/apps/gittensory-miner-extension/options.html @@ -21,14 +21,17 @@ gap: 6px; margin-top: 16px; } + input, textarea { border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 7px; background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; - min-height: 120px; padding: 9px 10px; + } + textarea { + min-height: 120px; resize: vertical; } button { @@ -61,6 +64,18 @@

Gittensory miner extension

Watched repositories + +

diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js index 9d929b2455..316858c654 100644 --- a/apps/gittensory-miner-extension/options.js +++ b/apps/gittensory-miner-extension/options.js @@ -5,17 +5,30 @@ function parseWatchedRepos(text) { .filter(Boolean); } +function parseRankedCandidatesJson(text) { + const trimmed = String(text ?? "").trim(); + if (!trimmed) return []; + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) { + throw new Error("Ranked candidates JSON must be an array."); + } + return parsed; +} + if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { globalThis.__gittensoryMinerOptionsInternals = { parseWatchedRepos, + parseRankedCandidatesJson, }; } const form = document.querySelector("#settings"); const status = document.querySelector("#status"); const watchedRepos = document.querySelector("#watchedRepos"); +const discoveryIndexUrl = document.querySelector("#discoveryIndexUrl"); +const rankedCandidatesJson = document.querySelector("#rankedCandidatesJson"); -if (!form || !status || !watchedRepos) { +if (!form || !status || !watchedRepos || !discoveryIndexUrl || !rankedCandidatesJson) { // options.html is not mounted (unit-test harness or partial load). } else { void refreshSettings(); @@ -24,9 +37,18 @@ form.addEventListener("submit", async (event) => { event.preventDefault(); try { const repos = parseWatchedRepos(watchedRepos.value); - await chrome.storage.sync.set({ watchedRepos: repos }); + const rankedCandidates = parseRankedCandidatesJson(rankedCandidatesJson.value); + await chrome.storage.sync.set({ + watchedRepos: repos, + discoveryIndexUrl: discoveryIndexUrl.value.trim(), + }); + await chrome.storage.local.set({ rankedCandidates }); await refreshSettings(); - showStatus(repos.length > 0 ? `Watching ${repos.length} repository(ies).` : "Cleared watched repositories."); + showStatus( + rankedCandidates.length > 0 + ? `Saved ${repos.length} watched repo(s) and ${rankedCandidates.length} ranked candidate(s).` + : `Watching ${repos.length} repository(ies).`, + ); } catch (error) { showStatus(error instanceof Error ? error.message : String(error)); } @@ -34,9 +56,14 @@ form.addEventListener("submit", async (event) => { } async function refreshSettings() { - const stored = await chrome.storage.sync.get({ watchedRepos: [] }); + const stored = await chrome.storage.sync.get({ watchedRepos: [], discoveryIndexUrl: "" }); + const local = await chrome.storage.local.get({ rankedCandidates: [] }); const repos = Array.isArray(stored.watchedRepos) ? stored.watchedRepos : []; watchedRepos.value = repos.join("\n"); + discoveryIndexUrl.value = typeof stored.discoveryIndexUrl === "string" ? stored.discoveryIndexUrl : ""; + const rankedCandidates = Array.isArray(local.rankedCandidates) ? local.rankedCandidates : []; + rankedCandidatesJson.value = + rankedCandidates.length > 0 ? JSON.stringify(rankedCandidates, null, 2) : ""; } function showStatus(message) { diff --git a/apps/gittensory-miner-extension/styles.css b/apps/gittensory-miner-extension/styles.css new file mode 100644 index 0000000000..e2fc361df8 --- /dev/null +++ b/apps/gittensory-miner-extension/styles.css @@ -0,0 +1,64 @@ +.gittensory-miner-opportunity-badge { + border: 1px solid rgba(126, 231, 188, 0.35); + border-radius: 8px; + background: rgba(15, 17, 23, 0.96); + color: #f4f7f5; + font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin: 12px 0; + padding: 12px; +} + +.gittensory-miner-opportunity-badge--floating { + position: fixed; + right: 16px; + bottom: 16px; + max-width: 280px; + z-index: 9999; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); +} + +.gittensory-miner-opportunity-badge__header { + align-items: center; + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.gittensory-miner-opportunity-badge__mark { + align-items: center; + background: #7ee7bc; + border-radius: 999px; + color: #111317; + display: inline-flex; + font-size: 11px; + font-weight: 700; + height: 20px; + justify-content: center; + width: 20px; +} + +.gittensory-miner-opportunity-badge__read-only { + color: rgba(244, 247, 245, 0.55); + font-size: 11px; + margin-left: auto; +} + +.gittensory-miner-opportunity-badge__score { + align-items: baseline; + display: flex; + gap: 8px; + margin-bottom: 6px; +} + +.gittensory-miner-opportunity-badge__score strong { + font-size: 15px; +} + +.gittensory-miner-opportunity-badge__score span { + color: rgba(244, 247, 245, 0.72); +} + +.gittensory-miner-opportunity-badge__why { + color: rgba(244, 247, 245, 0.78); + margin: 0; +} diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index d552c8b126..fc615c071d 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -1,25 +1,48 @@ import { readFileSync } from "node:fs"; import { Script, createContext } from "node:vm"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { rankCandidateIssues } from "../../packages/gittensory-miner/lib/opportunity-ranker.js"; const contentScript = readFileSync("apps/gittensory-miner-extension/content.js", "utf8"); const backgroundScript = readFileSync("apps/gittensory-miner-extension/background.js", "utf8"); +const badgeScript = readFileSync("apps/gittensory-miner-extension/opportunity-badge.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", () => { +const NOW = Date.parse("2026-07-03T12:00:00.000Z"); + +function rawIssue(overrides: Record = {}) { + return { + owner: "JSONbored", + repo: "gittensory", + repoFullName: "JSONbored/gittensory", + issueNumber: 145, + title: "Add miner extension badge", + labels: ["help wanted", "gittensor:feature"], + commentsCount: 1, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + htmlUrl: "https://github.com/JSONbored/gittensory/issues/145", + aiPolicyAllowed: true, + aiPolicySource: "CONTRIBUTING.md", + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("miner extension opportunity badge", () => { + it("ships a Manifest V3 issue-page content script with badge assets", () => { 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"); + expect(manifest.content_scripts[0].js).toEqual(["opportunity-badge.js", "content.js"]); + expect(manifest.content_scripts[0].css).toEqual(["styles.css"]); }); it("detects GitHub issue routes without matching pull requests", () => { const internals = loadContentInternals(); - expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues/145")).toEqual({ kind: "issue", owner: "JSONbored", @@ -27,95 +50,130 @@ describe("miner extension scaffold", () => { 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", () => { + it("looks up ranked opportunities using the same repo#issue key as the miner ranker", () => { + const ranked = rankCandidateIssues([rawIssue(), rawIssue({ issueNumber: 99, labels: ["question"] })], { + nowMs: NOW, + }); + const badge = loadBadgeInternals(); + + const match = badge.lookupRankedOpportunity(ranked, "JSONbored/gittensory", 145); + expect(match?.issueNumber).toBe(145); + expect(match?.rankScore).toBeGreaterThan(0); + expect(badge.lookupRankedOpportunity(ranked, "JSONbored/gittensory", 404)).toBeNull(); + }); + + it("formats tier, score, and a short why without duplicating ranking math", () => { + const ranked = rankCandidateIssues([rawIssue()], { nowMs: NOW })[0]!; + const badge = loadBadgeInternals(); + const formatted = badge.formatOpportunityBadge(ranked); + + expect(formatted.tier).toMatch(/High|Medium|Low/); + expect(formatted.score).toMatch(/^\d+\.\d{2}$/); + expect(formatted.why.length).toBeGreaterThan(0); + expect(badge.renderOpportunityBadgeMarkup(formatted)).toContain("Read-only"); + expect(badge.renderOpportunityBadgeMarkup(formatted)).not.toContain("