From aee191580440b73fcc30b83059aee1cd80fc766a Mon Sep 17 00:00:00 2001 From: Zhenvip <171159317+Zhenvip@users.noreply.github.com> Date: Tue, 26 May 2026 03:16:58 +0800 Subject: [PATCH] feat: add bounty issue discovery homepage --- package-lock.json | 417 +++++++--- .../migration.sql | 9 + prisma/schema.prisma | 28 +- src/app/deploy/page.tsx | 469 +++++++++++ src/app/layout.tsx | 4 +- src/app/page.tsx | 733 +++++++++--------- src/lib/github/webhook-handler.ts | 65 +- src/lib/issues/discovery.ts | 116 +++ tests/issue-discovery.test.ts | 134 ++++ 9 files changed, 1495 insertions(+), 480 deletions(-) create mode 100644 prisma/migrations/20260525190000_add_issue_discovery_metadata/migration.sql create mode 100644 src/app/deploy/page.tsx create mode 100644 src/lib/issues/discovery.ts create mode 100644 tests/issue-discovery.test.ts diff --git a/package-lock.json b/package-lock.json index cd9f570..9b7045e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@octokit/rest": "^21.1.1", "@octokit/webhooks": "^13.9.0", "@prisma/client": "^6.17.1", - "@pvium/sdk": "file:../sdks/node", + "@pvium/sdk": "0.2.3", "next": "^15.5.6", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -28,20 +28,11 @@ "typescript": "^5.8.3" } }, - "../sdks/node": { - "name": "@pvium/sdk", - "version": "0.2.2", - "license": "MIT", - "dependencies": { - "crypto-js": "^4.2.0", - "ethers": "^6.16.0", - "keccak256": "^1.0.6", - "merkletreejs": "^0.6.0" - }, - "devDependencies": { - "@types/crypto-js": "^4.2.2", - "typescript": "^5.8.2" - } + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" }, "node_modules/@emnapi/core": { "version": "1.10.0", @@ -379,9 +370,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -398,9 +386,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -417,9 +402,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -436,9 +418,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -455,9 +434,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -474,9 +450,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -493,9 +466,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -512,9 +482,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -531,9 +498,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -556,9 +520,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -581,9 +542,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -606,9 +564,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -631,9 +586,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -656,9 +608,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -681,9 +630,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -706,9 +652,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -868,9 +811,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -887,9 +827,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -906,9 +843,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -925,9 +859,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -969,6 +900,30 @@ "node": ">= 10" } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1471,8 +1426,16 @@ } }, "node_modules/@pvium/sdk": { - "resolved": "../sdks/node", - "link": true + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@pvium/sdk/-/sdk-0.2.3.tgz", + "integrity": "sha512-nsnUAma89tuhHZAg8eOLt4Qojeo+F6tTvQhwYOHauFzW2WUGfm+DttPXJKBWCPf+awWPmZzX3Vikfc5wo3B31A==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "ethers": "^6.16.0", + "keccak256": "^1.0.6", + "merkletreejs": "^0.6.0" + } }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -1966,9 +1929,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1983,9 +1943,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2000,9 +1957,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2017,9 +1971,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2034,9 +1985,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2051,9 +1999,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2068,9 +2013,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2085,9 +2027,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2176,6 +2115,12 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2446,12 +2391,38 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", "license": "Apache-2.0" }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -2476,6 +2447,36 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-reverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-reverse/-/buffer-reverse-1.0.1.tgz", + "integrity": "sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==", + "license": "MIT" + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -2693,6 +2694,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3522,6 +3529,49 @@ "node": ">=0.10.0" } }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -3998,6 +4048,26 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4035,6 +4105,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4564,6 +4640,32 @@ "node": ">=4.0" } }, + "node_modules/keccak": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz", + "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^2.0.0", + "node-gyp-build": "^4.2.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/keccak256": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/keccak256/-/keccak256-1.0.6.tgz", + "integrity": "sha512-8GLiM01PkdJVGUhR1e6M/AvWnSqYS0HaERI+K/QtStGDGlSTx2B1zTqZk4Zlqu5TxHJNTxWAdP9Y+WI50OApUw==", + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.0", + "buffer": "^6.0.3", + "keccak": "^3.0.2" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4664,6 +4766,20 @@ "node": ">= 8" } }, + "node_modules/merkletreejs": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/merkletreejs/-/merkletreejs-0.6.0.tgz", + "integrity": "sha512-cyiratjG7fyHsa4DVfYVPxcoAh3zmUuOPItIfZex8f0pUVptNEmiiTOoeS0JnDDTWy+n3FKnI0K1gCzti7rGMg==", + "license": "MIT", + "dependencies": { + "buffer-reverse": "^1.0.1", + "crypto-js": "^4.2.0", + "treeify": "^1.1.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4801,6 +4917,12 @@ } } }, + "node_modules/node-addon-api": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz", + "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -4837,6 +4959,17 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nypm": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", @@ -5318,6 +5451,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5475,6 +5622,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5752,6 +5919,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6017,6 +6193,15 @@ "node": ">=12" } }, + "node_modules/treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6047,8 +6232,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -6238,6 +6422,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6353,6 +6543,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/prisma/migrations/20260525190000_add_issue_discovery_metadata/migration.sql b/prisma/migrations/20260525190000_add_issue_discovery_metadata/migration.sql new file mode 100644 index 0000000..b39872e --- /dev/null +++ b/prisma/migrations/20260525190000_add_issue_discovery_metadata/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "Bounty" ADD COLUMN "issueTitle" TEXT; +ALTER TABLE "Bounty" ADD COLUMN "issueUrl" TEXT; +ALTER TABLE "Bounty" ADD COLUMN "issueState" TEXT; +ALTER TABLE "Bounty" ADD COLUMN "issueBodyExcerpt" TEXT; +ALTER TABLE "Bounty" ADD COLUMN "issueCreatedAt" TIMESTAMP(3); +ALTER TABLE "Bounty" ADD COLUMN "issueUpdatedAt" TIMESTAMP(3); + +CREATE INDEX "Bounty_status_amount_idx" ON "Bounty"("status", "amount"); +CREATE INDEX "Bounty_issueUpdatedAt_idx" ON "Bounty"("issueUpdatedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index df1171d..9f896d4 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([status, amount]) + @@index([issueUpdatedAt]) } model RewardAttempt { diff --git a/src/app/deploy/page.tsx b/src/app/deploy/page.tsx new file mode 100644 index 0000000..5c63fe6 --- /dev/null +++ b/src/app/deploy/page.tsx @@ -0,0 +1,469 @@ +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 ( +
+
+
+
+ + 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", + }, + 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/layout.tsx b/src/app/layout.tsx index 06ca38e..a1246e0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,9 +2,9 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; export const metadata: Metadata = { - title: "Pvium GitHub App", + title: "Pvium Bounty Issues", description: - "Turn merged GitHub pull requests into Pvium contributor rewards.", + "Discover Pvium bounty issues across connected GitHub repositories.", }; export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/src/app/page.tsx b/src/app/page.tsx index 659e33f..71628fb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,129 +1,42 @@ -import type { CSSProperties, ReactNode } from "react"; +import type { CSSProperties } from "react"; +import { prisma } from "@/lib/db/prisma"; +import { + buildIssueDiscoveryItems, + filterAndSortIssueDiscoveryItems, + normalizeIssueDiscoveryQuery, +} from "@/lib/issues/discovery"; -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.", -]; +type SearchParams = Record; -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", -]; +interface HomeProps { + searchParams?: Promise; +} -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 async function Home({ searchParams }: HomeProps) { + const query = normalizeIssueDiscoveryQuery((await searchParams) ?? {}); + const bounties = await prisma.bounty.findMany({ + include: { repository: true }, + orderBy: [{ issueUpdatedAt: "desc" }, { updatedAt: "desc" }], + take: 250, + }); + const items = filterAndSortIssueDiscoveryItems( + buildIssueDiscoveryItems(bounties), + query, + ); + const repositoryCount = new Set(items.map((item) => item.repositoryName)).size; + const totalAmount = items.reduce((sum, item) => sum + item.amount, 0); + const currencies = new Set(items.map((item) => item.currency)); + const totalAmountLabel = + items.length === 0 + ? "0" + : currencies.size === 1 + ? `${formatAmount(totalAmount)} ${Array.from(currencies)[0]}` + : "Mixed currencies"; -export default function Home() { return (
-
-
- +
+
Pvium logo +
+

Pvium GitHub App

+

Bounty issue discovery

+
-

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

-
+
+ {items.length ? ( + items.map((item) => ) + ) : ( +
+

No bounty issues found

+

+ Adjust the minimum bounty or wait for connected repositories to + register Pvium bounty labels. +

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

{title}

- {children} -
- ); -} - -function Endpoint({ label, value }: { label: string; value: string }) { +function Metric({ label, value }: { label: string; value: string }) { return ( -
- {label} - {value} +
+ {label} + {value}
); } -function ListBlock({ title, items }: { title: string; items: string[] }) { +function IssueRow({ + item, +}: { + item: ReturnType[number]; +}) { return ( -
-

{title}

- -
+
+
+
+ {item.repositoryName} + #{item.issueNumber} + {item.status} +
+

+ + {item.title} + +

+ {item.excerpt ?

{item.excerpt}

: null} +
+
+ {item.amountLabel} + Created {formatDate(item.createdAt)} + Updated {formatDate(item.updatedAt)} +
+
); } -function BulletList({ items }: { items: string[] }) { - return ( -
    - {items.map((item) => ( -
  • - {item} -
  • - ))} -
- ); +function statusStyle(status: string): CSSProperties { + const normalizedStatus = status.toLowerCase(); + + if (normalizedStatus === "open") { + return { ...styles.status, ...styles.statusOpen }; + } + + if (["paid", "invoice_created"].includes(normalizedStatus)) { + return { ...styles.status, ...styles.statusPaid }; + } + + return styles.status; } -function NumberedList({ items }: { items: string[] }) { - return ( -
    - {items.map((item) => ( -
  1. - {item} -
  2. - ))} -
- ); +function formatDate(date: Date) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); } -function CodeBlock({ value }: { value: string }) { - return
{value}
; +function formatAmount(value: number) { + return new Intl.NumberFormat("en", { + maximumFractionDigits: 2, + }).format(value); } const styles: Record = { page: { minHeight: "100vh", margin: 0, - padding: "48px 20px", - background: "#f7f8fb", - color: "#172033", + padding: "28px 20px 48px", + background: "#f5f7fb", + color: "#182235", 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: { + header: { display: "flex", alignItems: "center", justifyContent: "space-between", - gap: 12, - marginBottom: 18, + gap: 18, + maxWidth: 1120, + margin: "0 auto 20px", + flexWrap: "wrap", }, - topLinks: { + brandBlock: { display: "flex", alignItems: "center", - flexWrap: "wrap", - gap: 10, + gap: 14, + minWidth: 0, + }, + logoLink: { + display: "inline-flex", + flex: "0 0 auto", + lineHeight: 0, }, logo: { - width: 96, - height: 96, + width: 58, + height: 58, borderRadius: 8, objectFit: "contain", }, - logoLink: { - display: "inline-flex", - lineHeight: 0, + eyebrow: { + margin: "0 0 3px", + color: "#617188", + fontSize: 13, + fontWeight: 700, + textTransform: "uppercase", }, - poweredBy: { - display: "inline-flex", + title: { + margin: 0, + color: "#101827", + fontSize: 32, + lineHeight: 1.15, + letterSpacing: 0, + }, + nav: { + display: "flex", alignItems: "center", - padding: "9px 13px", - border: "1px solid #c8d0df", - borderRadius: 999, - background: "#ffffff", - color: "#172033", - fontSize: 14, - fontWeight: 600, - textDecoration: "none", + gap: 10, + flexWrap: "wrap", }, - installLink: { + primaryLink: { display: "inline-flex", alignItems: "center", - padding: "10px 14px", + justifyContent: "center", + minHeight: 40, + padding: "0 14px", borderRadius: 8, - background: "#172033", + background: "#182235", color: "#ffffff", fontSize: 14, fontWeight: 700, textDecoration: "none", }, - eyebrow: { - margin: "0 0 12px", - color: "#52627a", + secondaryLink: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minHeight: 40, + padding: "0 14px", + border: "1px solid #c9d2df", + borderRadius: 8, + background: "#ffffff", + color: "#182235", 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, + textDecoration: "none", }, - endpointGrid: { + metricsGrid: { display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))", + gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))", gap: 12, - maxWidth: 920, + maxWidth: 1120, + margin: "0 auto 12px", }, - endpoint: { - border: "1px solid #d9deea", + metric: { + minHeight: 82, + padding: 16, + border: "1px solid #dce2eb", borderRadius: 8, background: "#ffffff", - padding: 16, }, - endpointLabel: { + metricLabel: { display: "block", marginBottom: 8, - color: "#66748a", + color: "#66758d", fontSize: 13, fontWeight: 700, }, - endpointCode: { - color: "#172033", - fontSize: 14, - wordBreak: "break-word", + metricValue: { + display: "block", + color: "#111827", + fontSize: 24, + lineHeight: 1.15, }, - section: { - maxWidth: 980, - margin: "18px auto", - padding: 24, - border: "1px solid #d9deea", + controls: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", + alignItems: "end", + gap: 12, + maxWidth: 1120, + margin: "0 auto 16px", + padding: 14, + border: "1px solid #dce2eb", borderRadius: 8, background: "#ffffff", }, - sectionTitle: { - margin: "0 0 16px", - fontSize: 24, - letterSpacing: 0, + controlLabel: { + display: "grid", + gap: 6, + minWidth: 0, }, - paragraph: { - margin: "0 0 14px", - color: "#46556e", - fontSize: 15, - lineHeight: 1.7, + controlText: { + color: "#56667d", + fontSize: 13, + fontWeight: 700, + }, + select: { + width: "100%", + minHeight: 40, + border: "1px solid #c9d2df", + borderRadius: 8, + background: "#ffffff", + color: "#182235", + fontSize: 14, + padding: "0 10px", + }, + input: { + width: "100%", + minHeight: 40, + boxSizing: "border-box", + border: "1px solid #c9d2df", + borderRadius: 8, + background: "#ffffff", + color: "#182235", + fontSize: 14, + padding: "0 10px", }, - columns: { + applyButton: { + minHeight: 40, + border: "1px solid #182235", + borderRadius: 8, + background: "#182235", + color: "#ffffff", + fontSize: 14, + fontWeight: 700, + cursor: "pointer", + }, + issueList: { display: "grid", - gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", - gap: 18, + gap: 10, + maxWidth: 1120, + margin: "0 auto", + }, + issueRow: { + display: "flex", + justifyContent: "space-between", + flexWrap: "wrap", + gap: 20, + padding: 18, + border: "1px solid #dce2eb", + borderRadius: 8, + background: "#ffffff", }, - listBlock: { + issueMain: { minWidth: 0, + flex: "1 1 520px", + }, + issueMeta: { + display: "flex", + alignItems: "center", + gap: 8, + flexWrap: "wrap", + marginBottom: 7, + }, + repositoryName: { + color: "#38506f", + fontSize: 13, + fontWeight: 800, + }, + issueNumber: { + color: "#68778c", + fontSize: 13, + fontWeight: 700, + }, + status: { + display: "inline-flex", + alignItems: "center", + minHeight: 22, + padding: "0 8px", + borderRadius: 999, + background: "#eef1f5", + color: "#56667d", + fontSize: 12, + fontWeight: 800, + textTransform: "uppercase", + }, + statusOpen: { + background: "#e5f7ed", + color: "#106137", + }, + statusPaid: { + background: "#fff0cf", + color: "#7a4c00", + }, + issueTitle: { + margin: "0 0 8px", + fontSize: 19, + lineHeight: 1.3, + letterSpacing: 0, }, - listTitle: { - margin: "0 0 10px", - color: "#263247", - fontSize: 16, + issueLink: { + color: "#111827", + textDecoration: "none", }, - list: { + excerpt: { margin: 0, - paddingLeft: 22, - color: "#46556e", - fontSize: 15, - lineHeight: 1.7, + color: "#52627a", + fontSize: 14, + lineHeight: 1.6, }, - listItem: { - marginBottom: 8, + issueAside: { + display: "grid", + flex: "0 1 190px", + minWidth: 150, + alignContent: "start", + justifyItems: "end", + gap: 6, + textAlign: "right", }, - codeBlock: { - margin: "14px 0 0", - padding: 16, - overflowX: "auto", - borderRadius: 8, - background: "#141925", - color: "#eef3ff", + amount: { + color: "#0f7a49", + fontSize: 20, + lineHeight: 1.2, + }, + dateText: { + color: "#68778c", fontSize: 13, - lineHeight: 1.6, + lineHeight: 1.35, + }, + emptyState: { + padding: 28, + border: "1px solid #dce2eb", + borderRadius: 8, + background: "#ffffff", + }, + emptyTitle: { + margin: "0 0 8px", + color: "#111827", + fontSize: 20, + letterSpacing: 0, }, - inlineCode: { - padding: "2px 5px", - borderRadius: 5, - background: "#eef1f6", - color: "#263247", - fontSize: "0.92em", + emptyText: { + margin: 0, + color: "#52627a", + fontSize: 14, + lineHeight: 1.6, }, }; diff --git a/src/lib/github/webhook-handler.ts b/src/lib/github/webhook-handler.ts index 46a7c5e..22b0589 100644 --- a/src/lib/github/webhook-handler.ts +++ b/src/lib/github/webhook-handler.ts @@ -55,6 +55,13 @@ export async function handleGithubWebhook(params: { return handleIssueLabeled(params.payload); } + if ( + params.event === "issues" && + ["opened", "edited", "reopened", "closed"].includes(action) + ) { + return handleIssueChanged(params.payload); + } + if (params.event === "pull_request" && action === "closed") { return handlePullRequestClosed(params.payload); } @@ -96,6 +103,36 @@ async function upsertRepository(payload: GithubWebhookPayload) { }); } +function getIssueMetadata(issue: any) { + return { + issueNodeId: issue?.node_id ?? null, + issueTitle: issue?.title ?? null, + issueUrl: issue?.html_url ?? null, + issueState: issue?.state ?? null, + issueBodyExcerpt: createIssueBodyExcerpt(issue?.body), + issueCreatedAt: parseGithubDate(issue?.created_at), + issueUpdatedAt: parseGithubDate(issue?.updated_at), + }; +} + +function createIssueBodyExcerpt(body: unknown) { + if (typeof body !== "string") return null; + + const normalized = body.replace(/\s+/g, " ").trim(); + if (!normalized) return null; + + return normalized.length > 240 + ? `${normalized.slice(0, 237)}...` + : normalized; +} + +function parseGithubDate(value: unknown) { + if (typeof value !== "string") return null; + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + async function handleIssueLabeled(payload: GithubWebhookPayload) { const parsed = parseBountyLabel(payload.label?.name ?? ""); if (!parsed) { @@ -110,6 +147,7 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { } const repository = await upsertRepository(payload); + const issueMetadata = getIssueMetadata(payload.issue); const bounty = await prisma.bounty.upsert({ where: { repositoryId_issueNumber_labelName: { @@ -119,6 +157,7 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { }, }, update: { + ...issueMetadata, amount: parsed.amount, currency: parsed.currency, status: "OPEN", @@ -126,7 +165,7 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { create: { repositoryId: repository.id, issueNumber: payload.issue.number, - issueNodeId: payload.issue.node_id, + ...issueMetadata, labelName: parsed.raw, amount: parsed.amount, currency: parsed.currency, @@ -156,6 +195,30 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) { return { bountyId: bounty.id }; } +async function handleIssueChanged(payload: GithubWebhookPayload) { + const issueNumber = payload.issue?.number; + if (!issueNumber) { + return { ignored: true, reason: "Issue payload missing number" }; + } + + const repository = await upsertRepository(payload); + const result = await prisma.bounty.updateMany({ + where: { + repositoryId: repository.id, + issueNumber, + }, + data: getIssueMetadata(payload.issue), + }); + + console.log("[github-webhook] issue metadata refreshed", { + repository: payload.repository?.full_name, + issueNumber, + updatedBounties: result.count, + }); + + return { updated: result.count }; +} + async function handlePullRequestClosed(payload: GithubWebhookPayload) { const env = getEnv(); const pullRequest = payload.pull_request; diff --git a/src/lib/issues/discovery.ts b/src/lib/issues/discovery.ts new file mode 100644 index 0000000..bc067b1 --- /dev/null +++ b/src/lib/issues/discovery.ts @@ -0,0 +1,116 @@ +export type IssueDiscoverySort = "recent" | "top"; +export type IssueDiscoveryOrder = "asc" | "desc"; + +export interface IssueDiscoveryQuery { + minBounty: number; + sort: IssueDiscoverySort; + order: IssueDiscoveryOrder; +} + +export interface IssueDiscoveryBounty { + issueNumber: number; + issueTitle?: string | null; + issueUrl?: string | null; + issueState?: string | null; + issueBodyExcerpt?: string | null; + issueCreatedAt?: Date | null; + issueUpdatedAt?: Date | null; + amount: number | string | { toString(): string }; + currency: string; + status: string; + createdAt: Date; + updatedAt: Date; + repository: { + owner: string; + repo: string; + }; +} + +export interface IssueDiscoveryItem { + id: string; + repositoryName: string; + issueNumber: number; + issueReference: string; + title: string; + url: string; + amount: number; + amountLabel: string; + currency: string; + status: string; + excerpt: string | null; + createdAt: Date; + updatedAt: Date; +} + +export function buildIssueDiscoveryItems( + bounties: IssueDiscoveryBounty[], +): IssueDiscoveryItem[] { + return bounties.map((bounty) => { + const repositoryName = `${bounty.repository.owner}/${bounty.repository.repo}`; + const issueReference = `${repositoryName}#${bounty.issueNumber}`; + const amountText = bounty.amount.toString(); + const amount = Number(amountText); + const normalizedAmount = Number.isInteger(amount) + ? amount.toString() + : amount.toString().replace(/0+$/, "").replace(/\.$/, ""); + + return { + id: `${issueReference}:${amountText}:${bounty.currency}`, + repositoryName, + issueNumber: bounty.issueNumber, + issueReference, + title: bounty.issueTitle || issueReference, + url: + bounty.issueUrl || + `https://github.com/${repositoryName}/issues/${bounty.issueNumber}`, + amount, + amountLabel: `${normalizedAmount} ${bounty.currency}`, + currency: bounty.currency, + status: bounty.issueState || bounty.status, + excerpt: bounty.issueBodyExcerpt || null, + createdAt: bounty.issueCreatedAt || bounty.createdAt, + updatedAt: bounty.issueUpdatedAt || bounty.updatedAt, + }; + }); +} + +export function filterAndSortIssueDiscoveryItems( + items: IssueDiscoveryItem[], + query: IssueDiscoveryQuery, +): IssueDiscoveryItem[] { + const orderMultiplier = query.order === "asc" ? 1 : -1; + + return items + .filter((item) => item.amount >= query.minBounty) + .slice() + .sort((left, right) => { + if (query.sort === "top") { + return (left.amount - right.amount) * orderMultiplier; + } + + return ( + (left.updatedAt.getTime() - right.updatedAt.getTime()) * + orderMultiplier + ); + }); +} + +export function normalizeIssueDiscoveryQuery( + params: Record = {}, +): IssueDiscoveryQuery { + const minBountyValue = firstParam(params.minBounty); + const parsedMinBounty = Number(minBountyValue); + + return { + minBounty: + Number.isFinite(parsedMinBounty) && parsedMinBounty > 0 + ? parsedMinBounty + : 0, + sort: firstParam(params.sort) === "top" ? "top" : "recent", + order: firstParam(params.order) === "asc" ? "asc" : "desc", + }; +} + +function firstParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} diff --git a/tests/issue-discovery.test.ts b/tests/issue-discovery.test.ts new file mode 100644 index 0000000..8161ee7 --- /dev/null +++ b/tests/issue-discovery.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + buildIssueDiscoveryItems, + filterAndSortIssueDiscoveryItems, + normalizeIssueDiscoveryQuery, +} from "../src/lib/issues/discovery.ts"; + +const baseRepository = { + owner: "pvium", + repo: "github-app", +}; + +function bounty(overrides: Record = {}) { + return { + issueNumber: 43, + issueTitle: "Add Centralized Issue List", + issueUrl: "https://github.com/pvium/github-app/issues/43", + issueState: "open", + issueBodyExcerpt: "Create a homepage that aggregates bounty issues.", + issueCreatedAt: new Date("2026-05-25T07:22:54.000Z"), + issueUpdatedAt: new Date("2026-05-25T07:46:12.000Z"), + amount: { toString: () => "25.000000" }, + currency: "USDC", + status: "OPEN", + createdAt: new Date("2026-05-25T07:38:51.000Z"), + updatedAt: new Date("2026-05-25T07:46:12.000Z"), + repository: baseRepository, + ...overrides, + }; +} + +describe("issue discovery", () => { + it("builds issue cards from stored bounty metadata", () => { + assert.deepEqual(buildIssueDiscoveryItems([bounty()]), [ + { + id: "pvium/github-app#43:25.000000:USDC", + repositoryName: "pvium/github-app", + issueNumber: 43, + issueReference: "pvium/github-app#43", + title: "Add Centralized Issue List", + url: "https://github.com/pvium/github-app/issues/43", + amount: 25, + amountLabel: "25 USDC", + currency: "USDC", + status: "open", + excerpt: "Create a homepage that aggregates bounty issues.", + createdAt: new Date("2026-05-25T07:22:54.000Z"), + updatedAt: new Date("2026-05-25T07:46:12.000Z"), + }, + ]); + }); + + it("falls back to repository data when GitHub issue metadata is missing", () => { + const [item] = buildIssueDiscoveryItems([ + bounty({ + issueNumber: 44, + issueTitle: null, + issueUrl: null, + issueState: null, + issueBodyExcerpt: null, + issueCreatedAt: null, + issueUpdatedAt: null, + amount: "5", + }), + ]); + + assert.equal(item.title, "pvium/github-app#44"); + assert.equal(item.url, "https://github.com/pvium/github-app/issues/44"); + assert.equal(item.status, "OPEN"); + assert.equal(item.amountLabel, "5 USDC"); + assert.deepEqual(item.createdAt, new Date("2026-05-25T07:38:51.000Z")); + }); + + it("filters by minimum bounty and sorts by top payout", () => { + const items = buildIssueDiscoveryItems([ + bounty({ issueNumber: 1, amount: "10", issueTitle: "Small issue" }), + bounty({ issueNumber: 2, amount: "50", issueTitle: "Large issue" }), + bounty({ issueNumber: 3, amount: "25", issueTitle: "Medium issue" }), + ]); + + assert.deepEqual( + filterAndSortIssueDiscoveryItems(items, { + minBounty: 20, + sort: "top", + order: "desc", + }).map((item) => item.issueNumber), + [2, 3], + ); + }); + + it("sorts recent issues by selected order", () => { + const items = buildIssueDiscoveryItems([ + bounty({ + issueNumber: 1, + issueUpdatedAt: new Date("2026-05-20T00:00:00.000Z"), + }), + bounty({ + issueNumber: 2, + issueUpdatedAt: new Date("2026-05-22T00:00:00.000Z"), + }), + ]); + + assert.deepEqual( + filterAndSortIssueDiscoveryItems(items, { + minBounty: 0, + sort: "recent", + order: "asc", + }).map((item) => item.issueNumber), + [1, 2], + ); + }); + + it("normalizes discovery query params", () => { + assert.deepEqual(normalizeIssueDiscoveryQuery({}), { + minBounty: 0, + sort: "recent", + order: "desc", + }); + + assert.deepEqual( + normalizeIssueDiscoveryQuery({ + minBounty: "12.5", + sort: "top", + order: "asc", + }), + { + minBounty: 12.5, + sort: "top", + order: "asc", + }, + ); + }); +});