diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bca7e13b10..51b9ee2a65 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14405,6 +14405,91 @@ "summary", "outcomes" ] + }, + "NotificationFeed": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "unreadCount": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationFeedItem" + } + } + }, + "required": [ + "login", + "unreadCount", + "notifications" + ] + }, + "NotificationFeedItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number", + "nullable": true + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "deeplink": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "delivered", + "read" + ] + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "eventType", + "repoFullName", + "pullNumber", + "title", + "body", + "deeplink", + "status", + "createdAt" + ] + }, + "NotificationsMarked": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "marked": { + "type": "number" + } + }, + "required": [ + "login", + "marked" + ] } }, "parameters": {}, @@ -18694,6 +18779,96 @@ } ] } + }, + "/v1/contributors/{login}/notifications": { + "get": { + "summary": "Contributor badge notification feed", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The contributor's own badge notification feed (self-scoped), newest first, with an unread count.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationFeed" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/contributors/{login}/notifications/read": { + "post": { + "summary": "Mark contributor notifications read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Marks the contributor's delivered badge notifications read; an absent/empty ids array marks all.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationsMarked" + } + } + } + }, + "400": { + "description": "Invalid mark-read body" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 4a8d130bac..bfc33b627f 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -90,6 +90,8 @@ const CLI_COMMAND_SPEC = { "contributor-profile": [], "monitor-open-prs": [], "pr-outcomes": [], + notifications: [], + "notifications-read": [], "analyze-branch": [], preflight: [], "review-pr": [], @@ -3399,6 +3401,8 @@ async function runCli(args) { if (command === "contributor-profile") return contributorProfileCli(options); if (command === "monitor-open-prs") return monitorOpenPrsCli(options); if (command === "pr-outcomes") return prOutcomesCli(options); + if (command === "notifications") return notificationsCli(options); + if (command === "notifications-read") return notificationsReadCli(options); if (command === "review-pr") return reviewPrCli(options); if (command !== "analyze-branch" && command !== "preflight") { const suggestion = suggestCommand(command); @@ -3900,6 +3904,66 @@ async function prOutcomesCli(options) { } } +function printNotificationsHelp() { + process.stdout.write( + [ + "Usage: loopover-mcp notifications --login [--json]", + "", + "Your own badge notification feed (newest first) with an unread count, self-scoped.", + "Mirrors the loopover_list_notifications MCP tool and GET /v1/contributors/{login}/notifications. No source upload.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +// #6745: CLI mirror of loopover_list_notifications. Login resolves from --login / the active session / +// LOOPOVER_LOGIN / GITHUB_LOGIN, like the sibling contributor commands. +async function notificationsCli(options) { + if (options.help === true) return printNotificationsHelp(); + 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 getNotifications(login); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`LoopOver notifications for ${login}: ${payload.unreadCount} unread.\n`); + for (const item of payload.notifications ?? []) { + // `login` is the user's own value; the API chooses the title text, so it is sanitized before the terminal. + const flag = item.status === "delivered" ? "*" : " "; + process.stdout.write(`${sanitizePlainTextTerminalOutput(`${flag} ${item.repoFullName}#${item.pullNumber} ${item.title}`)}\n`); + } +} + +function printNotificationsReadHelp() { + process.stdout.write( + [ + "Usage: loopover-mcp notifications-read --login [--id ]... [--json]", + "", + "Mark your delivered notifications read. With no --id, marks all of them.", + "Mirrors the loopover_mark_notifications_read MCP tool and POST /v1/contributors/{login}/notifications/read.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +// #6745: CLI mirror of loopover_mark_notifications_read. Repeated --id flags collect into an ids array; omitting +// them marks every delivered notification read (mirrors the route's absent-body behavior). +async function notificationsReadCli(options) { + if (options.help === true) return printNotificationsReadHelp(); + 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 = Array.isArray(options.id) ? options.id : options.id ? [options.id] : undefined; + const payload = await postMarkNotificationsRead(login, ids); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`Marked ${payload.marked} LoopOver notification(s) read for ${login}.\n`); +} + function printRepoDecisionHelp() { process.stdout.write( [ @@ -4380,6 +4444,8 @@ function printHelp() { loopover-mcp repo-decision --login --repo owner/repo [--json] loopover-mcp monitor-open-prs --login [--json] loopover-mcp pr-outcomes --login [--limit N] [--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] @@ -4398,7 +4464,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, pr-outcomes, and agent plan/packet) + LOOPOVER_LOGIN or GITHUB_LOGIN (default --login for analyze-branch, preflight, review-pr, decision-pack, repo-decision, monitor-open-prs, pr-outcomes, notifications, notifications-read, and agent plan/packet) GITHUB_TOKEN for non-interactive login bootstrap GITTENSOR_SCORE_PREVIEW_CMD GITTENSOR_ROOT @@ -4443,7 +4509,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", "id", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { @@ -5533,6 +5599,15 @@ function getPrOutcomes(login, limit) { return apiGet(`/v1/contributors/${encodeURIComponent(login)}/pr-outcomes${suffix}`); } +// #6745: contributor notification feed + mark-read. `postMarkNotificationsRead` sends no ids to mark all +// delivered notifications read, mirroring markNotificationsReadShape's optional ids. +function getNotifications(login) { + return apiGet(`/v1/contributors/${encodeURIComponent(login)}/notifications`); +} +function postMarkNotificationsRead(login, ids) { + return apiPost(`/v1/contributors/${encodeURIComponent(login)}/notifications/read`, ids ? { ids } : {}); +} + // Mirror the API's own `summary` when it sends one, so the CLI and the loopover_monitor_open_prs MCP // tool (which returns monitor.summary verbatim) never drift into two different sentences for one payload. function openPrMonitorToolSummary(login, payload) { diff --git a/src/api/routes.ts b/src/api/routes.ts index 121428092c..911b0c6eaf 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -55,6 +55,10 @@ import { getPendingAgentAction, listAgentAuditEvents, listAuditEventsForTarget, + listNotificationDeliveriesForRecipient, + markNotificationDeliveriesRead, + MAX_NOTIFICATION_DELIVERY_ID_LENGTH, + MAX_NOTIFICATION_MARK_READ_IDS, listPendingAgentActions, recordAuditEvent, recordPostMergeIncidentReport, @@ -271,6 +275,7 @@ import { import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes"; +import { buildNotificationFeed } from "../notifications/service"; import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop"; @@ -442,6 +447,12 @@ async function readRequestBodyWithLimit(request: Request, maxBytes: number): Pro const MAX_LOCAL_BRANCH_REF_CHARS = 256; const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000; +// #6745: body of POST /v1/contributors/:login/notifications/read. Mirrors markNotificationsReadShape +// (src/mcp/server.ts) minus `login` (which is the path param): `ids` is optional (absent = mark all delivered). +const markNotificationsReadBodySchema = z.object({ + ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(), +}); + const preflightSchema = z.object({ repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), @@ -3343,6 +3354,28 @@ export function createApp() { return c.json(await buildContributorPrOutcomes(c.env, login, limit)); }); + // REST mirror of the `loopover_list_notifications` MCP tool (LoopoverMcp.listNotifications) — a contributor's + // own badge notification feed, self-scoped via requireContributorAccess. (#6745) + 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)); + }); + + // REST mirror of the `loopover_mark_notifications_read` MCP tool (LoopoverMcp.markNotificationsRead) — marks the + // contributor's own delivered badge notifications read; an absent/empty body marks all of them. (#6745) + 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 = markNotificationsReadBodySchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) return c.json({ error: "invalid_mark_read", 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/src/openapi/schemas.ts b/src/openapi/schemas.ts index 20234bf962..35ffffbc58 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -481,6 +481,35 @@ export const ContributorPrOutcomesSchema = z }) .openapi("ContributorPrOutcomes"); +export const NotificationFeedItemSchema = z + .object({ + id: z.string(), + eventType: z.string(), + repoFullName: z.string(), + pullNumber: z.number().nullable(), + title: z.string(), + body: z.string(), + deeplink: z.string(), + status: z.enum(["delivered", "read"]), + createdAt: z.string(), + }) + .openapi("NotificationFeedItem"); + +export const NotificationFeedSchema = z + .object({ + login: z.string(), + unreadCount: z.number(), + notifications: z.array(NotificationFeedItemSchema), + }) + .openapi("NotificationFeed"); + +export const NotificationsMarkedSchema = z + .object({ + login: z.string(), + marked: z.number(), + }) + .openapi("NotificationsMarked"); + export const ContributorOpportunitySchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 6dca754355..78812f79bf 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -25,6 +25,8 @@ import { ContributorDecisionPackSchema, ContributorOpenPrMonitorSchema, ContributorPrOutcomesSchema, + NotificationFeedSchema, + NotificationsMarkedSchema, ContributorRewardRiskStrategySchema, ContributorProfileSchema, ContributorScoringProfileSchema, @@ -792,6 +794,40 @@ export function buildOpenApiSpec() { }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/notifications", + summary: "Contributor badge notification feed", + request: { params: z.object({ login: z.string() }) }, + responses: { + 200: { + description: "The contributor's own badge notification feed (self-scoped), newest first, with an unread count.", + content: { "application/json": { schema: NotificationFeedSchema } }, + }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/contributors/{login}/notifications/read", + summary: "Mark contributor notifications read", + request: { + params: z.object({ login: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ ids: z.array(z.string()).optional() }), + }, + }, + }, + }, + responses: { + 200: { + description: "Marks the contributor's delivered badge notifications read; an absent/empty ids array marks all.", + content: { "application/json": { schema: NotificationsMarkedSchema } }, + }, + 400: { description: "Invalid mark-read body" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", diff --git a/test/unit/mcp-cli-notifications.test.ts b/test/unit/mcp-cli-notifications.test.ts new file mode 100644 index 0000000000..5f3705d8dd --- /dev/null +++ b/test/unit/mcp-cli-notifications.test.ts @@ -0,0 +1,115 @@ +// #6745: the CLI mirror for loopover_list_notifications / loopover_mark_notifications_read. The MCP tools and the +// new GET /notifications + POST /notifications/read routes serve a contributor's notification feed; only the +// stdio/CLI surface was missing. These pin: `notifications --json` stays byte-identical to the route, the +// plain-text path lists the feed, `notifications-read` forwards --id (or marks all), and login resolution matches +// the sibling contributor commands. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +// Any CLI command that calls the API must go through runAsync: the fixture server lives in this process, +// so run()'s execFileSync would block the event loop and the child's fetch would abort before a response. +import { closeFixtureServer, notificationsFixture, notificationsReadFixture, run, runAsync, runExpectingFailure, startFixtureServer } from "./support/mcp-cli-harness"; + +let apiUrl: string; +let markReadBodies: unknown[]; + +async function connect() { + markReadBodies = []; + apiUrl = await startFixtureServer({ onMarkNotificationsRead: (body) => markReadBodies.push(body) }); +} + +async function disconnect() { + await closeFixtureServer(); +} + +describe("loopover-mcp notifications CLI", () => { + beforeEach(connect); + afterEach(disconnect); + + it("--json emits exactly the feed the route returns", async () => { + const out = await runAsync(["notifications", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(JSON.parse(out)).toEqual(notificationsFixture()); + }); + + it("prints the unread count and a line per notification", async () => { + const out = await runAsync(["notifications", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(out).toContain("LoopOver notifications for JSONbored: 1 unread."); + expect(out).toContain("JSONbored/loopover#42 Your pull request JSONbored/loopover#42 was merged."); + expect(out).toContain("JSONbored/loopover#7 Changes requested on JSONbored/loopover#7."); + }); + + it("resolves the login from LOOPOVER_LOGIN, then GITHUB_LOGIN, like the sibling contributor commands", async () => { + const viaLoopoverLogin = await runAsync(["notifications", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "JSONbored" }); + expect(JSON.parse(viaLoopoverLogin)).toEqual(notificationsFixture()); + const viaGithubLogin = await runAsync(["notifications", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", GITHUB_LOGIN: "JSONbored" }); + expect(JSON.parse(viaGithubLogin)).toEqual(notificationsFixture()); + }); + + it("fails with the shared login-required message when no login is resolvable", () => { + const failure = runExpectingFailure(["notifications"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" }); + expect(failure.status).toBe(1); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login /); + }); + + // #6261: the API chooses the notification title text, so a hostile string must not repaint the terminal. + it("strips ANSI escapes from API-chosen text on the plain-text path but not from --json", async () => { + await closeFixtureServer(); + const esc = String.fromCharCode(27); + const hostileTitle = `${esc}[31mFAKE MERGE${esc}[0m`; + const hostileUrl = await startFixtureServer({ + notifications: { + unreadCount: 1, + notifications: [{ id: "x", eventType: "pull_request_merged", repoFullName: "acme/x", pullNumber: 1, title: hostileTitle, body: "b", deeplink: "https://x", status: "delivered", createdAt: "2026-06-01T00:00:00.000Z" }], + }, + }); + const env = { LOOPOVER_API_URL: hostileUrl, LOOPOVER_TOKEN: "session-token" }; + + const plain = await runAsync(["notifications", "--login", "JSONbored"], env); + expect(plain).not.toContain(esc); + expect(plain).toContain("FAKE MERGE"); + + const asJson = await runAsync(["notifications", "--login", "JSONbored", "--json"], env); + expect(JSON.parse(asJson).notifications[0].title).toBe(hostileTitle); + }); + + it("documents itself in --help, in its own --help, and in the shell-completion command list", () => { + expect(run(["--help"])).toContain("loopover-mcp notifications --login [--json]"); + expect(run(["notifications", "--help"])).toContain("Mirrors the loopover_list_notifications MCP tool"); + expect(run(["completion", "bash"])).toContain("notifications"); + }); +}); + +describe("loopover-mcp notifications-read CLI", () => { + beforeEach(connect); + afterEach(disconnect); + + it("--json emits exactly the { login, marked } the route returns", async () => { + const out = await runAsync(["notifications-read", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(JSON.parse(out)).toEqual(notificationsReadFixture()); + }); + + it("prints the marked count on the plain-text path", async () => { + const out = await runAsync(["notifications-read", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(out).toContain("Marked 2 LoopOver notification(s) read for JSONbored."); + }); + + it("marks all (empty body) when no --id is given", async () => { + await runAsync(["notifications-read", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(markReadBodies).toEqual([{}]); + }); + + it("forwards repeated --id flags as an ids array", async () => { + await runAsync(["notifications-read", "--login", "JSONbored", "--id", "d-42", "--id", "d-7", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(markReadBodies).toEqual([{ ids: ["d-42", "d-7"] }]); + }); + + it("fails with the shared login-required message when no login is resolvable", () => { + const failure = runExpectingFailure(["notifications-read"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_LOGIN: "", GITHUB_LOGIN: "" }); + expect(failure.status).toBe(1); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Pass --login /); + }); + + it("documents itself in --help, in its own --help, and in the shell-completion command list", () => { + expect(run(["--help"])).toContain("loopover-mcp notifications-read --login [--id ]... [--json]"); + expect(run(["notifications-read", "--help"])).toContain("Mirrors the loopover_mark_notifications_read MCP tool"); + expect(run(["completion", "bash"])).toContain("notifications-read"); + }); +}); diff --git a/test/unit/routes-notifications.test.ts b/test/unit/routes-notifications.test.ts new file mode 100644 index 0000000000..c1ce8ad43d --- /dev/null +++ b/test/unit/routes-notifications.test.ts @@ -0,0 +1,207 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { insertNotificationDeliveryIfAbsent, markNotificationDeliveryDelivered, markNotificationDeliveriesRead } from "../../src/db/repositories"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +// #6745: GET /v1/contributors/:login/notifications and POST /v1/contributors/:login/notifications/read — the REST +// mirrors of the loopover_list_notifications / loopover_mark_notifications_read MCP tools. Both gate on +// requireContributorAccess and reuse buildNotificationFeed / markNotificationDeliveriesRead, so these tests pin +// the ROUTE contract: the feed shape + unread count, the mark-all vs mark-by-id body, the guard, and parity with +// the MCP surface. +const apiHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}` }); +const jsonHeaders = (env: Env) => ({ authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" }); + +async function connectMcp(env: Env) { + const server = new LoopoverMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "notifications-parity-test", version: "0.0.1" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +async function seedDelivered(env: Env, login: string, dedupKey: string, pullNumber: number) { + const { delivery } = await insertNotificationDeliveryIfAbsent(env, { + dedupKey, + channel: "badge", + recipientLogin: login, + eventType: "pull_request_merged", + repoFullName: "acme/widgets", + pullNumber, + title: `Merged acme/widgets#${pullNumber}`, + body: `Your pull request acme/widgets#${pullNumber} was merged.`, + deeplink: `https://github.com/acme/widgets/pull/${pullNumber}`, + actorLogin: "maintainer", + }); + await markNotificationDeliveryDelivered(env, delivery.id); + return delivery; +} + +describe("GET /v1/contributors/:login/notifications (#6745)", () => { + it("returns the badge feed with an unread count, excluding still-pending (undelivered) rows", async () => { + const app = createApp(); + const env = createTestEnv(); + const unread = await seedDelivered(env, "miner1", "d-1", 1); + const readDelivery = await seedDelivered(env, "miner1", "d-2", 2); + await markNotificationDeliveriesRead(env, "miner1", [readDelivery.id]); + // A row that was inserted but never delivered stays pending and must not surface in the feed. + await insertNotificationDeliveryIfAbsent(env, { + dedupKey: "d-pending", + channel: "badge", + recipientLogin: "miner1", + eventType: "pull_request_merged", + repoFullName: "acme/widgets", + pullNumber: 3, + title: "Pending", + body: "pending", + deeplink: "https://github.com/acme/widgets/pull/3", + actorLogin: "maintainer", + }); + + const response = await app.request("/v1/contributors/Miner1/notifications", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + const feed = (await response.json()) as { login: string; unreadCount: number; notifications: Array> }; + expect(feed.login).toBe("miner1"); + expect(feed.unreadCount).toBe(1); + expect(feed.notifications).toHaveLength(2); + expect(feed.notifications.map((n) => n.id).sort()).toEqual([unread.id, readDelivery.id].sort()); + expect(feed.notifications).toContainEqual({ + id: unread.id, + eventType: "pull_request_merged", + repoFullName: "acme/widgets", + pullNumber: 1, + title: unread.title, + body: unread.body, + deeplink: unread.deeplink, + status: "delivered", + createdAt: unread.createdAt, + }); + }); + + it("returns an empty feed for a contributor with no notifications", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner1/notifications", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ login: "miner1", unreadCount: 0, notifications: [] }); + }); + + it("rejects an unauthenticated caller", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner1/notifications", {}, env); + expect(response.status).toBeGreaterThanOrEqual(401); + expect(response.status).toBeLessThan(404); + }); + + it("403s the shared mcp token unless fully unscoped (#2455 parity with the MCP surface)", async () => { + const app = createApp(); + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "acme/widgets" }); + const response = await app.request("/v1/contributors/miner1/notifications", { headers: { authorization: `Bearer ${env.LOOPOVER_MCP_TOKEN}` } }, env); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "forbidden_contributor" }); + }); + + it("returns the same feed the loopover_list_notifications MCP tool returns (mirror parity)", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedDelivered(env, "miner1", "d-1", 1); + await seedDelivered(env, "miner1", "d-2", 2); + const restBody = await (await app.request("/v1/contributors/miner1/notifications", { headers: apiHeaders(env) }, env)).json(); + const client = await connectMcp(env); + const viaTool = await client.callTool({ name: "loopover_list_notifications", arguments: { login: "miner1" } }); + expect((viaTool as { structuredContent?: unknown }).structuredContent).toEqual(restBody); + }); + + it("never leaks wallet/hotkey/trust-score terms in its payload", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedDelivered(env, "miner1", "d-1", 1); + const response = await app.request("/v1/contributors/miner1/notifications", { headers: apiHeaders(env) }, env); + expect(JSON.stringify(await response.json())).not.toMatch(/wallet|hotkey|coldkey|trust score|reward estimate/i); + }); +}); + +describe("POST /v1/contributors/:login/notifications/read (#6745)", () => { + it("marks every delivered notification read when the body is absent, and reports the count", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedDelivered(env, "miner1", "d-1", 1); + await seedDelivered(env, "miner1", "d-2", 2); + + const response = await app.request("/v1/contributors/Miner1/notifications/read", { method: "POST", headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ login: "miner1", marked: 2 }); + // The feed's unread count drops to zero afterward. + const feed = (await (await app.request("/v1/contributors/miner1/notifications", { headers: apiHeaders(env) }, env)).json()) as { unreadCount: number }; + expect(feed.unreadCount).toBe(0); + }); + + it("marks only the supplied ids when a body is given", async () => { + const app = createApp(); + const env = createTestEnv(); + const first = await seedDelivered(env, "miner1", "d-1", 1); + await seedDelivered(env, "miner1", "d-2", 2); + + const response = await app.request( + "/v1/contributors/miner1/notifications/read", + { method: "POST", headers: jsonHeaders(env), body: JSON.stringify({ ids: [first.id] }) }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ login: "miner1", marked: 1 }); + }); + + it("rejects a malformed ids body with 400", async () => { + const app = createApp(); + const env = createTestEnv(); + const bodies = [{ ids: [""] }, { ids: [123] }, { ids: Array.from({ length: 101 }, (_, i) => `id-${i}`) }]; + for (const body of bodies) { + const response = await app.request( + "/v1/contributors/miner1/notifications/read", + { method: "POST", headers: jsonHeaders(env), body: JSON.stringify(body) }, + env, + ); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_mark_read" }); + } + }); + + it("rejects an unauthenticated caller", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner1/notifications/read", { method: "POST" }, env); + expect(response.status).toBeGreaterThanOrEqual(401); + expect(response.status).toBeLessThan(404); + }); + + it("403s the shared mcp token unless fully unscoped (#2455 parity with the MCP surface)", async () => { + const app = createApp(); + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "acme/widgets" }); + const response = await app.request( + "/v1/contributors/miner1/notifications/read", + { method: "POST", headers: { authorization: `Bearer ${env.LOOPOVER_MCP_TOKEN}` } }, + env, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "forbidden_contributor" }); + }); + + it("returns the same {login,marked} the loopover_mark_notifications_read MCP tool returns (mirror parity)", async () => { + const restEnv = createTestEnv(); + await seedDelivered(restEnv, "miner1", "d-1", 1); + await seedDelivered(restEnv, "miner1", "d-2", 2); + const app = createApp(); + const restBody = await (await app.request("/v1/contributors/miner1/notifications/read", { method: "POST", headers: apiHeaders(restEnv) }, restEnv)).json(); + + const toolEnv = createTestEnv(); + await seedDelivered(toolEnv, "miner1", "d-1", 1); + await seedDelivered(toolEnv, "miner1", "d-2", 2); + const client = await connectMcp(toolEnv); + const viaTool = await client.callTool({ name: "loopover_mark_notifications_read", arguments: { login: "miner1" } }); + expect((viaTool as { structuredContent?: unknown }).structuredContent).toEqual(restBody); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index b58c1bccf9..45c8f47b41 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -177,6 +177,10 @@ export async function startFixtureServer( validateConfigWarnings?: string[]; openPrMonitor?: Record; prOutcomes?: Record; + /** #6745: overrides the notification feed / mark-read responses, and captures the mark-read POST body. */ + notifications?: Record; + notificationsRead?: Record; + onMarkNotificationsRead?: (body: unknown) => void; intakeStatus?: number; localBranchAnalysisStatus?: number; /** #6743: overrides the repo-doc refresh route's default "opened a new PR" response, e.g. to exercise @@ -318,6 +322,15 @@ export async function startFixtureServer( response.end(JSON.stringify({ ...prOutcomesFixture(login), ...(options.prOutcomes ?? {}) })); return; } + if (request.url === "/v1/contributors/JSONbored/notifications" && request.method === "GET") { + response.end(JSON.stringify({ ...notificationsFixture(), ...(options.notifications ?? {}) })); + return; + } + if (request.url === "/v1/contributors/JSONbored/notifications/read" && request.method === "POST") { + options.onMarkNotificationsRead?.(await readJsonRequest(request)); + response.end(JSON.stringify({ login: "jsonbored", marked: 2, ...(options.notificationsRead ?? {}) })); + return; + } if (request.url === "/v1/contributors/JSONbored/repos/JSONbored/loopover/decision" && request.method === "GET") { if (options.repoDecisionStatus && options.repoDecisionStatus >= 400) { response.statusCode = options.repoDecisionStatus; @@ -901,6 +914,43 @@ export function prOutcomesFixture(login = "JSONbored") { }; } +/** #6745: mirrors the NotificationFeed { login, unreadCount, notifications } shape the route/tool returns. */ +export function notificationsFixture() { + return { + login: "jsonbored", + unreadCount: 1, + notifications: [ + { + id: "d-42", + eventType: "pull_request_merged", + repoFullName: "JSONbored/loopover", + pullNumber: 42, + title: "Your pull request JSONbored/loopover#42 was merged.", + body: "Nice work.", + deeplink: "https://github.com/JSONbored/loopover/pull/42", + status: "delivered", + createdAt: "2026-06-01T00:00:00.000Z", + }, + { + id: "d-7", + eventType: "pull_request_changes_requested", + repoFullName: "JSONbored/loopover", + pullNumber: 7, + title: "Changes requested on JSONbored/loopover#7.", + body: "Please address review.", + deeplink: "https://github.com/JSONbored/loopover/pull/7", + status: "read", + createdAt: "2026-05-20T00:00:00.000Z", + }, + ], + }; +} + +/** #6745: mirrors the { login, marked } shape POST /notifications/read returns. */ +export function notificationsReadFixture() { + return { login: "jsonbored", marked: 2 }; +} + export function decisionPackFixture() { return { status: "ready",