Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ const CLI_COMMAND_SPEC = {
"decision-pack": [],
"repo-decision": [],
"contributor-profile": [],
notifications: [],
"notifications-read": [],
"monitor-open-prs": [],
"analyze-branch": [],
preflight: [],
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 <github-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 <github-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;
Expand Down Expand Up @@ -4268,6 +4309,8 @@ function printHelp() {
loopover-mcp decision-pack --login <github-login> [--json]
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
loopover-mcp monitor-open-prs --login <github-login> [--json]
loopover-mcp notifications --login <github-login> [--json]
loopover-mcp notifications-read --login <github-login> [--id <notification-id>]... [--json]
loopover-mcp analyze-branch --login <github-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 <github-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 <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
Expand All @@ -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
Expand Down Expand Up @@ -4331,7 +4374,7 @@ Use --profile <name> 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") {
Expand Down
33 changes: 33 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ import {
listIssueSignalSample,
listAgentRunsForActor,
listDigestSubscriptionsForLogin,
listNotificationDeliveriesForRecipient,
markNotificationDeliveriesRead,
MAX_NOTIFICATION_DELIVERY_ID_LENGTH,
MAX_NOTIFICATION_MARK_READ_IDS,
listProductUsageDailyRollups,
listOpenPullRequests,
listPrVisibilitySkipAuditEvents,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions test/unit/mcp-cli-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
144 changes: 144 additions & 0 deletions test/unit/routes-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
return { authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, "content-type": "application/json" };
}

async function seedDelivered(env: Env, recipientLogin: string, dedupKey: string): Promise<string> {
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 });
});
});
Loading