| // 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 👋</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'); |
| } |