mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge origin/2.7.10 into firmware-updates - resolve conflicts (CarPlay intents, BLE cleanup, channel fix)
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/6141f5ab-6bd5-43f5-9922-a7fde05f0d5d Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
parent
cf137003e9
commit
0898cfed21
25 changed files with 63317 additions and 62576 deletions
116
.github/copilot-instructions.md
vendored
Normal file
116
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# GitHub Copilot Instructions for Meshtastic-Apple
|
||||
|
||||
## Project Overview
|
||||
|
||||
Meshtastic-Apple is a SwiftUI client for iOS, iPadOS, and macOS (via Mac Catalyst) that communicates with Meshtastic LoRa mesh radio devices over BLE, TCP, and serial transports. The app handles mesh networking, messaging, node management, mapping, and radio configuration.
|
||||
|
||||
## Architecture
|
||||
|
||||
### App Entry Point
|
||||
- `Meshtastic/MeshtasticApp.swift` — `@main` `App` struct; initialises `AppState`, `Router`, `AccessoryManager`, `PersistenceController`, and Datadog observability.
|
||||
- `Meshtastic/MeshtasticAppDelegate.swift` — `UIApplicationDelegate` for SiriKit intent handling (CarPlay messaging via `INSendMessageIntent` etc.).
|
||||
|
||||
### State & Navigation
|
||||
- `Router` (`Meshtastic/Router/Router.swift`) is a `@MainActor` `ObservableObject` that owns a `NavigationState` struct and drives tab/deep-link routing.
|
||||
- `NavigationState` and the per-tab enums (`MessagesNavigationState`, `MapNavigationState`, `SettingsNavigationState`) live in `Meshtastic/Router/NavigationState.swift`.
|
||||
- Deep links use the `meshtastic:///` URL scheme (see README for the full table). `Router.route(url:)` dispatches them.
|
||||
- `AppState` wraps `Router` and is passed as an `@EnvironmentObject` throughout the view hierarchy.
|
||||
|
||||
### Connectivity
|
||||
- `AccessoryManager` (`Meshtastic/Accessory/Accessory Manager/`) is the central BLE/TCP/serial manager. It is split across extension files:
|
||||
- `AccessoryManager+Discovery.swift` — device scanning & connection
|
||||
- `AccessoryManager+Connect.swift` — connection lifecycle
|
||||
- `AccessoryManager+ToRadio.swift` — packets sent to the radio (including `sendWaypoint`)
|
||||
- `AccessoryManager+FromRadio.swift` — packets received from the radio
|
||||
- `AccessoryManager+Position.swift` — GPS position sharing
|
||||
- `AccessoryManager+MQTT.swift` — MQTT proxy
|
||||
- `AccessoryManager+TAK.swift` — TAK/CoT integration
|
||||
- Transport protocols are in `Meshtastic/Accessory/Transports/`.
|
||||
|
||||
### Persistence
|
||||
- Core Data is the sole persistence layer. Use `PersistenceController.shared` for the container; prefer `viewContext` for reads and a background context for writes.
|
||||
- The model lives in `Meshtastic/Meshtastic.xcdatamodeld` (55+ versioned migrations — always add a new model version for schema changes).
|
||||
- Query helpers: `QueryCoreData.swift` (`getNodeInfo`, etc.); update helpers: `UpdateCoreData.swift`.
|
||||
|
||||
### Protobufs
|
||||
- The `MeshtasticProtobufs` Swift Package (`MeshtasticProtobufs/Package.swift`) wraps the protobuf-generated Swift sources.
|
||||
- Regenerate with `./scripts/gen_protos.sh` whenever `protobufs/` submodule changes, then build and commit.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Language & Frameworks
|
||||
- **Swift only.** No Objective-C.
|
||||
- **SwiftUI** for all UI. Do not use UIKit directly unless unavoidable (e.g., `UIApplicationDelegateAdaptor`).
|
||||
- **SF Symbols** for all icons — never embed image assets for icons.
|
||||
- **Core Data** for all persistence — do not introduce SQLite, Realm, or other persistence libraries.
|
||||
- **OSLog / `Logger`** for all logging — never use `print()`. The project's SwiftLint config enforces this with a custom `disable_print` rule. Use the typed loggers defined in `Meshtastic/Extensions/Logger.swift`:
|
||||
- `Logger.admin`, `Logger.data`, `Logger.mesh`, `Logger.mqtt`, `Logger.radio`, `Logger.services`, `Logger.statistics`, `Logger.transport`, `Logger.tak`
|
||||
|
||||
### Platform Support
|
||||
- Target the **last two major OS versions** of iOS, iPadOS, and macOS (Mac Catalyst).
|
||||
- Guard iOS-only APIs with `#if !targetEnvironment(macCatalyst)` or `#if canImport(UIKit)`.
|
||||
|
||||
### SwiftLint
|
||||
- SwiftLint is enforced on every commit via `scripts/setup-hooks.sh`. Ensure no new errors or warnings are introduced.
|
||||
- Key limits (see `.swiftlint.yml`): line length 400, file length warning 3500, type body length warning 400, function body length warning 200, cyclomatic complexity warning 60.
|
||||
- Disabled rules: `operator_whitespace`, `multiple_closures_with_trailing_closure`, `todo`, `trailing_whitespace`.
|
||||
|
||||
### Formatting Conventions
|
||||
- Indent with **tabs**.
|
||||
- Opening braces on the same line as the declaration.
|
||||
- `// MARK: -` comments to separate logical sections within a file.
|
||||
- `// MARK: FileName` or file-level copyright comment at the top of files.
|
||||
- Extensions grouped by functionality in separate files (e.g., `AccessoryManager+ToRadio.swift`).
|
||||
- Prefer `guard` for early exit; avoid deeply nested `if` blocks.
|
||||
- Use trailing closure syntax; omit argument labels where idiomatic.
|
||||
|
||||
### Naming
|
||||
- Types: `UpperCamelCase`, max 60 chars (warning at 60, error at 70).
|
||||
- Variables/functions: `lowerCamelCase`, min 1 char, max 60 chars.
|
||||
- Enum raw values that map to URL path segments use `lowerCamelCase` (e.g., `SettingsNavigationState.appSettings`).
|
||||
- File names match the primary type they define.
|
||||
|
||||
### Concurrency
|
||||
- `Router` is `@MainActor`; all property reads and method calls must be `await`-ed in async contexts and tests.
|
||||
- Prefer `async/await` over callback-based APIs for new code.
|
||||
- Use `Task` for fire-and-forget async work; propagate cancellation via `Task.checkCancellation()`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Test target: `MeshtasticTests/`.
|
||||
- Use **Swift Testing** (`import Testing`, `@Suite`, `@Test`, `#expect`, `#require`) for new tests. XCTest is used in some legacy test files.
|
||||
- Tests are run via Xcode — there is no Makefile or CLI test runner.
|
||||
- Ensure all existing tests pass before submitting a PR.
|
||||
- Write tests for new features and bug fixes.
|
||||
|
||||
## Git & PR Workflow
|
||||
|
||||
- Branch from and target **`main`** (trunk-based development).
|
||||
- Use **rebase** instead of merge to incorporate upstream changes (`git config pull.rebase true`).
|
||||
- Keep branches small and focused on a single task.
|
||||
- Commit messages: imperative mood subject line (e.g., `Fix crash when BLE device disconnects`). Explain *what* and *why* in the body.
|
||||
- PR description must answer: what changed, why it changed, how it was tested, and include screenshots/videos when UI is affected (see `.github/pull_request_template.md`).
|
||||
- Self-review code before requesting review; comment complex areas.
|
||||
|
||||
## Deep Links
|
||||
|
||||
The app registers the `meshtastic:///` URL scheme. Use `Router.route(url:)` to handle incoming URLs. When adding a new deep link:
|
||||
1. Add a case to the appropriate `*NavigationState` enum in `NavigationState.swift`.
|
||||
2. Update `Router`'s routing helpers.
|
||||
3. Document the URL in the README.
|
||||
|
||||
## Adding or Updating Protobufs
|
||||
|
||||
1. Update the `protobufs/` git submodule.
|
||||
2. Run `./scripts/gen_protos.sh`.
|
||||
3. Build, test, and commit the generated changes.
|
||||
|
||||
## Core Data Schema Changes
|
||||
|
||||
1. Create a new model version in `Meshtastic.xcdatamodeld`.
|
||||
2. Set it as the current version.
|
||||
3. Add a migration policy if required (lightweight migration is preferred when possible).
|
||||
|
||||
## CI
|
||||
|
||||
CI is handled by Xcode Cloud via `ci_scripts/ci_pre_xcodebuild.sh`. Do not modify CI scripts without understanding the Xcode Cloud build environment.
|
||||
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 = '';
|
||||
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,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue