From 9f71f73cbc57489c9f911d93346ddd2c61b5df63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=8D=A0=E9=9C=84=EF=BC=88Zhanxiao=20Tian?= =?UTF-8?q?=EF=BC=89?= Date: Mon, 25 May 2026 23:03:23 +0800 Subject: [PATCH] Add centralized bounty issue discovery --- .../migration.sql | 7 + prisma/schema.prisma | 26 +- src/app/deploy-content.tsx | 481 +++++++++++++++++ src/app/deploy/page.tsx | 3 + src/app/issue-discovery.tsx | 506 +++++++++++++++++ src/app/page.tsx | 511 ++---------------- src/lib/github/webhook-handler.ts | 20 + 7 files changed, 1079 insertions(+), 475 deletions(-) create mode 100644 prisma/migrations/20260525144500_add_issue_discovery_fields/migration.sql create mode 100644 src/app/deploy-content.tsx create mode 100644 src/app/deploy/page.tsx create mode 100644 src/app/issue-discovery.tsx diff --git a/prisma/migrations/20260525144500_add_issue_discovery_fields/migration.sql b/prisma/migrations/20260525144500_add_issue_discovery_fields/migration.sql new file mode 100644 index 0000000..c2cf197 --- /dev/null +++ b/prisma/migrations/20260525144500_add_issue_discovery_fields/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "Bounty" +ADD COLUMN "issueTitle" TEXT, +ADD COLUMN "issueUrl" TEXT, +ADD COLUMN "issueState" TEXT, +ADD COLUMN "issueBodyExcerpt" TEXT, +ADD COLUMN "issueCreatedAt" TIMESTAMP(3), +ADD COLUMN "issueUpdatedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index df1171d..ffbced4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,16 +54,22 @@ model GithubUserLink { } model Bounty { - id String @id @default(cuid()) - repositoryId String - issueNumber Int - issueNodeId String? - labelName String - amount Decimal @db.Decimal(18, 6) - currency String - status BountyStatus @default(OPEN) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + repositoryId String + issueNumber Int + issueNodeId String? + issueTitle String? + issueUrl String? + issueState String? + issueBodyExcerpt String? @db.Text + issueCreatedAt DateTime? + issueUpdatedAt DateTime? + labelName String + amount Decimal @db.Decimal(18, 6) + currency String + status BountyStatus @default(OPEN) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt repository RepositoryInstallation @relation(fields: [repositoryId], references: [id], onDelete: Cascade) rewards RewardAttempt[] diff --git a/src/app/deploy-content.tsx b/src/app/deploy-content.tsx new file mode 100644 index 0000000..e39981e --- /dev/null +++ b/src/app/deploy-content.tsx @@ -0,0 +1,481 @@ +import type { CSSProperties, ReactNode } from "react"; + +const flowSteps = [ + "A repository owner installs the GitHub App.", + "A maintainer labels an issue with a Pvium bounty label, for example pvium:20USDC or pvium:10.", + "When a pull request is merged into a configured reward target branch and closes that issue, the app checks the PR author.", + "If the PR author is already linked to a Pvium account, the app creates a Pvium payment link and comments a Pay reward link on the PR.", + "If the PR author is not linked, the app generates a signed Pvium OAuth invite for type: github and comments the invite link on the PR.", + "Once the user accepts the invite and connects the matching GitHub account in Pvium, the Pvium webhook creates the payment link and comments the Pay reward link on the PR.", + "When Pvium sends a paid or funded webhook, the app marks the reward and bounty as PAID.", +]; + +const localSetupCommands = [ + "cd /Users/Projects/Javascript/paytrack/sdks/node", + "npm install", + "npm run build", + "", + "cd /Users/Projects/Javascript/paytrack/github-app", + "npm install", + "cp .env.example .env", + "npm run prisma:generate", + "npm run prisma:migrate", + "npm run dev", +]; + +const envExample = `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pvium_github_app" + +GITHUB_APP_ID="" +GITHUB_APP_PRIVATE_KEY="" +GITHUB_WEBHOOK_SECRET="" +GITHUB_REWARD_TARGET_BRANCHES="main,master" +PVIUM_BOUNTY_LABEL_PREFIX="pvium:" + +PVIUM_ENVIRONMENT="sandbox" +PVIUM_API_BASE_URL="" +PVIUM_CONSENT_HOST="" +PVIUM_SDK_LOG_REQUESTS="false" +PVIUM_API_KEY="" +PVIUM_CLIENT_ID="" +PVIUM_WEBHOOK_SECRET="" +PVIUM_INVITE_SIGNER_PRIVATE_KEY="" +PVIUM_OAUTH_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback" +PVIUM_REWARD_PAYMENT_MODEL="instant-batch" +PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY="" +PVIUM_REWARD_PAYMENT_CHAIN="base" +PVIUM_REWARD_PAYMENT_CHAIN_ID="8453" +PVIUM_REWARD_PAYMENT_CURRENCY="USDC" +PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS="" +PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS="6" +PVIUM_REWARD_PLATFORM_FEE_WALLET="" +PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS="0" +PVIUM_REWARD_MAX_FEE_AMOUNT="0" +PVIUM_INVOICE_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback" + +APP_BASE_URL="http://localhost:3000"`; + +const githubPermissions = [ + "Issues: read and write", + "Pull requests: read and write", + "Metadata: read-only", +]; + +const githubEvents = ["issues", "pull_request"]; + +const configItems = [ + "GITHUB_REWARD_TARGET_BRANCHES is a comma-separated list of base branches that can trigger reward processing when a PR is merged.", + "PVIUM_BOUNTY_LABEL_PREFIX controls the GitHub issue label prefix used to detect bounties. It defaults to pvium: when unset or empty.", + "PVIUM_ENVIRONMENT resolves Pvium hosts: test uses localhost, sandbox uses api-sandbox.pvium.com, and production uses api.pvium.com.", + "PVIUM_API_BASE_URL and PVIUM_CONSENT_HOST override the resolved Pvium hosts when needed.", + "PVIUM_SDK_LOG_REQUESTS=true logs SDK request method, host, path, status, duration, and network errors without logging full URLs or secrets.", + "PVIUM_REWARD_PAYMENT_MODEL controls the payment artifact. Use instant-batch for finalized instant batch payment links or invoice for the legacy invoice flow.", + "PVIUM_REWARD_PAYMENT_CHAIN is the chain used by both invoice payment channels and instant batch links.", + "PVIUM_REWARD_PAYMENT_CURRENCY is the invoice payment currency.", + "PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY signs instant batches. If omitted, the invite signer is used.", + "PVIUM_REWARD_PAYMENT_CHAIN_ID is the chain id used to finalize instant batches.", + "PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS and PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS define the instant batch payout token.", + "PVIUM_REWARD_PLATFORM_FEE_WALLET receives the platform fee. If omitted, no platform fee payee is added.", + "PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS sets the fee. For example, 100 is 1% and 250 is 2.5%.", + "PVIUM_REWARD_MAX_FEE_AMOUNT caps the computed platform fee. Use 0 for no cap.", +]; + +const pviumEvents = [ + "oauth.invite.accepted", + "invoice.paid", + "invoice.payment_completed", + "invoice.payment.succeeded", + "payment.attached", + "batch.funded", + "batch.payment_completed", + "batch.payment.succeeded", +]; + +const usageSteps = [ + "Install the GitHub App on a repository.", + "Add a bounty label to an issue, such as pvium:20USDC or pvium:20. If PVIUM_BOUNTY_LABEL_PREFIX is changed, use that prefix instead.", + "Merge a PR into a configured reward target branch with a closing reference like Closes #123.", + "The app comments on the merged PR.", + "If the contributor needs to link Pvium, they use the invite link in the comment.", + "Pvium redirects back to /api/pvium/oauth/callback with an OAuth code.", + "The app exchanges the code through the local Pvium SDK, verifies the accepted GitHub handle, saves the OAuth token set, creates the payment link, and comments a Pay reward link.", + "The maintainer clicks Pay reward and completes payment in Pvium.", +]; + +export default function DeployContent() { + return ( +
+
+
+
+ + Powered by Pvium + + + Install GitHub App + + + Issue Discovery + +
+ + Pvium logo + +
+

Pvium GitHub App

+

+ Reward GitHub contributors with Pvium payment links. +

+

+ Turn merged pull requests into payable rewards. Maintainers label + bounty issues, contributors close them with PRs, and Pvium handles the + invite, payment link, funded webhook, and paid status updates. +

+
+ + + +
+
+ +
+ +
+ +
+

+ The reward automation uses the local Pvium SDK at{" "} + + /Users/Projects/Javascript/paytrack/sdks/node + + . The package points @pvium/sdk{" "} + at file:../sdks/node, so + rebuild the SDK after changing it. +

+ +
+ +
+

+ Required values are documented in .env.example: +

+ +
+ +
+

+ Generate GITHUB_APP_PRIVATE_KEY{" "} + from the GitHub App settings page under Private keys, then copy the + full PEM contents into the environment with line breaks replaced by{" "} + \n. +

+

+ Configure the webhook URL as{" "} + + https://<your-host>/api/github/webhook + + . +

+
+ + +
+
+ +
+

+ Configure the Pvium webhook URL as{" "} + + https://<your-host>/api/pvium/webhook + + . Set PVIUM_WEBHOOK_SECRET to + the same secret configured on the Pvium client app. +

+ +

+ When{" "} + + PVIUM_REWARD_PLATFORM_FEE_WALLET + {" "} + is set and the fee basis points are greater than zero, instant batches + include the platform fee as the first payee with memo{" "} + platform fee. The contributor + reward amount is not reduced by the fee. +

+
+ +
+
+ + +
+
+ +
+ +

+ The app stores Pvium OAuth access and refresh tokens on the GitHub + user link so future merged PRs for the same contributor can create + rewards without asking the contributor to authorize again. Treat these + OAuth tokens as secrets; production deployments should encrypt them at + rest and restrict database access. +

+
+
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Endpoint({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function ListBlock({ title, items }: { title: string; items: string[] }) { + return ( +
+

{title}

+ +
+ ); +} + +function BulletList({ items }: { items: string[] }) { + return ( + + ); +} + +function NumberedList({ items }: { items: string[] }) { + return ( +
    + {items.map((item) => ( +
  1. + {item} +
  2. + ))} +
+ ); +} + +function CodeBlock({ value }: { value: string }) { + return
{value}
; +} + +const styles: Record = { + page: { + minHeight: "100vh", + margin: 0, + padding: "48px 20px", + background: "#f7f8fb", + color: "#172033", + fontFamily: + 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }, + hero: { + maxWidth: 980, + margin: "0 auto 24px", + padding: "32px 0 8px", + }, + brandRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + marginBottom: 18, + }, + topLinks: { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: 10, + }, + logo: { + width: 96, + height: 96, + borderRadius: 8, + objectFit: "contain", + }, + logoLink: { + display: "inline-flex", + lineHeight: 0, + }, + poweredBy: { + display: "inline-flex", + alignItems: "center", + padding: "9px 13px", + border: "1px solid #c8d0df", + borderRadius: 999, + background: "#ffffff", + color: "#172033", + fontSize: 14, + fontWeight: 600, + textDecoration: "none", + }, + installLink: { + display: "inline-flex", + alignItems: "center", + padding: "10px 14px", + borderRadius: 8, + background: "#172033", + color: "#ffffff", + fontSize: 14, + fontWeight: 700, + textDecoration: "none", + }, + secondaryLink: { + display: "inline-flex", + alignItems: "center", + padding: "10px 14px", + border: "1px solid #c8d0df", + borderRadius: 8, + background: "#ffffff", + color: "#172033", + fontSize: 14, + fontWeight: 700, + textDecoration: "none", + }, + eyebrow: { + margin: "0 0 12px", + color: "#52627a", + fontSize: 14, + fontWeight: 700, + textTransform: "uppercase", + }, + title: { + maxWidth: 820, + margin: "0 0 18px", + fontSize: 48, + lineHeight: 1.08, + letterSpacing: 0, + }, + lede: { + maxWidth: 760, + margin: "0 0 24px", + color: "#46556e", + fontSize: 18, + lineHeight: 1.65, + }, + endpointGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))", + gap: 12, + maxWidth: 920, + }, + endpoint: { + border: "1px solid #d9deea", + borderRadius: 8, + background: "#ffffff", + padding: 16, + }, + endpointLabel: { + display: "block", + marginBottom: 8, + color: "#66748a", + fontSize: 13, + fontWeight: 700, + }, + endpointCode: { + color: "#172033", + fontSize: 14, + wordBreak: "break-word", + }, + section: { + maxWidth: 980, + margin: "18px auto", + padding: 24, + border: "1px solid #d9deea", + borderRadius: 8, + background: "#ffffff", + }, + sectionTitle: { + margin: "0 0 16px", + fontSize: 24, + letterSpacing: 0, + }, + paragraph: { + margin: "0 0 14px", + color: "#46556e", + fontSize: 15, + lineHeight: 1.7, + }, + columns: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", + gap: 18, + }, + listBlock: { + minWidth: 0, + }, + listTitle: { + margin: "0 0 10px", + color: "#263247", + fontSize: 16, + }, + list: { + margin: 0, + paddingLeft: 22, + color: "#46556e", + fontSize: 15, + lineHeight: 1.7, + }, + listItem: { + marginBottom: 8, + }, + codeBlock: { + margin: "14px 0 0", + padding: 16, + overflowX: "auto", + borderRadius: 8, + background: "#141925", + color: "#eef3ff", + fontSize: 13, + lineHeight: 1.6, + }, + inlineCode: { + padding: "2px 5px", + borderRadius: 5, + background: "#eef1f6", + color: "#263247", + fontSize: "0.92em", + }, +}; diff --git a/src/app/deploy/page.tsx b/src/app/deploy/page.tsx new file mode 100644 index 0000000..d0327c3 --- /dev/null +++ b/src/app/deploy/page.tsx @@ -0,0 +1,3 @@ +import DeployContent from "../deploy-content"; + +export default DeployContent; diff --git a/src/app/issue-discovery.tsx b/src/app/issue-discovery.tsx new file mode 100644 index 0000000..e7fb944 --- /dev/null +++ b/src/app/issue-discovery.tsx @@ -0,0 +1,506 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { useMemo, useState } from "react"; + +export interface IssueSummary { + id: string; + title: string; + repository: string; + issueNumber: number; + url: string; + amount: string; + currency: string; + status: string; + issueState: string; + excerpt: string; + createdAt: string; + updatedAt: string; + issueCreatedAt: string; + issueUpdatedAt: string; +} + +type SortMode = "recent" | "top"; +type SortOrder = "desc" | "asc"; + +export function IssueDiscovery({ + issues, + loadError, +}: { + issues: IssueSummary[]; + loadError?: string; +}) { + const [sortMode, setSortMode] = useState("recent"); + const [sortOrder, setSortOrder] = useState("desc"); + const [minimumBounty, setMinimumBounty] = useState(""); + + const visibleIssues = useMemo(() => { + const minimum = Number.parseFloat(minimumBounty); + const hasMinimum = Number.isFinite(minimum); + + return [...issues] + .filter((issue) => !hasMinimum || Number(issue.amount) >= minimum) + .sort((left, right) => { + const direction = sortOrder === "desc" ? -1 : 1; + if (sortMode === "top") { + return (Number(left.amount) - Number(right.amount)) * direction; + } + + return ( + (Date.parse(left.issueUpdatedAt) - Date.parse(right.issueUpdatedAt)) * + direction + ); + }); + }, [issues, minimumBounty, sortMode, sortOrder]); + + const totalValue = issues.reduce((sum, issue) => sum + Number(issue.amount), 0); + + return ( +
+
+
+

Pvium Bounties

+

Issue Discovery

+

+ Browse bounty issues across connected repositories, prioritize by + recency or payout, and open the source issue when ready to work. +

+
+ +
+ +
+ + + +
+ + {loadError ? ( +
+ {loadError} +
+ ) : null} + +
+
+ + +
+ + + + +
+ +
+ {visibleIssues.length ? ( + visibleIssues.map((issue) => ) + ) : ( +
+

No issues match this view.

+

+ Install the app on a repository and label issues with Pvium bounty + labels such as pvium:25USDC. New bounty issues will appear here + after the GitHub webhook registers them. +

+
+ )} +
+
+ ); +} + +function IssueRow({ issue }: { issue: IssueSummary }) { + return ( + +
+
+ {issue.repository} + #{issue.issueNumber} + {formatStatus(issue.status)} + {issue.issueState} +
+

{issue.title}

+ {issue.excerpt ?

{issue.excerpt}

: null} +

+ Updated {formatDate(issue.issueUpdatedAt)} ยท Created{" "} + {formatDate(issue.issueCreatedAt)} +

+
+
+ {formatAmount(Number(issue.amount))} + {issue.currency} +
+
+ ); +} + +function Summary({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function countOpen(issues: IssueSummary[]) { + return issues.filter( + (issue) => + issue.status.toUpperCase() === "OPEN" && + issue.issueState.toLowerCase() === "open", + ).length; +} + +function formatAmount(value: number) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 6, + }).format(value); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); +} + +function formatStatus(value: string) { + return value + .toLowerCase() + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +const styles: Record = { + page: { + minHeight: "100vh", + padding: "40px 20px", + background: "#f6f7fb", + color: "#172033", + fontFamily: + 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }, + header: { + maxWidth: 1100, + margin: "0 auto 20px", + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 20, + }, + eyebrow: { + margin: "0 0 8px", + color: "#536279", + fontSize: 13, + fontWeight: 800, + textTransform: "uppercase", + }, + title: { + margin: "0 0 10px", + fontSize: 34, + lineHeight: 1.12, + letterSpacing: 0, + }, + lede: { + maxWidth: 680, + margin: 0, + color: "#4d5c73", + fontSize: 16, + lineHeight: 1.6, + }, + headerActions: { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + justifyContent: "flex-end", + gap: 10, + minWidth: 210, + }, + primaryLink: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minHeight: 40, + padding: "0 14px", + borderRadius: 8, + background: "#172033", + color: "#ffffff", + fontSize: 14, + fontWeight: 700, + textDecoration: "none", + }, + secondaryLink: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minHeight: 40, + padding: "0 14px", + border: "1px solid #c8d0df", + borderRadius: 8, + background: "#ffffff", + color: "#172033", + fontSize: 14, + fontWeight: 700, + textDecoration: "none", + }, + summaryGrid: { + maxWidth: 1100, + margin: "0 auto 16px", + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", + gap: 12, + }, + summary: { + border: "1px solid #d9deea", + borderRadius: 8, + background: "#ffffff", + padding: 16, + }, + summaryLabel: { + display: "block", + marginBottom: 6, + color: "#66748a", + fontSize: 13, + fontWeight: 700, + }, + summaryValue: { + color: "#172033", + fontSize: 24, + lineHeight: 1.2, + }, + notice: { + maxWidth: 1100, + margin: "0 auto 16px", + padding: 14, + border: "1px solid #f0c36d", + borderRadius: 8, + background: "#fff8e8", + color: "#715100", + fontSize: 14, + fontWeight: 700, + }, + toolbar: { + maxWidth: 1100, + margin: "0 auto 16px", + display: "flex", + alignItems: "end", + flexWrap: "wrap", + gap: 12, + padding: 14, + border: "1px solid #d9deea", + borderRadius: 8, + background: "#ffffff", + }, + segmentedControl: { + display: "inline-grid", + gridTemplateColumns: "1fr 1fr", + minHeight: 40, + border: "1px solid #c8d0df", + borderRadius: 8, + overflow: "hidden", + }, + segmentButton: { + minWidth: 92, + border: 0, + background: "#ffffff", + color: "#52627a", + fontSize: 14, + fontWeight: 800, + cursor: "pointer", + }, + segmentButtonActive: { + background: "#172033", + color: "#ffffff", + }, + fieldLabel: { + display: "grid", + gap: 6, + minWidth: 160, + }, + fieldText: { + color: "#66748a", + fontSize: 12, + fontWeight: 800, + textTransform: "uppercase", + }, + input: { + minHeight: 40, + padding: "0 12px", + border: "1px solid #c8d0df", + borderRadius: 8, + background: "#ffffff", + color: "#172033", + fontSize: 15, + }, + select: { + minHeight: 42, + padding: "0 12px", + border: "1px solid #c8d0df", + borderRadius: 8, + background: "#ffffff", + color: "#172033", + fontSize: 15, + }, + issueList: { + maxWidth: 1100, + margin: "0 auto", + display: "grid", + gap: 10, + }, + issueRow: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) 116px", + gap: 16, + alignItems: "center", + padding: 18, + border: "1px solid #d9deea", + borderRadius: 8, + background: "#ffffff", + color: "#172033", + textDecoration: "none", + }, + issueMain: { + minWidth: 0, + }, + issueMeta: { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: 8, + marginBottom: 8, + }, + repoName: { + color: "#52627a", + fontSize: 13, + fontWeight: 800, + }, + issueNumber: { + color: "#6c7b91", + fontSize: 13, + fontWeight: 700, + }, + statusPill: { + padding: "3px 7px", + borderRadius: 999, + background: "#edf7f0", + color: "#146b3a", + fontSize: 12, + fontWeight: 800, + }, + statePill: { + padding: "3px 7px", + borderRadius: 999, + background: "#eef1f6", + color: "#52627a", + fontSize: 12, + fontWeight: 800, + textTransform: "capitalize", + }, + issueTitle: { + margin: "0 0 8px", + fontSize: 18, + lineHeight: 1.35, + letterSpacing: 0, + }, + excerpt: { + margin: "0 0 10px", + color: "#4d5c73", + fontSize: 14, + lineHeight: 1.55, + }, + dateLine: { + margin: 0, + color: "#728096", + fontSize: 13, + }, + amountBox: { + justifySelf: "end", + display: "grid", + gap: 2, + minWidth: 100, + padding: "10px 12px", + borderRadius: 8, + background: "#f2f5fa", + textAlign: "right", + }, + amount: { + fontSize: 20, + lineHeight: 1.15, + }, + currency: { + color: "#52627a", + fontSize: 12, + fontWeight: 800, + }, + emptyState: { + padding: 28, + border: "1px solid #d9deea", + borderRadius: 8, + background: "#ffffff", + }, + emptyTitle: { + margin: "0 0 8px", + fontSize: 20, + }, + emptyText: { + maxWidth: 660, + margin: 0, + color: "#4d5c73", + fontSize: 15, + lineHeight: 1.6, + }, +}; diff --git a/src/app/page.tsx b/src/app/page.tsx index 659e33f..0f46505 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,466 +1,47 @@ -import type { CSSProperties, ReactNode } from "react"; - -const flowSteps = [ - "A repository owner installs the GitHub App.", - "A maintainer labels an issue with a Pvium bounty label, for example pvium:20USDC or pvium:10.", - "When a pull request is merged into a configured reward target branch and closes that issue, the app checks the PR author.", - "If the PR author is already linked to a Pvium account, the app creates a Pvium payment link and comments a Pay reward link on the PR.", - "If the PR author is not linked, the app generates a signed Pvium OAuth invite for type: github and comments the invite link on the PR.", - "Once the user accepts the invite and connects the matching GitHub account in Pvium, the Pvium webhook creates the payment link and comments the Pay reward link on the PR.", - "When Pvium sends a paid or funded webhook, the app marks the reward and bounty as PAID.", -]; - -const localSetupCommands = [ - "cd /Users/Projects/Javascript/paytrack/sdks/node", - "npm install", - "npm run build", - "", - "cd /Users/Projects/Javascript/paytrack/github-app", - "npm install", - "cp .env.example .env", - "npm run prisma:generate", - "npm run prisma:migrate", - "npm run dev", -]; - -const envExample = `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pvium_github_app" - -GITHUB_APP_ID="" -GITHUB_APP_PRIVATE_KEY="" -GITHUB_WEBHOOK_SECRET="" -GITHUB_REWARD_TARGET_BRANCHES="main,master" -PVIUM_BOUNTY_LABEL_PREFIX="pvium:" - -PVIUM_ENVIRONMENT="sandbox" -PVIUM_API_BASE_URL="" -PVIUM_CONSENT_HOST="" -PVIUM_SDK_LOG_REQUESTS="false" -PVIUM_API_KEY="" -PVIUM_CLIENT_ID="" -PVIUM_WEBHOOK_SECRET="" -PVIUM_INVITE_SIGNER_PRIVATE_KEY="" -PVIUM_OAUTH_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback" -PVIUM_REWARD_PAYMENT_MODEL="instant-batch" -PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY="" -PVIUM_REWARD_PAYMENT_CHAIN="base" -PVIUM_REWARD_PAYMENT_CHAIN_ID="8453" -PVIUM_REWARD_PAYMENT_CURRENCY="USDC" -PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS="" -PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS="6" -PVIUM_REWARD_PLATFORM_FEE_WALLET="" -PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS="0" -PVIUM_REWARD_MAX_FEE_AMOUNT="0" -PVIUM_INVOICE_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback" - -APP_BASE_URL="http://localhost:3000"`; - -const githubPermissions = [ - "Issues: read and write", - "Pull requests: read and write", - "Metadata: read-only", -]; - -const githubEvents = ["issues", "pull_request"]; - -const configItems = [ - "GITHUB_REWARD_TARGET_BRANCHES is a comma-separated list of base branches that can trigger reward processing when a PR is merged.", - "PVIUM_BOUNTY_LABEL_PREFIX controls the GitHub issue label prefix used to detect bounties. It defaults to pvium: when unset or empty.", - "PVIUM_ENVIRONMENT resolves Pvium hosts: test uses localhost, sandbox uses api-sandbox.pvium.com, and production uses api.pvium.com.", - "PVIUM_API_BASE_URL and PVIUM_CONSENT_HOST override the resolved Pvium hosts when needed.", - "PVIUM_SDK_LOG_REQUESTS=true logs SDK request method, host, path, status, duration, and network errors without logging full URLs or secrets.", - "PVIUM_REWARD_PAYMENT_MODEL controls the payment artifact. Use instant-batch for finalized instant batch payment links or invoice for the legacy invoice flow.", - "PVIUM_REWARD_PAYMENT_CHAIN is the chain used by both invoice payment channels and instant batch links.", - "PVIUM_REWARD_PAYMENT_CURRENCY is the invoice payment currency.", - "PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY signs instant batches. If omitted, the invite signer is used.", - "PVIUM_REWARD_PAYMENT_CHAIN_ID is the chain id used to finalize instant batches.", - "PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS and PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS define the instant batch payout token.", - "PVIUM_REWARD_PLATFORM_FEE_WALLET receives the platform fee. If omitted, no platform fee payee is added.", - "PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS sets the fee. For example, 100 is 1% and 250 is 2.5%.", - "PVIUM_REWARD_MAX_FEE_AMOUNT caps the computed platform fee. Use 0 for no cap.", -]; - -const pviumEvents = [ - "oauth.invite.accepted", - "invoice.paid", - "invoice.payment_completed", - "invoice.payment.succeeded", - "payment.attached", - "batch.funded", - "batch.payment_completed", - "batch.payment.succeeded", -]; - -const usageSteps = [ - "Install the GitHub App on a repository.", - "Add a bounty label to an issue, such as pvium:20USDC or pvium:20. If PVIUM_BOUNTY_LABEL_PREFIX is changed, use that prefix instead.", - "Merge a PR into a configured reward target branch with a closing reference like Closes #123.", - "The app comments on the merged PR.", - "If the contributor needs to link Pvium, they use the invite link in the comment.", - "Pvium redirects back to /api/pvium/oauth/callback with an OAuth code.", - "The app exchanges the code through the local Pvium SDK, verifies the accepted GitHub handle, saves the OAuth token set, creates the payment link, and comments a Pay reward link.", - "The maintainer clicks Pay reward and completes payment in Pvium.", -]; - -export default function Home() { - return ( -
-
- -

