From 5016abfd4dd1389b704385d2929571d88602e6fd Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:47:10 -0700 Subject: [PATCH 1/5] fix(ui): remove the maintainer-extension's public UI surface Closes #8597, first of the browser-extension removal epic's three sequenced sub-issues (#8596). Lands before the package itself is deleted (next sub-issue) so the "Download extension" button and its download page disappear before the zip they point at stops being generated -- never the other way around, which would leave a live broken download link on the public site. Removes the dedicated /extension route and its test, both nav links (site footer, command palette), the sitemap entry, and the README table row. Reworded roadmap.tsx's Phase 2 entry to drop the browser- extension framing while keeping its real remaining scope (maintainer trust checklist, install health, private/public rendering checks) -- swapped its "Open extension page" link for /app/maintainer, a route that already covers install health and maintainer controls, rather than just deleting the link outright. maintainer-install-trust.mdx needed more than the single section the original file-level grep found: removed the "## Browser extension states" section (heading through its code block and the screenshot- requirement sentence immediately after it), the "Extension sessions are scoped..." audit-expectations bullet, and the "Browser extension" cross-link in the closing paragraph -- also reworded the "Reject UI or extension PRs" bullet to just "Reject UI PRs" since that rule was never extension-specific. self-hosting-operations.mdx's "extension block" mention is a false positive (Docker Compose's `x-` extension fields, unrelated) -- left unchanged after verifying context, per the issue's own explicit instruction not to assume. routeTree.gen.ts regenerated via a real `npm --workspace @loopover/ui run build`, not hand-edited. Validated: typecheck clean, ui:lint 0 errors, ui:test 86 files/571 tests passing, docs:drift-check clean. --- README.md | 1 - .../content/docs/maintainer-install-trust.mdx | 26 +- apps/loopover-ui/public/sitemap.xml | 1 - .../src/components/site/command-palette.tsx | 1 - .../src/components/site/site-footer.tsx | 1 - apps/loopover-ui/src/routeTree.gen.ts | 21 -- .../loopover-ui/src/routes/extension.test.tsx | 98 ----- apps/loopover-ui/src/routes/extension.tsx | 338 ------------------ apps/loopover-ui/src/routes/roadmap.tsx | 12 +- 9 files changed, 8 insertions(+), 491 deletions(-) delete mode 100644 apps/loopover-ui/src/routes/extension.test.tsx delete mode 100644 apps/loopover-ui/src/routes/extension.tsx 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-ui/content/docs/maintainer-install-trust.mdx b/apps/loopover-ui/content/docs/maintainer-install-trust.mdx index 8dba6c6297..34df960244 100644 --- a/apps/loopover-ui/content/docs/maintainer-install-trust.mdx +++ b/apps/loopover-ui/content/docs/maintainer-install-trust.mdx @@ -114,27 +114,6 @@ Preview every public output path before enabling it. The same public-safety boun For the full boundary, keep [Privacy & security](/docs/privacy-security) as the source of truth. For AI-written text, use the [AI summaries policy](/docs/ai-summaries) before posting generated copy. -## Browser extension states - -The extension is a maintainer review aid. It should make state and scope obvious instead of implying that a contributor or public viewer can see private packets. - - no repo context, no private packet -Signed in without repo scope - -> prompt for authorized GitHub App installation or browser session -Authorized maintainer on PR page - -> public-safe PR panel + private maintainer blockers -Unauthorized viewer or stale session - -> public-safe state only, no private blockers -API unavailable or stale data - -> degraded state with retry guidance, never guessed scores`} -/> - -UI, frontend, browser-extension, or GitHub-overlay pull requests need maintainer-reviewable screenshots or a short recording that shows the relevant states. A checked template box is not enough evidence. - ## Audit expectations A healthy installation should leave an audit trail that maintainers can reason about without exposing repository source or contributor secrets. @@ -142,7 +121,6 @@ A healthy installation should leave an audit trail that maintainers can reason a - Installation health shows permissions and webhook readiness. - Settings preview shows the exact public copy before posting. - Command previews identify the maintainer action that produced them. -- Extension sessions are scoped to authorized review context. - Failures are inspectable through diagnostics instead of silent public output. ## CI checks are not reviewer approval @@ -156,7 +134,7 @@ If the repo enables **LoopOver Orb Review Agent**, document which blockers are e Maintainers should request changes or close PRs that misuse LoopOver output. The tool is a contribution operating layer, not a guarantee that work deserves merge. - Reject PRs with no linked issue, no reproduction, or no validation evidence. -- Reject UI or extension PRs that omit screenshots or recordings of changed flows. +- Reject UI PRs that omit screenshots or recordings of changed flows. - Reject copied snippets that leak private scoring, reward, trust, wallet, or hotkey text. - Reject duplicated work when the PR does not explain overlap and maintainer value. - Reject generated broad rewrites that are not scoped to the issue acceptance criteria. @@ -164,4 +142,4 @@ Maintainers should request changes or close PRs that misuse LoopOver output. The ## Next docs -Continue with [Maintainer workflow](/docs/maintainer-workflow) for daily PR review, [Troubleshooting](/docs/troubleshooting) for install diagnostics, and [Browser extension](/extension) for overlay behavior. +Continue with [Maintainer workflow](/docs/maintainer-workflow) for daily PR review and [Troubleshooting](/docs/troubleshooting) for install diagnostics. diff --git a/apps/loopover-ui/public/sitemap.xml b/apps/loopover-ui/public/sitemap.xml index da9be0522c..3e368c5a3e 100644 --- a/apps/loopover-ui/public/sitemap.xml +++ b/apps/loopover-ui/public/sitemap.xml @@ -4,7 +4,6 @@ https://loopover.ai/miners https://loopover.ai/maintainers https://loopover.ai/agents - https://loopover.ai/extension https://loopover.ai/roadmap https://loopover.ai/docs https://loopover.ai/docs/beta-onboarding diff --git a/apps/loopover-ui/src/components/site/command-palette.tsx b/apps/loopover-ui/src/components/site/command-palette.tsx index ebb7c6f962..4f2c63878c 100644 --- a/apps/loopover-ui/src/components/site/command-palette.tsx +++ b/apps/loopover-ui/src/components/site/command-palette.tsx @@ -66,7 +66,6 @@ const DEFAULT_ITEMS: PaletteItem[] = [ { label: "Privacy & security", to: "/docs/privacy-security", group: "Docs" }, { label: "Troubleshooting", to: "/docs/troubleshooting", group: "Docs" }, { label: "API reference", to: "/api", group: "Reference" }, - { label: "Browser extension", to: "/extension", group: "Reference" }, { label: "Changelog", to: "/changelog", group: "Reference" }, { label: "Roadmap", to: "/roadmap", group: "Reference" }, ]; diff --git a/apps/loopover-ui/src/components/site/site-footer.tsx b/apps/loopover-ui/src/components/site/site-footer.tsx index 4247976a75..c2ca8a4d31 100644 --- a/apps/loopover-ui/src/components/site/site-footer.tsx +++ b/apps/loopover-ui/src/components/site/site-footer.tsx @@ -15,7 +15,6 @@ const cols = [ { to: "/miners", label: "For miners" }, { to: "/maintainers", label: "For maintainers" }, { to: "/agents", label: "For coding agents" }, - { to: "/extension", label: "Browser extension" }, ], }, { diff --git a/apps/loopover-ui/src/routeTree.gen.ts b/apps/loopover-ui/src/routeTree.gen.ts index 77d7a40ec0..1409d48c93 100644 --- a/apps/loopover-ui/src/routeTree.gen.ts +++ b/apps/loopover-ui/src/routeTree.gen.ts @@ -14,7 +14,6 @@ import { Route as MinersRouteImport } from './routes/miners' import { Route as MaintainersRouteImport } from './routes/maintainers' import { Route as InstallRouteImport } from './routes/install' import { Route as FairnessRouteImport } from './routes/fairness' -import { Route as ExtensionRouteImport } from './routes/extension' import { Route as DocsRouteImport } from './routes/docs' import { Route as ChangelogRouteImport } from './routes/changelog' import { Route as AppRouteImport } from './routes/app' @@ -69,11 +68,6 @@ const FairnessRoute = FairnessRouteImport.update({ path: '/fairness', getParentRoute: () => rootRouteImport, } as any) -const ExtensionRoute = ExtensionRouteImport.update({ - id: '/extension', - path: '/extension', - getParentRoute: () => rootRouteImport, -} as any) const DocsRoute = DocsRouteImport.update({ id: '/docs', path: '/docs', @@ -223,7 +217,6 @@ export interface FileRoutesByFullPath { '/app': typeof AppRouteWithChildren '/changelog': typeof ChangelogRoute '/docs': typeof DocsRouteWithChildren - '/extension': typeof ExtensionRoute '/fairness': typeof FairnessRoute '/install': typeof InstallRouteWithChildren '/maintainers': typeof MaintainersRoute @@ -256,7 +249,6 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/agents': typeof AgentsRoute '/changelog': typeof ChangelogRoute - '/extension': typeof ExtensionRoute '/fairness': typeof FairnessRoute '/maintainers': typeof MaintainersRoute '/miners': typeof MinersRoute @@ -292,7 +284,6 @@ export interface FileRoutesById { '/app': typeof AppRouteWithChildren '/changelog': typeof ChangelogRoute '/docs': typeof DocsRouteWithChildren - '/extension': typeof ExtensionRoute '/fairness': typeof FairnessRoute '/install': typeof InstallRouteWithChildren '/maintainers': typeof MaintainersRoute @@ -330,7 +321,6 @@ export interface FileRouteTypes { | '/app' | '/changelog' | '/docs' - | '/extension' | '/fairness' | '/install' | '/maintainers' @@ -363,7 +353,6 @@ export interface FileRouteTypes { | '/' | '/agents' | '/changelog' - | '/extension' | '/fairness' | '/maintainers' | '/miners' @@ -398,7 +387,6 @@ export interface FileRouteTypes { | '/app' | '/changelog' | '/docs' - | '/extension' | '/fairness' | '/install' | '/maintainers' @@ -435,7 +423,6 @@ export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren ChangelogRoute: typeof ChangelogRoute DocsRoute: typeof DocsRouteWithChildren - ExtensionRoute: typeof ExtensionRoute FairnessRoute: typeof FairnessRoute InstallRoute: typeof InstallRouteWithChildren MaintainersRoute: typeof MaintainersRoute @@ -481,13 +468,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FairnessRouteImport parentRoute: typeof rootRouteImport } - '/extension': { - id: '/extension' - path: '/extension' - fullPath: '/extension' - preLoaderRoute: typeof ExtensionRouteImport - parentRoute: typeof rootRouteImport - } '/docs': { id: '/docs' path: '/docs' @@ -769,7 +749,6 @@ const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, ChangelogRoute: ChangelogRoute, DocsRoute: DocsRouteWithChildren, - ExtensionRoute: ExtensionRoute, FairnessRoute: FairnessRoute, InstallRoute: InstallRouteWithChildren, MaintainersRoute: MaintainersRoute, diff --git a/apps/loopover-ui/src/routes/extension.test.tsx b/apps/loopover-ui/src/routes/extension.test.tsx deleted file mode 100644 index 959d0fa0bb..0000000000 --- a/apps/loopover-ui/src/routes/extension.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -// #6824: create-token used to swallow clipboard rejections and still toast success. Mock the toast -// and api layers so the regression can assert the exact success/failure branch without a live API. -const { success, error } = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); -vi.mock("sonner", () => ({ toast: { success, error } })); - -const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() })); -vi.mock("@/lib/api/request", () => ({ - apiFetch: (...args: unknown[]) => apiFetch(...args), -})); -vi.mock("@/lib/api/origin", () => ({ - getApiOrigin: () => "https://api.example.test", -})); - -import { ExtensionTokenButton } from "./extension"; - -const TOKEN = "ext_test_token_6824"; - -function mockClipboard(writeText: () => Promise) { - const spy = vi.fn(writeText); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: spy }, - configurable: true, - writable: true, - }); - return spy; -} - -describe("ExtensionTokenButton clipboard honesty (#6824)", () => { - beforeEach(() => { - vi.clearAllMocks(); - apiFetch.mockResolvedValue({ - ok: true, - data: { token: TOKEN }, - status: 200, - durationMs: 1, - message: undefined, - }); - }); - - it("toasts success only when the auto-copy after create actually writes", async () => { - const writeText = mockClipboard(() => Promise.resolve()); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Create extension token" })); - - await waitFor(() => expect(writeText).toHaveBeenCalledWith(TOKEN)); - await waitFor(() => - expect(success).toHaveBeenCalledWith("Extension token created", { - description: "Copied to clipboard.", - }), - ); - expect(error).not.toHaveBeenCalled(); - expect(screen.getByTestId("extension-token-value").textContent).toBe(TOKEN); - expect(screen.getByRole("button", { name: "Copy extension token" })).toBeTruthy(); - }); - - it("does not claim clipboard success when writeText rejects after create", async () => { - // The exact gap: `.catch(() => undefined)` discarded the rejection, then success still fired. - mockClipboard(() => Promise.reject(new Error("denied"))); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Create extension token" })); - - await waitFor(() => - expect(error).toHaveBeenCalledWith("Couldn't copy extension token", expect.any(Object)), - ); - expect(success).not.toHaveBeenCalled(); - // Token must still render so the user can recover via the manual copy button. - expect(screen.getByTestId("extension-token-value").textContent).toBe(TOKEN); - expect(screen.getByRole("button", { name: "Copy extension token" })).toBeTruthy(); - }); - - it("manual Copy button copies the displayed token and reports failure honestly", async () => { - mockClipboard(() => Promise.resolve()); - render(); - fireEvent.click(screen.getByRole("button", { name: "Create extension token" })); - await waitFor(() => screen.getByRole("button", { name: "Copy extension token" })); - vi.clearAllMocks(); - - const writeText = mockClipboard(() => Promise.resolve()); - fireEvent.click(screen.getByRole("button", { name: "Copy extension token" })); - await waitFor(() => expect(writeText).toHaveBeenCalledWith(TOKEN)); - await waitFor(() => - expect(success).toHaveBeenCalledWith("Extension token copied", expect.any(Object)), - ); - - vi.clearAllMocks(); - mockClipboard(() => Promise.reject(new Error("denied"))); - fireEvent.click(screen.getByRole("button", { name: "Copy extension token" })); - await waitFor(() => - expect(error).toHaveBeenCalledWith("Couldn't copy extension token", expect.any(Object)), - ); - expect(success).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/loopover-ui/src/routes/extension.tsx b/apps/loopover-ui/src/routes/extension.tsx deleted file mode 100644 index 1d018c4fd3..0000000000 --- a/apps/loopover-ui/src/routes/extension.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { Copy, Download, Lock, Shield, GitPullRequestArrow } from "lucide-react"; -import { useState } from "react"; -import { motion } from "motion/react"; -import { toast } from "sonner"; - -import { Section, Eyebrow, Callout } from "@/components/site/primitives"; -import { BoundaryBadge, StatusPill } from "@/components/site/control-primitives"; -import { Reveal } from "@/components/site/reveal"; -import { apiFetch } from "@/lib/api/request"; -import { getApiOrigin } from "@/lib/api/origin"; -import { cn } from "@/lib/utils"; - -/** Write `text` to the clipboard or throw when the Clipboard API is missing / rejects. */ -async function writeClipboardText(text: string): Promise { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - throw new Error("Clipboard API unavailable"); - } - await navigator.clipboard.writeText(text); -} - -export const Route = createFileRoute("/extension")({ - head: () => ({ - meta: [ - { title: "Browser extension — LoopOver" }, - { - name: "description", - content: - "Private maintainer overlays on github.com. Never shown to PR authors or the public — reviewability context where you already work.", - }, - { property: "og:title", content: "LoopOver · maintainer browser extension" }, - { - property: "og:description", - content: - "A private overlay on GitHub PR pages that surfaces miner context, scoreability projections, and reviewability hints.", - }, - { property: "og:url", content: "/extension" }, - ], - links: [{ rel: "canonical", href: "/extension" }], - }), - component: ExtensionPage, -}); - -function ExtensionPage() { - return ( - <> -
-
-
- Self-hosted package -

- Maintainer overlays on the GitHub you already use. -

-

- The LoopOver browser extension surfaces miner context, scoreability projections, and - reviewability hints on PR pages — visible only to the maintainer running it. Nothing - is injected into the page or shown to the PR author. -

- -
- Maintainer-only - - Live API integration -
-
- - - - -
-
- -
-
- {[ - { - icon: , - title: "Never shown publicly", - body: "Overlays render in your browser only. The DOM injection never reaches the PR author or other visitors.", - }, - { - icon: , - title: "No source upload", - body: "The extension uses the existing private API. It reads metadata you already have permission to see — nothing more.", - }, - { - icon: , - title: "Lives where you work", - body: "No tab-switching to a dashboard. Context appears next to the diff, the review tab, and the files-changed view.", - }, - ].map((f) => ( -
-
- {f.icon} -
-

{f.title}

-

{f.body}

-
- ))} -
-
- -
- - Privacy posture. The extension does not read the PR diff, post comments, - or open issues. It calls the same private LoopOver API endpoints you already use, then - renders the response in your local DOM. Its permission boundary is storage, GitHub PR - pages, and the configured LoopOver API origin. Extension tokens are scoped to pull - context, stored in browser local storage rather than sync storage, and cleared on logout, - expiry, or revoked-session responses. GitHub personal access tokens are rejected. - -
- - ); -} - -const EXTENSION_PANELS = [ - { - label: "Reviewability", - badge: "live", - rows: [ - { k: "endpoint", v: "/v1/extension/pull-context" }, - { k: "auth", v: "extension session" }, - { k: "boundary", v: "private" }, - ], - }, - { - label: "Contributor", - badge: "cached", - rows: [ - { k: "profile", v: "backend" }, - { k: "history", v: "read-only" }, - { k: "public post", v: "never" }, - ], - }, - { - label: "Install", - badge: "manual", - rows: [ - { k: "package", v: "Manifest V3" }, - { k: "store", v: "out of scope" }, - { k: "source", v: "bundled" }, - ], - }, -] as const; - -// Exported for unit tests — the create-token path used to swallow clipboard failures and report -// false success (#6824). Keeping the button extractable lets the regression suite drive it without -// mounting the full marketing page / router. -export function ExtensionTokenButton() { - const [token, setToken] = useState(""); - const [busy, setBusy] = useState(false); - - const copyToken = async (value: string, { created }: { created: boolean }) => { - try { - await writeClipboardText(value); - toast.success(created ? "Extension token created" : "Extension token copied", { - description: created - ? "Copied to clipboard." - : "The token is ready to paste into the extension.", - }); - } catch { - // Match the app.runs / CodeBlock copy-fail channel: never claim success when writeText rejected. - toast.error("Couldn't copy extension token", { - description: created - ? "Token was created — use the Copy button next to it, or select the token and copy manually." - : "Select the token and copy it manually.", - }); - } - }; - - return ( -
- - {token && ( -
- - {token} - - -
- )} -
- ); -} - -function OverlayDemo() { - const [active, setActive] = useState(0); - return ( -
- {/* faux github header */} -
-
- - - - - github.com/jsonbored/loopover/pull/1218 - -
-
- {/* faux PR body */} -
-
- - Tighten queue-cap copy - - open - -
-
-
- jsonbored wants to merge 1 commit into main from feat/queue-copy -
-
-
+12
-
−4
-
1 file
-
-
-
- checks · 4 passing · 0 failing -
-
- - {/* loopover overlay panel */} - -
-
- - G - - loopover overlay -
- - you only - -
-
- {EXTENSION_PANELS.map((p, i) => ( - - ))} -
- -
- - {EXTENSION_PANELS[active].label} - - - {EXTENSION_PANELS[active].badge} - -
-
- {EXTENSION_PANELS[active].rows.map((r) => ( -
-
{r.k}
-
{r.v}
-
- ))} -
-
-
-
-
-
- ); -} diff --git a/apps/loopover-ui/src/routes/roadmap.tsx b/apps/loopover-ui/src/routes/roadmap.tsx index b01d80b82f..1b1accfd08 100644 --- a/apps/loopover-ui/src/routes/roadmap.tsx +++ b/apps/loopover-ui/src/routes/roadmap.tsx @@ -55,11 +55,11 @@ const ROADMAP_ITEMS: Array<{ "MCP doctor/status/init-client clarity, last-good decision packs, recommendation-change explanations, and command-copy flows for miners.", }, { - title: "Phase 2: maintainer trust and browser extension", + title: "Phase 2: maintainer trust", status: "planned", issue: 235, description: - "Maintainer trust checklist, install health next actions, screenshot-backed extension states, and private/public rendering checks.", + "Maintainer trust checklist, install health next actions, and private/public rendering checks.", }, { title: "Phase 3: repo owner intake console", @@ -88,7 +88,7 @@ const ROADMAP_ITEMS: Array<{ const BUILT_TITLES = new Set([ "Phase 0: stabilize while shipping", "Phase 1: miner command center", - "Phase 2: maintainer trust and browser extension", + "Phase 2: maintainer trust", "Phase 3: repo owner intake console", "Phase 4: adoption analytics and launch system", "Phase 5: ecosystem distribution", @@ -96,9 +96,9 @@ const BUILT_TITLES = new Set([ const LINK_MAP: Record = { "Phase 1: miner command center": { to: "/app/miner", label: "Open miner dashboard" }, - "Phase 2: maintainer trust and browser extension": { - to: "/extension", - label: "Open extension page", + "Phase 2: maintainer trust": { + to: "/app/maintainer", + label: "Open maintainer console", }, "Phase 3: repo owner intake console": { to: "/app/repos", label: "Open repos console" }, "Phase 4: adoption analytics and launch system": { From a73731c951a34acb9190d689e1f0744fb0a2edb0 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:29:33 -0700 Subject: [PATCH 2/5] build: delete both browser-extension packages and their shared build/CI/npm-script wiring Closes #8598. Second of three sequenced sub-issues under the browser- extension removal epic (#8596) -- lands after the UI download surface is gone (#8597) and before the backend API routes (#8599, next). Deletes apps/loopover-extension/ and apps/loopover-miner-extension/ wholesale, scripts/build-extension.ts, scripts/build-miner-extension.ts, and scripts/extension-zip-core.ts (verified only those two build scripts + the now-deleted test referenced it). Deletes 10 root-level tests that read the app directories' raw source directly -- 9 named in the issue's own audit, plus test/unit/miner-toolbar-badge.test.ts, which the original audit missed and would have ENOENT'd immediately. Trims (does not delete) codecov-policy.test.ts's shared coverage-gate test down to miner-ui only. Removes all shared build/CI wiring in one PR, as required (turbo.json's 5 task blocks + the ui#build dependsOn edge + //#typecheck's inputs; ci.yml's path filters, the 3 dedicated steps, and every stale comment mention; ui-deploy.yml's Validate-frontend chain; root package.json's 6 scripts + the ui:build/ui:test chains; .gitignore's dead zip entry). Beyond the issue's own file-level-grep audit, a full repo-wide sweep turned up more real references that would have silently broken or gone stale: two production smoke-test scripts (smoke-production.ts, smoke-ui-browser.ts) still probing the now-gone /extension route and zip download -- these would have started failing in production the moment #8597 merged; scripts/mcp-release-core.ts and scripts/orb-release-core.ts's UI/excluded-path arrays still listing the deleted directories; and several comments across packages/loopover-miner/lib/ and apps/loopover-miner-ui/ describing the extension as a consumer of the ranked-candidates API and discover snapshot -- reworded to describe their real remaining consumer (miner-ui's own dashboard) instead of leaving them referencing a deleted package. Left alone, correctly out of scope: src/openapi/ spec.ts and src/signals/extension-contributor-context.ts (src/**, #8599's territory), packages/loopover-mcp/CHANGELOG.md (immutable history), and every genuine "file extension"/"OAuth scope extension" false positive a broad grep surfaced. package-lock.json regenerated via real npm install/ci, not hand- edited, with one narrow exception: two "extraneous": true workspace stub entries that persisted across five different npm-tool-driven attempts (repeated install, prune, full clean reinstall) even after confirming via `npm ci` that they carry zero real dependency resolution and have zero effect on install correctness -- traced to gitignored build leftovers (dist/, coverage/, node_modules/, .turbo/) that `git rm` doesn't touch, which kept the directories technically present for npm's workspace glob after the tracked files were gone. Removed those leftover directories too, alongside the two dead JSON stubs npm's own tooling had already flagged as not real. Validated: npm ci clean, npx turbo run build --filter=@loopover/ui succeeds with exactly 2 tasks (engine + ui, no extension task in the graph), package-lock.json has zero remaining extension references, node_modules has zero @loopover/{extension,miner-extension} entries. Full npm run test:ci left to GitHub Actions rather than a local run. --- .../skills/contributing-to-loopover/SKILL.md | 3 - .../contributing-to-loopover/reference.md | 8 - .../reference.md | 2 +- .github/actions/deploy-ui-preview/action.yml | 4 +- .github/workflows/ci.yml | 42 +- .github/workflows/ui-deploy.yml | 2 +- .gitignore | 1 - CONTRIBUTING.md | 4 +- apps/loopover-extension/.gitignore | 1 - apps/loopover-extension/auth.js | 177 ------ apps/loopover-extension/background.js | 8 - apps/loopover-extension/content.js | 196 ------ apps/loopover-extension/manifest.json | 24 - apps/loopover-extension/options.html | 92 --- apps/loopover-extension/options.js | 80 --- apps/loopover-extension/package.json | 12 - apps/loopover-extension/styles.css | 206 ------ apps/loopover-miner-extension/.gitignore | 1 - apps/loopover-miner-extension/README.md | 53 -- apps/loopover-miner-extension/background.js | 207 ------ apps/loopover-miner-extension/content.js | 87 --- .../icons/icon-128.png | Bin 1385 -> 0 bytes .../icons/icon-16.png | Bin 221 -> 0 bytes .../icons/icon-32.png | Bin 354 -> 0 bytes .../icons/icon-48.png | Bin 496 -> 0 bytes apps/loopover-miner-extension/manifest.json | 35 -- .../opportunity-badge.js | 115 ---- apps/loopover-miner-extension/options.html | 91 --- apps/loopover-miner-extension/options.js | 133 ---- apps/loopover-miner-extension/package.json | 16 - apps/loopover-miner-extension/styles.css | 70 --- .../test/background.test.ts | 320 ---------- .../test/content.test.ts | 67 -- apps/loopover-miner-extension/test/helpers.ts | 195 ------ .../test/opportunity-badge.test.ts | 85 --- .../test/options.test.ts | 86 --- .../test/toolbar-badge.test.ts | 39 -- .../toolbar-badge.d.ts | 9 - .../loopover-miner-extension/toolbar-badge.js | 42 -- .../loopover-miner-extension/vitest.config.ts | 24 - apps/loopover-miner-ui/README.md | 3 +- .../src/lib/ranked-candidates.ts | 6 +- .../src/routes/ranked-candidates.tsx | 6 +- .../vite-ranked-candidates-api.ts | 10 +- .../content/docs/maintainer-install-trust.mdx | 2 +- package-lock.json | 27 +- package.json | 10 +- packages/loopover-miner/lib/discover-cli.ts | 4 +- .../loopover-miner/lib/portfolio-dashboard.ts | 5 +- .../loopover-miner/lib/ranked-candidates.ts | 7 +- scripts/build-extension.ts | 23 - scripts/build-miner-extension.ts | 39 -- scripts/extension-zip-core.ts | 96 --- scripts/mcp-release-core.ts | 4 +- scripts/orb-release-core.ts | 5 +- scripts/smoke-production.ts | 2 - scripts/smoke-ui-browser.ts | 2 +- test/unit/build-extension-zip-script.test.ts | 146 ----- test/unit/ci-extension-packages.test.ts | 40 -- test/unit/codecov-policy.test.ts | 11 +- test/unit/extension-auth.test.ts | 164 ----- test/unit/extension-background.test.ts | 183 ------ test/unit/extension-content.test.ts | 149 ----- test/unit/extension-options.test.ts | 291 --------- test/unit/miner-extension-content.test.ts | 589 ------------------ test/unit/miner-extension-icons.test.ts | 56 -- test/unit/miner-extension-live-fetch.test.ts | 346 ---------- test/unit/miner-toolbar-badge.test.ts | 205 ------ turbo.json | 43 +- 69 files changed, 53 insertions(+), 4958 deletions(-) delete mode 100644 apps/loopover-extension/.gitignore delete mode 100644 apps/loopover-extension/auth.js delete mode 100644 apps/loopover-extension/background.js delete mode 100644 apps/loopover-extension/content.js delete mode 100644 apps/loopover-extension/manifest.json delete mode 100644 apps/loopover-extension/options.html delete mode 100644 apps/loopover-extension/options.js delete mode 100644 apps/loopover-extension/package.json delete mode 100644 apps/loopover-extension/styles.css delete mode 100644 apps/loopover-miner-extension/.gitignore delete mode 100644 apps/loopover-miner-extension/README.md delete mode 100644 apps/loopover-miner-extension/background.js delete mode 100644 apps/loopover-miner-extension/content.js delete mode 100644 apps/loopover-miner-extension/icons/icon-128.png delete mode 100644 apps/loopover-miner-extension/icons/icon-16.png delete mode 100644 apps/loopover-miner-extension/icons/icon-32.png delete mode 100644 apps/loopover-miner-extension/icons/icon-48.png delete mode 100644 apps/loopover-miner-extension/manifest.json delete mode 100644 apps/loopover-miner-extension/opportunity-badge.js delete mode 100644 apps/loopover-miner-extension/options.html delete mode 100644 apps/loopover-miner-extension/options.js delete mode 100644 apps/loopover-miner-extension/package.json delete mode 100644 apps/loopover-miner-extension/styles.css delete mode 100644 apps/loopover-miner-extension/test/background.test.ts delete mode 100644 apps/loopover-miner-extension/test/content.test.ts delete mode 100644 apps/loopover-miner-extension/test/helpers.ts delete mode 100644 apps/loopover-miner-extension/test/opportunity-badge.test.ts delete mode 100644 apps/loopover-miner-extension/test/options.test.ts delete mode 100644 apps/loopover-miner-extension/test/toolbar-badge.test.ts delete mode 100644 apps/loopover-miner-extension/toolbar-badge.d.ts delete mode 100644 apps/loopover-miner-extension/toolbar-badge.js delete mode 100644 apps/loopover-miner-extension/vitest.config.ts delete mode 100644 scripts/build-extension.ts delete mode 100644 scripts/build-miner-extension.ts delete mode 100644 scripts/extension-zip-core.ts delete mode 100644 test/unit/build-extension-zip-script.test.ts delete mode 100644 test/unit/ci-extension-packages.test.ts delete mode 100644 test/unit/extension-auth.test.ts delete mode 100644 test/unit/extension-background.test.ts delete mode 100644 test/unit/extension-content.test.ts delete mode 100644 test/unit/extension-options.test.ts delete mode 100644 test/unit/miner-extension-content.test.ts delete mode 100644 test/unit/miner-extension-icons.test.ts delete mode 100644 test/unit/miner-extension-live-fetch.test.ts delete mode 100644 test/unit/miner-toolbar-badge.test.ts 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/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 ? `
    ${items.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` : ""} - ${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