Skip to content

Commit adb6960

Browse files
Copilotgarthvh
andauthored
Add Bug Report Analyzer GitHub Actions workflow (#1672)
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/18d7e6a9-e22d-470b-b475-a7a30c1daaa2 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
1 parent f2b8b7a commit adb6960

1 file changed

Lines changed: 341 additions & 0 deletions

File tree

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
name: Bug Report Analyzer
2+
3+
on:
4+
issues:
5+
types: [opened, labeled]
6+
workflow_dispatch:
7+
inputs:
8+
issue_number:
9+
description: 'Issue number to analyze'
10+
required: true
11+
type: number
12+
13+
permissions:
14+
issues: write
15+
contents: read
16+
models: read
17+
18+
jobs:
19+
analyze-bug-report:
20+
name: Analyze Bug Report
21+
runs-on: ubuntu-latest
22+
# Run when a bug or triage label is present on the issue, was just applied, or triggered manually
23+
if: |
24+
github.event_name == 'workflow_dispatch' ||
25+
contains(github.event.issue.labels.*.name, 'bug') ||
26+
contains(github.event.issue.labels.*.name, 'triage') ||
27+
github.event.label.name == 'bug' ||
28+
github.event.label.name == 'triage'
29+
30+
steps:
31+
- name: Checkout repository
32+
uses: actions/checkout@v4
33+
34+
- name: Analyze bug report and post findings
35+
uses: actions/github-script@v7
36+
env:
37+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38+
with:
39+
script: |
40+
const BOT_COMMENT_MARKER = '';
41+
const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions';
42+
43+
// ── tuneable constants ────────────────────────────────────────────
44+
// Minimum character count for a field to be considered non-blank.
45+
const MIN_FIELD_LENGTH = 10;
46+
// Steps-to-reproduce needs more detail than a one-liner to be useful.
47+
const MIN_STEPS_LENGTH = 30;
48+
// Cap how many tokens the model may return per response.
49+
const MAX_RESPONSE_TOKENS = 1200;
50+
// Low temperature → deterministic, factual answers (not creative).
51+
const MODEL_TEMPERATURE = 0.2;
52+
// How deep to recurse when scanning the repo for Swift files.
53+
const MAX_SEARCH_DEPTH = 4;
54+
// Max number of file paths sent to the model for relevance ranking.
55+
const MAX_FILES_TO_LIST = 300;
56+
// Max number of files whose contents are actually read and included.
57+
const MAX_FILES_TO_READ = 5;
58+
// Ask the model to return a slightly larger set so that if some paths
59+
// don't exist we still have MAX_FILES_TO_READ valid candidates to read.
60+
const FILE_SELECTION_BUFFER = 3;
61+
// Max lines read from each source file to stay within token budget.
62+
const MAX_LINES_PER_FILE = 250;
63+
64+
// ── helpers ──────────────────────────────────────────────────────
65+
66+
async function callModelsAPI(systemMessage, userMessage) {
67+
const response = await fetch(MODELS_API_URL, {
68+
method: 'POST',
69+
headers: {
70+
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
71+
'Content-Type': 'application/json',
72+
},
73+
body: JSON.stringify({
74+
model: 'gpt-4o-mini',
75+
messages: [
76+
{ role: 'system', content: systemMessage },
77+
{ role: 'user', content: userMessage },
78+
],
79+
max_tokens: MAX_RESPONSE_TOKENS,
80+
temperature: MODEL_TEMPERATURE,
81+
}),
82+
});
83+
if (!response.ok) {
84+
const text = await response.text();
85+
throw new Error(`Models API ${response.status}: ${text}`);
86+
}
87+
const data = await response.json();
88+
return data.choices[0].message.content.trim();
89+
}
90+
91+
function extractSection(body, heading) {
92+
// Matches GitHub issue form sections: ### Heading\ncontent
93+
const re = new RegExp(
94+
`###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###|$)`,
95+
'i'
96+
);
97+
const m = body.match(re);
98+
if (!m) return '';
99+
const value = m[1].trim();
100+
return value === '_No response_' ? '' : value;
101+
}
102+
103+
function isBlank(s) {
104+
return !s || s.length < MIN_FIELD_LENGTH;
105+
}
106+
107+
// ── main ─────────────────────────────────────────────────────────
108+
109+
// Support manual workflow_dispatch by fetching the issue when triggered that way.
110+
let issue;
111+
if (context.eventName === 'workflow_dispatch') {
112+
const issueNumber = parseInt(context.payload.inputs.issue_number, 10);
113+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
114+
core.setFailed(`Invalid issue_number: "${context.payload.inputs.issue_number}". Must be a positive integer.`);
115+
return;
116+
}
117+
try {
118+
const { data } = await github.rest.issues.get({
119+
owner: context.repo.owner,
120+
repo: context.repo.repo,
121+
issue_number: issueNumber,
122+
});
123+
issue = data;
124+
} catch (err) {
125+
core.setFailed(`Could not fetch issue #${issueNumber}: ${err.message}`);
126+
return;
127+
}
128+
} else {
129+
issue = context.payload.issue;
130+
}
131+
132+
const body = issue.body || '';
133+
const title = issue.title || '';
134+
135+
// Skip if we have already left an analysis comment on this issue.
136+
const { data: comments } = await github.rest.issues.listComments({
137+
owner: context.repo.owner,
138+
repo: context.repo.repo,
139+
issue_number: issue.number,
140+
per_page: 100,
141+
});
142+
if (comments.some(c => c.body.includes(BOT_COMMENT_MARKER))) {
143+
core.info('Already analyzed this issue – skipping.');
144+
return;
145+
}
146+
147+
// ── parse template fields ─────────────────────────────────────────
148+
149+
const firmwareVersion = extractSection(body, 'Firmware Version');
150+
const stepsToReproduce = extractSection(body, 'What did you do\\?');
151+
const expectedBehavior = extractSection(body, 'Expected Behavior');
152+
const currentBehavior = extractSection(body, 'Current Behavior');
153+
const additionalComments = extractSection(body, 'Additional comments');
154+
155+
// ── completeness check ────────────────────────────────────────────
156+
157+
const missing = [];
158+
if (isBlank(firmwareVersion))
159+
missing.push(
160+
'- **Firmware Version** – please provide the exact version string ' +
161+
'(e.g. `2.3.14.abcdef1`). You can find it under *Settings → Firmware* ' +
162+
'in the app or on the node screen.'
163+
);
164+
if (isBlank(stepsToReproduce) || stepsToReproduce.length < MIN_STEPS_LENGTH)
165+
missing.push(
166+
'- **Steps to Reproduce** – please list numbered, minimal steps that ' +
167+
'consistently trigger the issue. Include your iOS/iPadOS version and ' +
168+
'device model.'
169+
);
170+
if (isBlank(expectedBehavior))
171+
missing.push(
172+
'- **Expected Behavior** – describe what you expected to happen.'
173+
);
174+
if (isBlank(currentBehavior))
175+
missing.push(
176+
'- **Current Behavior** – describe what actually happens instead.'
177+
);
178+
179+
if (missing.length > 0) {
180+
const commentBody = `${BOT_COMMENT_MARKER}
181+
## 🤖 Additional Information Needed
182+
183+
Thank you for filing this bug report! To help us isolate the root cause we need a bit more detail:
184+
185+
${missing.join('\n')}
186+
187+
### Helpful extras (if applicable)
188+
- iOS / iPadOS version and device model
189+
- Whether this is a **regression** – did it work in an earlier version?
190+
- Console logs or a crash report from the app's [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature
191+
- Screenshots or a screen recording if the issue is visual
192+
193+
Please update the issue with the missing information and we'll take another look. Thank you! 🙏`;
194+
195+
await github.rest.issues.createComment({
196+
owner: context.repo.owner,
197+
repo: context.repo.repo,
198+
issue_number: issue.number,
199+
body: commentBody,
200+
});
201+
core.info('Posted "needs more info" comment.');
202+
return;
203+
}
204+
205+
// ── code analysis ─────────────────────────────────────────────────
206+
207+
const SYSTEM_MESSAGE =
208+
'You are an expert iOS/macOS Swift developer helping to triage bug ' +
209+
'reports for the Meshtastic Apple app – a SwiftUI mesh-radio ' +
210+
'communication app that uses Bluetooth LE and a Core Data stack. ' +
211+
'Be concise, specific, and reference real code paths when possible.';
212+
213+
try {
214+
const fs = require('fs');
215+
const path = require('path');
216+
217+
// Collect all Swift source file paths (max depth 4, skip generated dirs).
218+
const SKIP_DIRS = new Set([
219+
'node_modules', '.git', 'DerivedData', 'build',
220+
'MeshtasticProtobufs',
221+
]);
222+
223+
function collectSwiftFiles(dir, depth) {
224+
if (depth > MAX_SEARCH_DEPTH) return [];
225+
const results = [];
226+
let entries;
227+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
228+
catch (_) { return results; }
229+
for (const e of entries) {
230+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
231+
const full = path.join(dir, e.name);
232+
if (e.isDirectory()) {
233+
results.push(...collectSwiftFiles(full, depth + 1));
234+
} else if (e.name.endsWith('.swift')) {
235+
results.push(full);
236+
}
237+
}
238+
return results;
239+
}
240+
241+
const root = process.cwd();
242+
const allFiles = collectSwiftFiles(root, 0);
243+
const fileList = allFiles
244+
.map(f => path.relative(root, f))
245+
.slice(0, MAX_FILES_TO_LIST)
246+
.join('\n');
247+
248+
// Ask the model which files are most relevant.
249+
const fileSelectionPrompt =
250+
`Bug title: ${title}\n` +
251+
`Steps to reproduce: ${stepsToReproduce}\n` +
252+
`Expected: ${expectedBehavior}\n` +
253+
`Current: ${currentBehavior}\n` +
254+
(additionalComments ? `Additional: ${additionalComments}\n` : '') +
255+
`\nAvailable Swift source files:\n${fileList}\n\n` +
256+
'Return ONLY a JSON array (no markdown, no explanation) of the ' +
257+
`${MAX_FILES_TO_READ}–${MAX_FILES_TO_READ + FILE_SELECTION_BUFFER} ` +
258+
'file paths most likely to contain the bug.';
259+
260+
let relevantFiles = [];
261+
try {
262+
const raw = await callModelsAPI(SYSTEM_MESSAGE, fileSelectionPrompt);
263+
// Strip potential markdown fences before parsing.
264+
const cleaned = raw.replace(/```[a-z]*\n?/g, '').trim();
265+
relevantFiles = JSON.parse(cleaned);
266+
} catch (e) {
267+
core.warning(`File selection failed: ${e.message}`);
268+
}
269+
270+
// Read up to MAX_FILES_TO_READ files, capping each at MAX_LINES_PER_FILE lines to stay within token budget.
271+
let codeContext = '';
272+
for (const relPath of relevantFiles.slice(0, MAX_FILES_TO_READ)) {
273+
const absPath = path.join(root, relPath);
274+
if (!fs.existsSync(absPath)) continue;
275+
try {
276+
const content = fs.readFileSync(absPath, 'utf8');
277+
const snippet = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n');
278+
codeContext += `\n\n### ${relPath}\n\`\`\`swift\n${snippet}\n\`\`\``;
279+
} catch (_) {}
280+
}
281+
282+
const analysisPrompt =
283+
`Bug title: ${title}\n` +
284+
`Firmware Version: ${firmwareVersion}\n` +
285+
`Steps to reproduce: ${stepsToReproduce}\n` +
286+
`Expected: ${expectedBehavior}\n` +
287+
`Current: ${currentBehavior}\n` +
288+
(additionalComments ? `Additional: ${additionalComments}\n` : '') +
289+
(codeContext
290+
? `\nRelevant source code:${codeContext}\n`
291+
: '\n(No source files matched – reason from code structure)\n') +
292+
'\nPlease provide:\n' +
293+
'1. **Likely root cause** – a concise hypothesis with references to ' +
294+
'specific files, types, or functions.\n' +
295+
'2. **Relevant code areas** – file paths and line ranges worth ' +
296+
'investigating.\n' +
297+
'3. **Clarifying questions** – any details that would confirm or rule ' +
298+
'out the hypothesis.\n' +
299+
'4. **Suggested investigation steps** – what a developer should do ' +
300+
'next.\n';
301+
302+
const analysis = await callModelsAPI(SYSTEM_MESSAGE, analysisPrompt);
303+
304+
const commentBody = `${BOT_COMMENT_MARKER}
305+
## 🤖 Automated Bug Report Analysis
306+
307+
Thank you for the detailed report! Here is an automated analysis to help the maintainers investigate:
308+
309+
${analysis}
310+
311+
---
312+
*This analysis was generated automatically from the issue description and the repository source. A human maintainer will review and follow up shortly.*`;
313+
314+
await github.rest.issues.createComment({
315+
owner: context.repo.owner,
316+
repo: context.repo.repo,
317+
issue_number: issue.number,
318+
body: commentBody,
319+
});
320+
core.info('Posted analysis comment.');
321+
322+
} catch (error) {
323+
core.warning(`AI analysis failed (${error.message}). Posting fallback acknowledgement.`);
324+
325+
const fallback = `${BOT_COMMENT_MARKER}
326+
## 🤖 Bug Report Received
327+
328+
Thank you for this detailed bug report! A maintainer will review it and investigate the root cause.
329+
330+
If you can provide any of the following it will speed up the investigation:
331+
- Device logs from the <a href="https://meshtastic.org/docs/software/apple/ios-debug/">Debug Log</a> feature
332+
- Whether this is a regression (last known-good firmware version)
333+
- A minimal set of steps that consistently reproduce the issue`;
334+
335+
await github.rest.issues.createComment({
336+
owner: context.repo.owner,
337+
repo: context.repo.repo,
338+
issue_number: issue.number,
339+
body: fallback,
340+
});
341+
}

0 commit comments

Comments
 (0)