Implement keyword subscriptions in the GitHub label notifier

We store per-repository list of keywords in the Firestore - if any of the
keywords are found in the body of the newly opened issue then this is
considered as if the issue was labeled with a specified label.

We plan to use this to get early notifications about dart related issues
filed in the flutter/flutter repository: having words like `gen_snapshot`,
SIGSEGV, etc to notify everybody subscribed to dependency: dart label.

We are not planning to have any UI for editing keyword subscriptions for now.

Change-Id: Ie22342f0a34a46c3165c21f65998439cc5f5d581
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/128861
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/github-label-notifier/functions/README.md b/github-label-notifier/functions/README.md
index 0dc7e28..d31dc4e 100644
--- a/github-label-notifier/functions/README.md
+++ b/github-label-notifier/functions/README.md
@@ -62,4 +62,20 @@
 Subscriptions are intended to be managed by users themselves so they are indexed
 by Firebase UID issued by Firebase authentification.
 
+Additionally this collection contains configuration of keyword subscriptions
+for specific repositories at `github-label-subscriptions/{repositoryName}`,
+where `{repositoryName}` is full repository name with `/` replaced with `$`.
+
+```proto
+// Represents a subscription to keywords inside issue body. If the match is
+// found then this is treated as if the issue is labeled with the given label.
+message KeywordSubscription {
+    string label = 1;
+    repeated string keywords = 2;
+}
+```
+
+Note: security rules prevent editing subscriptions by anybody - so they
+can only be changed via Firebase Console UI.
+
 Mails are sent via SendGrid.
diff --git a/github-label-notifier/functions/lib/subscriptions_db.dart b/github-label-notifier/functions/lib/subscriptions_db.dart
index 4b8bf26..1b22136 100644
--- a/github-label-notifier/functions/lib/subscriptions_db.dart
+++ b/github-label-notifier/functions/lib/subscriptions_db.dart
@@ -6,6 +6,7 @@
 library github_label_notifier.subscriptions_db;
 
 import 'package:firebase_admin_interop/firebase_admin_interop.dart';
+import 'package:meta/meta.dart';
 
 final _app = FirebaseAdmin.instance.initializeApp();
 final _firestore = _app.firestore();
@@ -13,12 +14,12 @@
 void ensureInitialized() {
   // For some reason the very first query takes very long time (>10s).
   // We work around that by making a dummy query at the startup.
-  lookupSubscriberEmails('', '');
+  lookupLabelSubscriberEmails('', '');
 }
 
 /// Return a list of emails subscribed to the given label in the given
 /// repository.
-Future<List<String>> lookupSubscriberEmails(
+Future<List<String>> lookupLabelSubscriberEmails(
     String repositoryName, String labelName) async {
   final labelId = '${repositoryName}:${labelName}';
 
@@ -30,3 +31,42 @@
     for (final doc in subscriptions.documents) doc.data.getString('email')
   ];
 }
+
+/// Represents subscription to particular keywords in the issue body.
+///
+/// When a match is found we treat it as if the issue was labeled with the
+/// given label.
+class KeywordSubscription {
+  final String label;
+  final List<String> keywords;
+
+  KeywordSubscription({@required this.label, @required List<String> keywords})
+      : keywords = keywords.where(_isOk).toList();
+
+  /// Checks if the given string contains match to any of the keywords
+  /// and returns the matched one.
+  String match(String body) {
+    return _makeRegExp().firstMatch(body)?.group(0);
+  }
+
+  RegExp _makeRegExp() => RegExp(keywords.join('|'), caseSensitive: false);
+
+  static final _keywordRegExp = RegExp(r'^[\w_/]+$');
+  static bool _isOk(String keyword) => _keywordRegExp.hasMatch(keyword);
+}
+
+/// Get keyword subscriptions for the given repository.
+Future<KeywordSubscription> lookupKeywordSubscription(
+    String repositoryName) async {
+  final sanitizedRepositoryName = repositoryName.replaceAll('/', r'$');
+  final subscriptions = await _firestore
+      .document('github-label-subscriptions/$sanitizedRepositoryName')
+      .get();
+
+  if (!subscriptions.exists) {
+    return null;
+  }
+  return KeywordSubscription(
+      label: subscriptions.data.getString('label'),
+      keywords: subscriptions.data.getList('keywords').cast<String>());
+}
diff --git a/github-label-notifier/functions/node/index.dart b/github-label-notifier/functions/node/index.dart
index c7232d0..77f6481 100644
--- a/github-label-notifier/functions/node/index.dart
+++ b/github-label-notifier/functions/node/index.dart
@@ -71,7 +71,8 @@
 };
 
 final issueActionHandlers = <String, GitHubEventHandler>{
-  'labeled': onLabeled,
+  'labeled': onIssueLabeled,
+  'opened': onIssueOpened,
 };
 
 /// Handler for the 'labeled' issue event which triggers whenever an
@@ -79,11 +80,12 @@
 ///
 /// The handler will send mails to all users that subscribed to a
 /// particular label.
