| // 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: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 Set<String> 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. |
| final worklist = <_Comment>{}; |
| if (command.shouldProcessAll) { |
| worklist.addAll(await _processAllComments(repo, issue)); |
| } else { |
| if (command.symbolizeThis) { |
| worklist.add(_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.add(_Comment.fromIssue(issue)); |
| } else { |
| try { |
| final comment = await github.issues.getComment(repo, id); |
| worklist.add(_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<Set<_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 = <_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.add(_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.add(_Comment.fromComment(comment)); |
| } |
| } |
| |
| _log.info( |
| 'Found comments with crashes ${worklist.ids}, 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, |
| Set<_Comment> worklist, |
| SymbolizationOverrides overrides) async { |
| _log.info('Symbolizing ${worklist.ids} with overrides {$overrides}'); |
| |
| // Symbolize all collected comments. |
| final Map<int, SymbolizationResult> symbolized = |
| <int, SymbolizationResult>{}; |
| for (var comment in worklist) { |
| final result = |
| await symbolizer.symbolize(comment.body, overrides: overrides); |
| if (result is SymbolizationResultError || |
| (result is SymbolizationResultOk && result.results.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. |
| _postResultComment(repo, issue, worklist, symbolized); |
| _mailFailures(repo, issue, worklist, symbolized); |
| } |
| |
| /// Post a comment on the issue commenting successfully symbolized crashes. |
| void _postResultComment(RepositorySlug repo, Issue issue, |
| Set<_Comment> comments, Map<int, SymbolizationResult> symbolized) async { |
| if (symbolized.isEmpty) { |
| return; |
| } |
| |
| final successes = _getOnlySuccesses(comments, symbolized); |
| final failures = _getOnlyFailures(comments, symbolized); |
| |
| final buf = StringBuffer(); |
| for (var entry in successes.entries) { |
| for (var result in entry.value.results) { |
| buf.write(''' |
| crash from ${entry.key} 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]}'); |
| final message = note.message; |
| if (message != null && message.isNotEmpty) { |
| buf.write(': '); |
| buf.write(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 processing the request</summary> |
| '''); |
| |
| void appendNote(SymbolizationNote note) { |
| buf.write('* ${noteMessage[note.kind]}'); |
| final message = note.message; |
| if (message != null && message.isNotEmpty) { |
| if (message.contains('\n')) { |
| buf.writeln(':'); |
| buf.write(''' |
| ``` |
| $message |
| ```''' |
| .indentBy(' ')); |
| } else { |
| buf.writeln(': `$message`'); |
| } |
| } |
| buf.writeln(''); |
| } |
| |
| for (var entry in failures.entries) { |
| entry.value.when(ok: (results) { |
| for (var result in results) { |
| buf.writeln(''' |
| When processing ${entry.key} I found crash |
| |
| ``` |
| ${result.crash} |
| ``` |
| |
| but failed to symbolize it with the following notes: |
| '''); |
| result.notes.forEach(appendNote); |
| } |
| }, error: (note) { |
| buf.writeln(''' |
| When processing ${entry.key} I encountered the following error: |
| '''); |
| appendNote(note); |
| }); |
| } |
| |
| 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. |
| final successIds = <int>{ |
| for (var comment in comments) |
| if (successes.containsKey(comment.url)) comment.id |
| }; |
| buf.writeln('<!-- ${jsonEncode({'symbolized': successIds.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, Set<_Comment> comments, |
| Map<int, SymbolizationResult> symbolized) async { |
| if (failuresEmail == null) { |
| return; |
| } |
| |
| final failures = _getOnlyFailures(comments, symbolized); |
| 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) { |
| entry.value.when(ok: (results) { |
| for (var result in results) { |
| buf.writeln(''' |
| <p>When processing <a href="${entry.key}">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 ?? '')}'); |
| } |
| } |
| }, error: (note) { |
| buf.writeln(''' |
| <p>When processing <a href="${entry.key}">comment</a>, I failed with the following error:</p> |
| '''); |
| 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); |
| |
| @override |
| bool operator ==(Object other) { |
| return other is _Comment && other.id == id; |
| } |
| |
| @override |
| int get hashCode => id.hashCode; |
| } |
| |
| extension on Issue { |
| String get commentLikeHtmlUrl => '$htmlUrl#issue-$id'; |
| } |
| |
| /// Filter multimap of symbolization results to get all successes. |
| Map<String, SymbolizationResultOk> _getOnlySuccesses( |
| Set<_Comment> comments, Map<int, SymbolizationResult> results) { |
| return Map.fromEntries(comments |
| .map((comment) => MapEntry(comment.url, results[comment.id]!)) |
| .where((e) => e.value is SymbolizationResultOk) |
| .map((e) => MapEntry( |
| e.key, |
| _applyFilter( |
| e.value as SymbolizationResultOk, (r) => r.symbolized != null))) |
| .where((e) => e.value.results.isNotEmpty)); |
| } |
| |
| /// Filter multimap of symbolization results to get all failures. |
| Map<String, SymbolizationResult> _getOnlyFailures( |
| Set<_Comment> comments, Map<int, SymbolizationResult> results) { |
| return Map.fromEntries(comments |
| .map((comment) => MapEntry(comment.url, results[comment.id]!)) |
| .map((e) { |
| final result = e.value; |
| return MapEntry( |
| e.key, |
| result is SymbolizationResultOk |
| ? _applyFilter(result, (r) => r.symbolized == null) |
| : result); |
| }).where((e) => |
| e.value is SymbolizationResultError || |
| (e.value as SymbolizationResultOk).results.isNotEmpty)); |
| } |
| |
| SymbolizationResultOk _applyFilter(SymbolizationResultOk result, |
| bool Function(CrashSymbolizationResult) predicate) { |
| return SymbolizationResultOk( |
| results: result.results.where(predicate).toList()); |
| } |
| |
| extension on String { |
| String indentBy(String indent) => indent + split('\n').join('\n$indent'); |
| } |
| |
| extension on Set<_Comment> { |
| Iterable<int> get ids => map((e) => e.id); |
| } |