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
72 changes: 72 additions & 0 deletions apps/gittensory-miner-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const toolbarBadgeApi = globalThis.__gittensoryMinerToolbarBadge;

const PING_MESSAGE = "gittensory-miner:ping";
const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context";
const SYNC_RANKED_CANDIDATES_MESSAGE = "gittensory-miner:sync-ranked-candidates";

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (!message || typeof message.type !== "string") return false;
Expand All @@ -20,6 +21,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
);
return true;
}
if (message.type === SYNC_RANKED_CANDIDATES_MESSAGE) {
void syncRankedCandidatesFromMinerUi().then((result) => sendResponse({ ok: true, payload: result }));
return true;
}
return false;
});

Expand Down Expand Up @@ -79,6 +84,69 @@ async function loadRankedCandidates() {
};
}

const DEFAULT_MINER_UI_URL = "http://localhost:5174";
const SYNC_ALARM_NAME = "gittensory-miner:sync-ranked-candidates";
const SYNC_ALARM_PERIOD_MINUTES = 10;

async function loadMinerUiUrl() {
const stored = await chrome.storage.sync.get({ minerUiUrl: DEFAULT_MINER_UI_URL });
const url = typeof stored.minerUiUrl === "string" ? stored.minerUiUrl.trim() : "";
return url || DEFAULT_MINER_UI_URL;
}

/** Live-fetch replacement for the manual copy/paste workflow (#4859): pulls the miner's last discover run's
* ranked candidates from the local miner-ui's read-only /api/ranked-candidates endpoint (packages/gittensory-
* miner/lib/ranked-candidates.js via apps/gittensory-miner-ui/vite-ranked-candidates-api.ts) and writes them
* into the SAME chrome.storage.local keys the manual-paste flow (options.js) already writes
* (rankedCandidates/rankedCandidatesSavedAt) -- so content.js/opportunity-badge.js/toolbar-badge.js need zero
* changes; they already read from that one shared source regardless of which flow populated it.
*
* Never throws: any failure (miner-ui not running, network error, missing auth cookie because the dashboard
* was never opened in this browser, malformed response) resolves to a typed { ok: false } result and leaves
* whatever's already in storage untouched -- the existing manual-paste fallback (or a stale prior fetch) keeps
* working exactly as before, satisfying #4859's "keep paste as a fallback" requirement with no merge logic. */
async function syncRankedCandidatesFromMinerUi() {
const minerUiUrl = await loadMinerUiUrl();
try {
const response = await fetch(`${minerUiUrl}/api/ranked-candidates`);
if (!response.ok) {
return { ok: false, error: `miner UI responded ${response.status}`, minerUiUrl };
}
const payload = await response.json();
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : null;
if (!candidates) {
return { ok: false, error: "miner UI returned an unexpected payload shape", minerUiUrl };
}
const savedAt = Date.now();
await chrome.storage.local.set({ rankedCandidates: candidates, rankedCandidatesSavedAt: savedAt });
return { ok: true, count: candidates.length, savedAt, minerUiUrl };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
minerUiUrl,
};
}
}

// Ambient refresh so live data stays current without the user re-opening the options page: once on service-
// worker startup/install, then every SYNC_ALARM_PERIOD_MINUTES via chrome.alarms (a service worker can be
// killed and woken between calls, so a plain setInterval would not survive -- alarms are the MV3-correct
// primitive for this). Guarded per-API so the unit-test harness (which provides none of these) is a clean
// no-op, matching the toolbar-badge guard below.
if (chrome.alarms) {
chrome.alarms.create(SYNC_ALARM_NAME, { periodInMinutes: SYNC_ALARM_PERIOD_MINUTES });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === SYNC_ALARM_NAME) void syncRankedCandidatesFromMinerUi();
});
}
if (chrome.runtime.onStartup) {
chrome.runtime.onStartup.addListener(() => void syncRankedCandidatesFromMinerUi());
}
if (chrome.runtime.onInstalled) {
chrome.runtime.onInstalled.addListener(() => void syncRankedCandidatesFromMinerUi());
}

