diff --git a/src/db/repositories.ts b/src/db/repositories.ts index a9a6bc8450..7fb0a827b5 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1377,11 +1377,12 @@ export async function markNotificationDeliveryDelivered(env: Env, id: string): P export async function listNotificationDeliveriesForRecipient( env: Env, recipientLogin: string, - options: { channel?: NotificationChannel; unreadOnly?: boolean; limit?: number } = {}, + options: { channel?: NotificationChannel; eventType?: string; unreadOnly?: boolean; limit?: number } = {}, ): Promise { const db = getDb(env.DB); const conditions: SQL[] = [eq(notificationDeliveries.recipientLogin, recipientLogin.toLowerCase())]; if (options.channel) conditions.push(eq(notificationDeliveries.channel, options.channel)); + if (options.eventType) conditions.push(eq(notificationDeliveries.eventType, options.eventType)); if (options.unreadOnly) conditions.push(eq(notificationDeliveries.status, "delivered")); const rows = await db .select() diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 005ffefca7..f36ac58ea6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -361,6 +361,17 @@ const notificationsOutputSchema = { notifications: z.unknown().optional(), }; +const prOutcomeShape = { + login: z.string().min(1), + limit: z.number().int().positive().max(100).optional(), +}; + +const prOutcomeOutputSchema = { + login: z.string().optional(), + count: z.number().optional(), + outcomes: z.unknown().optional(), +}; + const predictGateShape = { login: z.string().min(1), owner: z.string().min(1), @@ -606,6 +617,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.predictGate(input)), ); + server.registerTool( + "gittensory_pr_outcome", + { + 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.", + inputSchema: prOutcomeShape, + outputSchema: prOutcomeOutputSchema, + }, + async (input) => this.toolResult(await this.prOutcomes(input.login, input.limit)), + ); + server.registerTool( "gittensory_list_notifications", { @@ -1236,6 +1258,23 @@ export class GittensoryMcp { }; } + private async prOutcomes(login: string, limit?: number): Promise { + this.requireContributorAccess(login); + const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { eventType: "pull_request_merged", limit: limit ?? 50 }); + const outcomes = deliveries.map((delivery) => ({ + repoFullName: delivery.repoFullName, + pullNumber: delivery.pullNumber, + outcome: "merged" as const, + attribution: delivery.body, + deeplink: delivery.deeplink, + recordedAt: delivery.createdAt, + })); + return { + summary: `Gittensory post-merge outcomes for ${login}: ${outcomes.length} merged PR(s).`, + data: { login: login.toLowerCase(), count: outcomes.length, outcomes } as unknown as Record, + }; + } + private async listNotifications(login: string): Promise { this.requireContributorAccess(login); const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { channel: "badge", limit: 50 }); diff --git a/src/notifications/events.ts b/src/notifications/events.ts index 301b49f7ad..81f0cf48f5 100644 --- a/src/notifications/events.ts +++ b/src/notifications/events.ts @@ -16,7 +16,13 @@ export function detectNotificationEvents( payload: GitHubWebhookPayload, detectedAt: string = nowIso(), ): DetectedNotificationEvent[] { - if (eventName !== "pull_request_review") return []; + if (eventName === "pull_request_review") return detectChangesRequested(payload, detectedAt); + if (eventName === "pull_request") return detectMerged(payload, detectedAt); + return []; +} + +// changes_requested → alert the author (#535). +function detectChangesRequested(payload: GitHubWebhookPayload, detectedAt: string): DetectedNotificationEvent[] { if (payload.action !== "submitted" && payload.action !== "edited") return []; const repoFullName = payload.repository?.full_name; @@ -48,3 +54,34 @@ export function detectNotificationEvents( }, ]; } + +// PR merged → a self-attributed post-merge outcome for the author (#702). Only fires on a real merge +// (action "closed" + merged_at set), never a close-without-merge. The author is both recipient and actor. +function detectMerged(payload: GitHubWebhookPayload, detectedAt: string): DetectedNotificationEvent[] { + if (payload.action !== "closed") return []; + + const pullRequest = payload.pull_request; + const mergedAt = pullRequest?.merged_at; + if (!mergedAt) return []; + + const repoFullName = payload.repository?.full_name; + const pullNumber = pullRequest?.number; + const authorLogin = pullRequest?.user?.login; + if (!repoFullName || !pullNumber || !authorLogin) return []; + if (isBotUser(pullRequest?.user)) return []; + + const dedupKey = `pull_request_merged:${repoFullName}#${pullNumber}:${mergedAt}`; + + return [ + { + eventType: "pull_request_merged", + recipientLogin: authorLogin, + repoFullName, + pullNumber, + dedupKey, + deeplink: pullRequest.html_url ?? `https://github.com/${repoFullName}/pull/${pullNumber}`, + actorLogin: authorLogin, + detectedAt, + }, + ]; +} diff --git a/src/notifications/service.ts b/src/notifications/service.ts index e092d52fdd..4fc978b7e7 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -30,6 +30,21 @@ export function buildChangesRequestedNotification(event: DetectedNotificationEve }; } +// Post-merge self-attribution (#702): the miner's OWN outcome record for a merged PR. Public-safe — frames +// what merged work does for the contributor's standing, never raw reward $/trust/score. +export function buildMergedOutcomeNotification(event: DetectedNotificationEvent): { title: string; body: string } { + const ref = `${event.repoFullName}#${event.pullNumber}`; + return { + title: sanitizePublicComment(`Merged: ${ref}`), + body: sanitizePublicComment(`Your pull request ${ref} merged. Merged contributions like this strengthen your standing and lane signals on ${event.repoFullName} — check your decision pack for the next high-fit issue to keep your momentum.`), + }; +} + +// 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); +} + function rateLimitWindowStart(now: string): string { return new Date(Date.parse(now) - NOTIFICATION_RATE_LIMIT.windowMinutes * 60_000).toISOString(); } @@ -42,7 +57,7 @@ export async function evaluateNotificationEvent(env: Env, event: DetectedNotific const channels = resolveNotificationChannels(subscriptions); if (channels.length === 0) return []; - const { title, body } = buildChangesRequestedNotification(event); + const { title, body } = buildNotificationContent(event); const now = nowIso(); const windowStart = rateLimitWindowStart(now); const pending: NotificationDeliveryRecord[] = []; diff --git a/src/types.ts b/src/types.ts index 48d1df7fc1..2f51d16252 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1091,7 +1091,7 @@ 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"; +export type NotificationEventType = "pull_request_changes_requested" | "pull_request_merged"; // A notification-worthy event extracted from a webhook payload (src/notifications/events.ts). export type DetectedNotificationEvent = { diff --git a/test/unit/mcp-notifications.test.ts b/test/unit/mcp-notifications.test.ts index 6a4310c17b..b2540720e3 100644 --- a/test/unit/mcp-notifications.test.ts +++ b/test/unit/mcp-notifications.test.ts @@ -50,6 +50,41 @@ describe("MCP notification tools", () => { expect((after.structuredContent as { unreadCount: number }).unreadCount).toBe(0); }); + it("returns a contributor's own post-merge outcomes via gittensory_pr_outcome (#702)", async () => { + const env = createTestEnv(); + // Seed a merged-PR outcome + a changes-requested delivery; only the merge should surface as an outcome. + await insertNotificationDeliveryIfAbsent(env, { + dedupKey: "pull_request_merged:owner/repo#7:m1", + channel: "badge", + recipientLogin: "miner", + eventType: "pull_request_merged", + repoFullName: "owner/repo", + pullNumber: 7, + title: "Merged: owner/repo#7", + body: "Your pull request owner/repo#7 merged. Merged contributions strengthen your standing on owner/repo.", + deeplink: "https://github.com/owner/repo/pull/7", + actorLogin: "miner", + }); + await seedDelivered(env, "miner", "changes-requested-1"); + const client = await connect(env); + + const result = await client.callTool({ name: "gittensory_pr_outcome", arguments: { login: "miner" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { count: number; outcomes: Array<{ repoFullName: string; pullNumber: number; outcome: string }> }; + expect(data.count).toBe(1); + expect(data.outcomes[0]).toMatchObject({ repoFullName: "owner/repo", pullNumber: 7, outcome: "merged" }); + expect(JSON.stringify(data)).not.toMatch(/reward|payout|trust score|wallet|\$/i); + }); + + it("is self-scoped: a session cannot read another login's outcomes", 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_pr_outcome", arguments: { login: "other" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("authenticated GitHub login"); + }); + it("forbids reading or clearing another login's notifications from a scoped session", async () => { const env = createTestEnv(); const { session } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); diff --git a/test/unit/notifications-events.test.ts b/test/unit/notifications-events.test.ts index be90277e1a..7f1113f616 100644 --- a/test/unit/notifications-events.test.ts +++ b/test/unit/notifications-events.test.ts @@ -161,3 +161,46 @@ describe("detectNotificationEvents", () => { ).toEqual([]); }); }); + +describe("detectNotificationEvents — merged PR (#702)", () => { + const mergedPayload: GitHubWebhookPayload = { + action: "closed", + repository: { name: "gittensory", full_name: "JSONbored/gittensory", owner: { login: "JSONbored" } }, + pull_request: { + number: 42, + title: "Add feature", + state: "closed", + user: { login: "contributor", type: "User" }, + html_url: "https://github.com/JSONbored/gittensory/pull/42", + merged_at: "2026-05-29T00:00:00.000Z", + }, + }; + + it("emits one self-attributed merged event for the PR author", () => { + const events = detectNotificationEvents("pull_request", mergedPayload, "2026-05-29T00:00:01.000Z"); + expect(events).toEqual([ + { + eventType: "pull_request_merged", + recipientLogin: "contributor", + repoFullName: "JSONbored/gittensory", + pullNumber: 42, + dedupKey: "pull_request_merged:JSONbored/gittensory#42:2026-05-29T00:00:00.000Z", + deeplink: "https://github.com/JSONbored/gittensory/pull/42", + actorLogin: "contributor", + detectedAt: "2026-05-29T00:00:01.000Z", + }, + ]); + }); + + it("ignores a close-without-merge, a bot author, and missing author metadata", () => { + expect(detectNotificationEvents("pull_request", { ...mergedPayload, pull_request: { ...mergedPayload.pull_request!, merged_at: null } })).toEqual([]); + expect(detectNotificationEvents("pull_request", { ...mergedPayload, action: "opened" })).toEqual([]); + expect(detectNotificationEvents("pull_request", { ...mergedPayload, pull_request: { ...mergedPayload.pull_request!, user: { login: "bot", type: "Bot" } } })).toEqual([]); + expect(detectNotificationEvents("pull_request", { ...mergedPayload, pull_request: { ...mergedPayload.pull_request!, user: undefined as never } })).toEqual([]); + }); + + it("falls back to the canonical PR URL when html_url is absent", () => { + const events = detectNotificationEvents("pull_request", { ...mergedPayload, pull_request: { ...mergedPayload.pull_request!, html_url: undefined as never } }, "2026-05-29T00:00:01.000Z"); + expect(events[0]?.deeplink).toBe("https://github.com/JSONbored/gittensory/pull/42"); + }); +}); diff --git a/test/unit/notifications-service.test.ts b/test/unit/notifications-service.test.ts index 24ca57f5d3..f9d077b63d 100644 --- a/test/unit/notifications-service.test.ts +++ b/test/unit/notifications-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildChangesRequestedNotification, + buildNotificationContent, buildNotificationFeed, deliverNotification, evaluateNotificationEvent, @@ -99,6 +100,27 @@ describe("notification channel resolution + copy", () => { }); }); +describe("merged-PR outcome attribution (#702)", () => { + it("builds public-safe merge attribution copy and routes by event type", () => { + const merged = buildNotificationContent(event({ eventType: "pull_request_merged" })); + expect(merged.title.toLowerCase()).toContain("merged"); + expect(merged.body.toLowerCase()).toContain("merged"); + expect(merged.body).toContain("owner/repo"); + expect(JSON.stringify(merged)).not.toMatch(/reward|payout|trust score|wallet|\$/i); + // The dispatcher still routes changes_requested to the review copy. + expect(buildNotificationContent(event()).title.toLowerCase()).toContain("changes requested"); + }); + + it("persists a merged outcome as a retrievable, eventType-filtered delivery", async () => { + const env = createTestEnv(); + await evaluateNotificationEvent(env, event({ eventType: "pull_request_merged", dedupKey: "pull_request_merged:owner/repo#7:m1" })); + await evaluateNotificationEvent(env, event({ dedupKey: "changes_requested:owner/repo#7:r:t" })); + const outcomes = await listNotificationDeliveriesForRecipient(env, "miner", { eventType: "pull_request_merged" }); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ eventType: "pull_request_merged", repoFullName: "owner/repo", pullNumber: 7 }); + }); +}); + describe("evaluateNotificationEvent", () => { it("creates exactly one badge delivery and is idempotent on a duplicate event", async () => { const env = createTestEnv();