| #!/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();
|
|
|