// Toolbar-icon badge (#5193). Reads `rankedCandidates` WITHOUT a default so `undefined` still means
// "cache never populated" (a dash), distinct from a populated-but-empty `[]` (cleared text). Read-only.
async function refreshToolbarBadge() {
Expand Down Expand Up @@ -107,9 +175,13 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
globalThis.__gittensoryMinerBackgroundInternals = {
PING_MESSAGE,
ISSUE_CONTEXT_MESSAGE,
SYNC_RANKED_CANDIDATES_MESSAGE,
DEFAULT_MINER_UI_URL,
loadIssueOpportunityContext,
loadMinerExtensionSettings,
loadRankedCandidates,
loadMinerUiUrl,
syncRankedCandidatesFromMinerUi,
refreshToolbarBadge,
};
}
2 changes: 1 addition & 1 deletion apps/gittensory-miner-extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": ["storage"],
"permissions": ["storage", "alarms"],
"host_permissions": ["https://github.com/*", "http://localhost/*", "http://127.0.0.1/*"],
"background": {
"service_worker": "background.js",
Expand Down
12 changes: 11 additions & 1 deletion apps/gittensory-miner-extension/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,24 @@ <h1>LoopOver miner extension</h1>
<textarea id="watchedRepos" name="watchedRepos" placeholder="owner/repo, one per line"></textarea>
</label>
<label>
Ranked candidates JSON (local cache)
Local miner UI URL
<input id="minerUiUrl" name="minerUiUrl" type="url" placeholder="http://localhost:5174" />
</label>
<p>
Ranked candidates sync automatically from the URL above (checked every 10 minutes, and once whenever
Chrome or this extension starts) — no action needed once it's running. Use "Sync now" for an
immediate pull, or paste JSON below by hand as a fallback when the miner UI isn't reachable.
</p>
<label>
Ranked candidates JSON (manual fallback)
<textarea
id="rankedCandidatesJson"
name="rankedCandidatesJson"
placeholder='[{"repoFullName":"owner/repo","issueNumber":1,"rankScore":0.82,...}]'
></textarea>
</label>
<button type="submit">Save</button>
<button type="button" id="syncNow">Sync ranked candidates now</button>
</form>
<p id="status" role="status"></p>
</main>
Expand Down
42 changes: 39 additions & 3 deletions apps/gittensory-miner-extension/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,37 @@ async function removeLegacyDiscoveryIndexUrl() {
await chrome.storage.sync.remove("discoveryIndexUrl");
}

// Mirrors background.js's own literal (#4859) -- these classic (non-ESM-importing) extension scripts share a
// message-type "protocol" via matching string literals, the same convention content.js already uses for
// ISSUE_CONTEXT_MESSAGE, not a cross-file import.
const SYNC_RANKED_CANDIDATES_MESSAGE = "gittensory-miner:sync-ranked-candidates";
const DEFAULT_MINER_UI_URL = "http://localhost:5174";

function normalizeMinerUiUrl(text) {
const trimmed = String(text ?? "").trim();
return trimmed || DEFAULT_MINER_UI_URL;
}

if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
globalThis.__gittensoryMinerOptionsInternals = {
parseWatchedRepos,
parseRankedCandidatesJson,
removeLegacyDiscoveryIndexUrl,
normalizeMinerUiUrl,
MAX_RANKED_CANDIDATES_JSON_BYTES,
SYNC_RANKED_CANDIDATES_MESSAGE,
DEFAULT_MINER_UI_URL,
};
}

const form = document.querySelector("#settings");
const status = document.querySelector("#status");
const watchedRepos = document.querySelector("#watchedRepos");
const rankedCandidatesJson = document.querySelector("#rankedCandidatesJson");
const minerUiUrl = document.querySelector("#minerUiUrl");
const syncNow = document.querySelector("#syncNow");

