diff --git a/.claude/skills/contributing-to-loopover/SKILL.md b/.claude/skills/contributing-to-loopover/SKILL.md index 127392c3d3..daae0f124e 100644 --- a/.claude/skills/contributing-to-loopover/SKILL.md +++ b/.claude/skills/contributing-to-loopover/SKILL.md @@ -241,9 +241,6 @@ npm audit --audit-level=moderate # the dependency-review job's local eq `command-reference:check`, `ui:lint`, `ui:typecheck`, `ui:test`, `ui:build`. If any step fails, fix it and re-run — do not push a red tree. (Full per-check table in `reference.md`; check `package.json`'s own `test:ci` script if this list and that script ever disagree — the script is the source of truth.) -One CI-gating exception `test:ci` does **not** run: the extension lint/typecheck checks -(`extension:lint`, `extension:typecheck`, `miner-extension:lint`, `miner-extension:typecheck`), gated in -CI's `validate-code` on `push || ui==true` — run them separately if you touch the VS Code / miner extensions. If `ui:lint` fails on formatting, run `npm --workspace @loopover/ui run format`. If `ui:openapi:check` fails, you forgot Phase 4's `ui:openapi`. diff --git a/.claude/skills/contributing-to-loopover/reference.md b/.claude/skills/contributing-to-loopover/reference.md index 71b2836b3e..009f7b08b1 100644 --- a/.claude/skills/contributing-to-loopover/reference.md +++ b/.claude/skills/contributing-to-loopover/reference.md @@ -96,20 +96,12 @@ build && npm --workspace @loopover/ui run preview`, or `npm run ui:preview` from builds everything first), to actually exercise the production build locally. `test/unit/loopover-ui-preview-script.test.ts` regression-guards the script itself against reverting to `vite preview`. -| ui → extension lint | `eslint` (VS Code + miner extensions) | `npm run extension:lint && npm run miner-extension:lint` | extension ESLint error (same `push \|\| ui==true` trigger as the `ui →` rows) | -| ui → extension typecheck | `tsc --noEmit` (extensions) | `npm run extension:typecheck && npm run miner-extension:typecheck` | extension type error (same `push \|\| ui==true` trigger) | | changes → dependency review (PR only) | dependency-review (moderate+; a step inside the `changes` job since 2026-07-24, not a separate job) | `npm audit --audit-level=moderate` | a **newly added** dep has a moderate+ advisory | **One command for *almost* everything except the dependency review:** `npm run test:ci`. There is **no** CodeQL/Analyze workflow in this repo. There is **no** root-level Prettier gate — Prettier is enforced only inside `ui:lint` (so it only bites `apps/loopover-ui/**`). -**One CI-gating exception `test:ci` does NOT cover:** the four extension lint/typecheck checks -(`extension:lint`, `extension:typecheck`, `miner-extension:lint`, `miner-extension:typecheck`) run in CI's -`validate-code` job (gated on `push || ui==true`, the `ui →` rows above) but are **absent from the `test:ci` -chain** — so a green local `test:ci` does not exercise them. Run them separately (or rely on CI) if your -change touches `apps/loopover-extension/**` or `apps/loopover-miner-extension/**`. - **Local-only checks with no separate named CI status — `npm run test:ci` is the only thing that catches these for a normal PR:** diff --git a/.claude/skills/contributor-pipeline-gardening/reference.md b/.claude/skills/contributor-pipeline-gardening/reference.md index a8b7e59dc8..0cedb9e93d 100644 --- a/.claude/skills/contributor-pipeline-gardening/reference.md +++ b/.claude/skills/contributor-pipeline-gardening/reference.md @@ -30,7 +30,7 @@ Two products, self-host-first: - **AMS (Autonomous Miner System)** — `packages/loopover-miner` (npm: `@loopover/miner`) + `packages/loopover-engine` (npm: `@loopover/engine`, shared core also used by ORB) + - `apps/loopover-miner-ui` + `apps/loopover-miner-extension`. The contributor/miner side: finds + `apps/loopover-miner-ui`. The contributor/miner side: finds issues, plans, writes code, opens PRs, autonomously. Self-host (a local Miner Node) is the only shipped deployment target; hosted AMS is a later phase (see "AMS/ORB Cloud Readiness" below). - **ORB (Owner/One-shot Review Brain)** — `src/**` (the Worker app: `src/review`, `src/queue`, diff --git a/.github/actions/deploy-ui-preview/action.yml b/.github/actions/deploy-ui-preview/action.yml index b38a6baf44..8e4ce148c3 100644 --- a/.github/actions/deploy-ui-preview/action.yml +++ b/.github/actions/deploy-ui-preview/action.yml @@ -62,8 +62,8 @@ runs: test -d client || { echo "::error::bundle missing client/ assets dir"; exit 1; } # 3) Allowlist file extensions — fail on anything that isn't a normal web/build output (blocks # smuggled scripts/binaries). A few extensionless CF asset files are explicitly permitted. - # `zip` covers the served downloads (e.g. /downloads/loopover-extension.zip) — a passive - # static asset wrangler only uploads (never executes), so allowing it doesn't run fork code. + # `zip` covers any served static download the app might publish under public/downloads/ — a + # passive static asset wrangler only uploads (never executes), so allowing it doesn't run fork code. # `.nojekyll` is a zero-byte, purely-advisory host-compatibility marker the SSR build tooling # (Nitro, TanStack Start's build layer) emits into client/ by default for broad static-host # portability — no executable content, same risk profile as _headers/_redirects/.assetsignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7696caa6b..cc50e0d68e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,17 +138,13 @@ jobs: observability: - 'grafana/dashboards/**' - 'prometheus/rules/**' - # The UI's own app/extension code -- triggers the FULL toolchain (lint/typecheck/test/build). + # The UI's own app code -- triggers the FULL toolchain (lint/typecheck/test/build). # A dependency bump (package.json/package-lock.json) stays here too since it can break the UI # build or types in ways only that full toolchain would catch. ui: - 'apps/loopover-ui/**' - 'apps/loopover-miner-ui/**' - - 'apps/loopover-extension/**' - - 'apps/loopover-miner-extension/**' - 'packages/loopover-ui-kit/**' - - 'scripts/build-extension.ts' - - 'scripts/build-miner-extension.ts' - 'package.json' - 'package-lock.json' # Backend changes that can drift the OpenAPI contract the UI type-checks against, but say @@ -448,7 +444,7 @@ jobs: # The matching "Save Turborepo cache" step is NOT alongside this one -- it's moved all the way down # to after "UI build" (the last step in this job that invokes turbo), since actions/cache/save takes # one synchronous snapshot at the moment it runs and every later turbo-routed step in between (Build - # MCP, Build miner CLI, Build UI-kit package, UI lint/typecheck, Extension lint/typecheck, UI build) + # MCP, Build miner CLI, Build UI-kit package, UI lint/typecheck, UI build) # would otherwise write to .turbo/cache AFTER the snapshot already happened, so none of their cache # entries would ever persist cross-run. Confirmed this was a real gap (not just theoretical) by # reading the actual step order before moving it. @@ -459,7 +455,7 @@ jobs: # shards) raced for the SAME cache key. actions/cache/save no-ops on a duplicate key within a run (see # the comment on validate-tests' own Save step below), and a validate-tests shard reaches its Save # step after only ~6 steps versus this job's ~20+, so a shard almost always won the race -- meaning - # the richer cache this job builds (engine + mcp + miner + ui-kit + ui + extension) was very likely + # the richer cache this job builds (engine + mcp + miner + ui-kit + ui) was very likely # silently discarded in favor of a shard's much leaner engine-only save, defeating most of the point # of the Save-repositioning fix above. Splitting the prefixes gives each job's Restore/Save pair its # own independent lineage instead of racing. @@ -729,7 +725,7 @@ jobs: # docs, manifest, engine-parity, branding, .nvmrc, release-manifest, observability, the MCP # version audit just above, ...) -- any ONE of those failing without continue-on-error otherwise # skips every step after it by GitHub Actions' own default, INCLUDING this build. "UI tests - # (ui-miner)"/"UI tests (miner-extension)" already had this guard (see their own comment below); + # (ui-miner)" already had this guard (see its own comment below); # this build step and everything through "UI tests (ui)" didn't, so a same-day MCP release # tripping the version-audit step above skipped ui-kit's build entirely, and ui-miner's tests # (which DID still run, guarded) then failed with a confusing "Failed to resolve import @@ -773,8 +769,8 @@ jobs: # task for any package (per the plan doc: test orchestration stays out of Turborepo entirely, so # Codecov's codecov/patch keeps one centralized source of truth), so this can't use the same # union-`--filter` fix already applied to "UI lint"/"UI typecheck" two steps above. Plain `&&` meant a - # failing @loopover/ui test silently prevented @loopover/ui-miner's and @loopover/miner-extension's - # tests from ever running in that invocation -- same class of failure-masking bug those two steps' + # failing @loopover/ui test silently prevented @loopover/ui-miner's tests from ever running in that + # invocation -- same class of failure-masking bug those two steps' # own comment already describes, just still present here. `!cancelled()` on all 3 (not `always()`) # matches the pattern already used by "Save Turborepo cache"/"Save TypeScript incremental build # cache" elsewhere in this file: still run after an earlier step's failure, just not after the job @@ -785,38 +781,20 @@ jobs: - name: UI tests (ui-miner) if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} run: npm --workspace @loopover/ui-miner run test - - name: UI tests (miner-extension) - if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} - run: npm --workspace @loopover/miner-extension run test - # Same rationale and --filter union semantics as "UI lint"/"UI typecheck" above -- and the same - # `!cancelled()` need as "Build UI-kit package" through "UI tests (ui)" above: an earlier - # unrelated step failing (a drift check, the MCP version audit) would otherwise skip these too, - # silently hiding real Extension lint/typecheck results behind it. - - name: Extension lint - if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} - run: npx turbo run lint --filter=@loopover/extension --filter=@loopover/miner-extension - - name: Extension typecheck - if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} - run: npx turbo run typecheck --filter=@loopover/extension --filter=@loopover/miner-extension # `npm run ui:build` also regenerates apps/loopover-ui/public/openapi.json (needed for a # standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift # check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this # step runs, that check already ran and passed in this same job -- the committed spec is # already byte-identical to what regenerating it here would produce. apps/loopover-ui's own # `build` script (vite build) doesn't touch openapi.json either, so routing through Turborepo - # preserves that skip: @loopover/ui#build's dependsOn (turbo.json) is - # ["^build", "@loopover/extension#build", "@loopover/miner-extension#build"] -- the exact same - # extension + miner-extension build pair this step ran explicitly before, now resolved by turbo's - # own graph instead of hand-chained `&&`, plus ^build (ui-kit, already a same-job cache hit from - # "Build UI-kit package" above). Verified locally: deleting the committed-gitignored extension zip - # and running this exact invocation regenerates it correctly (the cross-package outputs edge is - # honored, not just replayed from cache). + # preserves that skip: @loopover/ui#build's dependsOn (turbo.json) is just ["^build"] (ui-kit, + # already a same-job cache hit from "Build UI-kit package" above). - name: UI build if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} run: npx turbo run build --filter=@loopover/ui # Paired with "Restore Turborepo cache" above (same key, same gate condition) -- deliberately # positioned here, after every turbo-routed step in this job (Build engine package, Build MCP, Build - # miner CLI, Build UI-kit package, UI lint/typecheck, Extension lint/typecheck, UI build), rather than + # miner CLI, Build UI-kit package, UI lint/typecheck, UI build), rather than # immediately after "Build engine package" the way it was originally: actions/cache/save takes one # synchronous snapshot at the moment it runs, so anything written to .turbo/cache by a later step # would never be captured if this ran earlier. `!cancelled()` (not `always()`) still saves whatever @@ -896,7 +874,7 @@ jobs: # actions/cache/save no-ops on a duplicate key within a run -- see the shard-vs-shard version of that # same race below. Because a shard reaches its own Save after only ~6 steps versus validate-code's # ~20+, a shard almost always won that race, meaning validate-code's much richer cache (engine + mcp + - # miner + ui-kit + ui + extension) was very likely discarded every run in favor of a shard's + # miner + ui-kit + ui) was very likely discarded every run in favor of a shard's # engine-only one. Splitting the prefixes gives each job's own lineage instead of racing; within THIS # prefix, the 6 shards below still race each other for one slot, which is fine since they all save the # exact same content (engine + mcp + miner builds all run in this job) -- whichever shard wins, the diff --git a/.github/workflows/ui-deploy.yml b/.github/workflows/ui-deploy.yml index e154550aac..b89b7a1a4e 100644 --- a/.github/workflows/ui-deploy.yml +++ b/.github/workflows/ui-deploy.yml @@ -37,4 +37,4 @@ jobs: - name: Validate frontend env: VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai - run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm run extension:lint && npm run miner-extension:lint && npm run extension:typecheck && npm run miner-extension:typecheck && npm run extension:build && npm run miner-extension:build && npm --workspace @loopover/ui run build + run: npm run ui:openapi:check && npm run ui:lint && npm run ui:typecheck && npm --workspace @loopover/ui run build diff --git a/.gitignore b/.gitignore index 35a55cdfd4..2acd095f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,6 @@ coverage/ *.tsbuildinfo site/.vitepress/cache/ !migrations/*.sql -apps/loopover-ui/public/downloads/loopover-extension.zip .worker-configuration.gen-check.d.ts # Ad-hoc operator backup files (e.g. `cp file.yml file.yml.bak-notes-20260707`) -- general # catch-alls so a stray manual snapshot never dirties `git status` on a Git-backed self-host diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 752e5ee1c2..4ac4183333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -134,7 +134,7 @@ Every PR should include: - A link to a currently open issue this PR resolves (e.g. `Closes #123`) — there is no exemption for explaining why an issue isn't needed. - The exact validation commands run from the repo root. -- JPG/JPEG or PNG screenshot evidence for visible UI, frontend, docs, or extension changes, +- JPG/JPEG or PNG screenshot evidence for visible UI, frontend, or docs changes, attached in the PR description as organized, captioned, clickable thumbnails. SVG screenshots are not accepted as review evidence. Recordings/GIFs are supplemental to (never a replacement for) static screenshots for anything a still image CAN show — but for an effect a static image @@ -323,7 +323,7 @@ docs(contributing): clarify review gates ``` Use one of these types: `feat`, `fix`, `test`, `docs`, `refactor`, `build`, `ci`, `chore`, or -`revert`. Keep the scope lowercase and specific, such as `api`, `ui`, `mcp`, `extension`, `auth`, +`revert`. Keep the scope lowercase and specific, such as `api`, `ui`, `mcp`, `auth`, `github`, `signals`, `data`, `docs`, or `release`. Avoid vague scopes or summaries such as `misc`, `general`, `updates`, `update stuff`, or `small tweaks`. Do not end PR titles with a trailing period. Release PR titles must use `chore(release): `. diff --git a/README.md b/README.md index f605ceaae6..0e5e0c451b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,6 @@ See [Tuning your reviews](https://loopover.ai/docs/tuning) for the full flag, se | Miner package | [`@loopover/miner`](packages/loopover-miner/README.md) — local foundation CLI for the autonomous miner runtime | | API | [API browser](https://loopover.ai/api) and [OpenAPI JSON](https://api.loopover.ai/openapi.json) | | GitHub App | [Setup docs](https://loopover.ai/docs/github-app) — self-hosting is the only currently available path | -| Browser extension | [Extension page](https://loopover.ai/extension) | ## MCP Install diff --git a/apps/loopover-extension/.gitignore b/apps/loopover-extension/.gitignore deleted file mode 100644 index 849ddff3b7..0000000000 --- a/apps/loopover-extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/apps/loopover-extension/auth.js b/apps/loopover-extension/auth.js deleted file mode 100644 index 2e72b3dfa1..0000000000 --- a/apps/loopover-extension/auth.js +++ /dev/null @@ -1,177 +0,0 @@ -export const DEFAULT_API_ORIGIN = "https://api.loopover.ai"; -export const EXTENSION_SESSION_REQUIRED_MESSAGE = "Set an extension session token in LoopOver extension options."; -export const EXTENSION_SESSION_EXPIRED_MESSAGE = "Extension session expired or revoked. Create a fresh extension token in LoopOver."; - -export const STORAGE_KEYS = { - apiOrigin: "apiOrigin", - sessionToken: "sessionToken", - sessionExpiresAt: "sessionExpiresAt", - sessionLogin: "sessionLogin", - sessionScopes: "sessionScopes", - lastAuthenticatedAt: "lastAuthenticatedAt", -}; - -const LOCAL_SESSION_KEYS = [ - STORAGE_KEYS.sessionToken, - STORAGE_KEYS.sessionExpiresAt, - STORAGE_KEYS.sessionLogin, - STORAGE_KEYS.sessionScopes, - STORAGE_KEYS.lastAuthenticatedAt, -]; - -const GITHUB_TOKEN_PREFIX_PATTERN = /^(ghp|gho|ghu|ghs|ghr|github_pat)_/i; -const LOOPOVER_SESSION_PATTERN = /^gts_[a-f0-9]{64}$/i; - -export function extensionStorage(chromeLike = globalThis.chrome) { - if (!chromeLike?.storage?.local || !chromeLike?.storage?.sync) { - throw new Error("Browser extension storage is unavailable."); - } - return { local: chromeLike.storage.local, sync: chromeLike.storage.sync }; -} - -export function normalizeApiOrigin(value) { - const raw = typeof value === "string" && value.trim() ? value.trim() : DEFAULT_API_ORIGIN; - try { - const url = new URL(raw); - if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { - return DEFAULT_API_ORIGIN; - } - return url.origin; - } catch { - return DEFAULT_API_ORIGIN; - } -} - -export function looksLikeGitHubPersonalAccessToken(value) { - return GITHUB_TOKEN_PREFIX_PATTERN.test(String(value ?? "").trim()); -} - -export function validateExtensionSessionToken(value) { - const token = String(value ?? "").trim(); - if (!token) throw new Error(EXTENSION_SESSION_REQUIRED_MESSAGE); - if (looksLikeGitHubPersonalAccessToken(token)) { - throw new Error("GitHub personal access tokens are not accepted. Create a LoopOver extension token instead."); - } - if (!LOOPOVER_SESSION_PATTERN.test(token)) { - throw new Error("Extension tokens must be LoopOver session tokens that start with gts_."); - } - return token; -} - -export async function loadExtensionSession(storage = extensionStorage()) { - const [syncState, localState] = await Promise.all([ - storage.sync.get([STORAGE_KEYS.apiOrigin, ...LOCAL_SESSION_KEYS]), - storage.local.get(LOCAL_SESSION_KEYS), - ]); - await purgeLegacySyncSession(syncState, storage); - const sessionToken = typeof localState.sessionToken === "string" ? localState.sessionToken : ""; - const expiresAt = typeof localState.sessionExpiresAt === "string" ? localState.sessionExpiresAt : ""; - const sessionScopes = Array.isArray(localState.sessionScopes) - ? localState.sessionScopes.filter((scope) => typeof scope === "string") - : []; - return { - apiOrigin: normalizeApiOrigin(syncState.apiOrigin), - sessionToken, - expiresAt, - login: typeof localState.sessionLogin === "string" ? localState.sessionLogin : "", - scopes: sessionScopes, - lastAuthenticatedAt: typeof localState.lastAuthenticatedAt === "string" ? localState.lastAuthenticatedAt : "", - expired: isExpired(expiresAt), - }; -} - -export async function saveExtensionApiOrigin(apiOrigin, storage = extensionStorage()) { - await storage.sync.set({ [STORAGE_KEYS.apiOrigin]: normalizeApiOrigin(apiOrigin) }); -} - -export async function storeExtensionSessionToken(session, storage = extensionStorage()) { - const token = validateExtensionSessionToken(session?.token); - const next = { - [STORAGE_KEYS.sessionToken]: token, - [STORAGE_KEYS.sessionExpiresAt]: typeof session?.expiresAt === "string" ? session.expiresAt.trim() : "", - [STORAGE_KEYS.sessionLogin]: typeof session?.login === "string" ? session.login.trim() : "", - [STORAGE_KEYS.sessionScopes]: Array.isArray(session?.scopes) - ? session.scopes.filter((scope) => typeof scope === "string") - : [], - [STORAGE_KEYS.lastAuthenticatedAt]: new Date().toISOString(), - }; - await storage.local.set(next); - await storage.sync.remove(LOCAL_SESSION_KEYS); - return next; -} - -export async function clearExtensionSession(storage = extensionStorage()) { - await storage.local.remove(LOCAL_SESSION_KEYS); - await storage.sync.remove(LOCAL_SESSION_KEYS); -} - -export async function requireUsableExtensionSession(storage = extensionStorage()) { - const session = await loadExtensionSession(storage); - if (!session.sessionToken) throw new Error(EXTENSION_SESSION_REQUIRED_MESSAGE); - if (session.expired) { - await clearExtensionSession(storage); - throw new Error(EXTENSION_SESSION_EXPIRED_MESSAGE); - } - return session; -} - -export async function requestPullContext(target, options = {}) { - const storage = options.storage ?? extensionStorage(); - const session = await requireUsableExtensionSession(storage); - const url = new URL("/v1/extension/pull-context", session.apiOrigin); - url.searchParams.set("owner", target.owner); - url.searchParams.set("repo", target.repo); - url.searchParams.set("pullNumber", String(target.pullNumber)); - return fetchExtensionJson(url, session.sessionToken, { ...options, storage }); -} - -export async function logoutExtensionSession(options = {}) { - const storage = options.storage ?? extensionStorage(); - const fetchImpl = options.fetchImpl ?? fetch; - const session = await loadExtensionSession(storage); - if (session.sessionToken && !session.expired) { - const url = new URL("/v1/auth/logout", session.apiOrigin); - await fetchImpl(url.toString(), { - method: "POST", - headers: { - accept: "application/json", - authorization: `Bearer ${session.sessionToken}`, - }, - }).catch(() => undefined); - } - await clearExtensionSession(storage); - return { ok: true }; -} - -async function fetchExtensionJson(url, token, options = {}) { - const storage = options.storage ?? extensionStorage(); - const fetchImpl = options.fetchImpl ?? fetch; - const response = await fetchImpl(url.toString(), { - headers: { - accept: "application/json", - authorization: `Bearer ${token}`, - }, - }); - const payload = await response.json().catch(() => ({})); - if (response.status === 401 || isExtensionAuthFailure(response.status, payload)) { - await clearExtensionSession(storage); - throw new Error(EXTENSION_SESSION_EXPIRED_MESSAGE); - } - if (!response.ok) throw new Error(typeof payload.error === "string" ? payload.error : `${response.status} ${response.statusText}`); - return payload; -} - -function isExpired(expiresAt, now = Date.now()) { - if (!expiresAt) return false; - const time = Date.parse(expiresAt); - return Number.isFinite(time) && time <= now; -} - -function isExtensionAuthFailure(status, payload) { - if (status !== 403) return false; - return ["browser_session_required", "extension_session_required", "insufficient_scope", "unauthorized"].includes(String(payload?.error ?? "")); -} - -async function purgeLegacySyncSession(syncState, storage) { - if (LOCAL_SESSION_KEYS.some((key) => syncState[key] !== undefined)) await storage.sync.remove(LOCAL_SESSION_KEYS); -} diff --git a/apps/loopover-extension/background.js b/apps/loopover-extension/background.js deleted file mode 100644 index 070a794256..0000000000 --- a/apps/loopover-extension/background.js +++ /dev/null @@ -1,8 +0,0 @@ -import { logoutExtensionSession, requestPullContext } from "./auth.js"; - -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (!message || !["loopover:pull-context", "loopover:logout"].includes(message.type)) return false; - const task = message.type === "loopover:logout" ? logoutExtensionSession() : requestPullContext(message); - void task.then((payload) => sendResponse({ ok: true, payload })).catch((error) => sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) })); - return true; -}); diff --git a/apps/loopover-extension/content.js b/apps/loopover-extension/content.js deleted file mode 100644 index f5e58b5306..0000000000 --- a/apps/loopover-extension/content.js +++ /dev/null @@ -1,196 +0,0 @@ -const target = matchGitHubPageTarget(location.pathname); - -if (target) { - mountOverlay(target); -} - -// #7462: pull-request pages only — issue classification was dead (manifest matched issues/* -// but nothing consumed kind:"issue", and there is no issue-context backend route). -function matchGitHubPageTarget(pathname) { - const match = String(pathname ?? "").match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/|$)/); - if (!match) return null; - const [, owner, repo, number] = match; - return { kind: "pull_request", owner, repo, pullNumber: Number(number) }; -} - -function mountOverlay(target) { - if (document.querySelector("[data-loopover-pr-context]")) return; - const container = document.createElement("aside"); - const host = findPullRequestSidebar(); - container.className = `loopover-overlay ${host ? "loopover-overlay--sidebar" : "loopover-overlay--floating"}`; - container.dataset.loopoverPrContext = "true"; - container.innerHTML = ` -
- G - LoopOver - Private - -
-
Loading private context...
- `; - if (host) { - host.prepend(container); - } else { - document.body.appendChild(container); - } - const load = createOverlayLoader(container, target); - const refresh = container.querySelector(".loopover-overlay__refresh"); - refresh?.addEventListener("click", () => load()); - void load(); -} - -function findPullRequestSidebar() { - return ( - document.querySelector("#partial-discussion-sidebar") || - document.querySelector("[data-testid='pr-sidebar']") || - document.querySelector(".Layout-sidebar") || - document.querySelector(".discussion-sidebar") - ); -} - -function createOverlayLoader(container, target) { - let latestTicket = 0; - return async function load() { - const body = container.querySelector(".loopover-overlay__body"); - if (!body) return; - const ticket = ++latestTicket; - body.textContent = "Loading private context..."; - const response = await chrome.runtime.sendMessage({ type: "loopover:pull-context", ...target }); - // A newer load() has started while this request was in flight; discard the stale response. - if (ticket !== latestTicket) return; - if (!response?.ok) { - body.innerHTML = `
${escapeHtml(response?.error || "Context unavailable")}
`; - return; - } - body.innerHTML = renderPullContext(response.payload); - renderActions(body, response.payload?.actions); - }; -} - -function renderPullContext(payload) { - const sections = Array.isArray(payload?.sections) ? payload.sections : []; - if (sections.length > 0) { - return sections.map(renderSection).join(""); - } - return renderLegacyPanels(payload); -} - -function renderSection(section) { - const rows = Array.isArray(section?.rows) ? section.rows : []; - const items = Array.isArray(section?.items) ? section.items : []; - const actions = Array.isArray(section?.actions) ? section.actions : []; - const tone = ["good", "warn", "neutral", "private"].includes(section?.tone) ? section.tone : "neutral"; - return ` -
-
- ${escapeHtml(section?.label || "Panel")} - ${escapeHtml(section?.badge || "live")} -
- ${rows.length > 0 ? `
${rows.map((row) => `
${escapeHtml(row.label || "")}
${escapeHtml(row.value || "")}
`).join("")}
` : ""} - ${items.length > 0 ? `` : ""} - ${actions.length > 0 ? `
    ${actions.map((action) => `
  1. ${escapeHtml(action)}
  2. `).join("")}
` : ""} -
- `; -} - -function renderLegacyPanels(payload) { - const panels = Array.isArray(payload?.panels) ? payload.panels : []; - if (panels.length === 0) return `
No cached private context is available for this pull request.
`; - return panels - .map( - (panel) => ` -
-
- ${escapeHtml(panel.label || "Panel")} - ${escapeHtml(panel.badge || "live")} -
-
- ${(Array.isArray(panel.rows) ? panel.rows : []) - .map((row) => `
${escapeHtml(row.k || "")}
${escapeHtml(row.v || "")}
`) - .join("")} -
-
- `, - ) - .join(""); -} - -function escapeHtml(value) { - return String(value).replace(/[&<>"']/g, (char) => { - switch (char) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - default: - return "'"; - } - }); -} - -function renderActions(body, actions) { - const list = Array.isArray(actions) ? actions : []; - if (list.length === 0) return; - const container = document.createElement("section"); - container.className = "loopover-overlay__panel loopover-overlay__panel--private"; - container.innerHTML = ` -
- Actions - extension -
-
- `; - const actionsNode = container.querySelector(".loopover-overlay__action-buttons"); - if (!actionsNode) return; - for (const action of list) { - if (action?.id === "copy_public_safe_packet" && typeof action?.markdown === "string") { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = "Copy public-safe packet"; - button.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText(action.markdown); - button.textContent = "Copied"; - window.setTimeout(() => { - button.textContent = "Copy public-safe packet"; - }, 1400); - } catch { - button.textContent = "Copy failed"; - } - }); - actionsNode.appendChild(button); - continue; - } - if (action?.id === "view_private_blockers" && Array.isArray(action?.blockers)) { - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "Private blockers"; - details.appendChild(summary); - const listNode = document.createElement("ul"); - for (const blocker of action.blockers.slice(0, 8)) { - const item = document.createElement("li"); - item.textContent = String(blocker?.detail ?? ""); - listNode.appendChild(item); - } - details.appendChild(listNode); - actionsNode.appendChild(details); - } - } - body.appendChild(container); -} - -if (globalThis.__LOOPOVER_EXTENSION_TEST__) { - globalThis.__loopoverContentInternals = { - matchGitHubPageTarget, - createOverlayLoader, - renderPullContext, - renderSection, - renderLegacyPanels, - renderActions, - escapeHtml, - }; -} diff --git a/apps/loopover-extension/manifest.json b/apps/loopover-extension/manifest.json deleted file mode 100644 index 20a236432a..0000000000 --- a/apps/loopover-extension/manifest.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "manifest_version": 3, - "name": "LoopOver Maintainer Overlay", - "description": "Private LoopOver pull request context on GitHub.", - "version": "0.1.0", - "permissions": ["storage"], - "host_permissions": ["https://github.com/*", "https://api.loopover.ai/*"], - "background": { - "service_worker": "background.js", - "type": "module" - }, - "content_scripts": [ - { - "matches": ["https://github.com/*/*/pull/*"], - "js": ["content.js"], - "css": ["styles.css"], - "run_at": "document_idle" - } - ], - "options_page": "options.html", - "action": { - "default_title": "LoopOver" - } -} diff --git a/apps/loopover-extension/options.html b/apps/loopover-extension/options.html deleted file mode 100644 index 0aa269a032..0000000000 --- a/apps/loopover-extension/options.html +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - LoopOver Extension - - - -
-

LoopOver extension

-

Paste an extension session token from the LoopOver app. Session tokens stay in browser local storage, are never synced, and GitHub personal access tokens are rejected.

-
- - - -

-
- - - -
-
-

-
- - - diff --git a/apps/loopover-extension/options.js b/apps/loopover-extension/options.js deleted file mode 100644 index d71903bae7..0000000000 --- a/apps/loopover-extension/options.js +++ /dev/null @@ -1,80 +0,0 @@ -import { - DEFAULT_API_ORIGIN, - clearExtensionSession, - loadExtensionSession, - logoutExtensionSession, - saveExtensionApiOrigin, - storeExtensionSessionToken, -} from "./auth.js"; - -const form = document.querySelector("#settings"); -const status = document.querySelector("#status"); -const apiOrigin = document.querySelector("#apiOrigin"); -const sessionToken = document.querySelector("#sessionToken"); -const sessionExpiresAt = document.querySelector("#sessionExpiresAt"); -const sessionSummary = document.querySelector("#sessionSummary"); -const logout = document.querySelector("#logout"); -const clearLocal = document.querySelector("#clearLocal"); - -void refreshSettings(); - -form.addEventListener("submit", async (event) => { - event.preventDefault(); - try { - await saveExtensionApiOrigin(apiOrigin.value.trim() || DEFAULT_API_ORIGIN); - const token = sessionToken.value.trim(); - if (token) { - await storeExtensionSessionToken({ token, expiresAt: sessionExpiresAt.value.trim(), scopes: ["extension:pull_context"] }); - } - await refreshSettings(); - showStatus(token ? "Extension session saved locally." : "Settings saved."); - } catch (error) { - showStatus(error instanceof Error ? error.message : String(error)); - } -}); - -logout.addEventListener("click", async () => { - logout.disabled = true; - try { - await logoutExtensionSession(); - await refreshSettings(); - showStatus("Logged out and cleared the local extension session."); - } catch (error) { - await clearExtensionSession(); - await refreshSettings(); - showStatus(error instanceof Error ? error.message : String(error)); - } finally { - logout.disabled = false; - } -}); - -clearLocal.addEventListener("click", async () => { - await clearExtensionSession(); - await refreshSettings(); - showStatus("Local extension session cleared."); -}); - -async function refreshSettings() { - const settings = await loadExtensionSession(); - apiOrigin.value = settings.apiOrigin || DEFAULT_API_ORIGIN; - sessionToken.value = ""; - sessionToken.placeholder = settings.sessionToken ? "Stored locally - leave blank to keep current token" : "Paste gts_ extension session token"; - sessionExpiresAt.value = settings.expiresAt || ""; - logout.disabled = !settings.sessionToken; - clearLocal.disabled = !settings.sessionToken; - if (!settings.sessionToken) { - sessionSummary.textContent = "No extension session stored."; - } else if (settings.expired) { - sessionSummary.textContent = "Stored extension session is expired. Save a fresh token or clear local state."; - } else { - const expires = settings.expiresAt ? ` Expires ${settings.expiresAt}.` : ""; - sessionSummary.textContent = `Extension session stored in browser local storage.${expires}`; - } -} - -function showStatus(message) { - status.textContent = message; - window.setTimeout(() => { - status.textContent = ""; - }, 2600); -} diff --git a/apps/loopover-extension/package.json b/apps/loopover-extension/package.json deleted file mode 100644 index 260f2e5785..0000000000 --- a/apps/loopover-extension/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@loopover/extension", - "version": "0.1.0", - "private": true, - "description": "Maintainer-facing browser extension overlay for GitHub pull requests and issues.", - "type": "module", - "scripts": { - "build": "tsx ../../scripts/build-extension.ts", - "lint": "node --check auth.js && node --check background.js && node --check content.js && node --check options.js", - "typecheck": "npm run lint" - } -} diff --git a/apps/loopover-extension/styles.css b/apps/loopover-extension/styles.css deleted file mode 100644 index b98bcb869f..0000000000 --- a/apps/loopover-extension/styles.css +++ /dev/null @@ -1,206 +0,0 @@ -.loopover-overlay { - border: 1px solid rgba(126, 231, 188, 0.45); - border-radius: 8px; - background: #0f1117; - color: #f4f7f5; - font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -.loopover-overlay--sidebar { - width: 100%; - margin-bottom: 16px; - overflow: hidden; -} - -.loopover-overlay--floating { - position: fixed; - right: 16px; - bottom: 16px; - z-index: 2147483647; - width: min(360px, calc(100vw - 32px)); - max-height: min(560px, calc(100vh - 32px)); - overflow: auto; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); -} - -.loopover-overlay__header { - display: flex; - align-items: center; - gap: 8px; - border-bottom: 1px solid rgba(255, 255, 255, 0.12); - padding: 10px 12px; - font-weight: 650; -} - -.loopover-overlay__privacy { - border: 1px solid rgba(126, 231, 188, 0.35); - border-radius: 999px; - color: #7ee7bc; - font-size: 10px; - font-weight: 700; - line-height: 1; - padding: 3px 6px; - text-transform: uppercase; -} - -.loopover-overlay__mark { - display: inline-flex; - width: 22px; - height: 22px; - align-items: center; - justify-content: center; - border-radius: 6px; - background: #7ee7bc; - color: #111317; - font-weight: 800; -} - -.loopover-overlay__refresh { - margin-left: auto; - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 6px; - background: transparent; - color: inherit; - cursor: pointer; - font-size: 11px; - padding: 3px 7px; -} - -.loopover-overlay__body { - padding: 12px; -} - -.loopover-overlay__panel { - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 7px; - padding: 10px; -} - -.loopover-overlay__panel--good { - border-color: rgba(126, 231, 188, 0.35); -} - -.loopover-overlay__panel--warn { - border-color: rgba(255, 189, 105, 0.45); -} - -.loopover-overlay__panel--private { - border-color: rgba(145, 181, 255, 0.38); -} - -.loopover-overlay__panel + .loopover-overlay__panel { - margin-top: 8px; -} - -.loopover-overlay__panel-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} - -.loopover-overlay__panel-head span { - border: 1px solid rgba(126, 231, 188, 0.4); - border-radius: 999px; - color: #7ee7bc; - font-size: 10px; - padding: 1px 6px; - text-transform: uppercase; -} - -.loopover-overlay__panel--warn .loopover-overlay__panel-head span { - border-color: rgba(255, 189, 105, 0.45); - color: #ffbd69; -} - -.loopover-overlay__panel--private .loopover-overlay__panel-head span { - border-color: rgba(145, 181, 255, 0.45); - color: #91b5ff; -} - -.loopover-overlay dl { - margin: 0; -} - -.loopover-overlay dl div { - display: flex; - justify-content: space-between; - gap: 12px; -} - -.loopover-overlay dt { - color: rgba(244, 247, 245, 0.62); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} - -.loopover-overlay dd { - margin: 0; - color: rgba(244, 247, 245, 0.9); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - text-align: right; - overflow-wrap: anywhere; -} - -.loopover-overlay__list, -.loopover-overlay__actions { - margin: 8px 0 0; - padding-left: 18px; -} - -.loopover-overlay__list li, -.loopover-overlay__actions li { - color: rgba(244, 247, 245, 0.78); - margin-top: 4px; -} - -.loopover-overlay__actions li { - color: rgba(244, 247, 245, 0.9); -} - -.loopover-overlay__action-buttons { - display: grid; - gap: 8px; -} - -.loopover-overlay__action-buttons button, -.loopover-overlay__action-buttons details { - border: 1px solid rgba(145, 181, 255, 0.32); - border-radius: 6px; - background: rgba(145, 181, 255, 0.08); - color: #f4f7f5; - font: inherit; -} - -.loopover-overlay__action-buttons button { - cursor: pointer; - padding: 7px 9px; - text-align: left; -} - -.loopover-overlay__action-buttons details { - padding: 7px 9px; -} - -.loopover-overlay__action-buttons summary { - cursor: pointer; - font-weight: 650; -} - -.loopover-overlay__action-buttons ul { - margin: 6px 0 0; - padding-left: 18px; -} - -.loopover-overlay__action-buttons li { - color: rgba(244, 247, 245, 0.82); - margin-top: 4px; -} - -.loopover-overlay__error { - color: #ffb4a8; -} - -.loopover-overlay__empty { - color: rgba(244, 247, 245, 0.72); -} diff --git a/apps/loopover-miner-extension/.gitignore b/apps/loopover-miner-extension/.gitignore deleted file mode 100644 index 849ddff3b7..0000000000 --- a/apps/loopover-miner-extension/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/apps/loopover-miner-extension/README.md b/apps/loopover-miner-extension/README.md deleted file mode 100644 index 2fa006eaca..0000000000 --- a/apps/loopover-miner-extension/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# LoopOver Miner Extension - -Contributor-facing browser extension for GitHub **issue** pages. It is intentionally separate from -[`apps/loopover-extension/`](../loopover-extension/) (the **Maintainer Overlay**), which injects private PR/issue -context for maintainers. - -## What it does - -- Manifest V3 with issue-page `content_scripts` -- `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 badge surfaces the same ranked signal as `packages/loopover-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`, alongside a `chrome.storage.local.rankedCandidatesSavedAt` timestamp updated on -every save, and looks up the current issue there. When no ranked signal is cached for the current issue, the badge -degrades gracefully by staying hidden. The badge itself shows a "last synced" relative-time label (mirroring ORB's -shared `RefreshMeta` component's thresholds) so a contributor can tell how stale the pasted data is; the label is -omitted entirely for a cache saved before this field existed. - -The extension does not request the `unlimitedStorage` permission, so a paste is rejected with a clear error before -being parsed or saved once it exceeds a conservative size bound well under `chrome.storage.local`'s default 10 MiB -quota, instead of silently failing to save or leaving storage partially written. - -## Test coverage - -`npm test` runs with `--coverage` enabled (v8 provider) and enforces `vitest.config.ts`'s -`coverage.thresholds` — a measured baseline (#4865), not an aspirational target. The suite imports -`background.js`, `opportunity-badge.js`, and `toolbar-badge.js` directly (via the existing -`__LOOPOVER_MINER_EXTENSION_TEST__` hook) so v8 can attribute coverage; the root `test/unit/miner-*.test.ts` -files remain as broader behavior tests through the `node:vm` harness. - -`content.js`'s import-time logic (`test/content.test.ts`, #6189) and `options.js`'s pure helpers -(`test/options.test.ts`, #7008) are now behavior-tested through the same `__LOOPOVER_MINER_EXTENSION_TEST__` -hook: each imports the module on a non-mounted path (a non-issue `location`, a null-returning `document`), so -its DOM-independent logic runs without any jsdom mount harness. What genuinely still needs a real DOM harness — -and stays deferred — is the full options-page/content-script UI: the form-submit and live-sync event handlers -that only run once the page is actually mounted. Those files are not yet in `coverage.include` (their mounted -paths would report uncovered without the harness); add them and raise thresholds per-PR as that UI gets covered. - -## Host permissions - -`manifest.json` grants `https://github.com/*` (for the issue-page content script) plus loopback host permissions — -`http://localhost/*` and `http://127.0.0.1/*` — so the extension can reach the operator's own local miner-ui API -(#4860). Chrome match patterns cannot pin a port, so `http://localhost/*` is the narrowest grant the platform -allows; `https` is intentionally omitted because the local miner-ui dev server is plain HTTP. This is the enabling -permission for live-fetching ranked candidates from the local miner-ui instead of pasting them. diff --git a/apps/loopover-miner-extension/background.js b/apps/loopover-miner-extension/background.js deleted file mode 100644 index 55afdb045f..0000000000 --- a/apps/loopover-miner-extension/background.js +++ /dev/null @@ -1,207 +0,0 @@ -import "./opportunity-badge.js"; -import "./toolbar-badge.js"; - -const badgeApi = globalThis.__loopoverMinerOpportunityBadge; -const toolbarBadgeApi = globalThis.__loopoverMinerToolbarBadge; - -const PING_MESSAGE = "loopover-miner:ping"; -const ISSUE_CONTEXT_MESSAGE = "loopover-miner:issue-context"; -const SYNC_RANKED_CANDIDATES_MESSAGE = "loopover-miner:sync-ranked-candidates"; -// Short: this is a same-machine localhost call, not a round-trip to a remote server -- a stalled connection -// (miner-ui running but unresponsive) should fail fast and let the next 10-minute alarm retry, following the -// timeout pattern established in review-enrichment/src/external-fetch.ts. -const RANKED_CANDIDATES_FETCH_TIMEOUT_MS = 3000; -// Mirrors options.js's manual-paste guard (#4863): the chrome.storage.local 10 MiB QUOTA_BYTES is shared -// across every key, so a live-fetched payload gets the same 8 MiB ceiling the paste flow already enforces. -const MAX_RANKED_CANDIDATES_JSON_BYTES = 8 * 1024 * 1024; - -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 = loadIssueOpportunityContext(message); - void task.then((payload) => sendResponse({ ok: true, payload })).catch((error) => - sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }), - ); - return true; - } - if (message.type === SYNC_RANKED_CANDIDATES_MESSAGE) { - void syncRankedCandidatesFromMinerUi().then((result) => sendResponse({ ok: true, payload: result })); - return true; - } - return false; -}); - -async function loadIssueOpportunityContext(message) { - const settings = await loadMinerExtensionSettings(); - const repoFullName = `${message.owner}/${message.repo}`; - 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, savedAt } = 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: true, - issueNumber: message.issueNumber, - repoFullName, - badge: badgeApi.formatOpportunityBadge(rankedEntry), - savedAt, - status: "ready", - }; -} - -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 }; -} - -// Reads rankedCandidates alongside its savedAt sync timestamp (#5192). `savedAt` degrades to `null` -// (never NaN) when absent -- e.g. data written before this field existed, or storage was cleared. -async function loadRankedCandidates() { - const stored = await chrome.storage.local.get({ rankedCandidates: [], rankedCandidatesSavedAt: null }); - return { - rankedCandidates: Array.isArray(stored.rankedCandidates) ? stored.rankedCandidates : [], - savedAt: typeof stored.rankedCandidatesSavedAt === "number" ? stored.rankedCandidatesSavedAt : null, - }; -} - -const DEFAULT_MINER_UI_URL = "http://localhost:5174"; -const SYNC_ALARM_NAME = "loopover-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/loopover- - * miner/lib/ranked-candidates.js via apps/loopover-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`, { - signal: AbortSignal.timeout(RANKED_CANDIDATES_FETCH_TIMEOUT_MS), - }); - 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 }; - } - // Same byte-size guard options.js's manual-paste flow enforces (#4863, ported here for #7006): measure the - // real serialized UTF-8 size (not JS string .length) against the shared 10 MiB QUOTA_BYTES limit, so a - // live fetch can't silently write a partial payload past the quota the way an unbounded paste could. - const byteLength = new TextEncoder().encode(JSON.stringify(candidates)).length; - if (byteLength > MAX_RANKED_CANDIDATES_JSON_BYTES) { - return { - ok: false, - error: `ranked candidates payload is too large (${byteLength.toLocaleString()} bytes; limit ${MAX_RANKED_CANDIDATES_JSON_BYTES.toLocaleString()})`, - 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() { - // Swallow transient chrome.storage/chrome.action failures: this runs void-called on startup and from the - // onChanged listener, so an unhandled rejection would surface uncaught in the service-worker context. - try { - const { rankedCandidates } = await chrome.storage.local.get("rankedCandidates"); - const badge = toolbarBadgeApi.computeToolbarBadge(rankedCandidates); - await chrome.action.setBadgeText({ text: badge.text }); - await chrome.action.setBadgeBackgroundColor({ color: badge.backgroundColor }); - } catch (error) { - console.warn("loopover-miner: failed to refresh toolbar badge", error); - } -} - -// Paint on service-worker startup, then keep it live as the miner rewrites the cache. Guarded so environments -// without the action API surface (e.g. the unit-test harness) are a clean no-op. -if (chrome.action && chrome.storage.onChanged) { - void refreshToolbarBadge(); - chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName === "local" && changes && changes.rankedCandidates) void refreshToolbarBadge(); - }); -} - -if (globalThis.__LOOPOVER_MINER_EXTENSION_TEST__) { - globalThis.__loopoverMinerBackgroundInternals = { - PING_MESSAGE, - ISSUE_CONTEXT_MESSAGE, - SYNC_RANKED_CANDIDATES_MESSAGE, - DEFAULT_MINER_UI_URL, - loadIssueOpportunityContext, - loadMinerExtensionSettings, - loadRankedCandidates, - loadMinerUiUrl, - syncRankedCandidatesFromMinerUi, - refreshToolbarBadge, - }; -} diff --git a/apps/loopover-miner-extension/content.js b/apps/loopover-miner-extension/content.js deleted file mode 100644 index 25cb4daced..0000000000 --- a/apps/loopover-miner-extension/content.js +++ /dev/null @@ -1,87 +0,0 @@ -const badgeApi = globalThis.__loopoverMinerOpportunityBadge; - -const target = matchGitHubIssueTarget(location.pathname); - -if (target?.kind === "issue") { - mountOpportunityBadge(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 mountOpportunityBadge(target) { - if (document.querySelector("[data-loopover-miner-opportunity-badge]")) return; - const host = findIssueSidebar(); - const container = document.createElement("aside"); - container.className = host - ? "loopover-miner-opportunity-badge" - : "loopover-miner-opportunity-badge loopover-miner-opportunity-badge--floating"; - container.dataset.loopoverMinerOpportunityBadge = "true"; - container.hidden = true; - 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 loadOpportunityBadge(container, target) { - let response; - try { - response = await chrome.runtime.sendMessage({ - type: "loopover-miner:issue-context", - owner: target.owner, - repo: target.repo, - issueNumber: target.issueNumber, - }); - } catch { - // sendMessage genuinely rejects under MV3 (service worker asleep/restarting, "Extension context - // invalidated"). Clean up the same way every other failure path here does, instead of leaving an - // unhandled rejection and a permanently-hidden orphan