diff --git a/apps/scout-bot/src/index.ts b/apps/scout-bot/src/index.ts index 2d7566c..bf05da6 100644 --- a/apps/scout-bot/src/index.ts +++ b/apps/scout-bot/src/index.ts @@ -15,6 +15,7 @@ const SCOUT_TARGET_REPOS_RAW = "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_ISSUE_LABELS = SCOUT_ISSUE_LABELS_RAW .split(",") @@ -77,6 +78,72 @@ 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 }).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; @@ -215,7 +282,13 @@ npx -y @agent-bounty/mcp-server --endpoint https://agent.wooo.work 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 { @@ -229,7 +302,7 @@ async function scanRepositories() { for (const target of TARGET_REPOS) { try { - const issues = await octokit.issues.listForRepo({ + const issues = await octokit.issues.listForRepo({ owner: target.owner, repo: target.repo, state: "open", @@ -250,6 +323,12 @@ async function scanRepositories() { 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); } }