Skip to content
Open
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
36 changes: 36 additions & 0 deletions lib/bounty/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

61 changes: 42 additions & 19 deletions lib/bounty/handlers/pr-closed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -63,29 +65,50 @@ 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);

const body = buildLockedCommentBody(
issueId,
bounty.total_amount,
payload.pull_request.user.login,
payouts,
);

await github.rest.issues.createComment({
Expand Down
17 changes: 14 additions & 3 deletions lib/bounty/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,32 @@ 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}`;

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");
}