Pvium GitHub App

-

- Reward GitHub contributors with Pvium payment links. -

-

- Turn merged pull requests into payable rewards. Maintainers label - bounty issues, contributors close them with PRs, and Pvium handles the - invite, payment link, funded webhook, and paid status updates. -

-
- - - -
-
- -
- -
- -
-

- The reward automation uses the local Pvium SDK at{" "} - - /Users/Projects/Javascript/paytrack/sdks/node - - . The package points @pvium/sdk{" "} - at file:../sdks/node, so - rebuild the SDK after changing it. -

- -
- -
-

- Required values are documented in .env.example: -

- -
- -
-

- Generate GITHUB_APP_PRIVATE_KEY{" "} - from the GitHub App settings page under Private keys, then copy the - full PEM contents into the environment with line breaks replaced by{" "} - \n. -

-

- Configure the webhook URL as{" "} - - https://<your-host>/api/github/webhook - - . -

-
- - -
-
- -
-

- Configure the Pvium webhook URL as{" "} - - https://<your-host>/api/pvium/webhook - - . Set PVIUM_WEBHOOK_SECRET to - the same secret configured on the Pvium client app. -

- -

- When{" "} - - PVIUM_REWARD_PLATFORM_FEE_WALLET - {" "} - is set and the fee basis points are greater than zero, instant batches - include the platform fee as the first payee with memo{" "} - platform fee. The contributor - reward amount is not reduced by the fee. -

