Skip to content

Commit 447ee82

Browse files
authored
feat(api): REST + CLI mirror for contributor notifications (#6745) (#7018)
1 parent 99b461d commit 447ee82

8 files changed

Lines changed: 722 additions & 2 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14405,6 +14405,91 @@
1440514405
"summary",
1440614406
"outcomes"
1440714407
]
14408+
},
14409+
"NotificationFeed": {
14410+
"type": "object",
14411+
"properties": {
14412+
"login": {
14413+
"type": "string"
14414+
},
14415+
"unreadCount": {
14416+
"type": "number"
14417+
},
14418+
"notifications": {
14419+
"type": "array",
14420+
"items": {
14421+
"$ref": "#/components/schemas/NotificationFeedItem"
14422+
}
14423+
}
14424+
},
14425+
"required": [
14426+
"login",
14427+
"unreadCount",
14428+
"notifications"
14429+
]
14430+
},
14431+
"NotificationFeedItem": {
14432+
"type": "object",
14433+
"properties": {
14434+
"id": {
14435+
"type": "string"
14436+
},
14437+
"eventType": {
14438+
"type": "string"
14439+
},
14440+
"repoFullName": {
14441+
"type": "string"
14442+
},
14443+
"pullNumber": {
14444+
"type": "number",
14445+
"nullable": true
14446+
},
14447+
"title": {
14448+
"type": "string"
14449+
},
14450+
"body": {
14451+
"type": "string"
14452+
},
14453+
"deeplink": {
14454+
"type": "string"
14455+
},
14456+
"status": {
14457+
"type": "string",
14458+
"enum": [
14459+
"delivered",
14460+
"read"
14461+
]
14462+
},
14463+
"createdAt": {
14464+
"type": "string"
14465+
}
14466+
},
14467+
"required": [
14468+
"id",
14469+
"eventType",
14470+
"repoFullName",
14471+
"pullNumber",
14472+
"title",
14473+
"body",
14474+
"deeplink",
14475+
"status",
14476+
"createdAt"
14477+
]
14478+
},
14479+
"NotificationsMarked": {
14480+
"type": "object",
14481+
"properties": {
14482+
"login": {
14483+
"type": "string"
14484+
},
14485+
"marked": {
14486+
"type": "number"
14487+
}
14488+
},
14489+
"required": [
14490+
"login",
14491+
"marked"
14492+
]
1440814493
}
1440914494
},
1441014495
"parameters": {},
@@ -18694,6 +18779,96 @@
1869418779
}
1869518780
]
1869618781
}
18782+
},
18783+
"/v1/contributors/{login}/notifications": {
18784+
"get": {
18785+
"summary": "Contributor badge notification feed",
18786+
"parameters": [
18787+
{
18788+
"schema": {
18789+
"type": "string"
18790+
},
18791+
"required": true,
18792+
"name": "login",
18793+
"in": "path"
18794+
}
18795+
],
18796+
"responses": {
18797+
"200": {
18798+
"description": "The contributor's own badge notification feed (self-scoped), newest first, with an unread count.",
18799+
"content": {
18800+
"application/json": {
18801+
"schema": {
18802+
"$ref": "#/components/schemas/NotificationFeed"
18803+
}
18804+
}
18805+
}
18806+
}
18807+
},
18808+
"security": [
18809+
{
18810+
"LoopOverBearer": []
18811+
},
18812+
{
18813+
"LoopOverSessionCookie": []
18814+
}
18815+
]
18816+
}
18817+
},
18818+
"/v1/contributors/{login}/notifications/read": {
18819+
"post": {
18820+
"summary": "Mark contributor notifications read",
18821+
"parameters": [
18822+
{
18823+
"schema": {
18824+
"type": "string"
18825+
},
18826+
"required": true,
18827+
"name": "login",
18828+
"in": "path"
18829+
}
18830+
],
18831+
"requestBody": {
18832+
"content": {
18833+
"application/json": {
18834+
"schema": {
18835+
"type": "object",
18836+
"properties": {
18837+
"ids": {
18838+
"type": "array",
18839+
"items": {
18840+
"type": "string"
18841+
}
18842+
}
18843+
}
18844+
}
18845+
}
18846+
}
18847+
},
18848+
"responses": {
18849+
"200": {
18850+
"description": "Marks the contributor's delivered badge notifications read; an absent/empty ids array marks all.",
18851+
"content": {
18852+
"application/json": {
18853+
"schema": {
18854+
"$ref": "#/components/schemas/NotificationsMarked"
18855+
}
18856+
}
18857+
}
18858+
},
18859+
"400": {
18860+
"description": "Invalid mark-read body"
18861+
}
18862+
},
18863+
"security": [
18864+
{
18865+
"LoopOverBearer": []
18866+
},
18867+
{
18868+
"LoopOverSessionCookie": []
18869+
}
18870+
]
18871+
}
1869718872
}
1869818873
},
1869918874
"servers": [

packages/loopover-mcp/bin/loopover-mcp.js

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ const CLI_COMMAND_SPEC = {
9090
"contributor-profile": [],
9191
"monitor-open-prs": [],
9292
"pr-outcomes": [],
93+
notifications: [],
94+
"notifications-read": [],
9395
"analyze-branch": [],
9496
preflight: [],
9597
"review-pr": [],
@@ -3399,6 +3401,8 @@ async function runCli(args) {
33993401
if (command === "contributor-profile") return contributorProfileCli(options);
34003402
if (command === "monitor-open-prs") return monitorOpenPrsCli(options);
34013403
if (command === "pr-outcomes") return prOutcomesCli(options);
3404+
if (command === "notifications") return notificationsCli(options);
3405+
if (command === "notifications-read") return notificationsReadCli(options);
34023406
if (command === "review-pr") return reviewPrCli(options);
34033407
if (command !== "analyze-branch" && command !== "preflight") {
34043408
const suggestion = suggestCommand(command);
@@ -3900,6 +3904,66 @@ async function prOutcomesCli(options) {
39003904
}
39013905
}
39023906

3907+
function printNotificationsHelp() {
3908+
process.stdout.write(
3909+
[
3910+
"Usage: loopover-mcp notifications --login <github-login> [--json]",
3911+
"",
3912+
"Your own badge notification feed (newest first) with an unread count, self-scoped.",
3913+
"Mirrors the loopover_list_notifications MCP tool and GET /v1/contributors/{login}/notifications. No source upload.",
3914+
"",
3915+
"Pass --json for machine-readable output.",
3916+
].join("\n") + "\n",
3917+
);
3918+
}
3919+
3920+
// #6745: CLI mirror of loopover_list_notifications. Login resolves from --login / the active session /
3921+
// LOOPOVER_LOGIN / GITHUB_LOGIN, like the sibling contributor commands.
3922+
async function notificationsCli(options) {
3923+
if (options.help === true) return printNotificationsHelp();
3924+
const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
3925+
if (!login) throw new Error("Pass --login <github-login>, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
3926+
const payload = await getNotifications(login);
3927+
if (options.json) {
3928+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
3929+
return;
3930+
}
3931+
process.stdout.write(`LoopOver notifications for ${login}: ${payload.unreadCount} unread.\n`);
3932+
for (const item of payload.notifications ?? []) {
3933+
// `login` is the user's own value; the API chooses the title text, so it is sanitized before the terminal.
3934+
const flag = item.status === "delivered" ? "*" : " ";
3935+
process.stdout.write(`${sanitizePlainTextTerminalOutput(`${flag} ${item.repoFullName}#${item.pullNumber} ${item.title}`)}\n`);
3936+
}
3937+
}
3938+
3939+
function printNotificationsReadHelp() {
3940+
process.stdout.write(
3941+
[
3942+
"Usage: loopover-mcp notifications-read --login <github-login> [--id <delivery-id>]... [--json]",
3943+
"",
3944+
"Mark your delivered notifications read. With no --id, marks all of them.",
3945+
"Mirrors the loopover_mark_notifications_read MCP tool and POST /v1/contributors/{login}/notifications/read.",
3946+
"",
3947+
"Pass --json for machine-readable output.",
3948+
].join("\n") + "\n",
3949+
);
3950+
}
3951+
3952+
// #6745: CLI mirror of loopover_mark_notifications_read. Repeated --id flags collect into an ids array; omitting
3953+
// them marks every delivered notification read (mirrors the route's absent-body behavior).
3954+
async function notificationsReadCli(options) {
3955+
if (options.help === true) return printNotificationsReadHelp();
3956+
const login = options.login ?? activeProfile.session?.login ?? process.env.LOOPOVER_LOGIN ?? process.env.GITHUB_LOGIN;
3957+
if (!login) throw new Error("Pass --login <github-login>, log in with `loopover-mcp login`, or set LOOPOVER_LOGIN.");
3958+
const ids = Array.isArray(options.id) ? options.id : options.id ? [options.id] : undefined;
3959+
const payload = await postMarkNotificationsRead(login, ids);
3960+
if (options.json) {
3961+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
3962+
return;
3963+
}
3964+
process.stdout.write(`Marked ${payload.marked} LoopOver notification(s) read for ${login}.\n`);
3965+
}
3966+
39033967
function printRepoDecisionHelp() {
39043968
process.stdout.write(
39053969
[
@@ -4380,6 +4444,8 @@ function printHelp() {
43804444
loopover-mcp repo-decision --login <github-login> --repo owner/repo [--json]
43814445
loopover-mcp monitor-open-prs --login <github-login> [--json]
43824446
loopover-mcp pr-outcomes --login <github-login> [--limit N] [--json]
4447+
loopover-mcp notifications --login <github-login> [--json]
4448+
loopover-mcp notifications-read --login <github-login> [--id <delivery-id>]... [--json]
43834449
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]
43844450
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]
43854451
loopover-mcp review-pr --login <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
@@ -4398,7 +4464,7 @@ function printHelp() {
43984464
LOOPOVER_PROFILE
43994465
LOOPOVER_CONFIG_PATH or LOOPOVER_CONFIG_DIR
44004466
LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN, or a session from loopover-mcp login
4401-
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)
4467+
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)
44024468
GITHUB_TOKEN for non-interactive login bootstrap
44034469
GITTENSOR_SCORE_PREVIEW_CMD
44044470
GITTENSOR_ROOT
@@ -4443,7 +4509,7 @@ Use --profile <name> or LOOPOVER_PROFILE to run login, logout, whoami, status, d
44434509

44444510
function parseOptions(args) {
44454511
const options = {};
4446-
const repeatable = new Set(["label", "issue", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]);
4512+
const repeatable = new Set(["label", "issue", "id", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]);
44474513
for (let index = 0; index < args.length; index += 1) {
44484514
const arg = args[index];
44494515
if (arg === "--json") {
@@ -5533,6 +5599,15 @@ function getPrOutcomes(login, limit) {
55335599
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/pr-outcomes${suffix}`);
55345600
}
55355601

5602+
// #6745: contributor notification feed + mark-read. `postMarkNotificationsRead` sends no ids to mark all
5603+
// delivered notifications read, mirroring markNotificationsReadShape's optional ids.
5604+
function getNotifications(login) {
5605+
return apiGet(`/v1/contributors/${encodeURIComponent(login)}/notifications`);
5606+
}
5607+
function postMarkNotificationsRead(login, ids) {
5608+
return apiPost(`/v1/contributors/${encodeURIComponent(login)}/notifications/read`, ids ? { ids } : {});
5609+
}
5610+
55365611
// Mirror the API's own `summary` when it sends one, so the CLI and the loopover_monitor_open_prs MCP
55375612
// tool (which returns monitor.summary verbatim) never drift into two different sentences for one payload.
55385613
function openPrMonitorToolSummary(login, payload) {

src/api/routes.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ import {
5555
getPendingAgentAction,
5656
listAgentAuditEvents,
5757
listAuditEventsForTarget,
58+
listNotificationDeliveriesForRecipient,
59+
markNotificationDeliveriesRead,
60+
MAX_NOTIFICATION_DELIVERY_ID_LENGTH,
61+
MAX_NOTIFICATION_MARK_READ_IDS,
5862
listPendingAgentActions,
5963
recordAuditEvent,
6064
recordPostMergeIncidentReport,
@@ -271,6 +275,7 @@ import {
271275
import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality";
272276
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
273277
import { buildContributorPrOutcomes } from "../signals/contributor-pr-outcomes";
278+
import { buildNotificationFeed } from "../notifications/service";
274279
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
275280
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
276281
import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop";
@@ -442,6 +447,12 @@ async function readRequestBodyWithLimit(request: Request, maxBytes: number): Pro
442447
const MAX_LOCAL_BRANCH_REF_CHARS = 256;
443448
const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000;
444449

450+
// #6745: body of POST /v1/contributors/:login/notifications/read. Mirrors markNotificationsReadShape
451+
// (src/mcp/server.ts) minus `login` (which is the path param): `ids` is optional (absent = mark all delivered).
452+
const markNotificationsReadBodySchema = z.object({
453+
ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(),
454+
});
455+
445456
const preflightSchema = z.object({
446457
repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars),
447458
contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(),
@@ -3343,6 +3354,28 @@ export function createApp() {
33433354
return c.json(await buildContributorPrOutcomes(c.env, login, limit));
33443355
});
33453356

3357+
// REST mirror of the `loopover_list_notifications` MCP tool (LoopoverMcp.listNotifications) — a contributor's
3358+
// own badge notification feed, self-scoped via requireContributorAccess. (#6745)
3359+
app.get("/v1/contributors/:login/notifications", async (c) => {
3360+
const login = c.req.param("login");
3361+
const unauthorized = await requireContributorAccess(c, login);
3362+
if (unauthorized) return unauthorized;
3363+
const deliveries = await listNotificationDeliveriesForRecipient(c.env, login, { channel: "badge", limit: 50 });
3364+
return c.json(buildNotificationFeed(login, deliveries));
3365+
});
3366+
3367+
// REST mirror of the `loopover_mark_notifications_read` MCP tool (LoopoverMcp.markNotificationsRead) — marks the
3368+
// contributor's own delivered badge notifications read; an absent/empty body marks all of them. (#6745)
3369+
app.post("/v1/contributors/:login/notifications/read", async (c) => {
3370+
const login = c.req.param("login");
3371+
const unauthorized = await requireContributorAccess(c, login);
3372+
if (unauthorized) return unauthorized;
3373+
const parsed = markNotificationsReadBodySchema.safeParse(await c.req.json().catch(() => ({})));
3374+
if (!parsed.success) return c.json({ error: "invalid_mark_read", issues: parsed.error.issues }, 400);
3375+
const marked = await markNotificationDeliveriesRead(c.env, login, parsed.data.ids);
3376+
return c.json({ login: login.toLowerCase(), marked });
3377+
});
3378+
33463379
app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => {
33473380
const login = c.req.param("login");
33483381
const unauthorized = await requireContributorAccess(c, login);

0 commit comments

Comments
 (0)