diff --git a/migrations/0036_issue_watch_subscriptions.sql b/migrations/0036_issue_watch_subscriptions.sql new file mode 100644 index 0000000000..3cd57382e5 --- /dev/null +++ b/migrations/0036_issue_watch_subscriptions.sql @@ -0,0 +1,13 @@ +-- #699 path B: miners subscribe to watch a repo for NEW grabbable, high-multiplier issues. When such an +-- issue opens, the watchers are notified through the #535 notification pipeline. `labels_json` is an +-- optional label filter ([] = any label); UNIQUE(login, repo_full_name) makes subscribe idempotent. +CREATE TABLE IF NOT EXISTS issue_watch_subscriptions ( + id TEXT PRIMARY KEY, + login TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + labels_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS issue_watch_subscriptions_login_repo_unique ON issue_watch_subscriptions (login, repo_full_name); +CREATE INDEX IF NOT EXISTS issue_watch_subscriptions_repo_idx ON issue_watch_subscriptions (repo_full_name); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 903f90e169..5d94002c85 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -27,6 +27,7 @@ import { issues, githubRateLimitObservations, notificationDeliveries, + issueWatchSubscriptions, notificationSubscriptions, officialMinerDetections, pullRequestFiles, @@ -100,6 +101,7 @@ import type { NotificationChannel, NotificationDeliveryRecord, NotificationDeliveryStatus, + IssueWatchSubscription, NotificationSubscriptionRecord, ProductUsageActivationFunnel, ProductUsageDailyRollupRecord, @@ -1303,6 +1305,54 @@ export async function listNotificationSubscriptionsForLogin(env: Env, login: str return rows.map(toNotificationSubscriptionRecord); } +// ─── Issue-watch subscriptions (#699 path B) ───────────────────────────────────────────────────────── + +function toIssueWatchSubscription(row: typeof issueWatchSubscriptions.$inferSelect): IssueWatchSubscription { + return { login: row.login, repoFullName: row.repoFullName, labels: parseJson(row.labelsJson, []), createdAt: row.createdAt, updatedAt: row.updatedAt }; +} + +/** Subscribe a miner to a repo's new grabbable issues; idempotent on (login, repo) — re-subscribing just + * updates the label filter. `labels` ([]=any) are lowercased for case-insensitive matching at delivery. */ +export async function upsertIssueWatchSubscription(env: Env, input: { login: string; repoFullName: string; labels?: string[] | undefined }): Promise { + const db = getDb(env.DB); + const login = input.login.toLowerCase(); + const labels = [...new Set((input.labels ?? []).map((label) => label.toLowerCase().trim()).filter(Boolean))]; + await db + .insert(issueWatchSubscriptions) + .values({ id: crypto.randomUUID(), login, repoFullName: input.repoFullName, labelsJson: jsonString(labels), updatedAt: nowIso() }) + .onConflictDoUpdate({ target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName], set: { labelsJson: jsonString(labels), updatedAt: nowIso() } }); + const [row] = await db + .select() + .from(issueWatchSubscriptions) + .where(and(eq(issueWatchSubscriptions.login, login), eq(issueWatchSubscriptions.repoFullName, input.repoFullName))); + return row ? toIssueWatchSubscription(row) : { login, repoFullName: input.repoFullName, labels }; +} + +export async function listIssueWatchSubscriptionsForLogin(env: Env, login: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(issueWatchSubscriptions).where(eq(issueWatchSubscriptions.login, login.toLowerCase())).limit(200); + return rows.map(toIssueWatchSubscription); +} + +/** Returns whether a watch existed and was removed (so the caller can report it accurately). */ +export async function deleteIssueWatchSubscription(env: Env, login: string, repoFullName: string): Promise { + const db = getDb(env.DB); + const existing = await db + .select({ id: issueWatchSubscriptions.id }) + .from(issueWatchSubscriptions) + .where(and(eq(issueWatchSubscriptions.login, login.toLowerCase()), eq(issueWatchSubscriptions.repoFullName, repoFullName))); + if (existing.length === 0) return false; + await db.delete(issueWatchSubscriptions).where(and(eq(issueWatchSubscriptions.login, login.toLowerCase()), eq(issueWatchSubscriptions.repoFullName, repoFullName))); + return true; +} + +/** All miners watching a repo — the candidate recipients when a new grabbable issue opens there. */ +export async function listIssueWatchersForRepo(env: Env, repoFullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(issueWatchSubscriptions).where(eq(issueWatchSubscriptions.repoFullName, repoFullName)).limit(5000); + return rows.map(toIssueWatchSubscription); +} + // Idempotency guard: UNIQUE(dedup_key, channel) means a duplicate webhook / queue retry inserts nothing // and returns the existing row. Returns whether THIS call created the row (so only the first enqueues delivery). export async function insertNotificationDeliveryIfAbsent( diff --git a/src/db/schema.ts b/src/db/schema.ts index ae99a8fe64..039db757bb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -859,6 +859,24 @@ export const notificationDeliveries = sqliteTable( }), ); +// #699 path B: a miner's standing watch on a repo for NEW grabbable, high-multiplier issues. `labelsJson` +// is an optional label filter ([] = any). UNIQUE(login, repoFullName) makes subscribe idempotent. +export const issueWatchSubscriptions = sqliteTable( + "issue_watch_subscriptions", + { + id: text("id").primaryKey(), + login: text("login").notNull(), + repoFullName: text("repo_full_name").notNull(), + labelsJson: text("labels_json").notNull().default("[]"), + createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), + updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), + }, + (table) => ({ + loginRepo: uniqueIndex("issue_watch_subscriptions_login_repo_unique").on(table.login, table.repoFullName), + repo: index("issue_watch_subscriptions_repo_idx").on(table.repoFullName), + }), +); + export const githubAgentCommandAnswers = sqliteTable( "github_agent_command_answers", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 831a42da91..3cd1c441a7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,7 +22,10 @@ import { listContributorPullRequests, listIssueSignalSample, listIssues, + deleteIssueWatchSubscription, + listIssueWatchSubscriptionsForLogin, listNotificationDeliveriesForRecipient, + upsertIssueWatchSubscription, listOpenPullRequests, listPullRequests, listRecentMergedPullRequests, @@ -451,6 +454,20 @@ const markNotificationsReadShape = { .optional(), }; +// #699 path B: a miner's self-scoped issue-watch subscriptions. `action` defaults to `list`; `watch`/`unwatch` +// require repoFullName. `labels` ([]/omitted = any) filters which new issues notify. +const watchIssuesShape = { + 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(), +}; + +const watchIssuesOutputSchema = { + watching: z.array(z.object({ repoFullName: z.string(), labels: z.array(z.string()) })).optional(), + changed: z.string().optional(), +}; + const explainRepoDecisionOutputSchema = { status: z.string().optional(), login: z.string().optional(), @@ -722,6 +739,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.markNotificationsRead(input.login, input.ids)), ); + server.registerTool( + "gittensory_watch_issues", + { + 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 gittensory_list_notifications. Self-scoped to the authenticated login.", + inputSchema: watchIssuesShape, + outputSchema: watchIssuesOutputSchema, + }, + async (input) => this.toolResult(await this.watchIssues(input)), + ); + server.registerTool( "gittensory_explain_repo_decision", { @@ -1393,6 +1421,27 @@ export class GittensoryMcp { }; } + // #699 path B: manage a miner's issue-watch subscriptions. Self-scoped; watch/unwatch need repoFullName. + 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") { + 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 })); + return { + summary: `Watching ${watching.length} repo(s) for new grabbable issues${changed ? ` (${changed})` : ""}.`, + data: { watching, ...(changed ? { changed } : {}) } as unknown as Record, + }; + } + private async markNotificationsRead(login: string, ids?: string[]): Promise { this.requireContributorAccess(login); const marked = await markNotificationDeliveriesRead(this.env, login, ids); diff --git a/src/notifications/service.ts b/src/notifications/service.ts index 4fc978b7e7..ba939b7a59 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -3,10 +3,12 @@ import { countRecentNotificationDeliveries, getNotificationDeliveryById, insertNotificationDeliveryIfAbsent, + listIssueWatchersForRepo, listNotificationSubscriptionsForLogin, markNotificationDeliveryDelivered, } from "../db/repositories"; -import type { DetectedNotificationEvent, NotificationChannel, NotificationDeliveryRecord, NotificationSubscriptionRecord } from "../types"; +import { isGrabbableHighMultiplierIssue } from "../signals/engine"; +import type { DetectedNotificationEvent, IssueRecord, NotificationChannel, NotificationDeliveryRecord, NotificationSubscriptionRecord } from "../types"; import { nowIso } from "../utils/json"; // Per-recipient, per-channel safety cap. The killer event (changes_requested) delivers immediately, but a @@ -40,9 +42,55 @@ export function buildMergedOutcomeNotification(event: DetectedNotificationEvent) }; } +// #699 path B: a repo a miner watches opened a NEW grabbable, high-multiplier issue. For this eventType the +// `pullNumber` field carries the ISSUE number. Public-safe — "open to grab" framing, never raw reward/score. +export function buildIssueWatchNotification(event: DetectedNotificationEvent): { title: string; body: string } { + const ref = `${event.repoFullName}#${event.pullNumber}`; + return { + title: sanitizePublicComment(`New issue to grab on ${ref}`), + body: sanitizePublicComment(`A new maintainer-created issue opened on ${ref} that is open for you to grab. Maintainer-created issues are strong early targets on ${event.repoFullName} — claim it to line up your next contribution.`), + }; +} + // Maps a detected event to its public-safe notification content. export function buildNotificationContent(event: DetectedNotificationEvent): { title: string; body: string } { - return event.eventType === "pull_request_merged" ? buildMergedOutcomeNotification(event) : buildChangesRequestedNotification(event); + switch (event.eventType) { + case "pull_request_merged": + return buildMergedOutcomeNotification(event); + case "issue_watch_match": + return buildIssueWatchNotification(event); + default: + return buildChangesRequestedNotification(event); + } +} + +/** + * #699 path B: when a webhook opens a NEW grabbable, high-multiplier issue, fan out one notification event + * per watching miner (matching their optional label filter), skipping the issue's own author. DB-backed + * (reads the repo's watchers), so it lives here rather than in the pure payload-only detectNotificationEvents. + */ +export async function detectIssueWatchEvents(env: Env, repoFullName: string, issue: IssueRecord): Promise { + if (!isGrabbableHighMultiplierIssue(issue)) return []; + const watchers = await listIssueWatchersForRepo(env, repoFullName); + if (watchers.length === 0) return []; + const detectedAt = nowIso(); + const issueLabels = new Set(issue.labels.map((label) => label.toLowerCase().trim())); + const authorLogin = issue.authorLogin?.toLowerCase(); + return watchers + // An empty label filter matches any issue; otherwise at least one watched label must be present. + .filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label))) + // Don't ping the maintainer who opened the issue about their own issue. + .filter((watcher) => watcher.login.toLowerCase() !== authorLogin) + .map((watcher) => ({ + eventType: "issue_watch_match" as const, + recipientLogin: watcher.login, + repoFullName, + pullNumber: issue.number, // carries the ISSUE number for this eventType + dedupKey: `issue_watch_match:${repoFullName}#${issue.number}:${watcher.login.toLowerCase()}`, + deeplink: `https://github.com/${repoFullName}/issues/${issue.number}`, + actorLogin: issue.authorLogin ?? "unknown", + detectedAt, + })); } function rateLimitWindowStart(now: string): string { diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c30a8c8fd0..c63a33b522 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -82,7 +82,7 @@ import { fetchPublicContributorProfile } from "../github/public"; import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck } from "../rules/advisory"; import { detectNotificationEvents } from "../notifications/events"; -import { deliverNotification, evaluateNotificationEvent } from "../notifications/service"; +import { deliverNotification, detectIssueWatchEvents, evaluateNotificationEvent } from "../notifications/service"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; import { buildAndPersistContributorDecisionPack, loadDecisionPackSharedInputs } from "../services/decision-pack"; import { @@ -139,7 +139,7 @@ import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { resolveEffectiveSettings } from "../signals/focus-manifest"; import type { LocalBranchAnalysisInput } from "../signals/local-branch"; import { runGittensoryAiReview } from "../services/ai-review"; -import type { AdvisoryFinding, ContributorEvidenceRecord, GitHubWebhookPayload, JobMessage, JsonValue, PullRequestRecord, RepositorySettings } from "../types"; +import type { AdvisoryFinding, ContributorEvidenceRecord, DetectedNotificationEvent, GitHubWebhookPayload, JobMessage, JsonValue, PullRequestRecord, RepositorySettings } from "../types"; import { sha256Hex } from "../utils/crypto"; import { errorMessage, nowIso } from "../utils/json"; @@ -759,6 +759,7 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str } } + let issueWatchEvents: DetectedNotificationEvent[] = []; if (payload.repository?.full_name && payload.issue && !payload.issue.pull_request) { const issue = await upsertIssueFromGitHub(env, payload.repository.full_name, payload.issue); const repo = await getRepository(env, payload.repository.full_name); @@ -770,9 +771,12 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str advisory.findings.push(...buildIssueSlopAssessment({ title: issue.title, body: issue.body }).findings); } await persistAdvisory(env, advisory); + // #699 path B: a newly opened grabbable, high-multiplier issue notifies the miners watching this repo + // (fanned out through the same #535 pipeline below). + if (payload.action === "opened") issueWatchEvents = await detectIssueWatchEvents(env, payload.repository.full_name, issue); } - for (const notificationEvent of detectNotificationEvents(eventName, payload)) { + for (const notificationEvent of [...detectNotificationEvents(eventName, payload), ...issueWatchEvents]) { await recordAuditEvent(env, { eventType: "notification.event_detected", actor: notificationEvent.actorLogin, diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 98bc819dcd..2ebfec1453 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -214,6 +214,15 @@ function isMaintainerWipIssue(issue: IssueRecord): boolean { return isMaintainerAssociation(issue.authorAssociation) && issue.labels.some((label) => MAINTAINER_WIP_LABELS.has(label.toLowerCase().trim())); } +/** + * True iff an issue is the highest-multiplier, immediately-grabbable target (#699): open, maintainer-created + * (the biggest reward multiplier), and NOT flagged as the maintainer's own WIP/internal work. This is the + * exact condition the issue-watch monitor (#699 path B) notifies subscribers about. + */ +export function isGrabbableHighMultiplierIssue(issue: IssueRecord): boolean { + return issue.state === "open" && isMaintainerAssociation(issue.authorAssociation) && !isMaintainerWipIssue(issue); +} + export type ContributorFit = { login: string; generatedAt: string; diff --git a/src/types.ts b/src/types.ts index 8088be27d5..8cd7a1b9d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1120,7 +1120,17 @@ export type DigestSubscriptionRecord = { // unless a row is `paused`). export type NotificationChannel = "badge" | "email"; export type NotificationDeliveryStatus = "pending" | "delivered" | "read" | "suppressed"; -export type NotificationEventType = "pull_request_changes_requested" | "pull_request_merged"; +export type NotificationEventType = "pull_request_changes_requested" | "pull_request_merged" | "issue_watch_match"; + +/** #699 path B: a miner's standing watch on a repo for new grabbable issues. `labels` ([]=any) filters + * which issues notify. The `pullNumber` field of the resulting notification event carries the ISSUE number. */ +export type IssueWatchSubscription = { + login: string; + repoFullName: string; + labels: string[]; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; // A notification-worthy event extracted from a webhook payload (src/notifications/events.ts). export type DetectedNotificationEvent = { diff --git a/test/unit/issue-watch.test.ts b/test/unit/issue-watch.test.ts new file mode 100644 index 0000000000..a8dc1cc204 --- /dev/null +++ b/test/unit/issue-watch.test.ts @@ -0,0 +1,145 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; +import { + deleteIssueWatchSubscription, + listIssueWatchSubscriptionsForLogin, + listIssueWatchersForRepo, + upsertIssueWatchSubscription, +} from "../../src/db/repositories"; +import { isGrabbableHighMultiplierIssue } from "../../src/signals/engine"; +import { buildIssueWatchNotification, buildNotificationContent, detectIssueWatchEvents } from "../../src/notifications/service"; +import type { IssueRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +function issue(over: Partial = {}): IssueRecord { + return { repoFullName: "owner/repo", number: 5, title: "Add retry to sync", state: "open", authorAssociation: "OWNER", authorLogin: "maintainer", labels: [], linkedPrs: [], ...over }; +} + +describe("isGrabbableHighMultiplierIssue (#699)", () => { + it("is true only for an open, maintainer-created, non-WIP issue", () => { + expect(isGrabbableHighMultiplierIssue(issue())).toBe(true); + expect(isGrabbableHighMultiplierIssue(issue({ state: "closed" }))).toBe(false); + expect(isGrabbableHighMultiplierIssue(issue({ authorAssociation: "NONE" }))).toBe(false); // community-authored + expect(isGrabbableHighMultiplierIssue(issue({ labels: ["WIP"] }))).toBe(false); // maintainer WIP + }); +}); + +describe("issue-watch subscriptions (CRUD)", () => { + it("subscribes idempotently, lists, normalizes labels, and unwatches", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "Miner", repoFullName: "owner/repo", labels: ["Bug", " good first issue "] }); + let mine = await listIssueWatchSubscriptionsForLogin(env, "miner"); + expect(mine).toHaveLength(1); + expect(mine[0]).toMatchObject({ repoFullName: "owner/repo", labels: ["bug", "good first issue"] }); // lowercased + trimmed + + // Re-subscribe (idempotent on login+repo) updates the label filter, not a duplicate row. + await upsertIssueWatchSubscription(env, { login: "miner", repoFullName: "owner/repo", labels: [] }); + mine = await listIssueWatchSubscriptionsForLogin(env, "miner"); + expect(mine).toHaveLength(1); + expect(mine[0]!.labels).toEqual([]); + + // Watchers-for-repo lists across logins. + await upsertIssueWatchSubscription(env, { login: "other", repoFullName: "owner/repo" }); + expect(await listIssueWatchersForRepo(env, "owner/repo")).toHaveLength(2); + + expect(await deleteIssueWatchSubscription(env, "miner", "owner/repo")).toBe(true); + expect(await deleteIssueWatchSubscription(env, "miner", "owner/repo")).toBe(false); // already gone + expect(await listIssueWatchSubscriptionsForLogin(env, "miner")).toHaveLength(0); + }); +}); + +describe("detectIssueWatchEvents", () => { + it("fans out one event per matching watcher, skips the author, honours the label filter", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); // any label + await upsertIssueWatchSubscription(env, { login: "bob", repoFullName: "owner/repo", labels: ["bug"] }); // bug only + await upsertIssueWatchSubscription(env, { login: "maintainer", repoFullName: "owner/repo" }); // the issue's author + + const events = await detectIssueWatchEvents(env, "owner/repo", issue({ number: 9, labels: ["enhancement"], authorLogin: "maintainer" })); + // alice matches (any label); bob filtered out (no "bug"); maintainer skipped (own issue). + expect(events.map((e) => e.recipientLogin).sort()).toEqual(["alice"]); + expect(events[0]).toMatchObject({ + eventType: "issue_watch_match", + repoFullName: "owner/repo", + pullNumber: 9, // carries the issue number + deeplink: "https://github.com/owner/repo/issues/9", + }); + expect(events[0]!.dedupKey).toBe("issue_watch_match:owner/repo#9:alice"); + }); + + it("returns nothing for a non-grabbable issue or when there are no watchers", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); + expect(await detectIssueWatchEvents(env, "owner/repo", issue({ authorAssociation: "NONE" }))).toEqual([]); // community-authored + expect(await detectIssueWatchEvents(env, "owner/repo", issue({ labels: ["wip"] }))).toEqual([]); // maintainer WIP + expect(await detectIssueWatchEvents(env, "unwatched/repo", issue())).toEqual([]); // no watchers + }); + + it("handles an issue with no recorded author (actor falls back to 'unknown', no one is skipped)", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); + const events = await detectIssueWatchEvents(env, "owner/repo", issue({ number: 12, authorLogin: undefined, authorAssociation: "MEMBER" })); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ recipientLogin: "alice", actorLogin: "unknown", pullNumber: 12 }); + }); +}); + +describe("buildIssueWatchNotification", () => { + it("is public-safe (no reward/score/farming language)", () => { + const { title, body } = buildIssueWatchNotification({ + eventType: "issue_watch_match", + recipientLogin: "alice", + repoFullName: "owner/repo", + pullNumber: 9, + dedupKey: "k", + deeplink: "https://github.com/owner/repo/issues/9", + actorLogin: "maintainer", + detectedAt: "2026-06-14T00:00:00.000Z", + }); + expect(title).toContain("owner/repo#9"); + expect(`${title} ${body}`).not.toMatch(/reward|payout|trust score|scoreability|farming|wallet|hotkey|multiplier/i); + }); + + it("buildNotificationContent routes the issue_watch_match eventType to the issue-watch copy", () => { + const event = { eventType: "issue_watch_match" as const, recipientLogin: "alice", repoFullName: "owner/repo", pullNumber: 9, dedupKey: "k", deeplink: "https://github.com/owner/repo/issues/9", actorLogin: "maintainer", detectedAt: "2026-06-14T00:00:00.000Z" }; + expect(buildNotificationContent(event).title).toContain("New issue to grab on owner/repo#9"); + }); +}); + +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new GittensoryMcp(env, identity) : new GittensoryMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "issue-watch-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP gittensory_watch_issues", () => { + it("watches, lists, and unwatches a repo for the authenticated login", async () => { + const env = createTestEnv(); + const client = await connect(env); + + const watched = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "watch", repoFullName: "owner/repo", labels: ["bug"] } }); + expect(watched.isError).toBeFalsy(); + expect((watched.structuredContent as { watching: Array<{ repoFullName: string }> }).watching).toEqual([{ repoFullName: "owner/repo", labels: ["bug"] }]); + + const listed = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "list" } }); + expect((listed.structuredContent as { watching: unknown[] }).watching).toHaveLength(1); + + const unwatched = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "unwatch", repoFullName: "owner/repo" } }); + expect((unwatched.structuredContent as { watching: unknown[] }).watching).toHaveLength(0); + }); + + it("is self-scoped: a session cannot manage another login's watches", async () => { + const env = createTestEnv(); + const { session } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); + const client = await connect(env, { kind: "session", actor: "miner", session }); + const result = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "other", action: "list" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("authenticated GitHub login"); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index bab8f8ad04..23c2f86826 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -25,6 +25,7 @@ import { upsertInstallation, upsertOfficialMinerDetection, upsertPullRequestFromGitHub, + upsertIssueWatchSubscription, upsertRepositorySettings, upsertRepositoryFromGitHub, } from "../../src/db/repositories"; @@ -3804,6 +3805,33 @@ describe("queue processors", () => { expect(evaluateJob!.event.recipientLogin).toBe("contributor"); }); + it("notifies issue-watchers when a new grabbable maintainer-created issue opens (#699 path B)", async () => { + const enqueued: Array<{ type: string; event?: { eventType: string; recipientLogin: string; pullNumber: number } }> = []; + const env = createTestEnv({ JOBS: { async send(message: { type: string }) { enqueued.push(message); } } as unknown as Queue }); + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); // no .gittensory.yml → empty manifest + await upsertIssueWatchSubscription(env, { login: "watcher", repoFullName: "JSONbored/gittensory" }); + await upsertIssueWatchSubscription(env, { login: "maintainer", repoFullName: "JSONbored/gittensory" }); // the author — should be skipped + + await processJob(env, { + type: "github-webhook", + deliveryId: "issue-watch-open", + eventName: "issues", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 91, title: "Add caching to the registry sync", state: "open", user: { login: "maintainer" }, author_association: "OWNER", body: "We should cache the registry fetch." }, + }, + }); + + const watchEvents = enqueued.filter((m): m is { type: "notify-evaluate"; event: { eventType: string; recipientLogin: string; pullNumber: number } } => m.type === "notify-evaluate" && m.event?.eventType === "issue_watch_match"); + expect(watchEvents.map((m) => m.event.recipientLogin)).toEqual(["watcher"]); // maintainer (author) skipped + expect(watchEvents[0]!.event.pullNumber).toBe(91); + + const detected = await env.DB.prepare("select metadata_json from audit_events where event_type = 'notification.event_detected' and target_key = ?").bind("watcher").first<{ metadata_json: string }>(); + expect(JSON.parse(detected!.metadata_json)).toMatchObject({ eventType: "issue_watch_match", recipientLogin: "watcher", repoFullName: "JSONbored/gittensory" }); + }); + it("appends issue-side slop findings to the issue advisory only when slop is opted in (#533)", async () => { const env = createTestEnv(); vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); // no .gittensory.yml → empty manifest