From c702fcd6c029c22f541c8b4401e5b1d59a482c87 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 08:52:38 +0000 Subject: [PATCH] feat(bounties): link bounties to GitHub issues with a status comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 8 ++ src/app/api/bounties/route.ts | 41 +++++- .../payments/coinpayportal/webhook/route.ts | 18 ++- src/app/bounties/[id]/edit/page.tsx | 3 +- src/app/bounties/[id]/page.tsx | 14 ++ src/app/bounties/new/BountyForm.tsx | 25 ++++ src/lib/bounties.ts | 9 ++ src/lib/github-app.ts | 136 ++++++++++++++++++ src/lib/github-links.test.ts | 30 +++- src/lib/github-links.ts | 33 +++++ .../20260623000000_bounty_github_issue.sql | 7 + 11 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 src/lib/github-app.ts create mode 100644 supabase/migrations/20260623000000_bounty_github_issue.sql diff --git a/.env.example b/.env.example index 50c174be..d5e0d3f1 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,11 @@ LNBITS_URL=https://ln.example.com LNBITS_WALLET_ID= LNBITS_ADMIN_KEY= LNBITS_INVOICE_KEY= + +# GitHub App (optional) — lets bounties post a status comment on the GitHub +# issue they fund and mark it paid on payout. Register a GitHub App, install it +# on the target repos, and supply the App ID + private key (PEM). Newlines in +# the key may be escaped as \n on a single line. If unset, the comment feature +# is silently skipped. +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= diff --git a/src/app/api/bounties/route.ts b/src/app/api/bounties/route.ts index 1ba8612f..bed6a887 100644 --- a/src/app/api/bounties/route.ts +++ b/src/app/api/bounties/route.ts @@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server"; import { randomUUID } from "crypto"; import { createClient } from "@/lib/supabase/server"; import { getAuthContext } from "@/lib/auth/get-user"; -import { createBountySchema } from "@/lib/bounties"; +import { createBountySchema, formatBountyPayout } from "@/lib/bounties"; +import { parseGitHubIssueUrl } from "@/lib/github-links"; +import { postIssueComment } from "@/lib/github-app"; + +// Post the "bounty posted" status comment on the funded GitHub issue. +// Returns the comment id (to store for later editing) or null. Best-effort. +async function postBountyIssueComment(bounty: { + id: string; + title: string; + payout_usd: number | string; + payment_coin: string | null; + github_issue_url: string; +}): Promise { + const coords = parseGitHubIssueUrl(bounty.github_issue_url); + if (!coords) return null; + const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net").replace(/\/$/, ""); + const bountyUrl = `${appUrl}/bounties/${bounty.id}`; + const body = + `💰 **Bounty posted on [ugig.net](${appUrl})** — ${formatBountyPayout(bounty.payout_usd, bounty.payment_coin)}\n\n` + + `**${bounty.title}**\n\n` + + `[Claim this bounty →](${bountyUrl})\n\n` + + `Posted automatically by ugig.net.`; + return postIssueComment(coords.owner, coords.repo, coords.number, body); +} const BOUNTY_STATUSES = ["open", "paused", "closed"] as const; type BountyStatus = (typeof BOUNTY_STATUSES)[number]; @@ -115,6 +138,7 @@ export async function POST(request: NextRequest) { payment_coin: parsed.data.payment_coin || null, max_submissions: parsed.data.max_submissions ?? null, closes_at: parsed.data.closes_at || null, + github_issue_url: parsed.data.github_issue_url ?? null, questions, }) .select() @@ -124,6 +148,21 @@ export async function POST(request: NextRequest) { console.error("[POST /api/bounties] Supabase error:", error); return NextResponse.json({ error: error.message }, { status: 400 }); } + + // Best-effort: if the bounty funds a GitHub issue and the ugig App is + // installed on that repo, post a status comment and remember its id so we + // can edit it to "paid" later. Never blocks bounty creation. + if (data?.github_issue_url) { + const commentId = await postBountyIssueComment(data); + if (commentId) { + await (supabase as any) + .from("bounties") + .update({ github_comment_id: commentId }) + .eq("id", data.id); + data.github_comment_id = commentId; + } + } + return NextResponse.json({ data }, { status: 201 }); } catch (err) { console.error("[POST /api/bounties] Unexpected error:", err); diff --git a/src/app/api/payments/coinpayportal/webhook/route.ts b/src/app/api/payments/coinpayportal/webhook/route.ts index 99f5d7cd..566b0207 100644 --- a/src/app/api/payments/coinpayportal/webhook/route.ts +++ b/src/app/api/payments/coinpayportal/webhook/route.ts @@ -3,6 +3,8 @@ import { createServiceClient } from "@/lib/supabase/service"; import { verifyWebhookSignature, type CoinPayWebhookPayload } from "@/lib/coinpayportal"; import { LIFETIME_THRESHOLD_USD } from "@/lib/funding"; import { getUserDid, onPaymentReceived, onPaymentSent } from "@/lib/reputation-hooks"; +import { parseGitHubIssueUrl } from "@/lib/github-links"; +import { updateIssueComment } from "@/lib/github-app"; // POST /api/payments/coinpayportal/webhook - Handle CoinPayPortal webhooks export async function POST(request: NextRequest) { @@ -438,10 +440,24 @@ async function handleBountyPaymentConfirmed( const { data: bounty } = await (supabase as any) .from("bounties") - .select("id, title, creator_id, payout_usd") + .select("id, title, creator_id, payout_usd, github_issue_url, github_comment_id") .eq("id", submission.bounty_id) .single(); + // Best-effort: flip the GitHub issue status comment to "paid". Only runs on + // the first paid transition (the early-return above handles forwarded events), + // so the comment is edited once. + if (bounty?.github_issue_url && bounty.github_comment_id) { + const coords = parseGitHubIssueUrl(bounty.github_issue_url); + if (coords) { + const appUrl = (process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net").replace(/\/$/, ""); + const body = + `✅ **Bounty paid via [ugig.net](${appUrl})** — $${bounty.payout_usd} for "${bounty.title}".\n\n` + + `Updated automatically by ugig.net.`; + await updateIssueComment(coords.owner, coords.repo, bounty.github_comment_id, body); + } + } + await (supabase.from("notifications") as any).insert( [ { diff --git a/src/app/bounties/[id]/edit/page.tsx b/src/app/bounties/[id]/edit/page.tsx index 0e80d803..c0511b20 100644 --- a/src/app/bounties/[id]/edit/page.tsx +++ b/src/app/bounties/[id]/edit/page.tsx @@ -26,7 +26,7 @@ export default async function EditBountyPage({ const { data: bounty } = await (supabase as any) .from("bounties") .select( - "id, creator_id, title, description, payout_usd, payment_coin, max_submissions, questions, status" + "id, creator_id, title, description, payout_usd, payment_coin, max_submissions, github_issue_url, questions, status" ) .eq("id", id) .single(); @@ -67,6 +67,7 @@ export default async function EditBountyPage({ payout_usd: Number(bounty.payout_usd), payment_coin: bounty.payment_coin, max_submissions: bounty.max_submissions, + github_issue_url: bounty.github_issue_url, questions: bounty.questions || [], }} /> diff --git a/src/app/bounties/[id]/page.tsx b/src/app/bounties/[id]/page.tsx index c41c2f1f..e5eee7d9 100644 --- a/src/app/bounties/[id]/page.tsx +++ b/src/app/bounties/[id]/page.tsx @@ -10,6 +10,7 @@ import { Clock, Lock, Pencil, + Github, } from "lucide-react"; import { MarkdownContent } from "@/components/ui/MarkdownContent"; import { PriceBox, PriceBoxRow } from "@/components/ui/PriceBox"; @@ -26,6 +27,7 @@ interface BountyDetail { payout_currency: string; payment_coin: string | null; max_submissions: number | null; + github_issue_url: string | null; status: "open" | "paused" | "closed"; questions: { id: string; @@ -238,6 +240,18 @@ export default async function BountyDetailPage({ {(submissionCount ?? 0) === 1 ? "" : "s"} {bounty.max_submissions && ` / ${bounty.max_submissions}`} + {bounty.github_issue_url && ( + }> + + {bounty.github_issue_url.replace(/^https:\/\/(www\.)?github\.com\//, "")} + + + )} {!isCreator && bounty.status === "open" && user && !mySubmission && (

