From 327fb23ce87b6a4d3da5d4cf08793f54d1348230 Mon Sep 17 00:00:00 2001 From: serfersac Date: Wed, 29 Apr 2026 14:28:10 +0000 Subject: [PATCH] feat: implement payout distribution for multiple PR contributors --- lib/bounty/commands.ts | 36 +++++++++++++++++++ lib/bounty/handlers/pr-closed.ts | 61 ++++++++++++++++++++++---------- lib/bounty/ledger.ts | 17 +++++++-- 3 files changed, 92 insertions(+), 22 deletions(-) diff --git a/lib/bounty/commands.ts b/lib/bounty/commands.ts index 3dd708a..4e23497 100644 --- a/lib/bounty/commands.ts +++ b/lib/bounty/commands.ts @@ -19,3 +19,39 @@ export function extractIssueNumberFromPrBody(body: string | null): number | null return issueNumber; } +const MENTION_REGEX = /@([\w-]+)/g; +const PERCENTAGE_REGEX = /(\d+)%/; + +export function extractContributor(body: string | null): Array<{ username: string, percentage: number }> | null { + if (!body) { + return null; + } + + const lines = body.split('\n'); + const contributorLines = lines.filter((line) => line.includes('payout')); + + if (contributorLines.length === 0) { + return null; + } + + const contributors = contributorLines.map((line) => { + const usernameMatch = MENTION_REGEX.exec(line); + const percentageMatch = PERCENTAGE_REGEX.exec(line); + + if (!usernameMatch || !percentageMatch) { + return null; + } + + const username = usernameMatch[1]; + const percentage = Number.parseInt(percentageMatch[1], 10); + + if (!username || !Number.isInteger(percentage)) { + return null; + } + + return { username, percentage }; + }).filter((c) => c !== null) as Array<{ username: string, percentage: number }>; + + return contributors.length > 0 ? contributors : null; +} + diff --git a/lib/bounty/handlers/pr-closed.ts b/lib/bounty/handlers/pr-closed.ts index 11824d6..443ec37 100644 --- a/lib/bounty/handlers/pr-closed.ts +++ b/lib/bounty/handlers/pr-closed.ts @@ -5,7 +5,7 @@ import { buildIssueId } from "@/lib/bounty/issue-id"; import { buildLockedCommentBody } from "@/lib/bounty/ledger"; import { prClosedPayloadSchema } from "@/lib/bounty/schemas/payloads"; import { getSupabaseServiceClient } from "@/lib/clients/supabase/server"; -import { extractIssueNumberFromPrBody } from "@/lib/bounty/commands"; +import { extractIssueNumberFromPrBody, extractContributor } from "@/lib/bounty/commands"; async function getIssueInstallationClient(owner: string, repo: string, installationId?: number) { if (installationId) { @@ -52,9 +52,11 @@ export async function handlePrClosed(eventPayload: unknown) { .from("bounties") .update({ status: "LOCKED", - winning_pr_number: payload.pull_request.number, - winning_pr_author: payload.pull_request.user.login, - winning_pr_url: payload.pull_request.html_url ?? null, + winning_pull_request: { + pr_author: payload.pull_request.user.login, + pr_number: payload.pull_request.number, + pr_url: payload.pull_request.html_url ?? null, + }, locked_at: new Date().toISOString(), }) .eq("issue_id", issueId); @@ -63,21 +65,42 @@ export async function handlePrClosed(eventPayload: unknown) { throw new Error(`Failed to lock bounty: ${lockError.message}`); } - const { error: activityError } = await supabase.from("activity_events").insert({ - issue_id: issueId, - event_type: "BOUNTY_LOCKED", - actor_username: payload.pull_request.user.login, - amount: bounty.total_amount, - pr_number: payload.pull_request.number, - pr_url: payload.pull_request.html_url ?? null, - metadata: { - source: "pull_request.closed", - merged: true, - }, - }); + const contributors = extractContributor(payload.pull_request.body); + + if (contributors) { + const totalPercentage = contributors.reduce((acc, c) => acc + c.percentage, 0); + if (totalPercentage !== 100) { + throw new Error("Payout percentages do not add up to 100"); + } + } - if (activityError) { - throw new Error(`Failed to record lock activity: ${activityError.message}`); + const payouts = contributors + ? contributors.map((c) => ({ + username: c.username, + amount: bounty.total_amount * (c.percentage / 100), + })) + : [{ + username: payload.pull_request.user.login, + amount: bounty.total_amount, + }]; + + for (const payout of payouts) { + const { error: activityError } = await supabase.from("activity_events").insert({ + issue_id: issueId, + event_type: "BOUNTY_LOCKED", + actor_username: payout.username, + amount: payout.amount, + pr_number: payload.pull_request.number, + pr_url: payload.pull_request.html_url ?? null, + metadata: { + source: "pull_request.closed", + merged: true, + }, + }); + + if (activityError) { + throw new Error(`Failed to record lock activity: ${activityError.message}`); + } } const github = await getIssueInstallationClient(owner, repo, payload.installation?.id); @@ -85,7 +108,7 @@ export async function handlePrClosed(eventPayload: unknown) { const body = buildLockedCommentBody( issueId, bounty.total_amount, - payload.pull_request.user.login, + payouts, ); await github.rest.issues.createComment({ diff --git a/lib/bounty/ledger.ts b/lib/bounty/ledger.ts index f2cd2f5..f87c372 100644 --- a/lib/bounty/ledger.ts +++ b/lib/bounty/ledger.ts @@ -96,7 +96,7 @@ export function buildBountyActiveBody( export function buildLockedCommentBody( issueId: string, amount: number, - winnerUsername: string, + payouts: Array<{ username: string, amount: number }>, ): string { const bountyInfo = parseIssueId(issueId)!; const bountyUrl = `${process.env.NEXT_PUBLIC_APP_URL}/b/${bountyInfo.owner}/${bountyInfo.repo}/issues/${bountyInfo.issueNumber}`; @@ -104,13 +104,24 @@ export function buildLockedCommentBody( const lines = [ "🔒 **Bounty Locked**", "", - `@${winnerUsername} your PR was merged. Great work!`, + ]; + + if (payouts.length > 1) { + lines.push("Multiple contributors detected. Great work!"); + for (const payout of payouts) { + lines.push(`- @${payout.username} receiving ${payout.amount}% of the bounty.`); + } + } else if (payouts.length === 1) { + lines.push(`@${payouts[0].username} your PR was merged. Great work!`); + } + + lines.push( "", `The bounty is now ready for payout. Please wait for the maintainers to release the bounty. [Bounty Page](${bountyUrl})`, "", "---", "_Bountic: Autonomous USDC bounties for open source_", - ]; + ); return lines.join("\n"); }