diff --git a/prisma/migrations/20260525081500_add_issue_discovery_metadata/migration.sql b/prisma/migrations/20260525081500_add_issue_discovery_metadata/migration.sql
new file mode 100644
index 0000000..4a3ad43
--- /dev/null
+++ b/prisma/migrations/20260525081500_add_issue_discovery_metadata/migration.sql
@@ -0,0 +1,10 @@
+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);
+
+CREATE INDEX "Bounty_issueUpdatedAt_idx" ON "Bounty"("issueUpdatedAt");
+CREATE INDEX "Bounty_amount_idx" ON "Bounty"("amount");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index df1171d..75687ae 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -54,22 +54,30 @@ 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[]
@@unique([repositoryId, issueNumber, labelName])
@@index([repositoryId, issueNumber])
+ @@index([issueUpdatedAt])
+ @@index([amount])
}
model RewardAttempt {
diff --git a/src/app/deploy/page.tsx b/src/app/deploy/page.tsx
new file mode 100644
index 0000000..6e14b6f
--- /dev/null
+++ b/src/app/deploy/page.tsx
@@ -0,0 +1,466 @@
+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 /path/to/paytrack/sdks/node",
+ "npm install",
+ "npm run build",
+ "",
+ "cd /path/to/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 your local Pvium SDK checkout, for example{" "}
+
+ /path/to/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 (
+
+ );
+}
+
+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) => (
+ -
+ {item}
+
+ ))}
+
+ );
+}
+
+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",
+ },
+ 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/issue-discovery.tsx b/src/app/issue-discovery.tsx
new file mode 100644
index 0000000..098c26e
--- /dev/null
+++ b/src/app/issue-discovery.tsx
@@ -0,0 +1,330 @@
+"use client";
+
+import { useMemo, useState, type CSSProperties } from "react";
+
+type DiscoveryIssue = {
+ id: string;
+ title: string;
+ repositoryName: string;
+ amount: number;
+ currency: string;
+ status: string;
+ url: string;
+ excerpt: string;
+ createdAt: string;
+ updatedAt: string;
+ recentAt: string;
+};
+
+type ViewMode = "recent" | "top";
+type SortOrder = "desc" | "asc";
+
+export function IssueDiscovery({ issues }: { issues: DiscoveryIssue[] }) {
+ const [viewMode, setViewMode] = useState("recent");
+ const [sortOrder, setSortOrder] = useState("desc");
+ const [minimumBounty, setMinimumBounty] = useState("");
+
+ const minimumAmount = Number.parseFloat(minimumBounty);
+ const hasMinimum = Number.isFinite(minimumAmount);
+
+ const visibleIssues = useMemo(() => {
+ return issues
+ .filter((issue) => !hasMinimum || issue.amount >= minimumAmount)
+ .sort((left, right) => {
+ const direction = sortOrder === "desc" ? -1 : 1;
+ if (viewMode === "top") {
+ return (left.amount - right.amount) * direction;
+ }
+
+ return (
+ (new Date(left.recentAt).getTime() -
+ new Date(right.recentAt).getTime()) *
+ direction
+ );
+ });
+ }, [hasMinimum, issues, minimumAmount, sortOrder, viewMode]);
+
+ return (
+
+
+
+
+
+
+ {visibleIssues.length ? (
+ visibleIssues.map((issue) => )
+ ) : (
+ No bounty issues match this filter.
+ )}
+
+
+ );
+}
+
+function IssueCard({ issue }: { issue: DiscoveryIssue }) {
+ return (
+
+
+
+
{issue.repositoryName}
+
{issue.title}
+
+
+ {formatAmount(issue.amount)} {issue.currency}
+
+
+ {issue.excerpt ? {issue.excerpt}
: null}
+
+ {issue.status}
+ Created {formatDate(issue.createdAt)}
+ Updated {formatDate(issue.updatedAt)}
+
+
+ );
+}
+
+function formatAmount(amount: number) {
+ return new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 6,
+ }).format(amount);
+}
+
+function formatDate(value: string) {
+ return new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(new Date(value));
+}
+
+const styles = {
+ page: {
+ minHeight: "100vh",
+ background: "#f6f7fb",
+ color: "#101828",
+ padding: "48px min(6vw, 72px)",
+ fontFamily:
+ 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ },
+ header: {
+ display: "flex",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ gap: "24px",
+ margin: "0 auto 28px",
+ maxWidth: "1120px",
+ },
+ poweredBy: {
+ display: "inline-flex",
+ color: "#475467",
+ textDecoration: "none",
+ fontSize: "14px",
+ marginBottom: "18px",
+ },
+ title: {
+ margin: 0,
+ fontSize: "clamp(40px, 6vw, 72px)",
+ lineHeight: 0.95,
+ letterSpacing: 0,
+ },
+ lede: {
+ maxWidth: "640px",
+ margin: "18px 0 0",
+ color: "#475467",
+ fontSize: "18px",
+ lineHeight: 1.6,
+ },
+ deployLink: {
+ border: "1px solid #d0d5dd",
+ background: "#ffffff",
+ color: "#344054",
+ borderRadius: "8px",
+ padding: "10px 14px",
+ textDecoration: "none",
+ fontWeight: 700,
+ },
+ controls: {
+ display: "grid",
+ gridTemplateColumns: "minmax(260px, 1fr) 180px 180px",
+ gap: "12px",
+ maxWidth: "1120px",
+ margin: "0 auto 18px",
+ alignItems: "end",
+ },
+ segment: {
+ display: "grid",
+ gridTemplateColumns: "1fr 1fr",
+ border: "1px solid #d0d5dd",
+ borderRadius: "8px",
+ background: "#ffffff",
+ padding: "4px",
+ },
+ segmentButton: {
+ border: 0,
+ background: "transparent",
+ color: "#475467",
+ borderRadius: "6px",
+ padding: "10px 12px",
+ fontWeight: 700,
+ cursor: "pointer",
+ },
+ segmentButtonActive: {
+ background: "#101828",
+ color: "#ffffff",
+ },
+ field: {
+ display: "grid",
+ gap: "6px",
+ },
+ fieldLabel: {
+ color: "#667085",
+ fontSize: "13px",
+ fontWeight: 700,
+ },
+ input: {
+ width: "100%",
+ boxSizing: "border-box",
+ border: "1px solid #d0d5dd",
+ borderRadius: "8px",
+ background: "#ffffff",
+ color: "#101828",
+ padding: "11px 12px",
+ font: "inherit",
+ },
+ list: {
+ display: "grid",
+ gap: "12px",
+ maxWidth: "1120px",
+ margin: "0 auto",
+ },
+ card: {
+ display: "grid",
+ gap: "14px",
+ background: "#ffffff",
+ border: "1px solid #e4e7ec",
+ borderRadius: "8px",
+ padding: "20px",
+ color: "inherit",
+ textDecoration: "none",
+ boxShadow: "0 1px 2px rgba(16, 24, 40, 0.04)",
+ },
+ cardHeader: {
+ display: "flex",
+ justifyContent: "space-between",
+ gap: "18px",
+ },
+ repo: {
+ margin: "0 0 6px",
+ color: "#667085",
+ fontSize: "13px",
+ fontWeight: 700,
+ },
+ issueTitle: {
+ margin: 0,
+ fontSize: "22px",
+ lineHeight: 1.25,
+ letterSpacing: 0,
+ },
+ amount: {
+ flexShrink: 0,
+ height: "fit-content",
+ background: "#ecfdf3",
+ color: "#027a48",
+ borderRadius: "8px",
+ padding: "8px 10px",
+ fontWeight: 800,
+ },
+ excerpt: {
+ margin: 0,
+ color: "#475467",
+ lineHeight: 1.6,
+ },
+ metaRow: {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "10px",
+ color: "#667085",
+ fontSize: "14px",
+ },
+ status: {
+ color: "#344054",
+ fontWeight: 800,
+ textTransform: "capitalize" as const,
+ },
+ empty: {
+ background: "#ffffff",
+ border: "1px solid #e4e7ec",
+ borderRadius: "8px",
+ padding: "28px",
+ color: "#667085",
+ },
+} satisfies Record;
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 659e33f..e8d1116 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,466 +1,45 @@
-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 (
-
- );
-}
-
-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) => (
- -
- {item}
-
- ))}
-
- );
-}
-
-function CodeBlock({ value }: { value: string }) {
- return {value};
+import { IssueDiscovery } from "@/app/issue-discovery";
+import { prisma } from "@/lib/db/prisma";
+
+export const dynamic = "force-dynamic";
+
+export default async function Home() {
+ const bounties = await prisma.bounty.findMany({
+ include: {
+ repository: true,
+ },
+ orderBy: [{ updatedAt: "desc" }],
+ });
+
+ const issues = bounties.map((bounty) => {
+ const githubIssueUrl =
+ bounty.issueUrl ??
+ `https://github.com/${bounty.repository.owner}/${bounty.repository.repo}/issues/${bounty.issueNumber}`;
+ const systemStatus = bounty.status.toLowerCase().replaceAll("_", " ");
+ const issueStatus = bounty.issueState?.toLowerCase();
+ const displayStatus =
+ bounty.status === "OPEN" && issueStatus ? issueStatus : systemStatus;
+
+ return {
+ id: bounty.id,
+ title: bounty.issueTitle ?? `Issue #${bounty.issueNumber}`,
+ repositoryName: `${bounty.repository.owner}/${bounty.repository.repo}`,
+ amount: Number(bounty.amount),
+ currency: bounty.currency,
+ status: displayStatus,
+ url: githubIssueUrl,
+ excerpt: bounty.issueBodyExcerpt ?? "",
+ createdAt: (
+ bounty.issueCreatedAt ?? bounty.createdAt
+ ).toISOString(),
+ updatedAt: (
+ bounty.issueUpdatedAt ?? bounty.updatedAt
+ ).toISOString(),
+ recentAt: (
+ bounty.issueUpdatedAt ?? 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..43ddde5 100644
--- a/src/lib/github/webhook-handler.ts
+++ b/src/lib/github/webhook-handler.ts
@@ -51,8 +51,14 @@ export async function handleGithubWebhook(params: {
},
});
- if (params.event === "issues" && action === "labeled") {
- return handleIssueLabeled(params.payload);
+ if (params.event === "issues") {
+ if (action === "labeled") {
+ return handleIssueLabeled(params.payload);
+ }
+
+ if (["edited", "closed", "reopened"].includes(action)) {
+ return handleIssueUpdated(params.payload);
+ }
}
if (params.event === "pull_request" && action === "closed") {
@@ -122,11 +128,13 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
amount: parsed.amount,
currency: parsed.currency,
status: "OPEN",
+ ...getIssueMetadata(payload.issue),
},
create: {
repositoryId: repository.id,
issueNumber: payload.issue.number,
issueNodeId: payload.issue.node_id,
+ ...getIssueMetadata(payload.issue),
labelName: parsed.raw,
amount: parsed.amount,
currency: parsed.currency,
@@ -156,6 +164,55 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
return { bountyId: bounty.id };
}
+async function handleIssueUpdated(payload: GithubWebhookPayload) {
+ const issue = payload.issue;
+ if (!issue?.number) {
+ return { ignored: true, reason: "Issue payload is missing a number" };
+ }
+
+ const repository = await upsertRepository(payload);
+ const updated = await prisma.bounty.updateMany({
+ where: {
+ repositoryId: repository.id,
+ issueNumber: issue.number,
+ },
+ data: getIssueMetadata(issue),
+ });
+
+ console.log("[github-webhook] issue metadata updated", {
+ repository: payload.repository?.full_name,
+ issueNumber: issue.number,
+ bountiesUpdated: updated.count,
+ });
+
+ return { updated: updated.count };
+}
+
+function getIssueMetadata(issue: GithubWebhookPayload) {
+ const body = typeof issue?.body === "string" ? issue.body : "";
+
+ return {
+ issueTitle: issue?.title ?? null,
+ issueUrl: issue?.html_url ?? null,
+ issueState: issue?.state ?? null,
+ issueBodyExcerpt: excerptMarkdown(body),
+ issueCreatedAt: issue?.created_at ? new Date(issue.created_at) : null,
+ issueUpdatedAt: issue?.updated_at ? new Date(issue.updated_at) : null,
+ };
+}
+
+function excerptMarkdown(value: string) {
+ const excerpt = value
+ .replace(/```[\s\S]*?```/g, " ")
+ .replace(/!\[[^\]]*]\([^)]+\)/g, " ")
+ .replace(/\[[^\]]*]\(([^)]+)\)/g, "$1")
+ .replace(/[#>*_`~-]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return excerpt ? excerpt.slice(0, 280) : null;
+}
+
async function handlePullRequestClosed(payload: GithubWebhookPayload) {
const env = getEnv();
const pullRequest = payload.pull_request;