diff --git a/apps/gittensory-extension/content.js b/apps/gittensory-extension/content.js index cc29c8d217..5f9539f93e 100644 --- a/apps/gittensory-extension/content.js +++ b/apps/gittensory-extension/content.js @@ -49,6 +49,7 @@ async function load(container, target) { `, ) .join(""); + renderActions(body, response.payload?.actions); } function escapeHtml(value) { @@ -67,3 +68,54 @@ function escapeHtml(value) { } }); } + +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.innerHTML = ` +
+ + `; + const actionsNode = container.querySelector(".gittensory-overlay__actions"); + 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); +} diff --git a/src/api/routes.ts b/src/api/routes.ts index 73bcf117d8..414c877d23 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -97,6 +97,7 @@ import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } import { fetchPublicContributorProfile } from "../github/public"; import { GITTENSORY_MENTION_COMMAND_CATALOG } from "../github/commands"; import { handleGitHubWebhook } from "../github/webhook"; +import { sanitizePublicComment } from "../github/commands"; import { handleMcpRequest } from "../mcp/server"; import { buildOpenApiSpec } from "../openapi/spec"; import { generateSignalSnapshots } from "../queue/processors"; @@ -1107,6 +1108,24 @@ export function createApp() { profile: contributorContext?.profile, outcomeHistory: contributorContext?.outcomeHistory, }); + const publicSafePacketMarkdown = buildExtensionPublicSafePacket({ + repoFullName: fullName, + pullNumber, + reviewability, + contributor: contributor ?? "unknown", + }); + const privateBlockers = buildExtensionPrivateBlockers(reviewability); + await recordAuditEvent(c.env, { + eventType: "extension.pull_context_view", + actor: contributor ?? "unknown", + route: c.req.path, + outcome: "success", + metadata: { + redacted: true, + hasPublicPacket: publicSafePacketMarkdown.length > 0, + blockerCount: privateBlockers.length, + }, + }); await recordRouteProductUsage(c, { surface: "browser_extension", eventName: "pull_context_viewed", @@ -1122,6 +1141,21 @@ export function createApp() { 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) }] }, @@ -2687,3 +2721,49 @@ function normalizeOrigin(value: string | undefined): string | null { return null; } } + +function buildExtensionPublicSafePacket(args: { repoFullName: string; pullNumber: number; contributor: string; reviewability: { action: string; noiseSources: string[]; maintainerNextSteps: string[] } }): string { + const lines = [ + "# Public-safe PR packet", + "", + "## Linked context", + `- Repository: ${args.repoFullName}`, + `- Pull request: #${args.pullNumber}`, + `- Contributor: ${args.contributor}`, + "", + "## Review readiness", + `- Current action: ${args.reviewability.action.replace(/_/g, " ")}`, + ...args.reviewability.maintainerNextSteps.slice(0, 4).map((step) => `- ${step}`), + "", + "## Queue caution", + ...(args.reviewability.noiseSources.length > 0 + ? args.reviewability.noiseSources.slice(0, 4).map((source) => `- ${source}`) + : ["- No high-noise warning is visible from cached metadata."]), + "", + "## Safety", + "- Keep public comments limited to linked context, validation status, and maintainer-ready next steps.", + ]; + const markdown = sanitizePublicComment(lines.join("\n")); + return ensureExtensionPublicSafeText(markdown); +} + +function buildExtensionPrivateBlockers(reviewability: { noiseSources: string[]; maintainerNextSteps: string[]; privateSummary: string }) { + const items = [...reviewability.noiseSources.slice(0, 5), ...reviewability.maintainerNextSteps.slice(0, 3)]; + if (items.length === 0) items.push("No private blocker detail is currently cached."); + return items.map((detail, index) => ({ id: `blocker-${index + 1}`, detail: sanitizePublicComment(detail) })); +} + +function ensureExtensionPublicSafeText(text: string): string { + const compact = text.replace(/\s+/g, " ").trim(); + if (/\b(wallet|hotkey|coldkey|raw trust score|trust score|estimated score|score estimate|reward estimate|payout|farming|private reviewability|reviewability\s*\d|\/100)\b/i.test(compact)) { + return "# Public-safe PR packet\n\n- Public-safe packet unavailable. Regenerate after private context is sanitized."; + } + return text; +} + +export const __routesInternals = { + buildExtensionPublicSafePacket, + buildExtensionPrivateBlockers, + ensureExtensionPublicSafeText, + authenticateRequestIdentity, +}; diff --git a/src/auth/security.ts b/src/auth/security.ts index a4cbd598b5..593c1122c2 100644 --- a/src/auth/security.ts +++ b/src/auth/security.ts @@ -156,6 +156,10 @@ function serializeCookie(name: string, value: string, options: CookieOptions): s return parts.join("; "); } +export const __securityInternals = { + serializeCookie, +}; + function shouldUseSecureCookie(requestUrl: string): boolean { try { const hostname = new URL(requestUrl).hostname; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 620ee8864c..6864b06ef8 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1480,6 +1480,41 @@ describe("api routes", () => { expect(minerWithEmptyFit.status).toBe(200); await expect(minerWithEmptyFit.json()).resolves.toMatchObject({ status: "ready", repoFit: [] }); + await persistSignalSnapshot(env, { + id: "lane-pack", + signalType: "contributor-decision-pack", + targetKey: "lane-user", + payload: { + status: "ready", + source: "computed", + login: "lane-user", + generatedAt: new Date().toISOString(), + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + repoDecisions: [], + topActions: [], + pursueRepos: [{ repoFullName: "owner/pursue", recommendation: "watch" }], + cleanupFirst: [{ repoFullName: "owner/cleanup", recommendation: "cleanup_first" }], + maintainerLaneRepos: [{ repoFullName: "owner/maintainer", recommendation: "maintainer_lane" }], + avoidRepos: [{ repoFullName: "owner/avoid", recommendation: "avoid_for_now" }], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "ok" } }, + } as never, + generatedAt: new Date().toISOString(), + }); + const minerWithLaneBuckets = await app.request("/v1/app/miner-dashboard?login=lane-user", { headers: apiHeaders(env) }, env); + expect(minerWithLaneBuckets.status).toBe(200); + await expect(minerWithLaneBuckets.json()).resolves.toMatchObject({ + repoFit: expect.arrayContaining([ + expect.objectContaining({ repoFullName: "owner/pursue", lane: "pursue" }), + expect.objectContaining({ repoFullName: "owner/cleanup", lane: "cleanup-first" }), + expect.objectContaining({ repoFullName: "owner/maintainer", lane: "maintainer-lane" }), + expect.objectContaining({ repoFullName: "owner/avoid", lane: "avoid" }), + ]), + }); + await recordGitHubRateLimitObservation(env, { id: "rate-limit-healthy", repoFullName: "entrius/allways-ui", @@ -1914,12 +1949,29 @@ describe("api routes", () => { env, ); expect(extensionContext.status).toBe(200); - await expect(extensionContext.json()).resolves.toMatchObject({ + const extensionPayload = (await extensionContext.json()) as { + repoFullName: string; + pullNumber: number; + reviewability: { repoFullName: string; pullNumber: number }; + actions: Array<{ id: string; markdown?: string; blockers?: Array<{ detail: string }> }>; + panels: Array<{ label: string }>; + }; + expect(extensionPayload).toMatchObject({ repoFullName: "entrius/allways-ui", pullNumber: 12, 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" })]), }); + 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); + const blockers = extensionPayload.actions.find((action) => action.id === "view_private_blockers")?.blockers ?? []; + expect(blockers.length).toBeGreaterThan(0); + expect(JSON.stringify(blockers)).not.toMatch(/wallet|hotkey|coldkey|payout|farming|guaranteed payout/i); const missingPullContext = await app.request( "/v1/extension/pull-context?owner=entrius&repo=allways-ui&pullNumber=99", @@ -1930,6 +1982,7 @@ describe("api routes", () => { await expect(missingPullContext.json()).resolves.toMatchObject({ repoFullName: "entrius/allways-ui", pullNumber: 99, + 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" })]), }); diff --git a/test/unit/routes-extension.test.ts b/test/unit/routes-extension.test.ts new file mode 100644 index 0000000000..c71ff36225 --- /dev/null +++ b/test/unit/routes-extension.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { __routesInternals } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +describe("extension packet helper internals", () => { + it("falls back when extension packet text contains forbidden public terms", () => { + const result = __routesInternals.ensureExtensionPublicSafeText("# Public-safe PR packet\n\n- reviewability 91/100"); + expect(result).toContain("Public-safe packet unavailable"); + }); + + it("keeps safe extension packet text unchanged", () => { + const text = "# Public-safe PR packet\n\n- Repository: owner/repo\n- Keep public comments focused on linked context."; + expect(__routesInternals.ensureExtensionPublicSafeText(text)).toBe(text); + }); + + it("builds private blocker fallback when no blocker signals are present", () => { + const blockers = __routesInternals.buildExtensionPrivateBlockers({ + noiseSources: [], + maintainerNextSteps: [], + privateSummary: "", + }); + expect(blockers).toEqual([{ id: "blocker-1", detail: "No private blocker detail is currently cached." }]); + }); + + it("sanitizes extension packet markdown before returning it", () => { + const markdown = __routesInternals.buildExtensionPublicSafePacket({ + repoFullName: "owner/repo", + pullNumber: 12, + contributor: "alice", + reviewability: { + action: "review_now", + noiseSources: ["avoid payout language in public"], + maintainerNextSteps: ["remove wallet references"], + }, + }); + expect(markdown).toContain("# Public-safe PR packet"); + expect(markdown).not.toMatch(/wallet|payout|hotkey|reward estimate|estimated score|raw trust score/i); + }); + + it("authenticates request identity from browser session cookie fallback", async () => { + const env = createTestEnv(); + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 7 }); + const identity = await __routesInternals.authenticateRequestIdentity({ + env, + req: { + header(name: string) { + if (name.toLowerCase() === "cookie") return `gittensory_session=${token}`; + return undefined; + }, + }, + json: (_payload: { error: string }, status?: number) => Response.json({}, status === undefined ? undefined : { status }), + }); + expect(identity).toMatchObject({ kind: "session", actor: "jsonbored" }); + }); +}); diff --git a/test/unit/security-internals.test.ts b/test/unit/security-internals.test.ts new file mode 100644 index 0000000000..c61700d16a --- /dev/null +++ b/test/unit/security-internals.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { __securityInternals } from "../../src/auth/security"; + +describe("security internals", () => { + it("serializes cookies with optional HttpOnly and Secure flags", () => { + const minimal = __securityInternals.serializeCookie("a", "b", { + maxAge: 10, + path: "/", + httpOnly: false, + sameSite: "Lax", + secure: false, + }); + expect(minimal).toContain("a=b"); + expect(minimal).not.toContain("HttpOnly"); + expect(minimal).not.toContain("Secure"); + + const strict = __securityInternals.serializeCookie("a", "b", { + maxAge: 10, + path: "/", + httpOnly: true, + sameSite: "Strict", + secure: true, + }); + expect(strict).toContain("HttpOnly"); + expect(strict).toContain("Secure"); + expect(strict).toContain("SameSite=Strict"); + }); +});