Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationDeliveryRecord[]> {
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()
Expand Down
39 changes: 39 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -1236,6 +1258,23 @@ export class GittensoryMcp {
};
}

private async prOutcomes(login: string, limit?: number): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async listNotifications(login: string): Promise<ToolPayload> {
this.requireContributorAccess(login);
const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { channel: "badge", limit: 50 });
Expand Down
39 changes: 38 additions & 1 deletion src/notifications/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
},
];
}
17 changes: 16 additions & 1 deletion src/notifications/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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[] = [];
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
35 changes: 35 additions & 0 deletions test/unit/mcp-notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
43 changes: 43 additions & 0 deletions test/unit/notifications-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
22 changes: 22 additions & 0 deletions test/unit/notifications-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildChangesRequestedNotification,
buildNotificationContent,
buildNotificationFeed,
deliverNotification,
evaluateNotificationEvent,
Expand Down Expand Up @@ -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();
Expand Down