-Future<void> onLabeled(Map<String, dynamic> event) async {
+Future<void> onIssueLabeled(Map<String, dynamic> event) async {
   final labelName = event['label']['name'];
   final repositoryName = event['repository']['full_name'];
 
-  final emails = await db.lookupSubscriberEmails(repositoryName, labelName);
+  final emails =
+      await db.lookupLabelSubscriberEmails(repositoryName, labelName);
   if (emails.isEmpty) {
     return;
   }
@@ -110,6 +112,9 @@
 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)})</strong></p>
@@ -120,6 +125,58 @@
 ''');
 }
 
+/// 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 repositoryName = event['repository']['full_name'];
+  final subscription = await db.lookupKeywordSubscription(repositoryName);
+
+  final match = 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;
+
+  await sendgrid.sendMultiple(
+      from: 'noreply@dart.dev',
+      to: subscribers,
+      subject: '[$repositoryName] ${issueTitle} (#${issueNumber})',
+      text: '''
+${issueUrl}
+
+Reported by ${issueReporterUsername}
+
+Matches keyword: ${match}
+
+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)})</strong></p>
+<p>Reported by <a href="${issueReporterUrl}">${escape(issueReporterUsername)}</a></p>
+<p>Matches keyword: <b>${match}</b></p>
+<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>
+''');
+}
+
 class WebHookError {
   final int statusCode;
   final String message;
diff --git a/github-label-notifier/functions/node/index.test.dart b/github-label-notifier/functions/node/index.test.dart
index 5fd84d4..c9e4337 100644
--- a/github-label-notifier/functions/node/index.test.dart
+++ b/github-label-notifier/functions/node/index.test.dart
@@ -43,17 +43,29 @@
       'email': 'first@example.com',
       'subscriptions': [
         'dart-lang/webhook-test:some-label',
-        'dart-lang/webhook-test:feature'
-      ]
+        'dart-lang/webhook-test:feature',
+      ],
     }));
     await subscriptions.add(DocumentData.fromMap({
       'email': 'second@example.com',
       'subscriptions': [
         'dart-lang/webhook-test:bug',
-        'dart-lang/webhook-test:feature'
-      ]
+        'dart-lang/webhook-test:feature',
+        'dart-lang/webhook-test:special-label',
+      ],
     }));
 
+    await subscriptions
+        .document('dart-lang\$webhook-test')
+        .setData(DocumentData.fromMap({
+          'keywords': [
+            'jit',
+            'aot',
+            'third_party/dart',
+          ],
+          'label': 'special-label',
+        }));
+
     // Start mock SendGrid server at the address/port specified in
     // SENDGRID_MOCK_SERVER environment variable.
     // This server will simply record headers and bodies of all requests
@@ -151,6 +163,26 @@
         }
       };
 
+  Map<String, dynamic> makeIssueOpenedEvent(
+          {int number = 1, String repositoryName = 'dart-lang/webhook-test'}) =>
+      {
+        'action': 'opened',
+        'issue': {
+          'html_url':
+              'https://github.com/dart-lang/webhook-test/issues/${number}',
+          'number': number,
+          'title': 'TEST ISSUE TITLE',
+          'body': 'This is an amazing aot solution',
+          'user': {
+            'login': 'hest',
+            'html_url': 'https://github.com/hest',
+          },
+        },
+        'repository': {
+          'full_name': 'dart-lang/webhook-test',
+        },
+      };
+
   test('signing', () {
     expect(signEvent(makeLabeledEvent(labelName: 'bug')),
         equals('sha1=76af51cdb9c7a43b146d4df721ac8f83e53182e5'));
@@ -252,6 +284,31 @@
           }
         ]));
   });
+
+  test('ok - single keyword subscriber', () async {
+    final rs = await sendEvent(body: makeIssueOpenedEvent());
+    expect(rs.statusCode, equals(HttpStatus.ok));
+    expect(sendgridRequests.length, equals(1));
+
+    final rq = sendgridRequests.first;
+    expect(rq.headers['authorization'],
+        equals('Bearer ' + Platform.environment['SENDGRID_SECRET']));
+    expect(rq.body['subject'], contains('#1'));
+    expect(rq.body['subject'], contains('TEST ISSUE TITLE'));
+    expect(
+        rq.body['personalizations'],
+        unorderedEquals([
+          {
+            'to': [
+              {'email': 'second@example.com'}
+            ]
+          }
+        ]));
+    expect(
+        rq.body['content']
+            .firstWhere((c) => c['type'] == 'text/plain')['value'],
+        contains('Matches keyword: aot'));
+  });
 }
 
 /// Helper method to convert a [Buffer] to a [String].
diff --git a/github-label-notifier/ui/lib/app_component.dart b/github-label-notifier/ui/lib/app_component.dart
index e896c0a..5b2f7f9 100644
--- a/github-label-notifier/ui/lib/app_component.dart
+++ b/github-label-notifier/ui/lib/app_component.dart
@@ -13,7 +13,7 @@
 import 'src/services/github_service.dart';
 import 'src/services/subscription_service.dart';
 
-const repositoriesWithInstalledHook = ['dart-lang/sdk', 'dart-lang/language'];
+const repositoriesWithInstalledHook = ['dart-lang/sdk', 'flutter/flutter'];
 
 @Component(
   selector: 'my-app',