diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index ee02c9923d..6bb7ccfdd7 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -88,6 +88,8 @@ const CLI_COMMAND_SPEC = { "decision-pack": [], "repo-decision": [], "contributor-profile": [], + notifications: [], + "notifications-read": [], "monitor-open-prs": [], "analyze-branch": [], preflight: [], @@ -3326,6 +3328,8 @@ async function runCli(args) { if (command === "decision-pack") return decisionPackCli(options); if (command === "repo-decision") return repoDecisionCli(options); if (command === "contributor-profile") return contributorProfileCli(options); + if (command === "notifications") return notificationsCli(options); + if (command === "notifications-read") return notificationsReadCli(options); if (command === "monitor-open-prs") return monitorOpenPrsCli(options); if (command === "review-pr") return reviewPrCli(options); if (command !== "analyze-branch" && command !== "preflight") { @@ -3738,6 +3742,43 @@ async function contributorProfileCli(options) { `); } +// #6745: CLI mirror of the loopover_list_notifications MCP tool and GET /v1/contributors/{login}/notifications. +// Login resolves the same way as the sibling contributor commands above. +async function notificationsCli(options) { + const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); + const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/notifications`); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + const notifications = payload.notifications ?? []; + process.stdout.write( + [ + `LoopOver notifications for ${login}: ${payload.unreadCount ?? 0} unread.`, + ...notifications.map((n) => `- [${n.status === "read" ? "read" : "unread"}] ${sanitizePlainTextTerminalOutput(n.title)}`), + ].join("\n") + "\n", + ); +} + +// #6745: CLI mirror of the loopover_mark_notifications_read MCP tool and POST +// /v1/contributors/{login}/notifications/read. Omit --id to clear every delivered notification; repeat +// --id to clear specific ones. +async function notificationsReadCli(options) { + const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login , log in with `loopover-mcp login`, or set LOOPOVER_LOGIN."); + const ids = options.id ? (Array.isArray(options.id) ? options.id : [options.id]) : undefined; + const payload = await apiFetch(`/v1/contributors/${encodeURIComponent(login)}/notifications/read`, { + method: "POST", + body: JSON.stringify(ids ? { ids } : {}), + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`Marked ${payload.marked ?? 0} LoopOver notification(s) read for ${login}.\n`); +} + async function decisionPackCli(options) { if (options.help === true) return printDecisionPackHelp(); const login = options.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN; @@ -4268,6 +4309,8 @@ function printHelp() { loopover-mcp decision-pack --login [--json] loopover-mcp repo-decision --login --repo owner/repo [--json] loopover-mcp monitor-open-prs --login [--json] + loopover-mcp notifications --login [--json] + loopover-mcp notifications-read --login [--id ]... [--json] loopover-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--format table] [--json] loopover-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--format table] [--json] loopover-mcp review-pr --login [--repo owner/repo] [--base origin/main] [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] @@ -4286,7 +4329,7 @@ function printHelp() { LOOPOVER_PROFILE LOOPOVER_CONFIG_PATH or LOOPOVER_CONFIG_DIR LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN, or a session from loopover-mcp login - LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, and agent plan/packet) + LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, notifications, notifications-read, and agent plan/packet) GITHUB_TOKEN for non-interactive login bootstrap GITTENSOR_SCORE_PREVIEW_CMD GITTENSOR_ROOT @@ -4331,7 +4374,7 @@ Use --profile or LOOPOVER_PROFILE to run login, logout, whoami, status, d function parseOptions(args) { const options = {}; - const repeatable = new Set(["label", "issue", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); + const repeatable = new Set(["label", "issue", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote", "id"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { diff --git a/src/api/routes.ts b/src/api/routes.ts index d4ecc04cac..c6b8fd8dd5 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -77,6 +77,10 @@ import { listIssueSignalSample, listAgentRunsForActor, listDigestSubscriptionsForLogin, + listNotificationDeliveriesForRecipient, + markNotificationDeliveriesRead, + MAX_NOTIFICATION_DELIVERY_ID_LENGTH, + MAX_NOTIFICATION_MARK_READ_IDS, listProductUsageDailyRollups, listOpenPullRequests, listPrVisibilitySkipAuditEvents, @@ -131,6 +135,7 @@ import { getRepositoryCollaboratorPermission } from "../github/app"; import type { LoopOverFooterEnv } from "../github/footer"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile, fetchPublicRepoStats } from "../github/public"; +import { buildNotificationFeed } from "../notifications/service"; import { buildPublicAgentCommandComment, buildMaintainerQueueDigest, @@ -517,6 +522,12 @@ const intakeIdeaSchema = z.object({ // #6752: mirrors buildResultsPayloadShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so the // REST surface can never accept an input the MCP tool would reject, or vice versa. +// #6745: mirrors markNotificationsReadShape in src/mcp/server.ts VERBATIM (same bounds, same optionality) so +// the REST surface can never accept an input the MCP tool would reject, or vice versa. +const markNotificationsReadSchema = z.object({ + ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(), +}); + const resultsPayloadSchema = z.object({ repoFullName: z.string().min(1), prNumber: z.number().int().nullable().optional(), @@ -3309,6 +3320,28 @@ export function createApp() { return c.json(await buildContributorOpenPrMonitor(c.env, login)); }); + // #6745: REST mirror of the loopover_list_notifications MCP tool (src/mcp/server.ts), same + // requireContributorAccess self-scoping as the profile/decision-pack routes above. + app.get("/v1/contributors/:login/notifications", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const deliveries = await listNotificationDeliveriesForRecipient(c.env, login, { channel: "badge", limit: 50 }); + return c.json(buildNotificationFeed(login, deliveries)); + }); + + // #6745: REST mirror of the loopover_mark_notifications_read MCP tool (src/mcp/server.ts), same + // requireContributorAccess self-scoping. Omitting `ids` clears every delivered notification. + app.post("/v1/contributors/:login/notifications/read", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const parsed = markNotificationsReadSchema.safeParse((await c.req.json().catch(() => ({}))) ?? {}); + if (!parsed.success) return c.json({ error: "invalid_notifications_read_request", issues: parsed.error.issues }, 400); + const marked = await markNotificationDeliveriesRead(c.env, login, parsed.data.ids); + return c.json({ login: login.toLowerCase(), marked }); + }); + app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => { const login = c.req.param("login"); const unauthorized = await requireContributorAccess(c, login); diff --git a/test/unit/mcp-cli-notifications.test.ts b/test/unit/mcp-cli-notifications.test.ts new file mode 100644 index 0000000000..b6f1266f56 --- /dev/null +++ b/test/unit/mcp-cli-notifications.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness"; + +describe("loopover-mcp CLI — notifications / notifications-read (#6745)", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + async function env(onApiRequest?: (request: import("node:http").IncomingMessage) => void) { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const url = await startFixtureServer(onApiRequest ? { onApiRequest } : {}); + return { LOOPOVER_API_URL: url, LOOPOVER_TOKEN: "session-token", LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_API_TIMEOUT_MS: "1000" }; + } + + it("mirrors GET /v1/contributors/:login/notifications for an explicit --login (plain + json)", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + + const plain = await runAsync(["notifications", "--login", "octocat"], e); + expect(plain).toMatch(/LoopOver notifications for octocat: 1 unread\./); + expect(plain).toMatch(/Changes requested on owner\/repo#7/); + expect(requests.at(-1)).toBe("/v1/contributors/octocat/notifications"); + + const json = JSON.parse(await runAsync(["notifications", "--login", "octocat", "--json"], e)) as { login: string; unreadCount: number }; + expect(json).toMatchObject({ login: "octocat", unreadCount: 1 }); + }); + + it("resolves the login from LOOPOVER_LOGIN when --login is omitted, and url-encodes it", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + + await runAsync(["notifications"], { ...e, LOOPOVER_LOGIN: "a b/c" }); + expect(requests.at(-1)).toBe("/v1/contributors/a%20b%2Fc/notifications"); + }); + + it("errors (never issuing a request) when no login can be resolved", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + const failure = runExpectingFailure(["notifications"], { ...e, LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" }); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login/); + expect(requests.filter((url) => url.includes("/notifications"))).toHaveLength(0); + }); + + it("mirrors POST /v1/contributors/:login/notifications/read, marking all read when --id is omitted", async () => { + const requests: Array<{ url: string; body: string }> = []; + const e = await env((request) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => requests.push({ url: request.url ?? "", body })); + }); + + const plain = await runAsync(["notifications-read", "--login", "octocat"], e); + expect(plain).toMatch(/Marked 1 LoopOver notification\(s\) read for octocat\./); + expect(requests.at(-1)?.url).toBe("/v1/contributors/octocat/notifications/read"); + expect(JSON.parse(requests.at(-1)?.body || "{}")).toEqual({}); + }); + + it("passes repeated --id flags as the ids array, marking only those read", async () => { + const requests: Array<{ url: string; body: string }> = []; + const e = await env((request) => { + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => requests.push({ url: request.url ?? "", body })); + }); + + const json = JSON.parse(await runAsync(["notifications-read", "--login", "octocat", "--id", "n1", "--id", "n2", "--json"], e)) as { login: string; marked: number }; + expect(json).toMatchObject({ login: "octocat", marked: 2 }); + expect(JSON.parse(requests.at(-1)?.body || "{}")).toEqual({ ids: ["n1", "n2"] }); + }); + + it("errors (never issuing a request) when no login can be resolved", async () => { + const requests: string[] = []; + const e = await env((request) => requests.push(request.url ?? "")); + const failure = runExpectingFailure(["notifications-read"], { ...e, LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" }); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login/); + expect(requests.filter((url) => url.includes("/notifications"))).toHaveLength(0); + }); +}); diff --git a/test/unit/routes-notifications.test.ts b/test/unit/routes-notifications.test.ts new file mode 100644 index 0000000000..a35cbd9338 --- /dev/null +++ b/test/unit/routes-notifications.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { insertNotificationDeliveryIfAbsent, markNotificationDeliveryDelivered } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +function apiHeaders(env: Env): Record { + return { authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }; +} + +async function seedDelivered(env: Env, recipientLogin: string, dedupKey: string): Promise { + const { delivery } = await insertNotificationDeliveryIfAbsent(env, { + dedupKey, + channel: "badge", + recipientLogin, + eventType: "pull_request_changes_requested", + repoFullName: "owner/repo", + pullNumber: 7, + title: "Changes requested on owner/repo#7", + body: "A reviewer requested changes on your pull request owner/repo#7.", + deeplink: "https://github.com/owner/repo/pull/7", + actorLogin: "reviewer", + }); + await markNotificationDeliveryDelivered(env, delivery.id); + return delivery.id; +} + +describe("contributor notifications routes (#6745)", () => { + it("rejects unauthenticated access to both routes", async () => { + const app = createApp(); + const env = createTestEnv(); + expect((await app.request("/v1/contributors/alice/notifications", {}, env)).status).toBe(401); + expect((await app.request("/v1/contributors/alice/notifications/read", { method: "POST" }, env)).status).toBe(401); + }); + + it("lets a self-matching admin session read its own notifications and unread count", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "attacker" }); + const { token } = await createSessionForGitHubUser(env, { login: "attacker", id: 7 }); + const sessionHeaders = { authorization: `Bearer ${token}` }; + await seedDelivered(env, "attacker", "k1"); + + const res = await app.request("/v1/contributors/attacker/notifications", { headers: sessionHeaders }, env); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ login: "attacker", unreadCount: 1 }); + // never leaks a wallet/hotkey/reward term regardless of notification content + expect(JSON.stringify(body)).not.toMatch(/wallet|hotkey|coldkey|reward estimate|trust score/i); + }); + + it("forbids a session from reading another login's notifications", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "attacker" }); + const { token } = await createSessionForGitHubUser(env, { login: "attacker", id: 7 }); + const sessionHeaders = { authorization: `Bearer ${token}` }; + + const res = await app.request("/v1/contributors/victim/notifications", { headers: sessionHeaders }, env); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); + }); + + it("lets a static api token read any login's notifications", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedDelivered(env, "victim", "k1"); + + const res = await app.request("/v1/contributors/victim/notifications", { headers: apiHeaders(env) }, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ login: "victim", unreadCount: 1 }); + }); + + it("marks all of a login's delivered notifications read when ids is omitted", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedDelivered(env, "victim", "k1"); + await seedDelivered(env, "victim", "k2"); + + const res = await app.request( + "/v1/contributors/victim/notifications/read", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({}) }, + env, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ login: "victim", marked: 2 }); + const after = await app.request("/v1/contributors/victim/notifications", { headers: apiHeaders(env) }, env); + await expect(after.json()).resolves.toMatchObject({ unreadCount: 0 }); + }); + + it("marks only the given ids read, leaving the rest delivered", async () => { + const app = createApp(); + const env = createTestEnv(); + const firstId = await seedDelivered(env, "victim", "k1"); + await seedDelivered(env, "victim", "k2"); + + const res = await app.request( + "/v1/contributors/victim/notifications/read", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ ids: [firstId] }) }, + env, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ login: "victim", marked: 1 }); + const after = await app.request("/v1/contributors/victim/notifications", { headers: apiHeaders(env) }, env); + await expect(after.json()).resolves.toMatchObject({ unreadCount: 1 }); + }); + + it("rejects an over-cap or oversized ids entry via schema validation before touching the store", async () => { + const app = createApp(); + const env = createTestEnv(); + + const tooLong = await app.request( + "/v1/contributors/victim/notifications/read", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ ids: ["x".repeat(200)] }) }, + env, + ); + expect(tooLong.status).toBe(400); + + const tooMany = await app.request( + "/v1/contributors/victim/notifications/read", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ ids: Array.from({ length: 101 }, (_, i) => `id-${i}`) }) }, + env, + ); + expect(tooMany.status).toBe(400); + }); + + it("treats a malformed JSON body the same as an empty body (marks all read)", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedDelivered(env, "victim", "k1"); + + const res = await app.request( + "/v1/contributors/victim/notifications/read", + { method: "POST", headers: apiHeaders(env), body: "not-json" }, + env, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ login: "victim", marked: 1 }); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 872d08aaff..ceb0bbcf5b 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -304,6 +304,25 @@ export async function startFixtureServer( ); return; } + const notificationsMatch = /^\/v1\/contributors\/([^/]+)\/notifications$/.exec(new URL(request.url ?? "/", "http://localhost").pathname); + if (notificationsMatch && request.method === "GET") { + response.end( + JSON.stringify({ + login: decodeURIComponent(notificationsMatch[1]!), + unreadCount: 1, + notifications: [ + { id: "notif-1", eventType: "pull_request_changes_requested", repoFullName: "owner/repo", pullNumber: 7, title: "Changes requested on owner/repo#7", body: "x", deeplink: "https://github.com/owner/repo/pull/7", status: "delivered", createdAt: "2026-05-30T00:00:00.000Z" }, + ], + }), + ); + return; + } + const notificationsReadMatch = /^\/v1\/contributors\/([^/]+)\/notifications\/read$/.exec(new URL(request.url ?? "/", "http://localhost").pathname); + if (notificationsReadMatch && request.method === "POST") { + const body = (await readJsonRequest(request)) as { ids?: string[] }; + response.end(JSON.stringify({ login: decodeURIComponent(notificationsReadMatch[1]!), marked: body.ids ? body.ids.length : 1 })); + return; + } if (request.url === "/v1/contributors/JSONbored/open-pr-monitor" && request.method === "GET") { response.end(JSON.stringify({ ...openPrMonitorFixture(), ...(options.openPrMonitor ?? {}) })); return;