| | #!/usr/bin/env node
|
| |
|
| | const https = require('https');
|
| | const fs = require('fs');
|
| | const path = require('path');
|
| |
|
| |
|
| | const GITHUB_REPO = process.env.GITHUB_REPO || 'looplj/axonhub';
|
| | const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
| | const AXONHUB_BASE_URL = process.env.AXONHUB_BASE_URL || 'http://localhost:8090/v1';
|
| | const AXONHUB_API_KEY = process.env.AXONHUB_API_KEY;
|
| | const AXONHUB_MODEL = process.env.AXONHUB_MODEL || 'deepseek-chat';
|
| |
|
| | const STATE_FILE = path.join(__dirname, '.github_faq_state.json');
|
| | const DOCS_DIR = path.join(__dirname, '../../docs');
|
| | const EN_FAQ_PATH = path.join(DOCS_DIR, 'en/faq.md');
|
| | const ZH_FAQ_PATH = path.join(DOCS_DIR, 'zh/faq.md');
|
| |
|
| |
|
| |
|
| | function request(url, options = {}, body = null, retries = 3) {
|
| | return new Promise((resolve, reject) => {
|
| | const executeRequest = (attempt) => {
|
| | const isHttps = url.startsWith('https');
|
| | const client = isHttps ? https : require('http');
|
| |
|
| | const req = client.request(url, options, (res) => {
|
| | let data = '';
|
| | res.on('data', (chunk) => data += chunk);
|
| | res.on('end', () => {
|
| | if (res.statusCode >= 200 && res.statusCode < 300) {
|
| | try {
|
| | resolve(data ? JSON.parse(data) : {});
|
| | } catch (e) {
|
| | resolve(data);
|
| | }
|
| | } else {
|
| | const error = new Error(`Request failed with status ${res.statusCode}: ${data}`);
|
| | if (attempt < retries) {
|
| | const delay = Math.pow(2, attempt) * 1000;
|
| | console.warn(`Request failed (status ${res.statusCode}). Retrying in ${delay}ms... (Attempt ${attempt + 1}/${retries})`);
|
| | setTimeout(() => executeRequest(attempt + 1), delay);
|
| | } else {
|
| | reject(error);
|
| | }
|
| | }
|
| | });
|
| | });
|
| |
|
| | req.on('error', (error) => {
|
| | if (attempt < retries) {
|
| | const delay = Math.pow(2, attempt) * 1000;
|
| | console.warn(`Request error: ${error.message}. Retrying in ${delay}ms... (Attempt ${attempt + 1}/${retries})`);
|
| | setTimeout(() => executeRequest(attempt + 1), delay);
|
| | } else {
|
| | reject(error);
|
| | }
|
| | });
|
| |
|
| | if (body) {
|
| | req.write(typeof body === 'string' ? body : JSON.stringify(body));
|
| | }
|
| | req.end();
|
| | };
|
| |
|
| | executeRequest(0);
|
| | });
|
| | }
|
| |
|
| | async function fetchGithubIssues(since) {
|
| | let url = `https://api.github.com/repos/${GITHUB_REPO}/issues?state=all&per_page=100`;
|
| | if (since && since !== '1970-01-01T00:00:00Z') {
|
| | url += `&since=${since}`;
|
| | }
|
| | const options = {
|
| | method: 'GET',
|
| | headers: {
|
| | 'User-Agent': 'AxonHub-FAQ-Sync',
|
| | 'Accept': 'application/vnd.github.v3+json',
|
| | ...(GITHUB_TOKEN ? { 'Authorization': `token ${GITHUB_TOKEN}` } : {})
|
| | }
|
| | };
|
| | return request(url, options);
|
| | }
|
| |
|
| | async function fetchIssueComments(issueNumber) {
|
| | const url = `https://api.github.com/repos/${GITHUB_REPO}/issues/${issueNumber}/comments`;
|
| | const options = {
|
| | method: 'GET',
|
| | headers: {
|
| | 'User-Agent': 'AxonHub-FAQ-Sync',
|
| | 'Accept': 'application/vnd.github.v3+json',
|
| | ...(GITHUB_TOKEN ? { 'Authorization': `token ${GITHUB_TOKEN}` } : {})
|
| | }
|
| | };
|
| | return request(url, options);
|
| | }
|
| |
|
| | async function analyzeIssue(issue, comments, existingFaqs) {
|
| | if (!AXONHUB_API_KEY) {
|
| | throw new Error('AXONHUB_API_KEY is not set');
|
| | }
|
| |
|
| | const content = `
|
| | Title: ${issue.title}
|
| | Body: ${issue.body}
|
| | Comments:
|
| | ${comments.map(c => `- ${c.body}`).join('\n')}
|
| | `.trim();
|
| |
|
| | const prompt = `
|
| | You are a technical documentation assistant for AxonHub.
|
| | AxonHub is an all-in-one AI development platform that serves as a unified API gateway for multiple AI providers.
|
| |
|
| | First, classify the following GitHub issue into one of these categories:
|
| | - feature: A request for a new feature or enhancement.
|
| | - bug: A report of a bug or unexpected behavior.
|
| | - question: A question about how to use AxonHub or technical clarification.
|
| |
|
| | If the category is "question", determine if it contains a common question and its corresponding clear answer that should be added to the FAQ.
|
| | Check if the question is already covered in the existing FAQs provided below. If it is already covered or redundant, set "is_candidate" to false.
|
| |
|
| | Existing FAQs:
|
| | ${existingFaqs || 'None'}
|
| |
|
| | If it is a "question" and a good candidate (not already covered), extract the Question and Answer in both English and Chinese.
|
| | The question should be concise, and the answer should be helpful and accurate based on the discussion.
|
| |
|
| | Return ONLY a JSON object with the following structure:
|
| | {
|
| | "category": "feature" | "bug" | "question",
|
| | "is_candidate": boolean,
|
| | "en": { "question": "string", "answer": "string" },
|
| | "zh": { "question": "string", "answer": "string" }
|
| | }
|
| |
|
| | Issue Content:
|
| | ${content}
|
| | `.trim();
|
| |
|
| | const url = `${AXONHUB_BASE_URL}/chat/completions`;
|
| | const options = {
|
| | method: 'POST',
|
| | headers: {
|
| | 'Content-Type': 'application/json',
|
| | 'Authorization': `Bearer ${AXONHUB_API_KEY}`
|
| | }
|
| | };
|
| | const body = {
|
| | model: AXONHUB_MODEL,
|
| | messages: [
|
| | { role: 'system', content: 'You are a helpful assistant that outputs JSON.' },
|
| | { role: 'user', content: prompt }
|
| | ],
|
| | response_format: { type: 'json_object' }
|
| | };
|
| |
|
| | const response = await request(url, options, body);
|
| | return JSON.parse(response.choices[0].message.content);
|
| | }
|
| |
|
| | function updateFaqFile(filePath, qa, lang) {
|
| |
|
| | const dir = path.dirname(filePath);
|
| | if (!fs.existsSync(dir)) {
|
| | fs.mkdirSync(dir, { recursive: true });
|
| | }
|
| |
|
| | let content = '';
|
| | if (fs.existsSync(filePath)) {
|
| | content = fs.readFileSync(filePath, 'utf8');
|
| | } else {
|
| | content = lang === 'en' ? '# FAQ\n\n' : '# 常见问题\n\n';
|
| | }
|
| |
|
| |
|
| | if (content.includes(qa.question)) {
|
| | console.log(`Skipping duplicate FAQ in ${lang}: ${qa.question}`);
|
| | return false;
|
| | }
|
| |
|
| | const newEntry = `## ${qa.question}\n\n${qa.answer}\n\n`;
|
| | content += newEntry;
|
| | fs.writeFileSync(filePath, content, 'utf8');
|
| | return true;
|
| | }
|
| |
|
| | function getExistingFaqContent() {
|
| | let enFaq = '';
|
| | let zhFaq = '';
|
| | if (fs.existsSync(EN_FAQ_PATH)) {
|
| | enFaq = fs.readFileSync(EN_FAQ_PATH, 'utf8');
|
| | }
|
| | if (fs.existsSync(ZH_FAQ_PATH)) {
|
| | zhFaq = fs.readFileSync(ZH_FAQ_PATH, 'utf8');
|
| | }
|
| | return `English FAQ:\n${enFaq}\n\nChinese FAQ:\n${zhFaq}`;
|
| | }
|
| |
|
| | function saveState(state) {
|
| | fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
| | }
|
| |
|
| |
|
| |
|
| | async function main() {
|
| | if (!AXONHUB_API_KEY) {
|
| | console.error('Error: AXONHUB_API_KEY environment variable is not set.');
|
| | console.log('Please set it using: export AXONHUB_API_KEY=your_key');
|
| | process.exit(1);
|
| | }
|
| |
|
| | try {
|
| | let state = { last_check: '1970-01-01T00:00:00Z', processed_issues: [] };
|
| | if (fs.existsSync(STATE_FILE)) {
|
| | state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
| | }
|
| |
|
| | console.log(`Checking issues for ${GITHUB_REPO} since ${state.last_check}...`);
|
| | const issues = await fetchGithubIssues(state.last_check);
|
| | console.log(`Found ${issues.length} updated issues.`);
|
| |
|
| |
|
| | issues.sort((a, b) => new Date(a.updated_at) - new Date(b.updated_at));
|
| |
|
| | for (const issue of issues) {
|
| | try {
|
| |
|
| | if (issue.pull_request) continue;
|
| |
|
| |
|
| | if (state.processed_issues.includes(issue.id)) continue;
|
| |
|
| | console.log(`\n--- Processing issue #${issue.number}: ${issue.title} ---`);
|
| |
|
| | const comments = await fetchIssueComments(issue.number);
|
| | const existingFaqs = getExistingFaqContent();
|
| | const analysis = await analyzeIssue(issue, comments, existingFaqs);
|
| |
|
| | console.log(`Category: ${analysis.category}`);
|
| |
|
| | if (analysis.category === 'question' && analysis.is_candidate) {
|
| | console.log(`Adding issue #${issue.number} to FAQ.`);
|
| | const addedEn = updateFaqFile(EN_FAQ_PATH, analysis.en, 'en');
|
| | const addedZh = updateFaqFile(ZH_FAQ_PATH, analysis.zh, 'zh');
|
| |
|
| | if (!addedEn && !addedZh) {
|
| | console.log(`Issue #${issue.number} was already in FAQ (duplicate detection).`);
|
| | }
|
| | } else if (analysis.category !== 'question') {
|
| | console.log(`Skipping issue #${issue.number} (Category: ${analysis.category})`);
|
| | } else {
|
| | console.log(`Issue #${issue.number} is a question but not a good FAQ candidate or already covered.`);
|
| | }
|
| |
|
| |
|
| | state.processed_issues.push(issue.id);
|
| | if (new Date(issue.updated_at) > new Date(state.last_check)) {
|
| | state.last_check = issue.updated_at;
|
| | }
|
| | saveState(state);
|
| | console.log(`State updated for issue #${issue.number}.`);
|
| | } catch (issueError) {
|
| | console.error(`Error processing issue #${issue.number}:`, issueError.message);
|
| | console.log('Continuing with next issue...');
|
| | }
|
| | }
|
| |
|
| | console.log('\nSync completed successfully!');
|
| | } catch (error) {
|
| | console.error('Error during sync:', error.message);
|
| | process.exit(1);
|
| | }
|
| | }
|
| |
|
| | main();
|
| |
|