From c3f21c1d85e16a618f954683da55f36d92e554ac Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 7 Jun 2026 22:01:28 +0800 Subject: [PATCH] feat(web,contracts): implement PR Merge Validation for anti-fraud payout --- apps/web/src/app/api/mcp/[tool]/route.ts | 7 +- apps/web/src/app/api/webhooks/github/route.ts | 74 +++++++++++++++++++ packages/contracts/src/schemas/index.ts | 2 + 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/webhooks/github/route.ts diff --git a/apps/web/src/app/api/mcp/[tool]/route.ts b/apps/web/src/app/api/mcp/[tool]/route.ts index f6ba5d5..d8f825e 100644 --- a/apps/web/src/app/api/mcp/[tool]/route.ts +++ b/apps/web/src/app/api/mcp/[tool]/route.ts @@ -645,12 +645,15 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool // Update Task & Claim Status const newTaskStatus = result.overall_result === JudgeOverallResult.PASS - ? TaskStatus.COMPLETED + ? TaskStatus.PENDING_REVIEW : TaskStatus.FAILED_RETRYABLE; await tx.task.update({ where: { id: submission.task_id }, - data: { status: newTaskStatus } + data: { + status: newTaskStatus, + github_pr_url: parsed.github_pr_url + } }); await tx.claim.update({ diff --git a/apps/web/src/app/api/webhooks/github/route.ts b/apps/web/src/app/api/webhooks/github/route.ts new file mode 100644 index 0000000..612b4c7 --- /dev/null +++ b/apps/web/src/app/api/webhooks/github/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { capturePayment } from "@/lib/payment"; +import { TaskStatus } from "@agent-bounty/contracts"; + +export async function POST(request: NextRequest) { + try { + const eventType = request.headers.get("x-github-event"); + + // We only care about pull request events + if (eventType !== "pull_request") { + return NextResponse.json({ received: true }); + } + + const payload = await request.json(); + + // When a PR is merged, the action is "closed" and "merged" is true. + if (payload.action === "closed" && payload.pull_request) { + const pr = payload.pull_request; + if (pr.merged) { + const prUrl = pr.html_url; + + // Find the Task associated with this PR + const task = await prisma.task.findFirst({ + where: { + github_pr_url: prUrl, + status: TaskStatus.PENDING_REVIEW, + }, + }); + + if (task) { + // Arbitrary point conversion: 1 USD cent = 1 Point + const points = task.reward_amount; + + await prisma.$transaction(async (tx) => { + // Update Task to COMPLETED + await tx.task.update({ + where: { id: task.id }, + data: { + status: TaskStatus.COMPLETED, + reward_points: points, + }, + }); + + // Find the active claim for this task + const claim = await tx.claim.findFirst({ + where: { task_id: task.id, status: TaskStatus.PENDING_REVIEW }, + orderBy: { created_at: "desc" } + }); + + if (claim) { + await tx.claim.update({ + where: { id: claim.id }, + data: { status: TaskStatus.COMPLETED }, + }); + + // Process payout + await capturePayment(tx, task.id, `${claim.id}-capture-pr`); + } + }); + + console.log(`[GitHub Webhook] PR merged. Task ${task.id} COMPLETED and paid.`); + } else { + console.log(`[GitHub Webhook] No PENDING_REVIEW task found for PR: ${prUrl}`); + } + } + } + + return NextResponse.json({ received: true }); + } catch (error: any) { + console.error("[GitHub Webhook Error]", error); + return NextResponse.json({ error: "Internal Error" }, { status: 500 }); + } +} diff --git a/packages/contracts/src/schemas/index.ts b/packages/contracts/src/schemas/index.ts index facc6cd..7cd71f7 100644 --- a/packages/contracts/src/schemas/index.ts +++ b/packages/contracts/src/schemas/index.ts @@ -169,6 +169,8 @@ export const SubmitSolutionRequestSchema = z.object({ /** 接案時取得的冪等憑證,防止重複提交 */ claim_token: z.string().uuid(), deliverables: z.record(z.string(), z.string()), + /** 必須在目標專案開啟 Pull Request,並提供該 PR 的網址以供人類審核 */ + github_pr_url: z.string().url().optional(), }); export const SubmitSolutionResponseSchema = z.object({