@@ -1,373 +0,0 @@
import { Octokit } from "@octokit/rest" ;
import cron from "node-cron" ;
import dotenv from "dotenv" ;
dotenv . config ( ) ;
const GITHUB_TOKEN = process . env . GITHUB_TOKEN ;
const VIBEWORK_API_URL = process . env . VIBEWORK_API_URL || "https://agent.wooo.work/api" ;
const SCOUT_API_KEY = process . env . SCOUT_API_KEY ;
const SCOUT_AGENT_ID = process . env . SCOUT_AGENT_ID || "scout_official_1" ;
const SCOUT_CRON_EXPRESSION = process . env . SCOUT_CRON_EXPRESSION || "*/3 * * * *" ;
const SCOUT_ISSUE_LABELS_RAW = process . env . SCOUT_ISSUE_LABELS || process . env . SCOUT_ISSUE_LABEL || "" ;
const SCOUT_ENABLED = ( process . env . SCOUT_ENABLED || "true" ) . toLowerCase ( ) !== "false" ;
const SCOUT_TARGET_REPOS_RAW =
process . env . SCOUT_TARGET_REPOS ||
"open-webui/open-webui,microsoft/vscode,vercel/next.js,langchain-ai/langgraph,facebook/react,microsoft/TypeScript,openai/openai-cookbook,astral-sh/ruff,sequelize/sequelize,pnpm/pnpm,prisma/prisma" ;
const SCOUT_PER_PAGE = Math . min ( Math . max ( parseInt ( process . env . SCOUT_PER_PAGE || "50" , 10 ) , 1 ) , 100 ) ;
const SCOUT_MAX_ISSUES_PER_SCAN = Math . max ( parseInt ( process . env . SCOUT_MAX_ISSUES_PER_SCAN || "60" , 10 ) , 1 ) ;
const SCOUT_COMMENT_DELAY_SECONDS = Math . max ( parseInt ( process . env . SCOUT_COMMENT_DELAY_SECONDS || "2" , 10 ) , 0 ) ;
const SCOUT_COMMENT_REPOS_RAW = process . env . SCOUT_COMMENT_REPOS || "" ;
const SCOUT_COMMENT_REPOS = SCOUT_COMMENT_REPOS_RAW === "*"
? [ "*" ]
: SCOUT_COMMENT_REPOS_RAW . split ( "," ) . map ( r = > r . trim ( ) . toLowerCase ( ) ) . filter ( Boolean ) ;
const SCOUT_ISSUE_LABELS = SCOUT_ISSUE_LABELS_RAW
. split ( "," )
. map ( ( entry ) = > entry . trim ( ) )
. filter ( Boolean ) ;
const SCOUT_TARGET_REPOS = SCOUT_TARGET_REPOS_RAW
. split ( "," )
. map ( ( entry ) = > entry . trim ( ) )
. filter ( Boolean )
. map ( ( entry ) = > {
const [ owner , repo ] = entry . split ( "/" ) ;
return { owner , repo } ;
} )
. filter ( ( repo ) = > repo . owner && repo . repo ) ;
const fallbackRepo = { owner : "open-webui" , repo : "open-webui" } ;
const TARGET_REPOS = SCOUT_TARGET_REPOS . length > 0 ? SCOUT_TARGET_REPOS : [ fallbackRepo ] ;
if ( ! SCOUT_API_KEY ) {
console . warn ( "WARNING: SCOUT_API_KEY is not set. Draft API may be rejected by server." ) ;
}
if ( ! GITHUB_TOKEN ) {
console . warn ( "WARNING: GITHUB_TOKEN is not set. Scout bot cannot post comments." ) ;
}
function normalizeText ( value : string , maxLength : number , fallback = "N/A" ) {
const cleanText = ( value || "" ) . replace ( /\s+/g , " " ) . trim ( ) ;
if ( ! cleanText ) {
return fallback ;
}
return cleanText . length <= maxLength ? cleanText : ` ${ cleanText . slice ( 0 , Math . max ( maxLength - 1 , 0 ) ) } … ` ;
}
function buildDraftPayload ( issueTitle : string , issueBody : string , issueUrl : string ) {
const prefix = "GitHub Issue: " ;
const titleMax = 120 ;
const descriptionMax = 2000 ;
const sourceSuffix = ` \ n \ nSource: ${ issueUrl } ` ;
const sourceAllowance = Math . max ( descriptionMax - sourceSuffix . length , 20 ) ;
const safeTitle = normalizeText ( issueTitle , Math . max ( titleMax - prefix . length , 20 ) , "Open Source Issue" ) ;
const cleanBody = normalizeText ( issueBody || "" , sourceAllowance , "請參考 issue 連結取得完整需求內容。" ) ;
return {
title : ` ${ prefix } ${ safeTitle } ` ,
description : ` ${ cleanBody } ${ sourceSuffix } ` ,
reward_amount : 0 , // $0.00 Free AI Bounty
reward_currency : "USD" ,
required_stack : [ "TypeScript" , "GitHub" ] ,
test_file_content : "// Needs manual test writing based on issue" ,
} ;
}
const octokit = new Octokit ( {
auth : GITHUB_TOKEN ,
} ) ;
async function wait ( seconds : number ) {
return new Promise ( ( resolve ) = > setTimeout ( resolve , seconds * 1000 ) ) ;
}
function getGitHubBackoffSeconds ( error : unknown ) : number {
const status =
typeof error === "object" &&
error !== null &&
"status" in error &&
typeof ( error as { status? : unknown } ) . status === "number"
? ( error as { status : number } ) . status
: undefined ;
const message =
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof ( error as { message? : unknown } ) . message === "string"
? ( error as { message : string } ) . message . toLowerCase ( )
: "" ;
const responseHeaders =
typeof error === "object" &&
error !== null &&
"response" in error &&
error . response &&
typeof error . response === "object" &&
"headers" in error . response
? ( error . response as { headers? : Record < string , string > } ) . headers
: undefined ;
const lowerCaseHeaders = responseHeaders
? Object . fromEntries (
Object . entries ( responseHeaders ) . map ( ( [ key , value ] ) = > [ key . toLowerCase ( ) , value ] )
)
: undefined ;
if (
typeof status === "number" &&
status === 403 &&
message . includes ( "secondary rate limit" )
) {
const resetAtRaw = lowerCaseHeaders ? . [ "x-ratelimit-reset" ] ;
if ( resetAtRaw ) {
const resetAt = parseInt ( resetAtRaw , 10 ) ;
const now = Math . floor ( Date . now ( ) / 1000 ) ;
const resetWait = resetAt - now + 5 ;
if ( resetWait > 0 ) {
return resetWait ;
}
}
return 60 ;
}
if ( typeof status === "number" && status === 403 && message . includes ( "rate limit exceeded" ) ) {
const resetAtRaw = lowerCaseHeaders ? . [ "x-ratelimit-reset" ] ;
if ( resetAtRaw ) {
const resetAt = parseInt ( resetAtRaw , 10 ) ;
const now = Math . floor ( Date . now ( ) / 1000 ) ;
const resetWait = resetAt - now + 5 ;
if ( resetWait > 0 ) {
return resetWait ;
}
}
return 60 ;
}
return 0 ;
}
async function postDraftWithRetry ( payload : {
scout_id : string ;
title : string ;
description : string ;
reward_amount : number ;
reward_currency : string ;
required_stack : string [ ] ;
test_file_content : string ;
} ) {
let lastError : unknown = null ;
for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) {
try {
const response = await fetch ( ` ${ VIBEWORK_API_URL } /scout/draft ` , {
method : "POST" ,
headers : {
"Content-Type" : "application/json" ,
"Authorization" : ` Bearer ${ SCOUT_API_KEY } `
} ,
body : JSON.stringify ( payload ) ,
} ) ;
if ( ! response . ok ) {
const bodyText = await response . text ( ) ;
throw new Error ( ` Draft API failed (HTTP ${ response . status } ): ${ bodyText } ` ) ;
}
return await response . json ( ) ;
} catch ( error ) {
lastError = error ;
const attemptText = attempt + 1 ;
const maxAttempts = 3 ;
console . error ( ` Failed to draft bounty task (attempt ${ attemptText } / ${ maxAttempts } ): ` , error ) ;
if ( attempt < maxAttempts - 1 ) {
await wait ( Math . min ( 5 * ( attempt + 1 ) , 15 ) ) ;
}
}
}
console . error ( "Failed to draft bounty task after retries:" , lastError ) ;
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 ,
. . . buildDraftPayload ( issueTitle , issueBody || "" , issueUrl ) ,
} ;
return postDraftWithRetry ( payload ) ;
}
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 ) {
const comments = await octokit . issues . listComments ( {
owner ,
repo ,
issue_number : issue.number ,
} ) ;
const alreadyCommented = comments . data . some ( ( c ) = > c . body ? . includes ( "agent.wooo.work" ) ) ;
if ( alreadyCommented ) {
console . log ( ` Already commented on # ${ issue . number } , skipping. ` ) ;
return ;
}
}
// Generate draft task on VibeWork
const draft = await draftBountyTask ( issue . title , issue . body || "" , issueUrl ) ;
if ( ! draft ) {
console . log ( ` Failed to generate draft for # ${ issue . number } , skipping comment. ` ) ;
return ;
}
const commentBody = ` Hi there! 👋 我們注意到這個 issue 被標記為需要社群協助。
**VibeWork** 是一個開源的 AI Agent 協定,目前已經連結了全球大量的自主 AI Agent。這些 Agent 可以為您**免費 (FREE)** 嘗試解決這個問題。
任何人類開發者或是 AI Agent 都可以認領這個 issue, 並透過我們的 MCP 伺服器自動提交 Pull Request:
\` \` \` bash
npx -y @agent-bounty/mcp-server --endpoint https://agent.wooo.work
\` \` \`
🔗 [點此追蹤 AI 的解題進度]( ${ draft . checkout_url } ) (Task ID: \` ${ draft . task_id } \` )
*(備註:我們是一個開源的防詐欺 Bounty 協定,旨在幫助開源專案維護者更快地利用 AI 解決問題。如果您不希望我們在此張貼此資訊,請隨時告知!)*
` ;
if ( GITHUB_TOKEN ) {
const isAllowedToComment = SCOUT_COMMENT_REPOS . includes ( "*" ) || SCOUT_COMMENT_REPOS . includes ( ` ${ owner } / ${ repo } ` . toLowerCase ( ) ) ;
if ( ! isAllowedToComment ) {
console . log ( ` [SILENT MODE] Successfully generated draft task for # ${ issue . number } , but skipping GitHub comment (repo not in SCOUT_COMMENT_REPOS). ` ) ;
return ;
}
try {
await octokit . issues . createComment ( {
owner ,
repo ,
issue_number : issue.number ,
body : commentBody ,
} ) ;
console . log ( ` Successfully commented on # ${ issue . number } ` ) ;
await wait ( SCOUT_COMMENT_DELAY_SECONDS ) ;
} catch ( error ) {
const backoffSeconds = getGitHubBackoffSeconds ( error ) ;
if ( backoffSeconds > 0 ) {
console . warn ( ` GitHub API rate limited; sleeping ${ backoffSeconds } s before continuing. ` ) ;
await wait ( backoffSeconds ) ;
}
console . error ( ` Failed to comment on # ${ issue . number } : ` , error ) ;
}
} else {
console . log ( ` [DRY RUN] Would have posted scout comment for # ${ issue . number } : ${ draft . status } ` ) ;
}
}
async function scanRepositories() {
console . log ( "Starting GitHub scan..." ) ;
for ( const target of TARGET_REPOS ) {
try {
const issues = await octokit . issues . listForRepo ( {
owner : target.owner ,
repo : target.repo ,
state : "open" ,
. . . ( SCOUT_ISSUE_LABELS . length > 0 ? { labels : SCOUT_ISSUE_LABELS.join ( "," ) } : { } ) ,
per_page : SCOUT_PER_PAGE
} ) ;
console . log ( ` Found ${ issues . data . length } open issues in ${ target . owner } / ${ target . repo } ` ) ;
const issueCandidates = issues . data . slice ( 0 , SCOUT_MAX_ISSUES_PER_SCAN ) ;
for ( const issue of issueCandidates ) {
if ( ! issue . pull_request ) { // Ignore PRs
await processIssue ( target . owner , target . repo , issue ) ;
}
}
if ( issues . data . length > issueCandidates . length ) {
console . log ( ` Limited scan to first ${ issueCandidates . length } issues in ${ target . owner } / ${ target . repo } ` ) ;
}
} catch ( error ) {
const backoffSeconds = getGitHubBackoffSeconds ( error ) ;
if ( backoffSeconds > 0 ) {
console . warn ( ` GitHub API rate limited on ${ target . owner } / ${ target . repo } ; sleeping ${ backoffSeconds } s and skip remaining repos. ` ) ;
await wait ( backoffSeconds ) ;
return ;
}
console . error ( ` Error scanning ${ target . owner } / ${ target . repo } : ` , error ) ;
}
}
}
async function bootstrap() {
if ( ! SCOUT_ENABLED ) {
console . log ( "SCOUT_ENABLED is false. Scout bot is disabled." ) ;
return ;
}
// Delay first scan a bit so main web API is ready after startup.
await wait ( 10 ) ;
await scanRepositories ( ) ;
}
bootstrap ( ) ;
if ( SCOUT_ENABLED ) {
// 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 ( ) ;
} ) ;
} else {
console . log ( "Cron schedule is disabled because SCOUT_ENABLED=false." ) ;
}
console . log ( "VibeWork Scout Bot started and scheduled." ) ;