blob: 776de4e34aad37b4ed4f8eebbbddbbcf88d25187 [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Implementation of @flutter-symbolizer-bot.
///
/// The bot is triggered by command comments starting with bot mention
/// and followed by zero of more keywords.
///
/// See README-bot.md for bot command documentation.
library symbolizer.bot;
import 'dart:convert';
import 'package:github/github.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:sendgrid_mailer/sendgrid_mailer.dart';
import 'package:symbolizer/model.dart';
import 'package:symbolizer/parser.dart';
import 'package:symbolizer/symbolizer.dart';
final _log = Logger(Bot.account);
class Bot {
final GitHub github;
final Symbolizer symbolizer;
final Mailer mailer;
final String failuresEmail;
final bool dryRun;
Bot({
@required this.github,
@required this.symbolizer,
@required this.mailer,
@required this.failuresEmail,
this.dryRun = false,
});
static final account = 'flutter-symbolizer-bot';
static final accountMention = '@$account';
/// Returns [true] if the given [text] is potentially a command to the bot.
static bool isCommand(String text) {
return text.startsWith(Bot.accountMention);
}
/// Parse the given [text] as a command to the bot. See the library doc
/// comment at the beginning of the file for information about the command
/// format.
static BotCommand parseCommand(int issueNumber, String text) {
final command = text.split('\n').first;
if (!isCommand(command)) {
return null;
}
final issueNumberStr = issueNumber.toString();
var symbolizeThis = false;
final worklist = <String>{};
String engineHash;
String flutterVersion;
String os;
String arch;
String mode;
String format;
var force = false;
// Command is just a sequence of keywords which specify which comments
// to symbolize and which symbols to use.
for (var keyword in command.split(' ').skip(1)) {
switch (keyword) {
case 'this':
symbolizeThis = true;
break;
case 'x86':
case 'arm':
case 'arm64':
case 'x64':
arch = keyword;
break;
case 'debug':
case 'profile':
case 'release':
mode = keyword;
break;
case 'internal':
format = 'internal';
break;
case 'force':
force = true;
break;
case 'ios':
os = 'ios';
break;
default:
// Check if this keyword is a link to an comment on this issue.
var m = _commentLinkPattern.firstMatch(keyword);
if (m != null) {
if (m.namedGroup('issueNumber') == issueNumberStr) {
worklist.add(m.namedGroup('ref'));
}
break;
}
// Check if this keyword is an engine hash.
m = _engineHashPattern.firstMatch(keyword);
if (m != null) {
engineHash = m.namedGroup('sha');
break;
}
m = _flutterHashOrVersionPattern.firstMatch(keyword);
if (m != null) {
flutterVersion = m.namedGroup('version');
break;
}
break;
}
}
return BotCommand(
symbolizeThis: symbolizeThis,
worklist: worklist,
overrides: SymbolizationOverrides(
arch: arch,
engineHash: engineHash,
flutterVersion: flutterVersion,
mode: mode,
os: os,
force: force,
format: format,
),
);
}
/// Execute command contained in the given [commandComment] posted on the
/// [issue] in the repository [repo].
Future<void> executeCommand(
RepositorySlug repo,
Issue issue,
IssueComment commandComment, {
@required bool authorized,
}) async {
if (!authorized) {
await github.issues.createComment(repo, issue.number, '''
@${commandComment.user.login} Sorry, only **public members of Flutter org** can trigger my services.
Check your privacy settings as described [here](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-user-account/publicizing-or-hiding-organization-membership).
''');
return;
}
final command = parseCommand(issue.number, commandComment.body);
if (command == null) {
return;
}
// Comments which potentially contain crashes by their id.
Map<int, _Comment> worklist;
if (command.shouldProcessAll) {
worklist = await processAllComments(repo, issue);
} else {
worklist = {};
if (command.symbolizeThis) {
worklist[commandComment.id] = _Comment.fromComment(commandComment);
}
// Process worklist from the command and fetch comment bodies.
for (var ref in command.worklist) {
// ref has one of the following formats: issue-id or issuecomment-id
final c = ref.split('-');
final id = int.parse(c[1]);
if (c[0] == 'issue') {
worklist[issue.id] = _Comment.fromIssue(issue);
} else {
try {
final comment = await github.issues.getComment(repo, id);
worklist[comment.id] = _Comment.fromComment(comment);
} catch (e) {
// Ignore.
}
}
}
}
// Process comments from the worklist.
await symbolizeGiven(
repo, issue, commandComment.user, worklist, command.overrides);
}
/// Find all comments on the [issue] which potentially contain crashes
/// and were not previously symbolized by the bot.
Future<Map<int, _Comment>> processAllComments(
RepositorySlug repo, Issue issue) async {
_log.info(
'Requested to symbolize all comments on ${repo.fullName}#${issue.number}');
// Dictionary of comments to symbolize by their id.
final worklist = <int, _Comment>{};
final alreadySymbolized = <int>{};
// Collect all comments which might contain crashes in the worklist
// and ids of already symbolized comments in the [alreadySymbolized] set.
if (containsCrash(issue.body)) {
worklist[issue.id] = _Comment.fromIssue(issue);
}
await for (var comment
in github.issues.listCommentsByIssue(repo, issue.number)) {
if (comment.user.login == Bot.account) {
// From comments by the bot extract ids of already symbolized comments.
// Bot adds it to its comments as a JSON encoded object within
// HTML comment: <!-- {"symbolized": [id0, id1, ...]} -->
final m = _botInfoPattern.firstMatch(comment.body);
if (m != null) {
final state = jsonDecode(m.namedGroup('json').trim());
if (state is Map<String, dynamic> &&
state.containsKey('symbolized')) {
alreadySymbolized
.addAll((state['symbolized'] as List<dynamic>).cast<int>());
}
}
} else if (containsCrash(comment.body)) {
worklist[comment.id] = _Comment.fromComment(comment);
}
}
_log.info(
'Found comments with crashes ${worklist.keys}, and already symbolized ${alreadySymbolized}');
alreadySymbolized.forEach(worklist.remove);
return worklist;
}
/// Symbolize crashes from all comments in the [worklist] using the given
/// [overrides] and post response on the issue.
Future<void> symbolizeGiven(
RepositorySlug repo,
Issue issue,
User commandUser,
Map<int, _Comment> worklist,
SymbolizationOverrides overrides) async {
_log.info('Symbolizing ${worklist.keys} with overrides {$overrides}');
// Symbolize all collected comments.
final symbolized = <int, List<SymbolizationResult>>{};
for (var comment in worklist.values) {
final result =
await symbolizer.symbolize(comment.body, overrides: overrides);
if (result.isNotEmpty) {
symbolized[comment.id] = result;
}
}
if (symbolized.isEmpty) {
await github.issues.createComment(repo, issue.number, '''
@${commandUser.login} No crash reports found. I used the following overrides
when looking for reports
```
$overrides
```
Note that I can only find native Android and iOS crash reports automatically,
you need to explicitly point me to crash reports in other supported formats.
If the crash report is embedded into a log and prefixed with additional
information I might not be able to automatically strip those prefixes.
Currently I only support `flutter run -v`, `adb logcat` and device lab logs.
See [my documentation](https://github.com/flutter-symbolizer-bot/flutter-symbolizer-bot/blob/main/README.md#commands) for more details on how to do that.
''');
return;
}
// Post a comment containing all successfully symbolized crashes.
await postResultComment(repo, issue, worklist, symbolized);
await mailFailures(repo, issue, worklist, symbolized);
}
/// Post a comment on the issue commenting successfully symbolized crashes.
void postResultComment(
RepositorySlug repo,
Issue issue,
Map<int, _Comment> comments,
Map<int, List<SymbolizationResult>> symbolized) async {
if (symbolized.isEmpty) {
return;
}
final successes = symbolized.whereResult((r) => r.symbolized != null);
final failures = symbolized.whereResult((r) => r.symbolized == null);
final buf = StringBuffer();
for (var entry in successes.entries) {
for (var result in entry.value) {
buf.write('''
crash from ${comments[entry.key].url} symbolized using symbols for `${result.engineBuild.engineHash}` `${result.engineBuild.variant.os}-${result.engineBuild.variant.arch}-${result.engineBuild.variant.mode}`
```
${result.symbolized}
```
''');
for (var note in result.notes) {
buf.write('_(${noteMessage[note.kind]}');
if ((note.message ?? '').isNotEmpty) {
buf.write(': ');
buf.write(note.message);
}
buf.write(')_');
}
buf.writeln();
}
}
if (failures.isNotEmpty) {
buf.writeln();
// GitHub allows <details> HTML elements
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details
buf.writeln('''
<details>
<summary>There were failures symbolizing some of the crashes I found</summary>
''');
for (var entry in failures.entries) {
for (var result in entry.value) {
buf.writeln('''
When processing ${comments[entry.key].url} I found crash
```
${result.crash}
```
but failed to symbolize it with the following notes:
''');
for (var note in result.notes) {
buf.write('* ${noteMessage[note.kind]}');
if (note.message != null && note.message.isNotEmpty) {
if (note.message.contains('\n')) {
buf.writeln(':');
buf.write('''
```
${note.message}
```'''
.indentBy(' '));
} else {
buf.writeln(': `${note.message}`');
}
}
buf.writeln('');
}
}
}
buf.writeln('''
See [my documentation](https://github.com/flutter-symbolizer-bot/flutter-symbolizer-bot/blob/main/README.md#commands) for more details.
</details>
''');
}
// Append information about symbolized comments to the bot's comment so that
// we could skip them later.
buf.writeln(
'<!-- ${jsonEncode({'symbolized': successes.keys.toList()})} -->');
if (dryRun) {
print(buf);
} else {
await github.issues.createComment(repo, issue.number, buf.toString());
}
}
/// Mail failures to the [failuresEmail] mail address.
void mailFailures(
RepositorySlug repo,
Issue issue,
Map<int, _Comment> comments,
Map<int, List<SymbolizationResult>> symbolized) async {
if (failuresEmail == null) {
return;
}
final failures = symbolized.whereResult((r) => r.symbolized == null);
if (failures.isEmpty) {
return;
}
final escape = const HtmlEscape().convert;
final buf = StringBuffer();
buf.write('''<p>Hello &#x1f44b;</p>
<p>I was asked to symbolize crashes from <a href="${issue.htmlUrl}">issue ${issue.number}</a>, but failed.</p>
''');
for (var entry in failures.entries) {
for (var result in entry.value) {
buf.writeln('''
<p>When processing <a href="${comments[entry.key].url}">comment</a>, I found crash</p>
<pre>${escape(result.crash.toString())}</pre>
<p>but failed with the following notes:</p>
''');
for (var note in result.notes) {
buf.writeln(
'${noteMessage[note.kind]} <pre>${escape(note.message ?? '')}');
}
}
}
if (dryRun) {
print(buf);
} else {
await mailer.send(
Email(
[
Personalization([Address(failuresEmail)])
],
Address('noreply@dart.dev'),
'symbolization errors for issue #${issue.number}',
content: [Content('text/html', buf.toString())],
),
);
}
}
static final _botInfoPattern = RegExp(r'<!-- (?<json>.*) -->');
static final _commentLinkPattern = RegExp(
r'^https://github\.com/(?<fullName>[\-\w]+/[\-\w]+)/issues/(?<issueNumber>\d+)#(?<ref>issue(comment)?-\d+)$');
static final _engineHashPattern = RegExp(r'^engine#(?<sha>[a-f0-9]+)$');
static final _flutterHashOrVersionPattern =
RegExp(r'^flutter#v?(?<version>[a-f0-9\.]+)$');
}
extension on BotCommand {
/// [true] if the user requested to process all comments on the issue.
bool get shouldProcessAll => !symbolizeThis && worklist.isEmpty;
}
/// Class that represents a comment on the issue.
///
/// GitHub API does not treat issue body itself as a comment hence
/// the need for separate wrapper.
class _Comment {
final int id;
final String url;
final String body;
_Comment(this.id, this.url, this.body);
_Comment.fromComment(IssueComment comment)
: this(comment.id, comment.htmlUrl, comment.body);
_Comment.fromIssue(Issue issue)
: this(issue.id, issue.commentLikeHtmlUrl, issue.body);
}
extension on Issue {
String get commentLikeHtmlUrl => '$htmlUrl#issue-$id';
}
extension on Map<int, List<SymbolizationResult>> {
/// Filter multimap of symbolization results using the given predicate.
Map<int, List<SymbolizationResult>> whereResult(
bool Function(SymbolizationResult) predicate) =>
Map.fromEntries(entries
.map((e) => MapEntry(e.key, e.value.where(predicate).toList()))
.where((e) => e.value.isNotEmpty));
}
extension on String {
String indentBy(String indent) => indent + split('\n').join('\n$indent');
}