if (!form || !status || !watchedRepos || !rankedCandidatesJson) {
if (!form || !status || !watchedRepos || !rankedCandidatesJson || !minerUiUrl || !syncNow) {
// options.html is not mounted (unit-test harness or partial load).
} else {
void refreshSettings();
Expand All @@ -64,7 +80,7 @@ form.addEventListener("submit", async (event) => {
try {
const repos = parseWatchedRepos(watchedRepos.value);
const rankedCandidates = parseRankedCandidatesJson(rankedCandidatesJson.value);
await chrome.storage.sync.set({ watchedRepos: repos });
await chrome.storage.sync.set({ watchedRepos: repos, minerUiUrl: normalizeMinerUiUrl(minerUiUrl.value) });
await chrome.storage.local.set({ rankedCandidates, rankedCandidatesSavedAt: Date.now() });
await refreshSettings();
showStatus(
Expand All @@ -76,14 +92,34 @@ form.addEventListener("submit", async (event) => {
showStatus(error instanceof Error ? error.message : String(error));
}
});

// Live-fetch trigger (#4859): asks background.js's syncRankedCandidatesFromMinerUi to pull the miner-ui's
// current ranked candidates immediately, without waiting for the ambient alarm. Saves the URL field first so
// a URL the user just typed (but hasn't submitted the form for yet) is what gets used.
syncNow.addEventListener("click", async () => {
try {
await chrome.storage.sync.set({ minerUiUrl: normalizeMinerUiUrl(minerUiUrl.value) });
const response = await chrome.runtime.sendMessage({ type: SYNC_RANKED_CANDIDATES_MESSAGE });
const result = response?.payload;
if (!result?.ok) {
showStatus(`Could not reach the miner UI at ${result?.minerUiUrl ?? minerUiUrl.value}: ${result?.error ?? "unknown error"}. Falling back to the pasted JSON below.`);
return;
}
await refreshSettings();
showStatus(`Synced ${result.count} ranked candidate(s) from ${result.minerUiUrl}.`);
} catch (error) {
showStatus(error instanceof Error ? error.message : String(error));
}
});
}

async function refreshSettings() {
const stored = await chrome.storage.sync.get({ watchedRepos: [] });
const stored = await chrome.storage.sync.get({ watchedRepos: [], minerUiUrl: DEFAULT_MINER_UI_URL });
await removeLegacyDiscoveryIndexUrl();
const local = await chrome.storage.local.get({ rankedCandidates: [] });
const repos = Array.isArray(stored.watchedRepos) ? stored.watchedRepos : [];
watchedRepos.value = repos.join("\n");
minerUiUrl.value = normalizeMinerUiUrl(stored.minerUiUrl);
const rankedCandidates = Array.isArray(local.rankedCandidates) ? local.rankedCandidates : [];
rankedCandidatesJson.value =
rankedCandidates.length > 0 ? JSON.stringify(rankedCandidates, null, 2) : "";
Expand Down
11 changes: 10 additions & 1 deletion test/unit/miner-extension-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ describe("miner extension opportunity badge", () => {
"#status": { textContent: "" },
"#watchedRepos": { value: "JSONbored/gittensory" },
"#rankedCandidatesJson": { value: "" },
"#minerUiUrl": { value: "" },
"#syncNow": { addEventListener: () => {} },
};
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
Expand Down Expand Up @@ -343,6 +345,8 @@ describe("miner extension opportunity badge", () => {
"#status": { textContent: "" },
"#watchedRepos": { value: "JSONbored/gittensory" },
"#rankedCandidatesJson": { value: "[]" },
"#minerUiUrl": { value: "" },
"#syncNow": { addEventListener: () => {} },
};
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
Expand Down Expand Up @@ -394,6 +398,8 @@ describe("miner extension opportunity badge", () => {
"#status": { textContent: "" },
"#watchedRepos": { value: "" },
"#rankedCandidatesJson": { value: "" },
"#minerUiUrl": { value: "" },
"#syncNow": { addEventListener: () => {} },
};
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
Expand Down Expand Up @@ -432,7 +438,10 @@ describe("miner extension opportunity badge", () => {
await elements["#settings"].dispatchSubmit();

expect(setCalls).toHaveLength(1);
expect(setCalls[0]).toEqual({ watchedRepos: ["JSONbored/gittensory"] });
expect(setCalls[0]).toEqual({
watchedRepos: ["JSONbored/gittensory"],
minerUiUrl: "http://localhost:5174",
});
expect(removeCalls).toEqual(["discoveryIndexUrl", "discoveryIndexUrl"]);
expect("discoveryIndexUrl" in synced).toBe(false);
});
Expand Down
Loading
Loading