chore: remove scout-bot and update workspace root package.json
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/scout-bot/package.json apps/scout-bot/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/scout-bot ./apps/scout-bot
|
||||
|
||||
CMD ["pnpm", "--dir", "apps/scout-bot", "exec", "tsx", "src/index.ts"]
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "scout-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.42",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user