diff --git a/apps/scout-bot/src/index.ts b/apps/scout-bot/src/index.ts index 776262e..2d7566c 100644 --- a/apps/scout-bot/src/index.ts +++ b/apps/scout-bot/src/index.ts @@ -120,6 +120,31 @@ async function postDraftWithRetry(payload: { return null; } +async function isIssueAlreadyDrafted(issueUrl: string) { + try { + const response = await fetch( + `${VIBEWORK_API_URL}/scout/issue-exists?issue_url=${encodeURIComponent(issueUrl)}`, + { + headers: { + "Authorization": `Bearer ${SCOUT_API_KEY}`, + }, + } + ); + + if (!response.ok) { + const bodyText = await response.text(); + console.warn(`Issue existence check failed (HTTP ${response.status}): ${bodyText}`); + return false; + } + + const result = await response.json() as { exists?: boolean }; + return Boolean(result.exists); + } catch (error) { + console.warn("Issue existence check error:", error); + return false; + } +} + async function draftBountyTask(issueTitle: string, issueBody: string, issueUrl: string) { const payload = { scout_id: SCOUT_AGENT_ID, @@ -131,6 +156,18 @@ async function draftBountyTask(issueTitle: string, issueBody: string, issueUrl: async function processIssue(owner: string, repo: string, issue: any) { console.log(`Processing issue #${issue.number}: ${issue.title}`); + const issueUrl = issue.html_url; + + if (!issueUrl) { + console.log("Issue URL missing, skip."); + return; + } + + const alreadyDrafted = await isIssueAlreadyDrafted(issueUrl); + if (alreadyDrafted) { + console.log(`Issue already has draft task: ${issueUrl}, skipping.`); + return; + } // Check if we already commented if (GITHUB_TOKEN) { @@ -148,7 +185,7 @@ async function processIssue(owner: string, repo: string, issue: any) { } // Generate draft task on VibeWork - const draft = await draftBountyTask(issue.title, issue.body || "", issue.html_url); + const draft = await draftBountyTask(issue.title, issue.body || "", issueUrl); if (!draft) { console.log(`Failed to generate draft for #${issue.number}, skipping comment.`); @@ -226,7 +263,7 @@ async function bootstrap() { bootstrap(); -// Default: every 3 minutes for phase-1 high-velocity inbound traffic +// Default: every 1 minute for phase-1 high-velocity inbound traffic cron.schedule(SCOUT_CRON_EXPRESSION, () => { console.log(`Running scheduled GitHub scan (${SCOUT_CRON_EXPRESSION})...`); scanRepositories(); diff --git a/apps/web/src/app/api/scout/draft/route.ts b/apps/web/src/app/api/scout/draft/route.ts index 2ec3f10..43a4d42 100644 --- a/apps/web/src/app/api/scout/draft/route.ts +++ b/apps/web/src/app/api/scout/draft/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { ScoutDraftRequestSchema, ScoutDraftResponseSchema, TaskStatus } from "@agent-bounty/contracts"; import { prisma } from "@/lib/prisma"; import Stripe from "stripe"; +import { sendTrafficAlert } from "@/lib/traffic-alert"; const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-05-27.dahlia", @@ -69,6 +70,23 @@ export async function POST(request: NextRequest) { }; ScoutDraftResponseSchema.parse(responseData); + void sendTrafficAlert({ + level: "info", + action: "SCOUT_DRAFT_CREATED_OPEN", + surface: "scout/draft", + actorType: "SYSTEM", + actorId: "scout-service", + taskId: task.id, + message: `Scout 發布免扣費 OPEN 任務:${parsed.title}`, + metadata: { + reward_amount: parsed.reward_amount, + reward_currency: parsed.reward_currency, + scout_id: parsed.scout_id, + source: "beta_zero_friction", + auto_open: true, + }, + }); + return NextResponse.json(responseData); } @@ -114,6 +132,21 @@ export async function POST(request: NextRequest) { }; ScoutDraftResponseSchema.parse(responseData); // strict output validation + void sendTrafficAlert({ + level: "info", + action: "SCOUT_DRAFT_CREATED_PAYMENT_PENDING", + surface: "scout/draft", + actorType: "SYSTEM", + actorId: "scout-service", + taskId: task.id, + message: `Scout 建立付費草稿任務:${parsed.title}`, + metadata: { + reward_amount: parsed.reward_amount, + reward_currency: parsed.reward_currency, + scout_id: parsed.scout_id, + auto_open: false, + }, + }); return NextResponse.json(responseData); diff --git a/apps/web/src/app/api/scout/issue-exists/route.ts b/apps/web/src/app/api/scout/issue-exists/route.ts new file mode 100644 index 0000000..f0e3058 --- /dev/null +++ b/apps/web/src/app/api/scout/issue-exists/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +const validateApiKey = (request: NextRequest) => { + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json({ error: "Unauthorized: Missing Bearer token" }, { status: 401 }); + } + + const token = authHeader.split(" ")[1]; + if (process.env.API_KEY && token !== process.env.API_KEY) { + return NextResponse.json({ error: "Forbidden: Invalid API Key" }, { status: 403 }); + } + + return null; +}; + +export async function GET(request: NextRequest) { + const authError = validateApiKey(request); + if (authError) { + return authError; + } + + const issueUrl = new URL(request.url).searchParams.get("issue_url"); + if (!issueUrl) { + return NextResponse.json({ error: "Missing issue_url" }, { status: 400 }); + } + + try { + const latestTask = await prisma.task.findFirst({ + where: { + description: { contains: issueUrl }, + }, + select: { + id: true, + status: true, + created_at: true, + }, + orderBy: { + created_at: "desc", + }, + }); + + if (!latestTask) { + return NextResponse.json({ + issue_url: issueUrl, + exists: false, + }); + } + + return NextResponse.json({ + issue_url: issueUrl, + exists: true, + task_id: latestTask.id, + status: latestTask.status, + created_at: latestTask.created_at.toISOString(), + }); + } catch (error) { + console.error("[Issue Exists API Error]", error); + return NextResponse.json( + { error: "InternalError", message: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 60aa923..1dc71e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,9 +71,9 @@ services: - SCOUT_ISSUE_LABELS=${SCOUT_ISSUE_LABELS:-} # Keep compatibility with existing naming - SCOUT_ISSUE_LABEL=${SCOUT_ISSUE_LABEL:-} - - SCOUT_CRON_EXPRESSION=${SCOUT_CRON_EXPRESSION:-*/3 * * * *} - - SCOUT_PER_PAGE=${SCOUT_PER_PAGE:-50} - - SCOUT_MAX_ISSUES_PER_SCAN=${SCOUT_MAX_ISSUES_PER_SCAN:-60} + - SCOUT_CRON_EXPRESSION=${SCOUT_CRON_EXPRESSION:-*/1 * * * *} + - SCOUT_PER_PAGE=${SCOUT_PER_PAGE:-80} + - SCOUT_MAX_ISSUES_PER_SCAN=${SCOUT_MAX_ISSUES_PER_SCAN:-90} # GitHub token should be provided in deployment env for real posting. - GITHUB_TOKEN=${GITHUB_TOKEN:-} networks: