blob: 87946e0b7b4b2ccb67876415ea6a55116b4c05f7 [file] [log] [blame]
// Copyright (c) 2019, 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.
import 'dart:convert';
import 'dart:typed_data';
import 'package:firebase_functions_interop/firebase_functions_interop.dart';
import 'package:js/js_util.dart';
import 'package:node_interop/node_interop.dart';
import 'package:node_io/node_io.dart';
import 'package:node_http/node_http.dart' as http;
import 'package:github_label_notifier/github_utils.dart';
import 'package:github_label_notifier/sendgrid.dart' as sendgrid;
import 'package:github_label_notifier/subscriptions_db.dart' as db;
import 'package:symbolizer/model.dart';
import 'package:symbolizer/parser.dart';
import 'package:symbolizer/bot.dart';
final String symbolizerServer = Platform.environment['SYMBOLIZER_SERVER'] ??
'crash-symbolizer.c.dart-ci.internal:4040';
extension on ExpressHttpRequest {
Uint8List get rawBody {
if (!hasProperty(nativeInstance, 'rawBody')) return null;
return getProperty(nativeInstance, 'rawBody');
}
}
void main() {
db.ensureInitialized();
functions['githubWebhook'] = functions.https.onRequest((request) async {
try {
await githubWebhook(request);
} catch (e, stack) {
try {
request.response.statusCode =
e is WebHookError ? e.statusCode : HttpStatus.internalServerError;
} catch (e) {
console.error('Failed to set statusCode: ${e}');
}
console.error('Caught exception: ${e}\n${stack}');
request.response.write('FAILURE');
} finally {
await request.response.close();
}
});
}
/// Entry point for a [GitHub WebHook](https://developer.github.com/webhooks/).
///
/// Actual handlers are defined in [eventHandlers] dictionary.
Future<void> githubWebhook(ExpressHttpRequest request) async {
// First we need to validate that this is a request originating from GitHub
// by checking if it has all required header values and is properly signed.
if (request.method != 'POST') {
throw WebHookError(
HttpStatus.methodNotAllowed, 'Expected to be called via POST method');
}
if (request.body == null) {
throw WebHookError(HttpStatus.badRequest, 'Request is missing a body');
}
final signature = getRequiredHeaderValue(request, 'x-hub-signature');
final event = getRequiredHeaderValue(request, 'x-github-event');
final delivery = getRequiredHeaderValue(request, 'x-github-delivery');
final body = request.body;
if (!verifyEventSignatureRaw(request.rawBody, signature)) {
throw WebHookError(
HttpStatus.unauthorized, 'Failed to validate the signature');
}
// Event has passed the validation. Dispatch it to the handler.
print('Received event from GitHub: $delivery $event ${body['action']}');
await eventHandlers[event]?.call(body);
request.response.statusCode = HttpStatus.ok;
request.response.writeln('OK: ${event}');
}
typedef GitHubEventHandler = Future<void> Function(Map<String, dynamic> event);
final eventHandlers = <String, GitHubEventHandler>{
'issues': (event) => issueActionHandlers[event['action']]?.call(event),
'issue_comment': (event) =>
commentActionHandlers[event['action']]?.call(event)
};
final issueActionHandlers = <String, GitHubEventHandler>{
'labeled': onIssueLabeled,
'opened': onIssueOpened,
};
final commentActionHandlers = <String, GitHubEventHandler>{
'created': onIssueCommentCreated,
};
/// Handler for the 'labeled' issue event which triggers whenever an
/// issue is labeled with a new label.
///
/// The handler will send mails to all users that subscribed to a
/// particular label.
Future<void> onIssueLabeled(Map<String, dynamic> event) async {
final labelName = event['label']['name'];
final repositoryName = event['repository']['full_name'];
final emails =
await db.lookupLabelSubscriberEmails(repositoryName, labelName);
if (emails.isEmpty) {
return;
}
final issueData = event['issue'];
final issueTitle = issueData['title'];
final issueNumber = issueData['number'];
final issueUrl = issueData['html_url'];
final issueReporterUsername = issueData['user']['login'];
final issueReporterUrl = issueData['user']['html_url'];
final senderUser = event['sender']['login'];
final senderUrl = event['sender']['html_url'];
final escape = htmlEscape.convert;
await sendgrid.sendMultiple(
from: 'noreply@dart.dev',
to: emails,
subject: '[+${labelName}] ${issueTitle} (#${issueNumber})',
text: '''
${issueUrl}
Reported by ${issueReporterUsername}
Labeled ${labelName} by ${senderUser}
--
Sent by dart-github-label-notifier.web.app
''',
html: '''
<p><strong><a href="${issueUrl}">${escape(issueTitle)}</a>&nbsp;(${escape(repositoryName)}#${escape(issueNumber.toString())})</strong></p>
<p>Reported by <a href="${issueReporterUrl}">${escape(issueReporterUsername)}</a></p>
<p>Labeled <strong>${escape(labelName)}</strong> by <a href="${senderUrl}">${escape(senderUser)}</a></p>
<hr>
<p>Sent by <a href="https://dart-github-label-notifier.web.app/">GitHub Label Notifier</a></p>
''');
}
/// Handler for the 'opened' issue event which triggers whenever a new issue
/// is opened at the repository.
///
/// The handler will search the body of the open issue for specific keywords
/// and send emails to all subscribers to a specific label.
Future<void> onIssueOpened(Map<String, dynamic> event) async {
final symbolizedCrashes = <SymbolizationResult>[];
final repositoryName = event['repository']['full_name'];
final subscription = await db.lookupKeywordSubscription(repositoryName);
if (subscription != null &&
subscription.keywords.contains('crash') &&
containsCrash(event['issue']['body'])) {
symbolizedCrashes.addAll(await _trySymbolize(event['issue']));
}
final match = symbolizedCrashes.isNotEmpty
? 'crash'
: subscription?.match(event['issue']['body']);
if (match == null) {
return;
}
final subscribers =
await db.lookupLabelSubscriberEmails(repositoryName, subscription.label);
final issueData = event['issue'];
final issueTitle = issueData['title'];
final issueNumber = issueData['number'];
final issueUrl = issueData['html_url'];
final issueReporterUsername = issueData['user']['login'];
final issueReporterUrl = issueData['user']['html_url'];
final escape = htmlEscape.convert;
var symbolizedCrashesText = '', symbolizedCrashesHtml = '';
if (symbolizedCrashes.isNotEmpty) {
symbolizedCrashesText = [
'',
...symbolizedCrashes.expand((r) => [
if (r.symbolized != null)
'# engine ${r.engineBuild.engineHash} ${r.engineBuild.variant.pretty} crash'
else
'# engine crash',
for (var note in r.notes)
if (note.message != null)
'# ${noteMessage[note.kind]}: ${note.message}'
else
'# ${noteMessage[note.kind]}',
r.symbolized ?? r.crash.frames.toString(),
]),
''
].join('\n');
symbolizedCrashesHtml = symbolizedCrashes
.expand((r) => [
if (r.symbolized != null)
'<p>engine ${r.engineBuild.engineHash} ${r.engineBuild.variant.pretty} crash</p>'
else
'<p>engine crash</p>',
for (var note in r.notes)
if (note.message != null)
'<em>${noteMessage[note.kind]}: <pre>${escape(note.message)}</pre></em>'
else
'<em>${noteMessage[note.kind]}</em>',
'<pre>${escape(r.symbolized ?? r.crash.frames.toString())}</pre>',
])
.join('');
}
await sendgrid.sendMultiple(
from: 'noreply@dart.dev',
to: subscribers,
subject: '[$repositoryName] ${issueTitle} (#${issueNumber})',
text: '''
${issueUrl}
Reported by ${issueReporterUsername}
Matches keyword: ${match}
${symbolizedCrashesText}
You are getting this mail because you are subscribed to label ${subscription.label}.
--
Sent by dart-github-label-notifier.web.app
''',
html: '''
<p><strong><a href="${issueUrl}">${escape(issueTitle)}</a>&nbsp;(${escape(repositoryName)}#${escape(issueNumber.toString())})</strong></p>
<p>Reported by <a href="${issueReporterUrl}">${escape(issueReporterUsername)}</a></p>
<p>Matches keyword: <b>${match}</b></p>
${symbolizedCrashesHtml}
<p>You are getting this mail because you are subscribed to label ${subscription.label}</p>
<hr>
<p>Sent by <a href="https://dart-github-label-notifier.web.app/">GitHub Label Notifier</a></p>
''');
}
Future<void> onIssueCommentCreated(Map<String, dynamic> event) async {
final body = event['comment']['body'];
if (Bot.isCommand(body)) {
final response = await http.post(
'http://$symbolizerServer/command',
body: jsonEncode(event),
);
if (response.statusCode != HttpStatus.ok) {
throw WebHookError(HttpStatus.internalServerError,
'Failed to process ${event['comment']['html_url']}: ${response.body}');
}
}
}
class WebHookError {
final int statusCode;
final String message;
WebHookError(this.statusCode, this.message);
@override
String toString() => 'Error: $message';
}
/// Helper which gets a value of the header with the given name from a
/// request or throws an error if request does not contain such a header.
String getRequiredHeaderValue(ExpressHttpRequest request, String header) {
return request.headers.value(header) ??
(throw WebHookError(
HttpStatus.badRequest, 'Missing ${header} header value.'));
}
Future<List<SymbolizationResult>> _trySymbolize(
Map<String, dynamic> body) async {
try {
final response = await http
.post(
'http://$symbolizerServer/symbolize',
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 20));
return [
for (var crash in (jsonDecode(response.body) as List))
SymbolizationResult.fromJson(crash)
];
} catch (e, st) {
console.error('Symbolizer failed with $e: $st');
return [];
}
}