Skip to content
Merged
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
175 changes: 175 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
79 changes: 77 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ const CLI_COMMAND_SPEC = {
"contributor-profile": [],
"monitor-open-prs": [],
"pr-outcomes": [],
notifications: [],
"notifications-read": [],
"analyze-branch": [],
preflight: [],
"review-pr": [],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3900,6 +3904,66 @@ async function prOutcomesCli(options) {
}
}

function printNotificationsHelp() {
process.stdout.write(
[
"Usage: loopover-mcp notifications --login <github-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 <github-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 <github-login> [--id <delivery-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 <github-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(
[
Expand Down Expand Up @@ -4380,6 +4444,8 @@ function printHelp() {
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
loopover-mcp monitor-open-prs --login <github-login> [--json]
loopover-mcp pr-outcomes --login <github-login> [--limit N] [--json]
loopover-mcp notifications --login <github-login> [--json]
loopover-mcp notifications-read --login <github-login> [--id <delivery-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 @@ -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
Expand Down Expand Up @@ -4443,7 +4509,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", "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") {
Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ import {
getPendingAgentAction,
listAgentAuditEvents,
listAuditEventsForTarget,
listNotificationDeliveriesForRecipient,
markNotificationDeliveriesRead,
MAX_NOTIFICATION_DELIVERY_ID_LENGTH,
MAX_NOTIFICATION_MARK_READ_IDS,
listPendingAgentActions,
recordAuditEvent,
recordPostMergeIncidentReport,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading