diff --git a/apps/gittensory-extension/content.js b/apps/gittensory-extension/content.js index 5f9539f93e..7d0df77575 100644 --- a/apps/gittensory-extension/content.js +++ b/apps/gittensory-extension/content.js @@ -1,27 +1,50 @@ -const match = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/); +const target = matchPullRequestTarget(location.pathname); -if (match) { +if (target) { + mountOverlay(target); +} + +function matchPullRequestTarget(pathname) { + const match = String(pathname ?? "").match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/|$)/); + if (!match) return null; const [, owner, repo, pullNumber] = match; - mountOverlay({ owner, repo, pullNumber: Number(pullNumber) }); + return { owner, repo, pullNumber: Number(pullNumber) }; } function mountOverlay(target) { + if (document.querySelector("[data-gittensory-pr-context]")) return; const container = document.createElement("aside"); - container.className = "gittensory-overlay"; + const host = findPullRequestSidebar(); + container.className = `gittensory-overlay ${host ? "gittensory-overlay--sidebar" : "gittensory-overlay--floating"}`; + container.dataset.gittensoryPrContext = "true"; container.innerHTML = `
G Gittensory + Private
Loading private context...
`; - document.body.appendChild(container); + if (host) { + host.prepend(container); + } else { + document.body.appendChild(container); + } const refresh = container.querySelector(".gittensory-overlay__refresh"); refresh?.addEventListener("click", () => load(container, target)); void load(container, target); } +function findPullRequestSidebar() { + return ( + document.querySelector("#partial-discussion-sidebar") || + document.querySelector("[data-testid='pr-sidebar']") || + document.querySelector(".Layout-sidebar") || + document.querySelector(".discussion-sidebar") + ); +} + async function load(container, target) { const body = container.querySelector(".gittensory-overlay__body"); if (!body) return; @@ -31,11 +54,43 @@ async function load(container, target) { body.innerHTML = `
${escapeHtml(response?.error || "Context unavailable")}
`; return; } - const panels = Array.isArray(response.payload?.panels) ? response.payload.panels : []; - body.innerHTML = panels + 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")} @@ -49,7 +104,6 @@ async function load(container, target) { `, ) .join(""); - renderActions(body, response.payload?.actions); } function escapeHtml(value) { @@ -73,15 +127,15 @@ function renderActions(body, actions) { const list = Array.isArray(actions) ? actions : []; if (list.length === 0) return; const container = document.createElement("section"); - container.className = "gittensory-overlay__panel"; + container.className = "gittensory-overlay__panel gittensory-overlay__panel--private"; container.innerHTML = `
Actions extension
-
+
`; - const actionsNode = container.querySelector(".gittensory-overlay__actions"); + const actionsNode = container.querySelector(".gittensory-overlay__action-buttons"); if (!actionsNode) return; for (const action of list) { if (action?.id === "copy_public_safe_packet" && typeof action?.markdown === "string") { @@ -119,3 +173,14 @@ function renderActions(body, actions) { } body.appendChild(container); } + +if (globalThis.__GITTENSORY_EXTENSION_TEST__) { + globalThis.__gittensoryContentInternals = { + matchPullRequestTarget, + renderPullContext, + renderSection, + renderLegacyPanels, + renderActions, + escapeHtml, + }; +} diff --git a/apps/gittensory-extension/styles.css b/apps/gittensory-extension/styles.css index b7268d0e9a..4070d136c5 100644 --- a/apps/gittensory-extension/styles.css +++ b/apps/gittensory-extension/styles.css @@ -1,4 +1,18 @@ .gittensory-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; +} + +.gittensory-overlay--sidebar { + width: 100%; + margin-bottom: 16px; + overflow: hidden; +} + +.gittensory-overlay--floating { position: fixed; right: 16px; bottom: 16px; @@ -6,12 +20,7 @@ width: min(360px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 32px)); overflow: auto; - border: 1px solid rgba(126, 231, 188, 0.45); - border-radius: 8px; - background: #0f1117; - color: #f4f7f5; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45); - font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .gittensory-overlay__header { @@ -23,6 +32,17 @@ font-weight: 650; } +.gittensory-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; +} + .gittensory-overlay__mark { display: inline-flex; width: 22px; @@ -56,6 +76,18 @@ padding: 10px; } +.gittensory-overlay__panel--good { + border-color: rgba(126, 231, 188, 0.35); +} + +.gittensory-overlay__panel--warn { + border-color: rgba(255, 189, 105, 0.45); +} + +.gittensory-overlay__panel--private { + border-color: rgba(145, 181, 255, 0.38); +} + .gittensory-overlay__panel + .gittensory-overlay__panel { margin-top: 8px; } @@ -77,6 +109,16 @@ text-transform: uppercase; } +.gittensory-overlay__panel--warn .gittensory-overlay__panel-head span { + border-color: rgba(255, 189, 105, 0.45); + color: #ffbd69; +} + +.gittensory-overlay__panel--private .gittensory-overlay__panel-head span { + border-color: rgba(145, 181, 255, 0.45); + color: #91b5ff; +} + .gittensory-overlay dl { margin: 0; } @@ -96,8 +138,69 @@ margin: 0; color: rgba(244, 247, 245, 0.9); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: right; + overflow-wrap: anywhere; +} + +.gittensory-overlay__list, +.gittensory-overlay__actions { + margin: 8px 0 0; + padding-left: 18px; +} + +.gittensory-overlay__list li, +.gittensory-overlay__actions li { + color: rgba(244, 247, 245, 0.78); + margin-top: 4px; +} + +.gittensory-overlay__actions li { + color: rgba(244, 247, 245, 0.9); +} + +.gittensory-overlay__action-buttons { + display: grid; + gap: 8px; +} + +.gittensory-overlay__action-buttons button, +.gittensory-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; +} + +.gittensory-overlay__action-buttons button { + cursor: pointer; + padding: 7px 9px; + text-align: left; +} + +.gittensory-overlay__action-buttons details { + padding: 7px 9px; +} + +.gittensory-overlay__action-buttons summary { + cursor: pointer; + font-weight: 650; +} + +.gittensory-overlay__action-buttons ul { + margin: 6px 0 0; + padding-left: 18px; +} + +.gittensory-overlay__action-buttons li { + color: rgba(244, 247, 245, 0.82); + margin-top: 4px; } .gittensory-overlay__error { color: #ffb4a8; } + +.gittensory-overlay__empty { + color: rgba(244, 247, 245, 0.72); +} diff --git a/scripts/write-ui-openapi.ts b/scripts/write-ui-openapi.ts index 52c571afc8..0d73a3d411 100644 --- a/scripts/write-ui-openapi.ts +++ b/scripts/write-ui-openapi.ts @@ -11,10 +11,12 @@ const checkOnly = process.argv.includes("--check"); const spec = buildOpenApiSpec(); spec.servers = [{ url: "https://gittensory-api.aethereal.dev", description: "Production" }]; -const next = `${JSON.stringify(spec, null, 2)}\n`; +const current = await readFile(target, "utf8").catch(() => ""); +const currentSpec = parseCurrentSpec(current); +const orderedSpec = currentSpec ? preserveExistingObjectOrder(spec, currentSpec) : spec; +const next = `${JSON.stringify(orderedSpec, null, 2)}\n`; if (checkOnly) { - const current = await readFile(target, "utf8").catch(() => ""); if (current !== next) { console.error("apps/gittensory-ui/public/openapi.json is stale; run npm run ui:openapi."); process.exit(1); @@ -24,3 +26,30 @@ if (checkOnly) { await writeFile(target, next); console.log("wrote apps/gittensory-ui/public/openapi.json"); } + +function parseCurrentSpec(currentText: string): Record | null { + try { + return JSON.parse(currentText) as Record; + } catch { + return null; + } +} + +function preserveExistingObjectOrder(next: T, current: unknown): T { + if (Array.isArray(next)) return next.map((item, index) => preserveExistingObjectOrder(item, Array.isArray(current) ? current[index] : undefined)) as T; + if (!isPlainObject(next)) return next; + + const currentObject = isPlainObject(current) ? current : {}; + const ordered: Record = {}; + for (const key of Object.keys(currentObject)) { + if (key in next) ordered[key] = preserveExistingObjectOrder(next[key], currentObject[key]); + } + for (const key of Object.keys(next)) { + if (!(key in ordered)) ordered[key] = preserveExistingObjectOrder(next[key], currentObject[key]); + } + return ordered as T; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/api/routes.ts b/src/api/routes.ts index a611313922..0b032b6ab8 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -162,13 +162,17 @@ import { buildMaintainerCutReadiness, buildMaintainerLaneReport, buildPullRequestMaintainerPacket, + buildRoleContext, buildPreflightResult, buildQueueHealth, buildRegistryChangeReport, + type ContributorOutcomeHistory, + type PullRequestMaintainerPacket, + type RoleContext, } from "../signals/engine"; import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; -import { buildPullRequestReviewability } from "../signals/reward-risk"; +import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; @@ -1147,7 +1151,7 @@ export function createApp() { ]); const contributor = pullRequest?.authorLogin; const contributorContext = contributor ? await loadContributorFastContext(c.env, contributor).catch(() => null) : null; - const reviewability = buildPullRequestReviewability({ + const signalArgs = { repo, pullRequest, issues, @@ -1160,6 +1164,16 @@ export function createApp() { pullNumber, profile: contributorContext?.profile, outcomeHistory: contributorContext?.outcomeHistory, + }; + const packet = buildPullRequestMaintainerPacket(signalArgs); + const reviewability = buildPullRequestReviewability(signalArgs); + const roleContext = buildRoleContext({ + login: contributor ?? contributorContext?.profile.login ?? "unknown", + repo, + repoFullName: fullName, + pullRequests, + issues, + profile: contributorContext?.profile, }); const publicSafePacketMarkdown = buildExtensionPublicSafePacket({ repoFullName: fullName, @@ -1189,32 +1203,20 @@ export function createApp() { clientName: "browser_extension", metadata: { hasContributorContext: Boolean(contributorContext), hasCachedPullRequest: Boolean(pullRequest) }, }); - return c.json({ - generatedAt: nowIso(), - repoFullName: fullName, - pullNumber, - reviewability, - actions: [ - { - id: "copy_public_safe_packet", - label: "Copy public-safe packet", - visibility: "public_safe", - markdown: publicSafePacketMarkdown, - }, - { - id: "view_private_blockers", - label: "View private blockers", - visibility: "private", - requiresAuth: true, - blockers: privateBlockers, - }, - ], - panels: [ - { label: "Reviewability", badge: reviewability.action, rows: [{ k: "action", v: reviewability.action }, { k: "score", v: String(reviewability.score) }] }, - { label: "Contributor", badge: contributor ?? "unknown", rows: [{ k: "author", v: contributor ?? "unknown" }, { k: "prs", v: String(contributorContext?.contributorPullRequests.length ?? 0) }] }, - { label: "Boundary", badge: "private", rows: [{ k: "surface", v: "browser extension" }, { k: "public", v: "no" }] }, - ], - }); + return c.json( + buildExtensionPullContextPayload({ + fullName, + pullNumber, + pullRequest, + contributorContext, + packet, + reviewability, + roleContext, + pullRequests, + publicSafePacketMarkdown, + privateBlockers, + }), + ); }); app.get("/v1/registry/snapshot", async (c) => { @@ -2922,6 +2924,316 @@ async function loadContributorFastContext(env: Env, login: string) { }; } +type ExtensionContributorContext = Awaited> | null; + +type ExtensionPullContextSection = { + id: string; + label: string; + badge: string; + tone: "good" | "warn" | "neutral" | "private"; + rows: Array<{ label: string; value: string }>; + items: string[]; + actions: string[]; +}; + +type ExtensionQueueLevel = "low" | "medium" | "high" | "unknown"; + +const EXTENSION_REVIEWABILITY_TONES: Record = { + review_now: "good", + needs_author: "warn", + likely_duplicate: "warn", + close_or_redirect: "warn", + watch: "neutral", + maintainer_lane: "private", +}; + +const EXTENSION_QUEUE_TONES: Record = { + low: "good", + medium: "warn", + high: "warn", + unknown: "neutral", +}; + +const EXTENSION_QUEUE_DETAILS: Record = { + low: "Cached repo and author queue pressure are low enough for normal review flow.", + medium: "Some open PR pressure is visible; check queue hygiene before encouraging more work from the same lane.", + high: "Resolve open PR pressure before encouraging more work from the same lane.", + unknown: "Author repo-history context is unavailable; use cached repo open PR count as a lightweight pressure signal.", +}; + +function buildExtensionPullContextPayload(args: { + fullName: string; + pullNumber: number; + pullRequest: PullRequestRecord | null; + contributorContext: ExtensionContributorContext; + packet: PullRequestMaintainerPacket; + reviewability: PullRequestReviewability; + roleContext: RoleContext; + pullRequests: PullRequestRecord[]; + publicSafePacketMarkdown: string; + privateBlockers: ReturnType; +}) { + const contributor = args.pullRequest?.authorLogin ?? args.contributorContext?.profile.login ?? "unknown"; + const minerStatus = extensionMinerStatus(args.contributorContext); + const repoOutcome = args.contributorContext?.outcomeHistory.repoOutcomes.find((outcome) => outcome.repoFullName.toLowerCase() === args.fullName.toLowerCase()); + const repoOpenPullRequests = args.pullRequests.filter((pull) => pull.repoFullName === args.fullName && pull.state === "open").length; + const queue = extensionQueuePressure(repoOpenPullRequests, repoOutcome); + const linkedIssues = args.packet.reviewSignals.linkedIssues; + const duplicateCount = args.packet.reviewSignals.collisionClusters; + const publicActions = uniqueStrings([...args.reviewability.maintainerNextSteps, ...args.packet.contributorNextSteps]).slice(0, 5).map(sanitizeExtensionPrivateText); + const sections: ExtensionPullContextSection[] = [ + cleanExtensionSection({ + id: "miner-context", + label: "Miner Context", + badge: minerStatus.badge, + tone: minerStatus.tone, + rows: [ + { label: "author", value: contributor }, + { label: "status", value: minerStatus.label }, + { label: "source", value: minerStatus.source }, + ], + items: [minerStatus.detail], + actions: [], + }), + cleanExtensionSection({ + id: "lane-fit", + label: "Lane Fit", + badge: args.roleContext.maintainerLane ? "maintainer lane" : args.roleContext.role, + tone: args.roleContext.maintainerLane ? "private" : args.roleContext.role === "outside_contributor" ? "good" : "neutral", + rows: [ + { label: "role", value: args.roleContext.role }, + { label: "normal evidence", value: args.roleContext.normalContributorEvidenceAllowed ? "allowed" : "separate lane" }, + { label: "source", value: args.roleContext.source }, + ], + items: uniqueStrings([args.roleContext.guidance, ...args.roleContext.reasons]).slice(0, 4), + actions: [], + }), + cleanExtensionSection({ + id: "duplicate-risk", + label: "Duplicate Risk", + badge: duplicateCount > 0 ? "check overlap" : "clear", + tone: duplicateCount > 0 ? "warn" : "good", + rows: [ + { label: "clusters", value: String(duplicateCount) }, + { label: "action", value: duplicateCount > 0 ? "compare before review" : "no cached overlap" }, + ], + items: + duplicateCount > 0 + ? ["Compare linked issues, active PRs, and recent merges before detailed review."] + : ["No duplicate or WIP collision cluster includes this PR in cached metadata."], + actions: [], + }), + cleanExtensionSection({ + id: "linked-issue-state", + label: "Linked Issue State", + badge: linkedIssues.length > 0 ? "linked" : "missing", + tone: linkedIssues.length > 0 ? "good" : "warn", + rows: [ + { label: "issues", value: linkedIssues.length > 0 ? linkedIssues.map((issue) => `#${issue}`).join(", ") : "none cached" }, + { label: "policy", value: linkedIssues.length > 0 ? "review traceable" : "ask for context" }, + ], + items: + linkedIssues.length > 0 + ? [`Cached PR body links ${linkedIssues.map((issue) => `#${issue}`).join(", ")}.`] + : ["Ask for a linked issue or a clear no-issue rationale before deep review."], + actions: [], + }), + cleanExtensionSection({ + id: "queue-pressure", + label: "Queue Pressure", + badge: queue.level, + tone: queue.tone, + rows: [ + { label: "repo open PRs", value: String(repoOpenPullRequests) }, + { label: "author open PRs", value: queue.authorOpenPullRequests }, + { label: "author merged", value: queue.authorMergedPullRequests }, + ], + items: [queue.detail], + actions: [], + }), + cleanExtensionSection({ + id: "public-safe-actions", + label: "Public-Safe Packet Actions", + badge: args.reviewability.action, + tone: EXTENSION_REVIEWABILITY_TONES[args.reviewability.action], + rows: [ + { label: "priority", value: args.packet.reviewPriority }, + { label: "checks", value: `${args.packet.reviewSignals.checkFailureCount} failing` }, + { label: "reviews", value: `${args.packet.reviewSignals.reviewCount} cached` }, + ], + items: args.reviewability.whyThisHelps.slice(0, 3), + actions: publicActions, + }), + cleanExtensionSection({ + id: "boundary", + label: "Boundary", + badge: "private", + tone: "private", + rows: [ + { label: "surface", value: "browser extension" }, + { label: "public posting", value: "none" }, + { label: "source upload", value: "none" }, + ], + items: ["This panel is maintainer-private context and does not create comments, labels, checks, or source uploads."], + actions: [], + }), + ]; + + return { + generatedAt: nowIso(), + repoFullName: args.fullName, + pullNumber: args.pullNumber, + contributor: { + login: sanitizeExtensionPrivateText(contributor), + minerStatus: minerStatus.status, + role: sanitizeExtensionPrivateText(args.roleContext.role), + maintainerLane: args.roleContext.maintainerLane, + }, + privacy: { + surface: "browser_extension", + publicPosting: false, + sourceUpload: false, + githubMutations: false, + }, + reviewability: args.reviewability, + actions: [ + { + id: "copy_public_safe_packet", + label: "Copy public-safe packet", + visibility: "public_safe", + markdown: args.publicSafePacketMarkdown, + }, + { + id: "view_private_blockers", + label: "View private blockers", + visibility: "private", + requiresAuth: true, + blockers: args.privateBlockers, + }, + ], + sections, + panels: [ + { + label: "Reviewability", + badge: sanitizeExtensionPrivateText(args.reviewability.action), + rows: [ + { k: "action", v: sanitizeExtensionPrivateText(args.reviewability.action) }, + { k: "score", v: String(args.reviewability.score) }, + ], + }, + { + label: "Contributor", + badge: sanitizeExtensionPrivateText(contributor), + rows: [ + { k: "author", v: sanitizeExtensionPrivateText(contributor) }, + { k: "prs", v: String(args.contributorContext?.contributorPullRequests.length ?? 0) }, + ], + }, + { + label: "Boundary", + badge: "private", + rows: [ + { k: "surface", v: "browser extension" }, + { k: "public", v: "no" }, + ], + }, + ], + }; +} + +function extensionMinerStatus(context: ExtensionContributorContext): { + status: "confirmed" | "not_found" | "unavailable"; + badge: string; + label: string; + source: string; + detail: string; + tone: ExtensionPullContextSection["tone"]; +} { + if (!context) { + return { + status: "unavailable", + badge: "unavailable", + label: "official context unavailable", + source: "unavailable", + detail: "Official contributor context is unavailable; this panel does not guess or post publicly.", + tone: "neutral", + }; + } + if (context.profile.gittensor) { + return { + status: "confirmed", + badge: "confirmed", + label: "confirmed miner", + source: "official Gittensor API", + detail: "Official miner context is available for private maintainer triage without exposing wallet or key material.", + tone: "good", + }; + } + return { + status: "not_found", + badge: "non-miner", + label: "no confirmed miner record", + source: context.profile.source, + detail: "No confirmed miner record is cached for this GitHub login; use normal PR review signals.", + tone: "neutral", + }; +} + +function extensionQueuePressure( + repoOpenPullRequests: number, + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, +): { level: ExtensionQueueLevel; tone: ExtensionPullContextSection["tone"]; authorOpenPullRequests: string; authorMergedPullRequests: string; detail: string } { + if (!repoOutcome) { + const level = repoOpenPullRequests >= 6 ? "medium" : "unknown"; + return { + level, + tone: EXTENSION_QUEUE_TONES[level], + authorOpenPullRequests: "unknown", + authorMergedPullRequests: "unknown", + detail: EXTENSION_QUEUE_DETAILS[level], + }; + } + const authorOpenPullRequests = repoOutcome.openPullRequests; + const level = extensionQueueLevel(repoOpenPullRequests, authorOpenPullRequests); + return { + level, + tone: EXTENSION_QUEUE_TONES[level], + authorOpenPullRequests: String(authorOpenPullRequests), + authorMergedPullRequests: String(repoOutcome.mergedPullRequests), + detail: EXTENSION_QUEUE_DETAILS[level], + }; +} + +function extensionQueueLevel(repoOpenPullRequests: number, authorOpenPullRequests: number): "low" | "medium" | "high" { + if (repoOpenPullRequests >= 8 || authorOpenPullRequests >= 4) return "high"; + if (repoOpenPullRequests >= 4 || authorOpenPullRequests >= 2) return "medium"; + return "low"; +} + +function cleanExtensionSection(section: ExtensionPullContextSection): ExtensionPullContextSection { + return { + id: section.id, + label: sanitizeExtensionPrivateText(section.label), + badge: sanitizeExtensionPrivateText(section.badge), + tone: section.tone, + rows: section.rows.map((row) => ({ label: sanitizeExtensionPrivateText(row.label), value: sanitizeExtensionPrivateText(row.value) })), + items: section.items.map(sanitizeExtensionPrivateText), + actions: section.actions.map(sanitizeExtensionPrivateText), + }; +} + +function sanitizeExtensionPrivateText(value: unknown): string { + const text = String(value).replace( + /\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|private keys?|raw trust scores?|trust scores?|raw rankings?|private rankings?|reward estimates?|payouts?|farming)\b|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+/gi, + "private signal", + ); + return text.replace(/\s+/g, " ").trim(); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + async function loadCheckSummariesForPullRequests(env: Env, repoFullName: string, input: Parameters[0], pullRequests: Parameters[1]) { const currentPullRequest = findCurrentBranchPullRequest(input, pullRequests); return currentPullRequest ? listCheckSummaries(env, repoFullName, currentPullRequest.number) : []; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index facdf5f6bd..aaad3ca6ba 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -447,6 +447,12 @@ describe("api routes", () => { if (url.includes("/users/oktofeesh1/repos")) { return Response.json([{ language: "TypeScript" }, { language: "Python" }, { language: "TypeScript" }]); } + if (url.endsWith("/users/other")) { + return Response.json({ login: "other", public_repos: 4, followers: 1 }); + } + if (url.includes("/users/other/repos")) { + return Response.json([{ language: "TypeScript" }]); + } return new Response("not found", { status: 404 }); }); @@ -800,6 +806,14 @@ describe("api routes", () => { ); expect(invalidQueueIntelligence.status).toBe(400); + const missingQueuePullRequests = await app.request( + "/v1/internal/queue-intelligence", + { method: "POST", headers: internalHeaders(env), body: JSON.stringify({}) }, + env, + ); + expect(missingQueuePullRequests.status).toBe(400); + await expect(missingQueuePullRequests.json()).resolves.toMatchObject({ error: "invalid_request", detail: "pullRequests array required" }); + const invalidRepoContext = await app.request( "/v1/internal/queue-intelligence", { @@ -2410,20 +2424,47 @@ describe("api routes", () => { const extensionPayload = (await extensionContext.json()) as { repoFullName: string; pullNumber: number; + contributor: { login: string; minerStatus: string }; + privacy: { surface: string; publicPosting: boolean; sourceUpload: boolean; githubMutations: boolean }; reviewability: { repoFullName: string; pullNumber: number }; actions: Array<{ id: string; markdown?: string; blockers?: Array<{ detail: string }> }>; panels: Array<{ label: string }>; + sections: Array<{ id: string; label: string; badge?: string }>; }; expect(extensionPayload).toMatchObject({ repoFullName: "entrius/allways-ui", pullNumber: 12, + contributor: { login: "oktofeesh1", minerStatus: "confirmed" }, + privacy: { surface: "browser_extension", publicPosting: false, sourceUpload: false, githubMutations: false }, reviewability: { repoFullName: "entrius/allways-ui", pullNumber: 12 }, actions: expect.arrayContaining([ expect.objectContaining({ id: "copy_public_safe_packet", visibility: "public_safe" }), expect.objectContaining({ id: "view_private_blockers", visibility: "private", requiresAuth: true }), ]), panels: expect.arrayContaining([expect.objectContaining({ label: "Reviewability" }), expect.objectContaining({ label: "Boundary" })]), + sections: expect.arrayContaining([ + expect.objectContaining({ id: "miner-context", label: "Miner Context", badge: "confirmed" }), + expect.objectContaining({ id: "lane-fit", label: "Lane Fit" }), + expect.objectContaining({ id: "duplicate-risk", label: "Duplicate Risk", badge: "check overlap" }), + expect.objectContaining({ id: "linked-issue-state", label: "Linked Issue State", badge: "linked" }), + expect.objectContaining({ id: "queue-pressure", label: "Queue Pressure" }), + expect.objectContaining({ id: "public-safe-actions", label: "Public-Safe Packet Actions" }), + expect.objectContaining({ id: "boundary", label: "Boundary", badge: "private" }), + ]), }); + expect(JSON.stringify(extensionPayload)).not.toMatch(/wallet|hotkey|coldkey|raw trust|private ranking|github_pat|ghp_|payout|reward estimate|farming/i); + + const nonMinerExtensionContext = await app.request( + "/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=13", + { headers: { authorization: `Bearer ${extensionSessionBody.token}` } }, + env, + ); + expect(nonMinerExtensionContext.status).toBe(200); + await expect(nonMinerExtensionContext.json()).resolves.toMatchObject({ + contributor: { login: "other", minerStatus: "not_found" }, + sections: expect.arrayContaining([expect.objectContaining({ id: "miner-context", badge: "non-miner" })]), + }); + const packet = extensionPayload.actions.find((action) => action.id === "copy_public_safe_packet")?.markdown ?? ""; expect(packet).toContain("# Public-safe PR packet"); expect(packet).not.toMatch(/wallet|hotkey|coldkey|reward estimate|payout|farming|raw trust score|estimated score|score estimate|private reviewability/i); @@ -2440,8 +2481,14 @@ describe("api routes", () => { await expect(missingPullContext.json()).resolves.toMatchObject({ repoFullName: "entrius/allways-ui", pullNumber: 99, + contributor: { login: "unknown", minerStatus: "unavailable" }, actions: expect.arrayContaining([expect.objectContaining({ id: "copy_public_safe_packet" }), expect.objectContaining({ id: "view_private_blockers" })]), panels: expect.arrayContaining([expect.objectContaining({ label: "Contributor", badge: "unknown" })]), + sections: expect.arrayContaining([ + expect.objectContaining({ id: "miner-context", badge: "unavailable" }), + expect.objectContaining({ id: "duplicate-risk", badge: "clear" }), + expect.objectContaining({ id: "linked-issue-state", badge: "missing" }), + ]), }); const expiringExtensionSession = await app.request("/v1/auth/extension/session", { method: "POST", headers: cookieHeaders }, env); diff --git a/test/unit/extension-content.test.ts b/test/unit/extension-content.test.ts new file mode 100644 index 0000000000..563cad626a --- /dev/null +++ b/test/unit/extension-content.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from "node:fs"; +import { Script, createContext } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +const contentScript = readFileSync("apps/gittensory-extension/content.js", "utf8"); + +describe("extension content script", () => { + it("matches only GitHub pull request routes", () => { + const internals = loadContentInternals(); + + expect(internals.matchPullRequestTarget("/JSONbored/gittensory/pull/146")).toEqual({ + owner: "JSONbored", + repo: "gittensory", + pullNumber: 146, + }); + expect(internals.matchPullRequestTarget("/JSONbored/gittensory/pull/146/files")).toEqual({ + owner: "JSONbored", + repo: "gittensory", + pullNumber: 146, + }); + expect(internals.matchPullRequestTarget("/JSONbored/gittensory/issues/146")).toBeNull(); + expect(internals.matchPullRequestTarget("/JSONbored/gittensory")).toBeNull(); + }); + + it("renders private pull-context sections and escapes API text", () => { + const internals = loadContentInternals(); + + const html = internals.renderPullContext({ + sections: [ + { + label: "Miner ", + badge: "confirmed", + tone: "good", + rows: [{ label: "author", value: "alice