name: Bug Report Analyzer on: issues: types: [opened, labeled] workflow_dispatch: inputs: issue_number: description: 'Issue number to analyze' required: true type: number permissions: issues: write contents: read models: read jobs: analyze-bug-report: name: Analyze Bug Report runs-on: ubuntu-latest # Run when a bug or triage label is present on the issue, was just applied, or triggered manually if: | github.event_name == 'workflow_dispatch' || contains(github.event.issue.labels.*.name, 'bug') || contains(github.event.issue.labels.*.name, 'triage') || github.event.label.name == 'bug' || github.event.label.name == 'triage' steps: - name: Checkout repository uses: actions/checkout@v4 - name: Analyze bug report and post findings uses: actions/github-script@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: script: | const BOT_COMMENT_MARKER = ''; const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions'; // ── tuneable constants ──────────────────────────────────────────── // Minimum character count for a field to be considered non-blank. const MIN_FIELD_LENGTH = 10; // Steps-to-reproduce needs more detail than a one-liner to be useful. const MIN_STEPS_LENGTH = 30; // Cap how many tokens the model may return per response. const MAX_RESPONSE_TOKENS = 1200; // Low temperature → deterministic, factual answers (not creative). const MODEL_TEMPERATURE = 0.2; // How deep to recurse when scanning the repo for Swift files. const MAX_SEARCH_DEPTH = 4; // Max number of file paths sent to the model for relevance ranking. const MAX_FILES_TO_LIST = 300; // Max number of files whose contents are actually read and included. const MAX_FILES_TO_READ = 5; // Ask the model to return a slightly larger set so that if some paths // don't exist we still have MAX_FILES_TO_READ valid candidates to read. const FILE_SELECTION_BUFFER = 3; // Max lines read from each source file to stay within token budget. const MAX_LINES_PER_FILE = 250; // ── helpers ────────────────────────────────────────────────────── async function callModelsAPI(systemMessage, userMessage) { const response = await fetch(MODELS_API_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'gpt-5.4', messages: [ { role: 'system', content: systemMessage }, { role: 'user', content: userMessage }, ], max_tokens: MAX_RESPONSE_TOKENS, temperature: MODEL_TEMPERATURE, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Models API ${response.status}: ${text}`); } const data = await response.json(); return data.choices[0].message.content.trim(); } function extractSection(body, heading) { // Matches GitHub issue form sections: ### Heading\ncontent const re = new RegExp( `###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###|$)`, 'i' ); const m = body.match(re); if (!m) return ''; const value = m[1].trim(); return value === '_No response_' ? '' : value; } function isBlank(s) { return !s || s.length < MIN_FIELD_LENGTH; } // ── main ───────────────────────────────────────────────────────── // Support manual workflow_dispatch by fetching the issue when triggered that way. let issue; if (context.eventName === 'workflow_dispatch') { const issueNumber = parseInt(context.payload.inputs.issue_number, 10); if (!Number.isInteger(issueNumber) || issueNumber <= 0) { core.setFailed(`Invalid issue_number: "${context.payload.inputs.issue_number}". Must be a positive integer.`); return; } try { const { data } = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, }); issue = data; } catch (err) { core.setFailed(`Could not fetch issue #${issueNumber}: ${err.message}`); return; } } else { issue = context.payload.issue; } const body = issue.body || ''; const title = issue.title || ''; // Skip if we have already left an analysis comment on this issue. const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, per_page: 100, }); if (comments.some(c => c.body.includes(BOT_COMMENT_MARKER))) { core.info('Already analyzed this issue – skipping.'); return; } // ── parse template fields ───────────────────────────────────────── const firmwareVersion = extractSection(body, 'Firmware Version'); const stepsToReproduce = extractSection(body, 'What did you do\\?'); const expectedBehavior = extractSection(body, 'Expected Behavior'); const currentBehavior = extractSection(body, 'Current Behavior'); const additionalComments = extractSection(body, 'Additional comments'); // ── completeness check ──────────────────────────────────────────── const missing = []; if (isBlank(firmwareVersion)) missing.push( '- **Firmware Version** – please provide the exact version string ' + '(e.g. `2.3.14.abcdef1`). You can find it under *Settings → Firmware* ' + 'in the app or on the node screen.' ); if (isBlank(stepsToReproduce) || stepsToReproduce.length < MIN_STEPS_LENGTH) missing.push( '- **Steps to Reproduce** – please list numbered, minimal steps that ' + 'consistently trigger the issue. Include your iOS/iPadOS version and ' + 'device model.' ); if (isBlank(expectedBehavior)) missing.push( '- **Expected Behavior** – describe what you expected to happen.' ); if (isBlank(currentBehavior)) missing.push( '- **Current Behavior** – describe what actually happens instead.' ); if (missing.length > 0) { const commentBody = `${BOT_COMMENT_MARKER} ## 🤖 Additional Information Needed Thank you for filing this bug report! To help us isolate the root cause we need a bit more detail: ${missing.join('\n')} ### Helpful extras (if applicable) - iOS / iPadOS version and device model - Whether this is a **regression** – did it work in an earlier version? - Console logs or a crash report from the app's [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature - Screenshots or a screen recording if the issue is visual Please update the issue with the missing information and we'll take another look. Thank you! 🙏`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: commentBody, }); core.info('Posted "needs more info" comment.'); return; } // ── code analysis ───────────────────────────────────────────────── const SYSTEM_MESSAGE = 'You are an expert iOS/macOS Swift developer helping to triage bug ' + 'reports for the Meshtastic Apple app – a SwiftUI mesh-radio ' + 'communication app that uses Bluetooth LE and a Core Data stack. ' + 'Be concise, specific, and reference real code paths when possible.'; try { const fs = require('fs'); const path = require('path'); // Collect all Swift source file paths (max depth 4, skip generated dirs). const SKIP_DIRS = new Set([ 'node_modules', '.git', 'DerivedData', 'build', 'MeshtasticProtobufs', ]); function collectSwiftFiles(dir, depth) { if (depth > MAX_SEARCH_DEPTH) return []; const results = []; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return results; } for (const e of entries) { if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue; const full = path.join(dir, e.name); if (e.isDirectory()) { results.push(...collectSwiftFiles(full, depth + 1)); } else if (e.name.endsWith('.swift')) { results.push(full); } } return results; } const root = process.cwd(); const allFiles = collectSwiftFiles(root, 0); const fileList = allFiles .map(f => path.relative(root, f)) .slice(0, MAX_FILES_TO_LIST) .join('\n'); // Ask the model which files are most relevant. const fileSelectionPrompt = `Bug title: ${title}\n` + `Steps to reproduce: ${stepsToReproduce}\n` + `Expected: ${expectedBehavior}\n` + `Current: ${currentBehavior}\n` + (additionalComments ? `Additional: ${additionalComments}\n` : '') + `\nAvailable Swift source files:\n${fileList}\n\n` + 'Return ONLY a JSON array (no markdown, no explanation) of the ' + `${MAX_FILES_TO_READ}–${MAX_FILES_TO_READ + FILE_SELECTION_BUFFER} ` + 'file paths most likely to contain the bug.'; let relevantFiles = []; try { const raw = await callModelsAPI(SYSTEM_MESSAGE, fileSelectionPrompt); // Strip potential markdown fences before parsing. const cleaned = raw.replace(/```[a-z]*\n?/g, '').trim(); relevantFiles = JSON.parse(cleaned); } catch (e) { core.warning(`File selection failed: ${e.message}`); } // Read up to MAX_FILES_TO_READ files, capping each at MAX_LINES_PER_FILE lines to stay within token budget. let codeContext = ''; for (const relPath of relevantFiles.slice(0, MAX_FILES_TO_READ)) { const absPath = path.join(root, relPath); if (!fs.existsSync(absPath)) continue; try { const content = fs.readFileSync(absPath, 'utf8'); const snippet = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n'); codeContext += `\n\n### ${relPath}\n\`\`\`swift\n${snippet}\n\`\`\``; } catch (_) {} } const analysisPrompt = `Bug title: ${title}\n` + `Firmware Version: ${firmwareVersion}\n` + `Steps to reproduce: ${stepsToReproduce}\n` + `Expected: ${expectedBehavior}\n` + `Current: ${currentBehavior}\n` + (additionalComments ? `Additional: ${additionalComments}\n` : '') + (codeContext ? `\nRelevant source code:${codeContext}\n` : '\n(No source files matched – reason from code structure)\n') + '\nPlease provide:\n' + '1. **Likely root cause** – a concise hypothesis with references to ' + 'specific files, types, or functions.\n' + '2. **Relevant code areas** – file paths and line ranges worth ' + 'investigating.\n' + '3. **Clarifying questions** – any details that would confirm or rule ' + 'out the hypothesis.\n' + '4. **Suggested investigation steps** – what a developer should do ' + 'next.\n'; const analysis = await callModelsAPI(SYSTEM_MESSAGE, analysisPrompt); const commentBody = `${BOT_COMMENT_MARKER} ## 🤖 Automated Bug Report Analysis Thank you for the detailed report! Here is an automated analysis to help the maintainers investigate: ${analysis} --- *This analysis was generated automatically from the issue description and the repository source. A human maintainer will review and follow up shortly.*`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: commentBody, }); core.info('Posted analysis comment.'); } catch (error) { core.warning(`AI analysis failed (${error.message}). Posting fallback acknowledgement.`); const fallback = `${BOT_COMMENT_MARKER} ## 🤖 Bug Report Received Thank you for this detailed bug report! A maintainer will review it and investigate the root cause. If you can provide any of the following it will speed up the investigation: - Device logs from the Debug Log feature - Whether this is a regression (last known-good firmware version) - A minimal set of steps that consistently reproduce the issue`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: fallback, }); }