Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions apps/gittensory-miner-extension/README.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 46 additions & 9 deletions apps/gittensory-miner-extension/background.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) }),
);
Expand All @@ -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,
};
}
58 changes: 39 additions & 19 deletions apps/gittensory-miner-extension/content.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const badgeApi = globalThis.__gittensoryMinerOpportunityBadge;

const target = matchGitHubIssueTarget(location.pathname);

if (target?.kind === "issue") {
mountIssueShell(target);
mountOpportunityBadge(target);
}

function matchGitHubIssueTarget(pathname) {
Expand All @@ -11,46 +13,64 @@ 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,
repo: target.repo,
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,
};
}
3 changes: 2 additions & 1 deletion apps/gittensory-miner-extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
],
Expand Down
91 changes: 91 additions & 0 deletions apps/gittensory-miner-extension/opportunity-badge.js
Original file line number Diff line number Diff line change
@@ -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 "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&#39;";
}
});
}

export function renderOpportunityBadgeMarkup(badge) {
if (!badge || typeof badge !== "object") return "";
return `
<div class="gittensory-miner-opportunity-badge__header">
<span class="gittensory-miner-opportunity-badge__mark">G</span>
<span>Gittensory opportunity</span>
<span class="gittensory-miner-opportunity-badge__read-only">Read-only</span>
</div>
<div class="gittensory-miner-opportunity-badge__score">
<strong>${escapeOpportunityHtml(badge.tier)}</strong>
<span>${escapeOpportunityHtml(badge.score)}</span>
</div>
<p class="gittensory-miner-opportunity-badge__why">${escapeOpportunityHtml(badge.why)}</p>
`;
}

if (typeof globalThis !== "undefined") {
globalThis.__gittensoryMinerOpportunityBadge = {
issueLookupKey,
lookupRankedOpportunity,
scoreToTier,
buildOpportunityWhy,
formatOpportunityBadge,
escapeOpportunityHtml,
renderOpportunityBadgeMarkup,
};
}
17 changes: 16 additions & 1 deletion apps/gittensory-miner-extension/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -61,6 +64,18 @@ <h1>Gittensory miner extension</h1>
Watched repositories
<textarea id="watchedRepos" name="watchedRepos" placeholder="owner/repo, one per line"></textarea>
</label>
<label>
Discovery index URL (optional)
<input id="discoveryIndexUrl" name="discoveryIndexUrl" placeholder="https://example.com/discovery-index" />
</label>
<label>
Ranked candidates JSON (local cache)
<textarea
id="rankedCandidatesJson"
name="rankedCandidatesJson"
placeholder='[{"repoFullName":"owner/repo","issueNumber":1,"rankScore":0.82,...}]'
></textarea>
</label>
<button type="submit">Save</button>
</form>
<p id="status" role="status"></p>
Expand Down
Loading