Scroll down to submit your answers. diff --git a/src/app/bounties/new/BountyForm.tsx b/src/app/bounties/new/BountyForm.tsx index e11a6335..80cd3e5a 100644 --- a/src/app/bounties/new/BountyForm.tsx +++ b/src/app/bounties/new/BountyForm.tsx @@ -22,6 +22,7 @@ interface BountyInitialData { payout_usd?: number; payment_coin?: string | null; max_submissions?: number | null; + github_issue_url?: string | null; questions?: Array<{ id: string; type: QuestionType; @@ -58,6 +59,7 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) { const [maxSubmissions, setMaxSubmissions] = useState( initialData?.max_submissions != null ? String(initialData.max_submissions) : "" ); + const [githubIssueUrl, setGithubIssueUrl] = useState(initialData?.github_issue_url ?? ""); const [questions, setQuestions] = useState( initialData?.questions && initialData.questions.length > 0 ? initialData.questions.map((q) => ({ @@ -103,6 +105,11 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) { return setError("Max submissions must be a positive number or blank"); } + const issueUrl = githubIssueUrl.trim(); + if (issueUrl && !/^https:\/\/(www\.)?github\.com\/[^/]+\/[^/]+\/issues\/\d+/.test(issueUrl)) { + return setError("GitHub issue URL must look like https://github.com/owner/repo/issues/123"); + } + setSubmitting(true); try { const url = isEdit ? `/api/bounties/${bountyId}` : "/api/bounties"; @@ -116,6 +123,7 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) { payout_usd: payoutNum, payment_coin: coin || null, max_submissions: max, + github_issue_url: issueUrl || undefined, questions: questions.map((q) => ({ id: q.id, type: q.type, @@ -191,6 +199,23 @@ export function BountyForm({ initialData, bountyId }: BountyFormProps = {}) { /> +

+ + setGithubIssueUrl(e.target.value)} + placeholder="https://github.com/owner/repo/issues/123" + className="w-full border rounded-md px-3 py-2 bg-background" + /> +

+ Link the issue this bounty funds. If the ugig GitHub App is installed on the repo, we + post a status comment on the issue and mark it paid when the payout settles. +

+
+
diff --git a/src/lib/bounties.ts b/src/lib/bounties.ts index 122abc7c..9e340ad6 100644 --- a/src/lib/bounties.ts +++ b/src/lib/bounties.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { formatCurrency } from "@/lib/utils"; +import { isGitHubIssueLink, GITHUB_ISSUE_LINK_HINT } from "@/lib/github-links"; /** * Human label for a bounty payout, matching the gig card style: @@ -31,6 +32,14 @@ export const createBountySchema = z.object({ payment_coin: z.string().max(16).nullable().optional(), max_submissions: z.number().int().positive().max(100000).nullable().optional(), closes_at: z.string().datetime().optional(), + // Optional GitHub issue this bounty funds. When set (and the ugig GitHub App + // is installed on the repo), a status comment is posted on the issue. + github_issue_url: z + .string() + .trim() + .refine((v) => v === "" || isGitHubIssueLink(v), GITHUB_ISSUE_LINK_HINT) + .transform((v) => (v === "" ? undefined : v)) + .optional(), questions: z.array(questionSchema).min(1).max(20), }); diff --git a/src/lib/github-app.ts b/src/lib/github-app.ts new file mode 100644 index 00000000..2c979ac2 --- /dev/null +++ b/src/lib/github-app.ts @@ -0,0 +1,136 @@ +// Minimal GitHub App client for posting/editing a single status comment on the +// GitHub issue a bounty is funding. Zero external dependencies — the App JWT is +// signed with node:crypto (RS256), and all calls go through fetch against the +// REST API. +// +// Configure via env: +// GITHUB_APP_ID — the numeric App ID +// GITHUB_APP_PRIVATE_KEY — the App private key (PEM). Literal "\n" sequences +// are normalized so the key can live on one env line. +// +// The App must be installed on the target repo. We resolve the installation on +// demand (GET /repos/{owner}/{repo}/installation) so no installation_id needs to +// be stored — if the App isn't installed, the call returns null and callers skip +// the comment gracefully. +import crypto from "crypto"; + +const API = "https://api.github.com"; +const API_HEADERS = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ugig.net-bounties", +}; + +export function isGitHubAppConfigured(): boolean { + return Boolean(process.env.GITHUB_APP_ID && process.env.GITHUB_APP_PRIVATE_KEY); +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +// A short-lived (10 min) App JWT, signed RS256 with the App private key. +function appJwt(): string { + const appId = process.env.GITHUB_APP_ID as string; + const privateKey = (process.env.GITHUB_APP_PRIVATE_KEY as string).replace(/\\n/g, "\n"); + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + // iat back-dated 60s to tolerate clock skew; GitHub caps exp at 10 min. + const payload = base64url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })); + const signature = base64url( + crypto.createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey) + ); + return `${header}.${payload}.${signature}`; +} + +async function installationToken(owner: string, repo: string): Promise { + const jwt = appJwt(); + const authHeaders = { ...API_HEADERS, Authorization: `Bearer ${jwt}` }; + + const instRes = await fetch(`${API}/repos/${owner}/${repo}/installation`, { + headers: authHeaders, + }); + if (!instRes.ok) { + // 404 = App not installed on this repo; anything else is a real error. + console.warn(`[github-app] no installation for ${owner}/${repo} (${instRes.status})`); + return null; + } + const installation = (await instRes.json()) as { id: number }; + + const tokenRes = await fetch(`${API}/app/installations/${installation.id}/access_tokens`, { + method: "POST", + headers: authHeaders, + }); + if (!tokenRes.ok) { + console.warn(`[github-app] token exchange failed (${tokenRes.status})`); + return null; + } + const { token } = (await tokenRes.json()) as { token: string }; + return token; +} + +/** + * Post a comment on an issue. Returns the new comment id, or null if the App is + * unconfigured / not installed / the call failed. Never throws — callers treat + * the comment as best-effort. + */ +export async function postIssueComment( + owner: string, + repo: string, + issueNumber: number, + body: string +): Promise { + if (!isGitHubAppConfigured()) return null; + try { + const token = await installationToken(owner, repo); + if (!token) return null; + const res = await fetch(`${API}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { + method: "POST", + headers: { ...API_HEADERS, Authorization: `Bearer ${token}` }, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + console.warn(`[github-app] post comment failed (${res.status})`); + return null; + } + const comment = (await res.json()) as { id: number }; + return comment.id; + } catch (err) { + console.warn("[github-app] post comment error:", err); + return null; + } +} + +/** + * Edit an existing issue comment in place. Best-effort: returns true on success, + * false otherwise. Never throws. + */ +export async function updateIssueComment( + owner: string, + repo: string, + commentId: number, + body: string +): Promise { + if (!isGitHubAppConfigured()) return false; + try { + const token = await installationToken(owner, repo); + if (!token) return false; + const res = await fetch(`${API}/repos/${owner}/${repo}/issues/comments/${commentId}`, { + method: "PATCH", + headers: { ...API_HEADERS, Authorization: `Bearer ${token}` }, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + console.warn(`[github-app] update comment failed (${res.status})`); + return false; + } + return true; + } catch (err) { + console.warn("[github-app] update comment error:", err); + return false; + } +} diff --git a/src/lib/github-links.test.ts b/src/lib/github-links.test.ts index b6d05134..d2ce75f4 100644 --- a/src/lib/github-links.test.ts +++ b/src/lib/github-links.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isGitHubPrLink } from "./github-links"; +import { isGitHubPrLink, isGitHubIssueLink, parseGitHubIssueUrl } from "./github-links"; describe("isGitHubPrLink", () => { it("accepts a single pull request URL", () => { @@ -32,3 +32,31 @@ describe("isGitHubPrLink", () => { expect(isGitHubPrLink("not a url")).toBe(false); }); }); + +describe("isGitHubIssueLink / parseGitHubIssueUrl", () => { + it("accepts a single issue URL and parses its coordinates", () => { + expect(isGitHubIssueLink("https://github.com/org/repo/issues/123")).toBe(true); + expect(parseGitHubIssueUrl("https://github.com/org/repo/issues/123")).toEqual({ + owner: "org", + repo: "repo", + number: 123, + }); + }); + + it("accepts trailing fragments/queries", () => { + expect( + parseGitHubIssueUrl("https://github.com/org/repo/issues/7#issuecomment-99") + ).toEqual({ owner: "org", repo: "repo", number: 7 }); + expect(isGitHubIssueLink("https://www.github.com/org/repo/issues/7?foo=bar")).toBe(true); + }); + + it("rejects PRs, non-issue, and non-GitHub URLs", () => { + expect(isGitHubIssueLink("https://github.com/org/repo/pull/123")).toBe(false); + expect(isGitHubIssueLink("https://github.com/org/repo")).toBe(false); + expect(isGitHubIssueLink("https://github.com/org/repo/issues")).toBe(false); + expect(isGitHubIssueLink("https://gitlab.com/org/repo/issues/1")).toBe(false); + expect(isGitHubIssueLink("https://evil.com/github.com/org/repo/issues/1")).toBe(false); + expect(isGitHubIssueLink("http://github.com/org/repo/issues/1")).toBe(false); + expect(parseGitHubIssueUrl("not a url")).toBeNull(); + }); +}); diff --git a/src/lib/github-links.ts b/src/lib/github-links.ts index d059e6f7..6fd4a2cc 100644 --- a/src/lib/github-links.ts +++ b/src/lib/github-links.ts @@ -24,3 +24,36 @@ export function isGitHubPrLink(value: string): boolean { export const GITHUB_PR_LINK_HINT = "Must be a GitHub pull request URL (…/pull/123) or PR search URL (…/pulls?q=…)"; + +// Validation for a single GitHub issue URL (…/owner/repo/issues/123). Used to +// link a bounty to the issue it funds. PR URLs are intentionally rejected here — +// PRs live under /pull/ and are validated by isGitHubPrLink above. +const ISSUE_PATH = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/; + +export function isGitHubIssueLink(value: string): boolean { + return parseGitHubIssueUrl(value) !== null; +} + +/** + * Parse a GitHub issue URL into its coordinates, or null if it isn't one. + * Accepts trailing path/hash/query segments (e.g. #issuecomment-…). + */ +export function parseGitHubIssueUrl( + value: string +): { owner: string; repo: string; number: number } | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== "https:" || !/^(www\.)?github\.com$/i.test(url.hostname)) { + return null; + } + const match = ISSUE_PATH.exec(url.pathname); + if (!match) return null; + return { owner: match[1], repo: match[2], number: Number(match[3]) }; +} + +export const GITHUB_ISSUE_LINK_HINT = + "Must be a GitHub issue URL (https://github.com/owner/repo/issues/123)"; diff --git a/supabase/migrations/20260623000000_bounty_github_issue.sql b/supabase/migrations/20260623000000_bounty_github_issue.sql new file mode 100644 index 00000000..6cdfedfc --- /dev/null +++ b/supabase/migrations/20260623000000_bounty_github_issue.sql @@ -0,0 +1,7 @@ +-- Link a bounty to the GitHub issue it funds (pattern B: link + bot comment). +-- github_issue_url is the canonical issue URL the creator pasted. +-- github_comment_id stores the id of the status comment the ugig GitHub App +-- posted on that issue, so we can edit it in place when the bounty is paid. +ALTER TABLE bounties + ADD COLUMN IF NOT EXISTS github_issue_url text, + ADD COLUMN IF NOT EXISTS github_comment_id bigint;