-
- -
-
- - -
-
- -
- -

- The app stores Pvium OAuth access and refresh tokens on the GitHub - user link so future merged PRs for the same contributor can create - rewards without asking the contributor to authorize again. Treat these - OAuth tokens as secrets; production deployments should encrypt them at - rest and restrict database access. -

-
-
- ); -} - -function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function Endpoint({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function ListBlock({ title, items }: { title: string; items: string[] }) { - return ( -
-

{title}

- -
- ); -} - -function BulletList({ items }: { items: string[] }) { - return ( -
    - {items.map((item) => ( -
  • - {item} -
  • - ))} -
- ); -} - -function NumberedList({ items }: { items: string[] }) { - return ( -
    - {items.map((item) => ( -
  1. - {item} -
  2. - ))} -
- ); -} - -function CodeBlock({ value }: { value: string }) { - return
{value}
; +import { prisma } from "@/lib/db/prisma"; +import { IssueDiscovery, type IssueSummary } from "./issue-discovery"; + +export const dynamic = "force-dynamic"; + +export default async function Home() { + let loadError: string | undefined; + const bounties = await prisma.bounty + .findMany({ + include: { repository: true }, + orderBy: [{ issueUpdatedAt: "desc" }, { updatedAt: "desc" }], + take: 200, + }) + .catch((error) => { + console.error("[issue-discovery] failed to load bounties", error); + loadError = + "Issue data is unavailable. Check DATABASE_URL and database connectivity."; + return []; + }); + + const issues: IssueSummary[] = bounties.map((bounty) => { + const issueUrl = + bounty.issueUrl || + `https://github.com/${bounty.repository.owner}/${bounty.repository.repo}/issues/${bounty.issueNumber}`; + + return { + id: bounty.id, + title: bounty.issueTitle || `Issue #${bounty.issueNumber}`, + repository: `${bounty.repository.owner}/${bounty.repository.repo}`, + issueNumber: bounty.issueNumber, + url: issueUrl, + amount: bounty.amount.toString(), + currency: bounty.currency, + status: bounty.status, + issueState: bounty.issueState || "open", + excerpt: bounty.issueBodyExcerpt || "", + createdAt: bounty.createdAt.toISOString(), + updatedAt: bounty.updatedAt.toISOString(), + issueCreatedAt: + bounty.issueCreatedAt?.toISOString() || bounty.createdAt.toISOString(), + issueUpdatedAt: + bounty.issueUpdatedAt?.toISOString() || bounty.updatedAt.toISOString(), + }; + }); + + return ; } - -const styles: Record = { - page: { - minHeight: "100vh", - margin: 0, - padding: "48px 20px", - background: "#f7f8fb", - color: "#172033", - fontFamily: - 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - }, - hero: { - maxWidth: 980, - margin: "0 auto 24px", - padding: "32px 0 8px", - }, - brandRow: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - marginBottom: 18, - }, - topLinks: { - display: "flex", - alignItems: "center", - flexWrap: "wrap", - gap: 10, - }, - logo: { - width: 96, - height: 96, - borderRadius: 8, - objectFit: "contain", - }, - logoLink: { - display: "inline-flex", - lineHeight: 0, - }, - poweredBy: { - display: "inline-flex", - alignItems: "center", - padding: "9px 13px", - border: "1px solid #c8d0df", - borderRadius: 999, - background: "#ffffff", - color: "#172033", - fontSize: 14, - fontWeight: 600, - textDecoration: "none", - }, - installLink: { - display: "inline-flex", - alignItems: "center", - padding: "10px 14px", - borderRadius: 8, - background: "#172033", - color: "#ffffff", - fontSize: 14, - fontWeight: 700, - textDecoration: "none", - }, - eyebrow: { - margin: "0 0 12px", - color: "#52627a", - fontSize: 14, - fontWeight: 700, - textTransform: "uppercase", - }, - title: { - maxWidth: 820, - margin: "0 0 18px", - fontSize: 48, - lineHeight: 1.08, - letterSpacing: 0, - }, - lede: { - maxWidth: 760, - margin: "0 0 24px", - color: "#46556e", - fontSize: 18, - lineHeight: 1.65, - }, - endpointGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))", - gap: 12, - maxWidth: 920, - }, - endpoint: { - border: "1px solid #d9deea", - borderRadius: 8, - background: "#ffffff", - padding: 16, - }, - endpointLabel: { - display: "block", - marginBottom: 8, - color: "#66748a", - fontSize: 13, - fontWeight: 700, - }, - endpointCode: { - color: "#172033", - fontSize: 14, - wordBreak: "break-word", - }, - section: { - maxWidth: 980, - margin: "18px auto", - padding: 24, - border: "1px solid #d9deea", - borderRadius: 8, - background: "#ffffff", - }, - sectionTitle: { - margin: "0 0 16px", - fontSize: 24, - letterSpacing: 0, - }, - paragraph: { - margin: "0 0 14px", - color: "#46556e", - fontSize: 15, - lineHeight: 1.7, - }, - columns: { - display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", - gap: 18, - }, - listBlock: { - minWidth: 0, - }, - listTitle: { - margin: "0 0 10px", - color: "#263247", - fontSize: 16, - }, - list: { - margin: 0, - paddingLeft: 22, - color: "#46556e", - fontSize: 15, - lineHeight: 1.7, - }, - listItem: { - marginBottom: 8, - }, - codeBlock: { - margin: "14px 0 0", - padding: 16, - overflowX: "auto", - borderRadius: 8, - background: "#141925", - color: "#eef3ff", - fontSize: 13, - lineHeight: 1.6, - }, - inlineCode: { - padding: "2px 5px", - borderRadius: 5, - background: "#eef1f6", - color: "#263247", - fontSize: "0.92em", - }, -}; diff --git a/src/lib/github/webhook-handler.ts b/src/lib/github/webhook-handler.ts index 46a7c5e..59270ea 100644 --- a/src/lib/github/webhook-handler.ts +++ b/src/lib/github/webhook-handler.ts @@ -110,6 +110,7 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { } const repository = await upsertRepository(payload); + const issueDetails = extractIssueDetails(payload.issue); const bounty = await prisma.bounty.upsert({ where: { repositoryId_issueNumber_labelName: { @@ -122,11 +123,13 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { amount: parsed.amount, currency: parsed.currency, status: "OPEN", + ...issueDetails, }, create: { repositoryId: repository.id, issueNumber: payload.issue.number, issueNodeId: payload.issue.node_id, + ...issueDetails, labelName: parsed.raw, amount: parsed.amount, currency: parsed.currency, @@ -156,6 +159,23 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { return { bountyId: bounty.id }; } +function extractIssueDetails(issue: any) { + return { + issueTitle: issue?.title, + issueUrl: issue?.html_url, + issueState: issue?.state, + issueBodyExcerpt: summarizeIssueBody(issue?.body), + issueCreatedAt: issue?.created_at ? new Date(issue.created_at) : undefined, + issueUpdatedAt: issue?.updated_at ? new Date(issue.updated_at) : undefined, + }; +} + +function summarizeIssueBody(body: string | null | undefined) { + const normalized = body?.replace(/\s+/g, " ").trim(); + if (!normalized) return undefined; + return normalized.length > 220 ? `${normalized.slice(0, 217)}...` : normalized; +} + async function handlePullRequestClosed(payload: GithubWebhookPayload) { const env = getEnv(); const pullRequest = payload.pull_request;