feat(bounties): link bounties to GitHub issues with a status comment#488
Conversation
Optionally attach a GitHub issue URL to a bounty. When the ugig GitHub App is installed on the repo, posting the bounty drops a "bounty posted → claim it" comment on the issue, and the CoinPay paid-webhook edits that comment in place to "bounty paid". All GitHub calls are best-effort and never block bounty creation or payment. - new src/lib/github-app.ts: zero-dep GitHub App client (RS256 JWT via node:crypto, on-demand installation token, post/update issue comment) - github-links: isGitHubIssueLink + parseGitHubIssueUrl (+ tests) - bounties schema accepts optional github_issue_url - migration adds github_issue_url + github_comment_id to bounties - form field, edit round-trip, and issue link on the bounty detail page - document GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY in .env.example Pre-commit build step skipped: it is capped at --max-old-space-size=1024 (Railway build cap) and OOMs locally. Verified separately: pnpm lint (0), tsc --noEmit (0), vitest (pass), and `next build` with an adequate heap (pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
Greptile SummaryThis PR links bounties to GitHub issues by posting a status comment ("💰 bounty posted → claim it") when a bounty is created, and editing it to "✅ bounty paid" when the payout settles. It adds a new zero-dependency GitHub App client, schema/migration for two new columns, optional UI field, and graceful degradation when the App is unconfigured.
Confidence Score: 4/5Safe to merge after fixing the edit-form issue where clearing the GitHub issue URL is silently ignored; the GitHub App integration degrades gracefully and never blocks core bounty flows. The GitHub App path is fully opt-in and isolated from bounty creation/payment — a regression there would not affect existing functionality. The one real functional gap is in BountyForm.tsx: submitting an empty github_issue_url serialises as omitted (not null), so a previously-set URL can never be cleared through the edit form. The Markdown injection in comment bodies and the bigint/number type mismatch are lower-priority concerns that don't block core flows. src/app/bounties/new/BountyForm.tsx — the issueUrl || undefined pattern prevents clearing a previously set URL; and supabase/migrations/20260623000000_bounty_github_issue.sql — bigint vs the number type used throughout TypeScript code. Important Files Changed
Reviews (1): Last reviewed commit: "feat(bounties): link bounties to GitHub ..." | Re-trigger Greptile |
| @@ -116,6 +123,7 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) { | |||
| payout_usd: payoutNum, | |||
There was a problem hiding this comment.
Clearing
github_issue_url on edit is silently ignored
issueUrl || undefined evaluates to undefined when the field is empty, and JSON.stringify omits undefined-valued keys entirely. The PATCH request body therefore never includes github_issue_url, so Supabase never clears it — a previously-set URL is permanently stuck. Send null instead to allow the field to be unset, and validate/reject null in the Zod schema only when the field is explicitly required.
| const body = | ||
| `💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` + | ||
| `**${bounty.title}**\n\n` + |
There was a problem hiding this comment.
Unescaped
bounty.title allows Markdown injection in GitHub comments
bounty.title is user-supplied text (validated only for length) and is embedded directly inside a Markdown bold block. A title like **foo** [claim](https://evil.com) will break out of the formatting and could produce a misleading or spoofed-looking comment on the funded issue. Wrapping the title in a code span or escaping Markdown special characters prevents this.
| const body = | |
| `💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` + | |
| `**${bounty.title}**\n\n` + | |
| const escapedTitle = bounty.title.replace(/[`*_[\]()\\]/g, "\\$&"); | |
| const body = | |
| `💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` + | |
| `**${escapedTitle}**\n\n` + |
| const body = | ||
| `✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${bounty.title}".\n\n` + | ||
| `<sub>Updated automatically by ugig.net.</sub>`; |
There was a problem hiding this comment.
Same unescaped title in the "paid" comment
bounty.title is interpolated directly into the Markdown body here as well. The same escaping applied to the "posted" comment should be applied here for consistency.
| const body = | |
| `✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${bounty.title}".\n\n` + | |
| `<sub>Updated automatically by ugig.net.</sub>`; | |
| const escapedTitle = bounty.title.replace(/[`*_[\]()\\]/g, "\\$&"); | |
| const body = | |
| `✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${escapedTitle}".\n\n` + | |
| `<sub>Updated automatically by ugig.net.</sub>`; |
| ADD COLUMN IF NOT EXISTS github_issue_url text, | ||
| ADD COLUMN IF NOT EXISTS github_comment_id bigint; |
There was a problem hiding this comment.
bigint column may be returned as a string by the Supabase JS client
PostgREST serialises int8/bigint columns as JSON strings to avoid precision loss. The TypeScript code in both route.ts and the webhook handler treats github_comment_id as a number, and updateIssueComment accepts commentId: number. If Supabase returns a string, the template-literal URL still resolves correctly at runtime, but callers relying on a numeric comparison (e.g. if (commentId)) should be aware the value may be "0" (truthy) rather than 0 (falsy). Using integer (int4) would avoid the ambiguity entirely — GitHub comment IDs fit comfortably within int4's ~2.1 billion range.
Pattern B: link a bounty to the GitHub issue it funds, and have a ugig GitHub App post a status comment on that issue (posted → paid).
What's included
src/lib/github-app.ts(new) — zero-dependency GitHub App client: RS256 App JWT vianode:crypto, on-demand installation-token resolution,postIssueComment/updateIssueComment. All best-effort, never throws.github-links.ts—isGitHubIssueLink+parseGitHubIssueUrl(+ tests).github_issue_url.20260623000000_bounty_github_issue.sql— addsgithub_issue_url+github_comment_idtobounties.GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY.Degrades gracefully: if the App is unconfigured or not installed on the repo, the comment is silently skipped and the bounty still creates/pays normally.
Verification
pnpm lint(0),tsc --noEmit(0),vitest(bounty + webhook + github-links suites pass), andnext buildwith an adequate heap (pass). The pre-commit build step is capped at--max-old-space-size=1024and OOMs locally, so it was run separately with a larger heap.After merge (manual)
ojgvudxovrbdikzyoeex.GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEYon the Railway service.🤖 Generated with Claude Code