-
Notifications
You must be signed in to change notification settings - Fork 45
feat(bounties): link bounties to GitHub issues with a status comment #488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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` + | ||||||||||||||||
| `<sub>Updated automatically by ugig.net.</sub>`; | ||||||||||||||||
|
Comment on lines
+454
to
+456
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
| await updateIssueComment(coords.owner, coords.repo, bounty.github_comment_id, body); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| await (supabase.from("notifications") as any).insert( | ||||||||||||||||
| [ | ||||||||||||||||
| { | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<DraftQuestion[]>( | ||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 = {}) { | |
| /> | ||
| </div> | ||
|
|
||
| <div className="space-y-2"> | ||
| <label className="text-sm font-medium"> | ||
| GitHub issue URL <span className="text-muted-foreground">(optional)</span> | ||
| </label> | ||
| <input | ||
| type="url" | ||
| value={githubIssueUrl} | ||
| onChange={(e) => setGithubIssueUrl(e.target.value)} | ||
| placeholder="https://github.com/owner/repo/issues/123" | ||
| className="w-full border rounded-md px-3 py-2 bg-background" | ||
| /> | ||
| <p className="text-xs text-muted-foreground"> | ||
| 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. | ||
| </p> | ||
| </div> | ||
|
|
||
| <div> | ||
| <div className="flex items-center justify-between mb-3"> | ||
| <label className="text-sm font-medium">Submission form</label> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | null> { | ||
| 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<number | null> { | ||
| 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<boolean> { | ||
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bounty.titleallows Markdown injection in GitHub commentsbounty.titleis 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.