diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 59b5136ab2..84e3849c9a 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14527,6 +14527,49 @@ "recommendation", "summary" ] + }, + "ContributorWatches": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "watching": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorWatchEntry" + } + }, + "summary": { + "type": "string" + }, + "changed": { + "type": "string" + } + }, + "required": [ + "login", + "watching", + "summary" + ] + }, + "ContributorWatchEntry": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repoFullName", + "labels" + ] } }, "parameters": {}, @@ -18937,6 +18980,155 @@ } ] } + }, + "/v1/contributors/{login}/watches": { + "get": { + "summary": "List contributor issue-watch subscriptions", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The contributor's own issue-watch subscriptions (self-scoped). Mirrors loopover_watch_issues action=list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatches" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "post": { + "summary": "Watch a repository for new grabbable issues", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50 + } + }, + "required": [ + "repoFullName" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Subscribes the contributor to new grabbable issues on the repo (optional label filter).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatches" + } + } + } + }, + "400": { + "description": "Invalid watch body" + }, + "403": { + "description": "Session cannot watch this repository (forbidden_watch_repo)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "delete": { + "summary": "Unwatch a repository", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "required": true, + "name": "repoFullName", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Removes the contributor's issue-watch subscription for repoFullName (query param).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatches" + } + } + } + }, + "400": { + "description": "Missing or invalid repoFullName query param" + }, + "403": { + "description": "Session cannot unwatch this repository (forbidden_watch_repo)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 9ee4ce1972..f8c7125308 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -93,6 +93,7 @@ const CLI_COMMAND_SPEC = { "explain-review-risk": [], notifications: [], "notifications-read": [], + "watch-issues": ["list", "watch", "unwatch"], "analyze-branch": [], preflight: [], "review-pr": [], @@ -1149,6 +1150,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Return a contributor's own post-merge outcome records — for each merged PR, a public-safe attribution of what it did for their standing on the repo. Self-scoped: only the authenticated login's outcomes.", }, + { + name: "loopover_watch_issues", + category: "utility", + description: + "Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP). action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your watches. When a matching issue opens you're notified via loopover_list_notifications. Self-scoped to the authenticated login.", + }, { name: "loopover_compare_pr_variants", category: "branch", @@ -2117,6 +2124,24 @@ registerStdioTool( }, ); +// #6746: CLI/stdio mirror of remote loopover_watch_issues — proxies GET/POST/DELETE /v1/contributors/:login/watches. +registerStdioTool( + "loopover_watch_issues", + { + description: stdioToolDescription("loopover_watch_issues"), + inputSchema: { + login: z.string().min(1), + action: z.enum(["watch", "unwatch", "list"]).default("list"), + repoFullName: z.string().min(3).max(200).optional(), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), + }, + }, + async ({ login, action, repoFullName, labels }) => { + const payload = await manageWatches(login, action ?? "list", repoFullName, labels); + return toolResult(payload.summary ?? `Watching ${payload.watching?.length ?? 0} repo(s).`, payload); + }, +); + registerStdioTool( "loopover_compare_pr_variants", { @@ -3474,6 +3499,7 @@ async function runCli(args) { if (command === "explain-review-risk") return explainReviewRiskCli(options); if (command === "notifications") return notificationsCli(options); if (command === "notifications-read") return notificationsReadCli(options); + if (command === "watch-issues") return watchIssuesCli(args.slice(1)); if (command === "review-pr") return reviewPrCli(options); if (command !== "analyze-branch" && command !== "preflight") { const suggestion = suggestCommand(command); @@ -4100,6 +4126,69 @@ async function notificationsReadCli(options) { process.stdout.write(`Marked ${payload.marked} LoopOver notification(s) read for ${login}.\n`); } +function printWatchIssuesHelp() { + process.stdout.write( + [ + "Usage: loopover-mcp watch-issues list|watch|unwatch [--login ] [--json]", + "", + "Manage your issue-watch subscriptions (new grabbable issues on watched repos).", + "Mirrors the loopover_watch_issues MCP tool and GET/POST/DELETE /v1/contributors/{login}/watches.", + "", + " list List your current watches.", + " watch Subscribe to a repo. Optional --labels a,b filter.", + " unwatch Remove a subscription. Also accepts --repo owner/repo.", + "", + "DELETE uses ?repoFullName= query (not a JSON body) for CLI simplicity.", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +// #6746: CLI mirror of loopover_watch_issues. Subcommands list|watch|unwatch proxy the REST routes. +async function watchIssuesCli(args) { + const subcommand = args[0]; + if (!subcommand || subcommand === "--help" || subcommand === "help") return printWatchIssuesHelp(); + const positional = args[1] && !args[1].startsWith("--") ? args[1] : undefined; + const options = parseOptions(args.slice(1)); + if (options.help === true) return printWatchIssuesHelp(); + 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."); + if (subcommand === "list") { + const payload = await manageWatches(login, "list"); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${sanitizePlainTextTerminalOutput(payload.summary)}\n`); + for (const entry of payload.watching ?? []) { + const labelSuffix = entry.labels?.length ? ` [${entry.labels.join(", ")}]` : ""; + process.stdout.write(` ${sanitizePlainTextTerminalOutput(`${entry.repoFullName}${labelSuffix}`)}\n`); + } + return; + } + if (subcommand === "watch" || subcommand === "unwatch") { + const repoFullName = positional ?? options.repo ?? options.repoFullName; + if (!repoFullName || !String(repoFullName).includes("/")) { + throw new Error(`Usage: loopover-mcp watch-issues ${subcommand} --login `); + } + let labels; + if (subcommand === "watch" && options.labels != null) { + labels = String(options.labels) + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + } + const payload = await manageWatches(login, subcommand, repoFullName, labels); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${sanitizePlainTextTerminalOutput(payload.summary)}\n`); + return; + } + throw new Error(`Unknown watch-issues subcommand: ${subcommand}. Use list | watch | unwatch.`); +} + function printRepoDecisionHelp() { process.stdout.write( [ @@ -4585,6 +4674,7 @@ function printHelp() { loopover-mcp explain-review-risk --repo owner/repo --title [--login ] [--body ] [--json] loopover-mcp notifications --login [--json] loopover-mcp notifications-read --login [--id ]... [--json] + loopover-mcp watch-issues list|watch|unwatch [--login ] [--labels a,b] [--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] @@ -4603,7 +4693,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, notifications, notifications-read, 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, watch-issues, and agent plan/packet) GITHUB_TOKEN for non-interactive login bootstrap GITTENSOR_SCORE_PREVIEW_CMD GITTENSOR_ROOT @@ -5747,6 +5837,17 @@ function postMarkNotificationsRead(login, ids) { return apiPost(`/v1/contributors/${encodeURIComponent(login)}/notifications/read`, ids ? { ids } : {}); } +// #6746: thin REST proxies for issue-watch list/watch/unwatch. +function manageWatches(login, action, repoFullName, labels) { + const base = `/v1/contributors/${encodeURIComponent(login)}/watches`; + if (action === "list") return apiGet(base); + if (action === "watch") return apiPost(base, stripUndefined({ repoFullName, labels })); + if (action === "unwatch") { + return apiFetch(`${base}?repoFullName=${encodeURIComponent(repoFullName)}`, { method: "DELETE" }); + } + throw new Error(`Unknown watch action: ${action}`); +} + // 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 6b194d749c..e74096c322 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -200,9 +200,11 @@ import { import { buildStaticControlPanelRoleSummary, canLoginAccessRepo, + canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, } from "../services/control-panel-roles"; +import { listContributorWatches, unwatchContributorRepo, watchContributorRepo } from "../services/issue-watch"; import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities"; import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; @@ -455,6 +457,12 @@ const markNotificationsReadBodySchema = z.object({ ids: z.array(z.string().min(1).max(MAX_NOTIFICATION_DELIVERY_ID_LENGTH)).max(MAX_NOTIFICATION_MARK_READ_IDS).optional(), }); +// #6746: body of POST /v1/contributors/:login/watches. Mirrors watchIssuesShape minus login/action. +const watchIssuesBodySchema = z.object({ + repoFullName: z.string().min(3).max(200), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), +}); + const preflightSchema = z.object({ repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), @@ -3427,6 +3435,43 @@ export function createApp() { return c.json({ login: login.toLowerCase(), marked }); }); + // REST mirror of the `loopover_watch_issues` MCP tool — list / watch / unwatch issue-watch subscriptions. + // DELETE takes `?repoFullName=` (query is simpler for CLI than a JSON body). (#6746) + app.get("/v1/contributors/:login/watches", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + return c.json(await listContributorWatches(c.env, login)); + }); + + app.post("/v1/contributors/:login/watches", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const parsed = watchIssuesBodySchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "invalid_watch", issues: parsed.error.issues }, 400); + const identity = await authenticateRequestIdentity(c); + if (identity?.kind === "session" && !(await canWatchRepo(c.env, login, parsed.data.repoFullName))) { + return c.json({ error: "forbidden_watch_repo" }, 403); + } + return c.json(await watchContributorRepo(c.env, login, parsed.data.repoFullName, parsed.data.labels)); + }); + + app.delete("/v1/contributors/:login/watches", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + const repoFullName = c.req.query("repoFullName"); + if (!repoFullName || repoFullName.length < 3 || repoFullName.length > 200) { + return c.json({ error: "invalid_unwatch", detail: "repoFullName query param is required (3–200 chars)" }, 400); + } + const identity = await authenticateRequestIdentity(c); + if (identity?.kind === "session" && !(await canWatchRepo(c.env, login, repoFullName))) { + return c.json({ error: "forbidden_watch_repo" }, 403); + } + return c.json(await unwatchContributorRepo(c.env, login, repoFullName)); + }); + app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => { const login = c.req.param("login"); const unauthorized = await requireContributorAccess(c, login); @@ -6194,6 +6239,8 @@ function canSessionAccessPath(env: Env, identity: Extract/*`; the handler's // requireContributorAccess then enforces actor === login (self-only). @@ -6267,6 +6314,12 @@ function isIssueQualityPath(path: string): boolean { return /^\/v1\/repos\/[^/]+\/[^/]+\/issue-quality$/.test(path); } +// #6746: let any browser session reach the self-scoped watches routes; requireContributorAccess then +// enforces actor === login, and POST/DELETE additionally gate on canWatchRepo (forbidden_watch_repo). +function isContributorWatchesPath(path: string): boolean { + return /^\/v1\/contributors\/[^/]+\/watches$/.test(path); +} + function isRepoFocusManifestPath(path: string): boolean { return /^\/v1\/repos\/[^/]+\/[^/]+\/focus-manifest(?:\/refresh)?$/.test(path); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c493366092..810b063649 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -31,6 +31,7 @@ import { type AuthIdentity, } from "../auth/security"; import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; +import { manageContributorWatches } from "../services/issue-watch"; import { countOpenIssues, countPendingAgentActions, @@ -57,10 +58,7 @@ import { listContributorPullRequests, listIssueSignalSample, listIssues, - deleteIssueWatchSubscription, - listIssueWatchSubscriptionsForLogin, listNotificationDeliveriesForRecipient, - upsertIssueWatchSubscription, upsertRepositorySettings, listOpenPullRequests, listPullRequests, @@ -3843,25 +3841,23 @@ export class LoopoverMcp { }; } - // #699 path B: manage a miner's issue-watch subscriptions. Self-scoped; watch/unwatch need repoFullName. + // #699 path B / #6746: manage a miner's issue-watch subscriptions via shared helpers. Self-scoped; + // watch/unwatch need repoFullName and (for sessions) requireWatchableRepo. private async watchIssues(input: z.infer>): Promise { this.requireContributorAccess(input.login); - let changed: string | undefined; - if (input.action === "watch" || input.action === "unwatch") { - if (!input.repoFullName) return { summary: `${input.action} requires repoFullName.`, data: {} }; + if ((input.action === "watch" || input.action === "unwatch") && input.repoFullName) { await this.requireWatchableRepo(input.login, input.repoFullName); - if (input.action === "watch") { - await upsertIssueWatchSubscription(this.env, { login: input.login, repoFullName: input.repoFullName, labels: input.labels }); - changed = `watching ${input.repoFullName}${input.labels && input.labels.length > 0 ? ` (labels: ${input.labels.join(", ")})` : ""}`; - } else { - const removed = await deleteIssueWatchSubscription(this.env, input.login, input.repoFullName); - changed = removed ? `unwatched ${input.repoFullName}` : `was not watching ${input.repoFullName}`; - } } - const watching = (await listIssueWatchSubscriptionsForLogin(this.env, input.login)).map((sub) => ({ repoFullName: sub.repoFullName, labels: sub.labels })); + const result = await manageContributorWatches(this.env, input); + if ("missingRepo" in result) return { summary: result.summary, data: {} }; return { - summary: `Watching ${watching.length} repo(s) for new grabbable issues${changed ? ` (${changed})` : ""}.`, - data: { watching, ...(changed ? { changed } : {}) } as unknown as Record, + summary: result.summary, + data: { + login: result.login, + watching: result.watching, + summary: result.summary, + ...(result.changed ? { changed: result.changed } : {}), + } as unknown as Record, }; } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index daf774cd7d..91b394bd18 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -510,6 +510,22 @@ export const NotificationsMarkedSchema = z }) .openapi("NotificationsMarked"); +export const ContributorWatchEntrySchema = z + .object({ + repoFullName: z.string(), + labels: z.array(z.string()), + }) + .openapi("ContributorWatchEntry"); + +export const ContributorWatchesSchema = z + .object({ + login: z.string(), + watching: z.array(ContributorWatchEntrySchema), + summary: z.string(), + changed: z.string().optional(), + }) + .openapi("ContributorWatches"); + export const ContributorOpportunitySchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 574e765773..7fc758c329 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -27,6 +27,7 @@ import { ContributorPrOutcomesSchema, NotificationFeedSchema, NotificationsMarkedSchema, + ContributorWatchesSchema, ContributorRewardRiskStrategySchema, ContributorProfileSchema, ContributorScoringProfileSchema, @@ -830,6 +831,61 @@ export function buildOpenApiSpec() { 400: { description: "Invalid mark-read body" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/watches", + summary: "List contributor issue-watch subscriptions", + request: { params: z.object({ login: z.string() }) }, + responses: { + 200: { + description: "The contributor's own issue-watch subscriptions (self-scoped). Mirrors loopover_watch_issues action=list.", + content: { "application/json": { schema: ContributorWatchesSchema } }, + }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/contributors/{login}/watches", + summary: "Watch a repository for new grabbable issues", + request: { + params: z.object({ login: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + repoFullName: z.string().min(3).max(200), + labels: z.array(z.string()).max(50).optional(), + }), + }, + }, + }, + }, + responses: { + 200: { + description: "Subscribes the contributor to new grabbable issues on the repo (optional label filter).", + content: { "application/json": { schema: ContributorWatchesSchema } }, + }, + 400: { description: "Invalid watch body" }, + 403: { description: "Session cannot watch this repository (forbidden_watch_repo)" }, + }, + }); + registry.registerPath({ + method: "delete", + path: "/v1/contributors/{login}/watches", + summary: "Unwatch a repository", + request: { + params: z.object({ login: z.string() }), + query: z.object({ repoFullName: z.string().min(3).max(200) }), + }, + responses: { + 200: { + description: "Removes the contributor's issue-watch subscription for repoFullName (query param).", + content: { "application/json": { schema: ContributorWatchesSchema } }, + }, + 400: { description: "Missing or invalid repoFullName query param" }, + 403: { description: "Session cannot unwatch this repository (forbidden_watch_repo)" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", diff --git a/src/services/issue-watch.ts b/src/services/issue-watch.ts new file mode 100644 index 0000000000..02ca875307 --- /dev/null +++ b/src/services/issue-watch.ts @@ -0,0 +1,105 @@ +import { + deleteIssueWatchSubscription, + listIssueWatchSubscriptionsForLogin, + upsertIssueWatchSubscription, +} from "../db/repositories"; + +/** One issue-watch subscription row, as returned by MCP/REST/CLI. */ +export type ContributorWatchEntry = { + repoFullName: string; + labels: string[]; +}; + +/** Shared payload for list/watch/unwatch (#6746). */ +export type ContributorWatchesResult = { + login: string; + watching: ContributorWatchEntry[]; + summary: string; + changed?: string; +}; + +function watchingSummary(count: number, changed?: string): string { + return `Watching ${count} repo(s) for new grabbable issues${changed ? ` (${changed})` : ""}.`; +} + +async function loadWatching(env: Env, login: string): Promise { + return (await listIssueWatchSubscriptionsForLogin(env, login)).map((sub) => ({ + repoFullName: sub.repoFullName, + labels: sub.labels, + })); +} + +/** List a contributor's issue-watch subscriptions. */ +export async function listContributorWatches(env: Env, login: string): Promise { + const watching = await loadWatching(env, login); + return { + login: login.toLowerCase(), + watching, + summary: watchingSummary(watching.length), + }; +} + +/** + * Subscribe `login` to new grabbable issues on `repoFullName`. + * Callers that use a session identity must gate with `canWatchRepo` first (REST/MCP). + */ +export async function watchContributorRepo( + env: Env, + login: string, + repoFullName: string, + labels?: string[], +): Promise { + await upsertIssueWatchSubscription(env, { login, repoFullName, labels }); + const changed = `watching ${repoFullName}${labels && labels.length > 0 ? ` (labels: ${labels.join(", ")})` : ""}`; + const watching = await loadWatching(env, login); + return { + login: login.toLowerCase(), + watching, + summary: watchingSummary(watching.length, changed), + changed, + }; +} + +/** + * Remove an issue-watch subscription. Callers that use a session identity must gate with + * `canWatchRepo` first (same as MCP `requireWatchableRepo`). + */ +export async function unwatchContributorRepo(env: Env, login: string, repoFullName: string): Promise { + const removed = await deleteIssueWatchSubscription(env, login, repoFullName); + const changed = removed ? `unwatched ${repoFullName}` : `was not watching ${repoFullName}`; + const watching = await loadWatching(env, login); + return { + login: login.toLowerCase(), + watching, + summary: watchingSummary(watching.length, changed), + changed, + }; +} + +export type ManageContributorWatchesInput = { + login: string; + action: "list" | "watch" | "unwatch"; + repoFullName?: string | undefined; + labels?: string[] | undefined; +}; + +/** + * Orchestrator used by MCP (and available to REST). Does not enforce `canWatchRepo` — + * session callers must check that before watch/unwatch. + * Returns `{ missingRepo: true, summary }` when watch/unwatch omit repoFullName. + */ +export async function manageContributorWatches( + env: Env, + input: ManageContributorWatchesInput, +): Promise { + if (input.action === "watch" || input.action === "unwatch") { + if (!input.repoFullName) { + return { missingRepo: true, summary: `${input.action} requires repoFullName.` }; + } + if (input.action === "watch") { + return watchContributorRepo(env, input.login, input.repoFullName, input.labels); + } + return unwatchContributorRepo(env, input.login, input.repoFullName); + } + return listContributorWatches(env, input.login); +} diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index abcbb2fce5..c1a7a56f5f 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -233,6 +233,14 @@ describe("api route guards and error branches", () => { expect(ownPrOutcomes.status).toBe(200); await expect(ownPrOutcomes.json()).resolves.toMatchObject({ login: "attacker", outcomes: expect.any(Array) }); + const victimWatches = await app.request("/v1/contributors/victim/watches", { headers: sessionHeaders }, env); + expect(victimWatches.status).toBe(403); + await expect(victimWatches.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); + + const ownWatches = await app.request("/v1/contributors/attacker/watches", { headers: sessionHeaders }, env); + expect(ownWatches.status).toBe(200); + await expect(ownWatches.json()).resolves.toMatchObject({ login: "attacker", watching: expect.any(Array) }); + const victimRepoDecision = await app.request("/v1/contributors/victim/repos/owner/private-repo/decision", { headers: sessionHeaders }, env); expect(victimRepoDecision.status).toBe(403); await expect(victimRepoDecision.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); diff --git a/test/unit/issue-watch.test.ts b/test/unit/issue-watch.test.ts index cf39979d30..b6240e4180 100644 --- a/test/unit/issue-watch.test.ts +++ b/test/unit/issue-watch.test.ts @@ -182,6 +182,13 @@ describe("MCP loopover_watch_issues", () => { expect((unwatched.structuredContent as { watching: unknown[] }).watching).toHaveLength(0); }); + it("returns a clear summary when watch/unwatch omit repoFullName", async () => { + const env = createTestEnv(); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "miner", action: "watch" } }); + expect(result.isError).toBeFalsy(); + expect((result as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? JSON.stringify(result)).toMatch(/watch requires repoFullName/); + }); it("lets a session watch a tracked PUBLIC repo it does not maintain (the miner use case)", async () => { const env = createTestEnv(); diff --git a/test/unit/mcp-cli-watches.test.ts b/test/unit/mcp-cli-watches.test.ts new file mode 100644 index 0000000000..cfb87c618a --- /dev/null +++ b/test/unit/mcp-cli-watches.test.ts @@ -0,0 +1,126 @@ +// #6746: the CLI mirror for loopover_watch_issues. GET/POST/DELETE /v1/contributors/:login/watches serve the +// same payload as the MCP tool; these pin: `watch-issues list|watch|unwatch --json` stays byte-identical to the +// route fixtures, plain-text paths print the summary, and login resolution matches sibling contributor commands. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, run, runAsync, runExpectingFailure, startFixtureServer, watchesFixture } from "./support/mcp-cli-harness"; + +let apiUrl: string; +let watchRequests: Array<{ method: string; url: string; body?: unknown }>; + +async function connect() { + watchRequests = []; + apiUrl = await startFixtureServer({ onWatchRequest: (info) => watchRequests.push(info) }); +} + +async function disconnect() { + await closeFixtureServer(); +} + +describe("loopover-mcp watch-issues CLI", () => { + beforeEach(connect); + afterEach(disconnect); + + it("list --json emits exactly the watches the route returns", async () => { + const out = await runAsync(["watch-issues", "list", "--login", "JSONbored", "--json"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(JSON.parse(out)).toEqual(watchesFixture("JSONbored")); + }); + + it("list prints the summary and a line per watched repo", async () => { + const out = await runAsync(["watch-issues", "list", "--login", "JSONbored"], { LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token" }); + expect(out).toContain("Watching 1 repo(s) for new grabbable issues."); + expect(out).toContain("JSONbored/loopover [bug]"); + }); + + it("watch POSTs { repoFullName, labels } and --json returns the updated payload", async () => { + const out = await runAsync(["watch-issues", "watch", "acme/widgets", "--login", "JSONbored", "--labels", "bug,good first issue", "--json"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + }); + const payload = JSON.parse(out) as { watching: Array<{ repoFullName: string; labels: string[] }>; changed?: string }; + expect(payload.watching).toEqual([{ repoFullName: "acme/widgets", labels: ["bug", "good first issue"] }]); + expect(payload.changed).toContain("watching acme/widgets"); + expect(watchRequests).toContainEqual({ + method: "POST", + url: "/v1/contributors/JSONbored/watches", + body: { repoFullName: "acme/widgets", labels: ["bug", "good first issue"] }, + }); + }); + + it("unwatch DELETEs with ?repoFullName= and --json returns the emptied list", async () => { + const out = await runAsync(["watch-issues", "unwatch", "JSONbored/loopover", "--login", "JSONbored", "--json"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + }); + const payload = JSON.parse(out) as { watching: unknown[]; changed?: string }; + expect(payload.watching).toEqual([]); + expect(payload.changed).toBe("unwatched JSONbored/loopover"); + expect(watchRequests.some((r) => r.method === "DELETE" && r.url.includes("repoFullName=JSONbored%2Floopover"))).toBe(true); + }); + + it("resolves the login from LOOPOVER_LOGIN, then GITHUB_LOGIN", async () => { + const viaLoopoverLogin = await runAsync(["watch-issues", "list", "--json"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_LOGIN: "JSONbored", + }); + expect(JSON.parse(viaLoopoverLogin)).toEqual(watchesFixture("JSONbored")); + const viaGithubLogin = await runAsync(["watch-issues", "list", "--json"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + GITHUB_LOGIN: "JSONbored", + }); + expect(JSON.parse(viaGithubLogin)).toEqual(watchesFixture("JSONbored")); + }); + + it("fails with the shared login-required message when no login is resolvable", () => { + const failure = runExpectingFailure(["watch-issues", "list"], { + 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("fails when watch/unwatch omit the repo", () => { + const failure = runExpectingFailure(["watch-issues", "watch", "--login", "JSONbored"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + }); + expect(failure.status).toBe(1); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/watch-issues watch /); + }); + + it("rejects an unknown subcommand", () => { + const failure = runExpectingFailure(["watch-issues", "pause", "--login", "JSONbored"], { + LOOPOVER_API_URL: apiUrl, + LOOPOVER_TOKEN: "session-token", + }); + expect(failure.status).toBe(1); + expect(`${failure.stdout}${failure.stderr}`).toMatch(/Unknown watch-issues subcommand/); + }); + + it("strips ANSI escapes from API-chosen summary on the plain-text path but not from --json", async () => { + await closeFixtureServer(); + const esc = String.fromCharCode(27); + const hostileSummary = `${esc}[31mFAKE WATCH${esc}[0m`; + const hostileUrl = await startFixtureServer({ + watches: { summary: hostileSummary, watching: [{ repoFullName: "acme/x", labels: [] }] }, + }); + const env = { LOOPOVER_API_URL: hostileUrl, LOOPOVER_TOKEN: "session-token" }; + + const plain = await runAsync(["watch-issues", "list", "--login", "JSONbored"], env); + expect(plain).not.toContain(esc); + expect(plain).toContain("FAKE WATCH"); + + const asJson = await runAsync(["watch-issues", "list", "--login", "JSONbored", "--json"], env); + expect(JSON.parse(asJson).summary).toBe(hostileSummary); + }); + + it("documents itself in --help, in its own --help, and in the shell-completion command list", () => { + expect(run(["--help"])).toContain("loopover-mcp watch-issues list|watch|unwatch"); + expect(run(["watch-issues", "--help"])).toContain("Mirrors the loopover_watch_issues MCP tool"); + expect(run(["completion", "bash"])).toContain("watch-issues"); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 6bc40a746b..c433631c7a 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -21,6 +21,7 @@ // (#6741 registered the loopover_draft_pr_body CLI mirror, taking the count from 76 to 77.) // (#6747 registered the loopover_pr_outcome CLI mirror, taking the count from 77 to 78.) // (#6980 registered the loopover_explain_review_risk CLI mirror, taking the count from 78 to 79.) +// (#6746 registered the loopover_watch_issues CLI mirror, taking the count from 79 to 80.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -68,14 +69,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 79 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 80 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(79); + expect(primary.length).toBe(80); expect(legacy.length).toBe(0); - expect(names.length).toBe(79); + expect(names.length).toBe(80); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -87,14 +88,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 79-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 80-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }>; }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(79); + expect(payload.count).toBe(80); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 87d7fa726c..3399fa0148 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -25,6 +25,10 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/contributors/{login}/decision-pack"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/open-pr-monitor"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/pr-outcomes"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]?.get).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]?.post).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]?.delete).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/repos/{owner}/{repo}/decision"]).toBeDefined(); expect(spec.paths["/v1/preflight/pr"]).toBeDefined(); expect(spec.paths["/v1/preflight/review-risk"]).toBeDefined(); diff --git a/test/unit/routes-watches.test.ts b/test/unit/routes-watches.test.ts new file mode 100644 index 0000000000..22239d6f3d --- /dev/null +++ b/test/unit/routes-watches.test.ts @@ -0,0 +1,238 @@ +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 { createSessionForGitHubUser } from "../../src/auth/security"; +import { upsertIssueWatchSubscription, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +// #6746: GET/POST/DELETE /v1/contributors/:login/watches — REST mirrors of loopover_watch_issues. +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: "watches-parity-test", version: "0.0.1" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("GET /v1/contributors/:login/watches (#6746)", () => { + it("lists the contributor's watches with a summary", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "miner1", repoFullName: "acme/widgets", labels: ["bug"] }); + + const response = await app.request("/v1/contributors/Miner1/watches", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + login: "miner1", + watching: [{ repoFullName: "acme/widgets", labels: ["bug"] }], + summary: "Watching 1 repo(s) for new grabbable issues.", + }); + }); + + it("returns an empty watching list when there are no subscriptions", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner1/watches", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + login: "miner1", + watching: [], + summary: "Watching 0 repo(s) for new grabbable issues.", + }); + }); + + it("rejects an unauthenticated caller", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner1/watches", {}, 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/watches", { 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 payload the loopover_watch_issues MCP tool returns for action=list (mirror parity)", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "miner1", repoFullName: "acme/widgets", labels: ["bug"] }); + const restBody = await (await app.request("/v1/contributors/miner1/watches", { headers: apiHeaders(env) }, env)).json(); + const client = await connectMcp(env); + const viaTool = await client.callTool({ name: "loopover_watch_issues", arguments: { login: "miner1", action: "list" } }); + expect((viaTool as { structuredContent?: unknown }).structuredContent).toEqual(restBody); + }); +}); + +describe("POST /v1/contributors/:login/watches (#6746)", () => { + it("watches a repo and returns the updated list with changed", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request( + "/v1/contributors/miner1/watches", + { method: "POST", headers: jsonHeaders(env), body: JSON.stringify({ repoFullName: "acme/widgets", labels: ["bug"] }) }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + login: "miner1", + watching: [{ repoFullName: "acme/widgets", labels: ["bug"] }], + summary: "Watching 1 repo(s) for new grabbable issues (watching acme/widgets (labels: bug)).", + changed: "watching acme/widgets (labels: bug)", + }); + }); + + it("rejects a malformed body with 400", async () => { + const app = createApp(); + const env = createTestEnv(); + const bodies = [{}, { repoFullName: "ab" }, { repoFullName: "acme/widgets", labels: [""] }, { repoFullName: 1 }]; + for (const body of bodies) { + const response = await app.request( + "/v1/contributors/miner1/watches", + { 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_watch" }); + } + }); + + it("403s a session that cannot watch a private inaccessible repo", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private", full_name: "victim/private", private: true, owner: { login: "victim" }, default_branch: "main" }, 321); + const { token } = await createSessionForGitHubUser(env, { login: "miner1", id: 1 }); + const response = await app.request( + "/v1/contributors/miner1/watches", + { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ repoFullName: "victim/private" }), + }, + env, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "forbidden_watch_repo" }); + }); + + it("lets a session watch a tracked public repo", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" }, 100); + const { token } = await createSessionForGitHubUser(env, { login: "miner1", id: 1 }); + const response = await app.request( + "/v1/contributors/miner1/watches", + { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ repoFullName: "owner/repo" }), + }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + login: "miner1", + watching: [{ repoFullName: "owner/repo", labels: [] }], + changed: "watching owner/repo", + }); + }); + + it("returns the same payload the MCP tool returns for action=watch (mirror parity)", async () => { + const restEnv = createTestEnv(); + const app = createApp(); + const restBody = await ( + await app.request( + "/v1/contributors/miner1/watches", + { method: "POST", headers: jsonHeaders(restEnv), body: JSON.stringify({ repoFullName: "acme/widgets", labels: ["bug"] }) }, + restEnv, + ) + ).json(); + + const toolEnv = createTestEnv(); + const client = await connectMcp(toolEnv); + const viaTool = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner1", action: "watch", repoFullName: "acme/widgets", labels: ["bug"] }, + }); + expect((viaTool as { structuredContent?: unknown }).structuredContent).toEqual(restBody); + }); +}); + +describe("DELETE /v1/contributors/:login/watches (#6746)", () => { + it("unwatches via ?repoFullName= and reports changed", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "miner1", repoFullName: "acme/widgets" }); + const response = await app.request("/v1/contributors/miner1/watches?repoFullName=acme%2Fwidgets", { method: "DELETE", headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + login: "miner1", + watching: [], + summary: "Watching 0 repo(s) for new grabbable issues (unwatched acme/widgets).", + changed: "unwatched acme/widgets", + }); + }); + + it("reports was-not-watching when the subscription is already gone", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/contributors/miner1/watches?repoFullName=acme%2Fwidgets", { method: "DELETE", headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + login: "miner1", + watching: [], + changed: "was not watching acme/widgets", + }); + }); + + it("rejects a missing or too-short repoFullName query with 400", async () => { + const app = createApp(); + const env = createTestEnv(); + for (const url of ["/v1/contributors/miner1/watches", "/v1/contributors/miner1/watches?repoFullName=ab"]) { + const response = await app.request(url, { method: "DELETE", headers: apiHeaders(env) }, env); + expect(response.status, url).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_unwatch" }); + } + }); + + it("403s a session that cannot unwatch an inaccessible private repo", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private", full_name: "victim/private", private: true, owner: { login: "victim" }, default_branch: "main" }, 321); + const { token } = await createSessionForGitHubUser(env, { login: "miner1", id: 1 }); + const response = await app.request("/v1/contributors/miner1/watches?repoFullName=victim%2Fprivate", { + method: "DELETE", + headers: { authorization: `Bearer ${token}` }, + }, env); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "forbidden_watch_repo" }); + }); + + it("returns the same payload the MCP tool returns for action=unwatch (mirror parity)", async () => { + const restEnv = createTestEnv(); + await upsertIssueWatchSubscription(restEnv, { login: "miner1", repoFullName: "acme/widgets" }); + const app = createApp(); + const restBody = await ( + await app.request("/v1/contributors/miner1/watches?repoFullName=acme%2Fwidgets", { method: "DELETE", headers: apiHeaders(restEnv) }, restEnv) + ).json(); + + const toolEnv = createTestEnv(); + await upsertIssueWatchSubscription(toolEnv, { login: "miner1", repoFullName: "acme/widgets" }); + const client = await connectMcp(toolEnv); + const viaTool = await client.callTool({ + name: "loopover_watch_issues", + arguments: { login: "miner1", action: "unwatch", repoFullName: "acme/widgets" }, + }); + 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 664a37aca5..bcbc36ef51 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -54,7 +54,7 @@ export function run(args: string[], env: Record = {}) { stdio: ["ignore", "pipe", "pipe"], }); } catch (error) { - // A failing command's message may land on stdout (--json contract) or stderr (plain text) — + // A failing command's message may land on stdout (--json contract) or stderr (plain text) // fold both into the thrown Error's message so callers can keep matching it with toThrow(/regex/). const failure = error as NodeJS.ErrnoException & { stdout?: string }; if (failure instanceof Error && failure.stdout) failure.message = `${failure.message}\n${failure.stdout}`; @@ -87,7 +87,7 @@ export function runAsync(args: string[], env: Record = {}) { }); } -/** Run a command expected to fail and return its exit code + both streams, instead of throwing — +/** Run a command expected to fail and return its exit code + both streams, instead of throwing * for asserting the shape of the failure output itself (e.g. the --json `{ ok: false, error }` contract). */ export function runExpectingFailure(args: string[], env: Record = {}) { try { @@ -185,6 +185,9 @@ export async function startFixtureServer( notifications?: Record; notificationsRead?: Record; onMarkNotificationsRead?: (body: unknown) => void; + /** #6746: overrides watches list/watch/unwatch responses and captures mutating request details. */ + watches?: Record; + onWatchRequest?: (info: { method: string; url: string; 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 @@ -335,6 +338,41 @@ export async function startFixtureServer( response.end(JSON.stringify({ login: "jsonbored", marked: 2, ...(options.notificationsRead ?? {}) })); return; } + const watchesMatch = request.url?.match(/^\/v1\/contributors\/([^/]+)\/watches(?:\?(.*))?$/); + if (watchesMatch && (request.method === "GET" || request.method === "POST" || request.method === "DELETE")) { + const login = decodeURIComponent(watchesMatch[1]!); + const query = watchesMatch[2] ? new URLSearchParams(watchesMatch[2]) : null; + const body = request.method === "POST" ? await readJsonRequest(request) : undefined; + options.onWatchRequest?.({ method: request.method, url: request.url ?? "", body }); + const base = { ...watchesFixture(login), ...(options.watches ?? {}) }; + if (request.method === "POST") { + const repoFullName = typeof (body as { repoFullName?: string })?.repoFullName === "string" ? (body as { repoFullName: string }).repoFullName : "owner/repo"; + const labels = Array.isArray((body as { labels?: string[] })?.labels) ? (body as { labels: string[] }).labels : []; + response.end( + JSON.stringify({ + ...base, + watching: [{ repoFullName, labels }], + changed: `watching ${repoFullName}${labels.length > 0 ? ` (labels: ${labels.join(", ")})` : ""}`, + summary: `Watching 1 repo(s) for new grabbable issues (watching ${repoFullName}).`, + }), + ); + return; + } + if (request.method === "DELETE") { + const repoFullName = query?.get("repoFullName") ?? "owner/repo"; + response.end( + JSON.stringify({ + ...base, + watching: [], + changed: `unwatched ${repoFullName}`, + summary: `Watching 0 repo(s) for new grabbable issues (unwatched ${repoFullName}).`, + }), + ); + return; + } + response.end(JSON.stringify(base)); + return; + } if (request.url === "/v1/contributors/JSONbored/repos/JSONbored/loopover/decision" && request.method === "GET") { if (options.repoDecisionStatus && options.repoDecisionStatus >= 400) { response.statusCode = options.repoDecisionStatus; @@ -497,7 +535,7 @@ export async function startFixtureServer( ); return; } - // #6744 propose: the CREATE side of the approval queue — bare path POST (no trailing slash), so it does NOT + // #6744 propose: the CREATE side of the approval queue bare path POST (no trailing slash), so it does NOT // collide with the decision `.../pending-actions/:id/:decision` POST stub below. Echoes the posted actionClass // + pullNumber so a test can assert the CLI serialized the right body. if (request.url === "/v1/repos/owner/repo/agent/pending-actions" && request.method === "POST") { @@ -622,7 +660,7 @@ export async function startFixtureServer( { gateType: "missing-linked-issue", blocked: 3, blockedThenMerged: 0, overridden: 0, falsePositiveRate: null }, ], overall: { blocked: 11, blockedThenMerged: 2, falsePositiveRate: 0.182 }, - signals: ["Highest false-positive gate: `duplicate-pr` — 25% of its 8 blocks merged anyway (1 overridden). Keep it advisory until this drops."], + signals: ["Highest false-positive gate: `duplicate-pr` 25% of its 8 blocks merged anyway (1 overridden). Keep it advisory until this drops."], }), ); return; @@ -639,7 +677,7 @@ export async function startFixtureServer( { band: "high", sampleSize: 4, merged: 1, closed: 3, mergeRate: 0.25 }, ], recommendations: { total: 20, positive: 14, negative: 3, pending: 3, positiveRate: 0.82 }, - signals: ["Higher-slop bands merge less often — the slop signal is tracking real outcomes."], + signals: ["Higher-slop bands merge less often the slop signal is tracking real outcomes."], }), ); return; @@ -1034,6 +1072,15 @@ export function notificationsReadFixture() { return { login: "jsonbored", marked: 2 }; } +/** #6746: mirrors ContributorWatches { login, watching, summary } from GET /watches. */ +export function watchesFixture(login = "jsonbored") { + return { + login: login.toLowerCase(), + watching: [{ repoFullName: "JSONbored/loopover", labels: ["bug"] }], + summary: "Watching 1 repo(s) for new grabbable issues.", + }; +} + export function decisionPackFixture() { return { status: "ready", @@ -1171,7 +1218,7 @@ export function slopRiskFixture(input: { }; } -/** #6748: fixture for POST /v1/lint/improvement-potential — mirrors a mild positive signal when tests accompany code. */ +/** #6748: fixture for POST /v1/lint/improvement-potential mirrors a mild positive signal when tests accompany code. */ export function improvementPotentialFixture(input: { changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>; tests?: string[]; @@ -1230,7 +1277,7 @@ export function issueSlopFixture(input: { title?: string; body?: string } = {}) : titleOnly ? [{ code: "title_restatement", title: "Issue body only restates the title", severity: "warning", detail: "Add specific detail beyond the title." }] : []; - // #6990: blunted like the route — band + findings only, no numeric score or rubric. + // #6990: blunted like the route band + findings only, no numeric score or rubric. return { band: slopRisk <= 0 ? "clean" : slopRisk < 25 ? "low" : slopRisk < 60 ? "elevated" : "high", findings,