Skip to content
Merged
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
13 changes: 13 additions & 0 deletions apps/gittensory-miner-extension/README.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions apps/gittensory-miner-extension/background.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
56 changes: 56 additions & 0 deletions apps/gittensory-miner-extension/content.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
23 changes: 23 additions & 0 deletions apps/gittensory-miner-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
70 changes: 70 additions & 0 deletions apps/gittensory-miner-extension/options.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gittensory Miner Extension</title>
<style>
body {
margin: 0;
background: #0f1117;
color: #f4f7f5;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
max-width: 560px;
margin: 0 auto;
padding: 32px 20px;
}
label {
display: grid;
gap: 6px;
margin-top: 16px;
}
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;
resize: vertical;
}
button {
margin-top: 18px;
border: 0;
border-radius: 7px;
background: #7ee7bc;
color: #111317;
cursor: pointer;
font-weight: 700;
padding: 9px 12px;
}
p {
color: rgba(244, 247, 245, 0.68);
}
#status {
min-height: 20px;
}
</style>
</head>
<body>
<main>
<h1>Gittensory miner extension</h1>
<p>
Configure which repositories this contributor-facing extension should watch on GitHub issue pages. This is a
separate product surface from the maintainer overlay at <code>apps/gittensory-extension/</code>.
</p>
<form id="settings">
<label>
Watched repositories
<textarea id="watchedRepos" name="watchedRepos" placeholder="owner/repo, one per line"></textarea>
</label>
<button type="submit">Save</button>
</form>
<p id="status" role="status"></p>
</main>
<script src="options.js" type="module"></script>
</body>
</html>
47 changes: 47 additions & 0 deletions apps/gittensory-miner-extension/options.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading