diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index ae781be604..06dd6cf02a 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 discovery-index URL can be saved for a +future hosted client path; it is not read yet, so when only unranked hosted metadata would be 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..bebc7cb081 100644 --- a/apps/gittensory-miner-extension/background.js +++ b/apps/gittensory-miner-extension/background.js @@ -1,3 +1,7 @@ +import "./opportunity-badge.js"; + +const badgeApi = globalThis.__gittensoryMinerOpportunityBadge; + const PING_MESSAGE = "gittensory-miner:ping"; const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context"; @@ -8,7 +12,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 +21,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 = badgeApi.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: badgeApi.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..9a9c96aaa3 --- /dev/null +++ b/apps/gittensory-miner-extension/opportunity-badge.js @@ -0,0 +1,95 @@ +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}`; +} + +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; +} + +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"; +} + +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("; "); +} + +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, + }; +} + +function escapeOpportunityHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function renderOpportunityBadgeMarkup(badge) { + if (!badge || typeof badge !== "object") return ""; + return ` +
${escapeOpportunityHtml(badge.why)}
+ `; +} + +const opportunityBadgeApi = { + issueLookupKey, + lookupRankedOpportunity, + scoreToTier, + buildOpportunityWhy, + formatOpportunityBadge, + escapeOpportunityHtml, + renderOpportunityBadgeMarkup, +}; + +globalThis.__gittensoryMinerOpportunityBadge = opportunityBadgeApi; + +if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { + globalThis.__gittensoryMinerOpportunityBadgeTestExports = opportunityBadgeApi; +} 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 @@