Skip to content

feat(bounties): link bounties to GitHub issues with a status comment#488

Merged
ralyodio merged 1 commit into
masterfrom
feat/bounty-github-issues
Jun 23, 2026
Merged

feat(bounties): link bounties to GitHub issues with a status comment#488
ralyodio merged 1 commit into
masterfrom
feat/bounty-github-issues

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

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 via node:crypto, on-demand installation-token resolution, postIssueComment / updateIssueComment. All best-effort, never throws.
  • github-links.tsisGitHubIssueLink + parseGitHubIssueUrl (+ tests).
  • bounties schema accepts optional github_issue_url.
  • migration 20260623000000_bounty_github_issue.sql — adds github_issue_url + github_comment_id to bounties.
  • create route posts the "💰 bounty posted → claim it" comment, stores its id.
  • CoinPay paid webhook edits that comment in place to "✅ bounty paid".
  • UI — optional issue-URL field on the bounty form, edit round-trip, GitHub link on the bounty detail page.
  • .env.example documents 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), and next build with an adequate heap (pass). The pre-commit build step is capped at --max-old-space-size=1024 and OOMs locally, so it was run separately with a larger heap.

After merge (manual)

  1. Apply the migration to Supabase ojgvudxovrbdikzyoeex.
  2. Register the ugig GitHub App, install it on the repos, set GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY on the Railway service.

🤖 Generated with Claude Code

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>
@ralyodio ralyodio merged commit 0c782ec into master Jun 23, 2026
@ralyodio ralyodio deleted the feat/bounty-github-issues branch June 23, 2026 08:53
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown

Greptile Summary

This 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.

  • src/lib/github-app.ts (new): RS256 App JWT via node:crypto, on-demand installation-token resolution, postIssueComment/updateIssueComment — all best-effort, never throws.
  • Schema + migration: github_issue_url text and github_comment_id bigint added to bounties; Zod validation in createBountySchema/updateBountySchema inherited via .partial().
  • Create route + webhook: Comment posted after insert, edited to "paid" in the CoinPay webhook — both guarded with if (!isGitHubAppConfigured()) return null/false so the feature is fully opt-in.

Confidence Score: 4/5

Safe 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

Filename Overview
src/lib/github-app.ts New zero-dependency GitHub App client; correctly gated by isGitHubAppConfigured(), all errors caught/swallowed. Each operation makes 2 API round-trips (no token caching), fine for a best-effort feature.
src/app/api/bounties/route.ts POST route extended to post a GitHub comment after bounty insert and store comment_id; best-effort and non-blocking. bounty.title is interpolated unsanitized into the Markdown comment body.
src/app/api/payments/coinpayportal/webhook/route.ts Webhook handler updated to flip the GitHub comment to 'paid' on first payout confirmation; bounty.title is interpolated unsanitized into the comment body.
src/app/bounties/new/BountyForm.tsx Adds optional GitHub issue URL field. When the field is cleared on edit, issueUrl
src/lib/github-links.ts Adds isGitHubIssueLink and parseGitHubIssueUrl with correct hostname, protocol, and path validation. Tests cover happy path and rejections. Well-implemented.
supabase/migrations/20260623000000_bounty_github_issue.sql Adds github_issue_url (text) and github_comment_id (bigint) columns with IF NOT EXISTS guard. Bigint in SQL vs number in TypeScript is a latent type mismatch.
src/lib/bounties.ts Schema correctly validates and trims github_issue_url, transforms empty string to undefined, and updateBountySchema inherits the field via partial extension.

Fix All in Codex Fix All in Claude Code

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Codex Fix in Claude Code

Comment on lines +22 to +24
const body =
`💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` +
`**${bounty.title}**\n\n` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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` +

Fix in Codex Fix in Claude Code

Comment on lines +454 to +456
const body =
`✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${bounty.title}".\n\n` +
`<sub>Updated automatically by ugig.net.</sub>`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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>`;

Fix in Codex Fix in Claude Code

Comment on lines +6 to +7
ADD COLUMN IF NOT EXISTS github_issue_url text,
ADD COLUMN IF NOT EXISTS github_comment_id bigint;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant