mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047c1c8f5f | ||
|
|
4854c72584 | ||
|
|
adb6960c1b | ||
|
|
f2b8b7afd7 | ||
|
|
546210be4e | ||
|
|
e07c4db6be | ||
|
|
b9e6fa9106 | ||
|
|
46b5cf77b2 |
2 changed files with 341 additions and 2 deletions
341
.github/workflows/bug-report-analyzer.yml
vendored
Normal file
341
.github/workflows/bug-report-analyzer.yml
vendored
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
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 = '<!-- meshtastic-bug-analyzer -->';
|
||||||
|
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 <a href="https://meshtastic.org/docs/software/apple/ios-debug/">Debug Log</a> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2157,7 +2157,6 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
|
|
@ -2196,7 +2195,6 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements;
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue