diff --git a/lib/data-retrieval/extract-issue-bounty.ts b/lib/data-retrieval/extract-issue-bounty.ts new file mode 100644 index 00000000..03d79eb1 --- /dev/null +++ b/lib/data-retrieval/extract-issue-bounty.ts @@ -0,0 +1,63 @@ +interface BountySource { + body?: string | null + labels?: Array +} + +interface IssueComment { + body?: string | null + user?: { + login?: string | null + } | null +} + +function formatUsd(amount: string) { + const normalized = amount.replace(/,/g, "") + const numeric = Number(normalized) + + if (!Number.isFinite(numeric)) { + return `$${amount}` + } + + return `$${Number.isInteger(numeric) ? numeric : numeric.toFixed(2)}` +} + +function getLabelName(label: string | { name?: string | null }) { + return typeof label === "string" ? label : label.name || "" +} + +export function extractIssueBounty( + issue: BountySource, + comments: IssueComment[] = [], +) { + const bodyBounty = issue.body?.match( + /(?:^|\n)\s*\/bounty\s+\$?\s*([0-9][0-9,]*(?:\.[0-9]{1,2})?)\s*(?:\$|usd)?\b/i, + ) + if (bodyBounty?.[1]) { + return formatUsd(bodyBounty[1]) + } + + for (const comment of comments) { + const commentAuthor = comment.user?.login + if (commentAuthor !== "algora-pbc" && commentAuthor !== "algora-pbc[bot]") { + continue + } + + const algoraBounty = comment.body?.match( + /##\s*.*?\$?([0-9][0-9,]*(?:\.[0-9]{1,2})?)\s+bounty/i, + ) + if (algoraBounty?.[1]) { + return formatUsd(algoraBounty[1]) + } + } + + for (const label of issue.labels || []) { + const labelBounty = getLabelName(label).match( + /^\$([0-9][0-9,]*(?:\.[0-9]{1,2})?)$/, + ) + if (labelBounty?.[1]) { + return formatUsd(labelBounty[1]) + } + } + + return null +} diff --git a/scripts/issue-notifications.ts b/scripts/issue-notifications.ts index d2f02446..d649a5ad 100644 --- a/scripts/issue-notifications.ts +++ b/scripts/issue-notifications.ts @@ -1,7 +1,8 @@ -import { WebhookClient, type MessageCreateOptions } from "discord.js" +import { type MessageCreateOptions, WebhookClient } from "discord.js" +import { EXCLUDED_BOTS } from "lib/constants" +import { extractIssueBounty } from "lib/data-retrieval/extract-issue-bounty" import { getRepos } from "lib/data-retrieval/getRepos" import { octokit } from "lib/sdks" -import { EXCLUDED_BOTS } from "lib/constants" const discordWebhook = new WebhookClient({ url: process.env.ISSUES_DISCORD_WEBHOOK_URL || "", @@ -15,6 +16,9 @@ interface Issue { login: string } created_at: string + body?: string | null + labels?: Array + bounty?: string | null } function getUTCDateTime(): string { @@ -47,11 +51,27 @@ async function getRecentIssues(repo: string): Promise { issue.user.login as (typeof EXCLUDED_BOTS)[number], ), ) as Issue[] + + const issuesWithBounties = await Promise.all( + filteredIssues.map(async (issue) => { + const { data: comments } = await octokit.issues.listComments({ + owner, + repo: repoName, + issue_number: issue.number, + }) + + return { + ...issue, + bounty: extractIssueBounty(issue, comments), + } + }), + ) + console.log( - `[${getUTCDateTime()}] Found ${filteredIssues.length} new issues in ${repo}`, + `[${getUTCDateTime()}] Found ${issuesWithBounties.length} new issues in ${repo}`, ) - return filteredIssues + return issuesWithBounties } async function notifyDiscord(issues: Issue[], repo: string) { @@ -61,14 +81,13 @@ async function notifyDiscord(issues: Issue[], repo: string) { `[${getUTCDateTime()}] Sending notification for ${issues.length} issues from ${repo} to Discord`, ) - const messageContent = - `New issues in ${repo}:\n` + - issues - .map( - (issue) => - `• #${issue.number} ${issue.title} by ${issue.user.login} - <${issue.html_url}>`, - ) - .join("\n") + const issueLines = issues + .map((issue) => { + const bounty = issue.bounty ? ` [${issue.bounty} bounty]` : "" + return `- #${issue.number}${bounty} ${issue.title} by ${issue.user.login} - <${issue.html_url}>` + }) + .join("\n") + const messageContent = `New issues in ${repo}:\n${issueLines}` const messageOptions: MessageCreateOptions = { content: messageContent, diff --git a/tests/extract-issue-bounty.test.ts b/tests/extract-issue-bounty.test.ts new file mode 100644 index 00000000..b310afcf --- /dev/null +++ b/tests/extract-issue-bounty.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test" +import { extractIssueBounty } from "lib/data-retrieval/extract-issue-bounty" + +describe("extractIssueBounty", () => { + it("extracts bounty commands from issue bodies", () => { + expect(extractIssueBounty({ body: "Build this\n\n/bounty $12" })).toBe( + "$12", + ) + expect(extractIssueBounty({ body: "/bounty 50$" })).toBe("$50") + }) + + it("extracts bounty amounts from Algora comments", () => { + expect( + extractIssueBounty({ body: "" }, [ + { + user: { login: "algora-pbc" }, + body: "## $8 bounty [tscircuit](https://algora.io/tscircuit)", + }, + ]), + ).toBe("$8") + }) + + it("uses dollar labels as a fallback", () => { + expect(extractIssueBounty({ labels: [{ name: "$10" }] })).toBe("$10") + }) + + it("ignores non-bounty comments and labels", () => { + expect( + extractIssueBounty( + { body: "No bounty here", labels: [{ name: "help wanted" }] }, + [{ user: { login: "someone" }, body: "## $100 bounty" }], + ), + ).toBeNull() + }) +})