diff --git a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx index b72106c..0b899c7 100644 --- a/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx +++ b/app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx @@ -26,10 +26,13 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) { startTransition(async () => { try { const response = await approveBounty({ owner, repo, issueNumber }); - const { payoutType, recipientEmail, recipientWallet } = response.payout; - + const payouts = response.payout.payouts ?? [response.payout]; + const { payoutType, recipientEmail, recipientWallet } = payouts[0]; + let message = ""; - if (payoutType === "wallet" && recipientWallet) { + if (payouts.length > 1) { + message = `Approved ${payouts.length} split payouts totaling $${response.payout.amount.toFixed(2)} USDC`; + } else if (payoutType === "wallet" && recipientWallet) { message = `Payout sent to wallet ${recipientWallet.slice(0, 6)}...${recipientWallet.slice(-4)}`; } else if (payoutType === "email" && recipientEmail) { message = `Payout sent to ${recipientEmail}`; diff --git a/lib/api/client.ts b/lib/api/client.ts index d78c429..63ca1bc 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -166,6 +166,15 @@ export async function approveBounty(params: { txHash: string | null; transactionId: string; approvedBy: string; + payouts?: Array<{ + recipient: string; + amount: number; + payoutType: "wallet" | "email" | "unclaimed"; + recipientEmail?: string | null; + recipientWallet?: string | null; + txHash: string | null; + transactionId: string; + }>; }; }> { const res = await fetch( diff --git a/lib/bounty/payout-recipients.ts b/lib/bounty/payout-recipients.ts new file mode 100644 index 0000000..bb26086 --- /dev/null +++ b/lib/bounty/payout-recipients.ts @@ -0,0 +1,89 @@ +export type PullRequestCommitContributor = { + author?: { login?: string | null } | null; + committer?: { login?: string | null } | null; + commit?: { message?: string | null } | null; +}; + +export type PayoutShare = { + username: string; + amount: number; +}; + +function addUniqueLogin( + logins: string[], + seen: Set, + login: string | null | undefined, +) { + const normalized = login?.trim(); + if (!normalized) return; + + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + + seen.add(key); + logins.push(normalized); +} + +function getLoginFromNoreplyEmail(email: string): string | null { + const localPart = email.trim().toLowerCase().split("@")[0]; + if (!localPart) return null; + + const login = localPart.includes("+") + ? localPart.slice(localPart.indexOf("+") + 1) + : localPart; + + return /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/.test(login) ? login : null; +} + +export function getCoauthorLogins(message: string | null | undefined): string[] { + if (!message) return []; + + const coauthorRegex = /^Co-authored-by:\s*.+?<([^>]+)>/gim; + const logins: string[] = []; + const seen = new Set(); + + for (const match of message.matchAll(coauthorRegex)) { + const email = match[1]; + const login = email ? getLoginFromNoreplyEmail(email) : null; + addUniqueLogin(logins, seen, login); + } + + return logins; +} + +export function getUniqueContributorLogins( + primaryAuthor: string, + commits: PullRequestCommitContributor[], +): string[] { + const logins: string[] = []; + const seen = new Set(); + + addUniqueLogin(logins, seen, primaryAuthor); + + for (const commit of commits) { + addUniqueLogin(logins, seen, commit.author?.login); + addUniqueLogin(logins, seen, commit.committer?.login); + for (const coauthorLogin of getCoauthorLogins(commit.commit?.message)) { + addUniqueLogin(logins, seen, coauthorLogin); + } + } + + return logins; +} + +export function splitBountyAmount(totalAmount: number, usernames: string[]): PayoutShare[] { + const recipients = usernames.filter((username) => username.trim().length > 0); + + if (recipients.length === 0) { + return []; + } + + const totalCents = Math.round(totalAmount * 100); + const baseCents = Math.floor(totalCents / recipients.length); + const remainderCents = totalCents % recipients.length; + + return recipients.map((username, index) => ({ + username, + amount: (baseCents + (index < remainderCents ? 1 : 0)) / 100, + })); +} diff --git a/lib/bounty/services/approve-payout.ts b/lib/bounty/services/approve-payout.ts index 934f48c..b4128d2 100644 --- a/lib/bounty/services/approve-payout.ts +++ b/lib/bounty/services/approve-payout.ts @@ -5,6 +5,7 @@ import { syncGithubBountyArtifacts } from "@/lib/bounty/services/github-sync"; import { getSupabaseServiceClient } from "@/lib/clients/supabase/server"; import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server"; import { buildIssueId } from "@/lib/bounty/issue-id"; +import { getUniqueContributorLogins, splitBountyAmount } from "@/lib/bounty/payout-recipients"; export async function approveBountyPayout(params: { owner: string; @@ -42,6 +43,7 @@ export async function approveBountyPayout(params: { } let winningPrBody: string | null = null; + let contributorLogins = [bounty.winning_pr_author]; if (bounty.winning_pr_number) { try { const installationId = await getGithubRepoInstallationId(params.owner, params.repo); @@ -52,21 +54,49 @@ export async function approveBountyPayout(params: { pull_number: bounty.winning_pr_number, }); winningPrBody = prResponse.data.body ?? null; + + const commitsResponse = await github.rest.pulls.listCommits({ + owner: params.owner, + repo: params.repo, + pull_number: bounty.winning_pr_number, + per_page: 100, + }); + + contributorLogins = getUniqueContributorLogins( + bounty.winning_pr_author, + commitsResponse.data, + ); } catch (err) { - console.warn("Failed to fetch PR body for wallet extraction:", err); + console.warn("Failed to fetch PR metadata for payout split:", err); } } - // only single payout for now, later multiple payouts - const payoutResult = await resolveAndPayout({ - owner: params.owner, - repo: params.repo, - issueNumber: params.issueNumber, - winningPrAuthor: bounty.winning_pr_author, - winningPrBody, - amount: bounty.total_amount, - issueId, - }); + const payoutShares = splitBountyAmount(bounty.total_amount, contributorLogins); + const payoutResults = []; + + for (const share of payoutShares) { + const payoutResult = await resolveAndPayout({ + owner: params.owner, + repo: params.repo, + issueNumber: params.issueNumber, + winningPrAuthor: share.username, + winningPrBody: share.username === bounty.winning_pr_author ? winningPrBody : null, + amount: share.amount, + issueId, + }); + + payoutResults.push({ + recipient: share.username, + amount: share.amount, + ...payoutResult, + }); + } + + const primaryPayout = payoutResults[0]; + const payoutTxHash = payoutResults + .map((result) => result.txHash) + .filter((txHash): txHash is string => Boolean(txHash)) + .join(","); const now = new Date().toISOString(); @@ -74,7 +104,7 @@ export async function approveBountyPayout(params: { .from("bounties") .update({ status: "PAID", - payout_tx_hash: payoutResult.txHash, + payout_tx_hash: payoutTxHash || null, paid_at: now, approved_by: params.approvedBy, }) @@ -84,38 +114,44 @@ export async function approveBountyPayout(params: { throw new Error(`Failed to update bounty status to PAID: ${updateError.message}`); } - const { error: payoutEventError } = await supabase.from("payout_events").insert({ - issue_id: issueId, - recipient_username: bounty.winning_pr_author, - amount: bounty.total_amount, - locus_transaction_id: payoutResult.transactionId, - transaction_hash: payoutResult.txHash, - status: "SUCCESS", - metadata: { - approved_by: params.approvedBy, - payout_source: "web", - payout_type: payoutResult.payoutType, - recipient_email: payoutResult.recipientEmail, - recipient_wallet: payoutResult.recipientWallet, - }, - }); + const { error: payoutEventError } = await supabase.from("payout_events").insert( + payoutResults.map((result) => ({ + issue_id: issueId, + recipient_username: result.recipient, + amount: result.amount, + locus_transaction_id: result.transactionId, + transaction_hash: result.txHash, + status: "SUCCESS" as const, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: result.payoutType, + recipient_email: result.recipientEmail, + recipient_wallet: result.recipientWallet, + split_recipients: payoutResults.length, + }, + })), + ); if (payoutEventError) { throw new Error(`Failed to persist payout event: ${payoutEventError.message}`); } - const { error: activityError } = await supabase.from("activity_events").insert({ - issue_id: issueId, - event_type: "PAYOUT_SENT", - actor_username: bounty.winning_pr_author, - amount: bounty.total_amount, - tx_hash: payoutResult.txHash, - metadata: { - approved_by: params.approvedBy, - payout_source: "web", - payout_type: payoutResult.payoutType, - }, - }); + const { error: activityError } = await supabase.from("activity_events").insert( + payoutResults.map((result) => ({ + issue_id: issueId, + event_type: "PAYOUT_SENT" as const, + actor_username: result.recipient, + amount: result.amount, + tx_hash: result.txHash, + metadata: { + approved_by: params.approvedBy, + payout_source: "web", + payout_type: result.payoutType, + split_recipients: payoutResults.length, + }, + })), + ); if (activityError) { throw new Error(`Failed to persist payout activity: ${activityError.message}`); @@ -126,12 +162,13 @@ export async function approveBountyPayout(params: { return { issueId, amount: bounty.total_amount, - recipient: bounty.winning_pr_author, - payoutType: payoutResult.payoutType, - recipientEmail: payoutResult.recipientEmail, - recipientWallet: payoutResult.recipientWallet, - txHash: payoutResult.txHash, - transactionId: payoutResult.transactionId, + recipient: primaryPayout.recipient, + payoutType: primaryPayout.payoutType, + recipientEmail: primaryPayout.recipientEmail, + recipientWallet: primaryPayout.recipientWallet, + txHash: primaryPayout.txHash, + transactionId: primaryPayout.transactionId, approvedBy: params.approvedBy, + payouts: payoutResults, }; -} \ No newline at end of file +}