Various tweaks to GitHub Label notifier

* Move keyword subscriptions to a separate collection
* Minimize false positives by changing how regexp is compiled - so that we only match full words
* Surface information about subscribing to keywords in the UI

Change-Id: Ic18be2e594a7684970d2031743428154ff5bd021
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/128900
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/github-label-notifier/functions/README.md b/github-label-notifier/functions/README.md
index d31dc4e..9407ddb 100644
--- a/github-label-notifier/functions/README.md
+++ b/github-label-notifier/functions/README.md
@@ -2,6 +2,9 @@
 WebHook endpoint which can react to issues being labeled and send notifications
 to subscribers.
 
+It also can inspect newly opened issues for specific keywords and treat matches
+as if a specific label was assigned to an issue.
+
 # Testing
 
 To run local tests use `test.sh` script from this folder.
@@ -35,6 +38,11 @@
       allow create: if request.auth.uid != null;
     }
 
+    match /github-keyword-subscriptions/{repo} {
+      allow read: if true;
+      allow write, create, update, delete: if false
+    }
+
     match /{document=**} {
       allow read, write: if false;
     }
@@ -44,7 +52,7 @@
 
 # Implementation Details
 
-Subscriptions are stored in the Firestore collection called
+Subscriptions to labels are stored in the Firestore collection called
 `github-label-subscriptions` with each document at
 `github-label-subscriptions/{userId}` path has the following format
 
@@ -62,9 +70,9 @@
 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 `$`.
+Subscriptions to keywords for specific repositories are stored at
+`github-keyword-subscriptions/{repositoryName}`, where `{repositoryName}`
+is full repository name with `/` replaced with `$`.
 
 ```proto
 // Represents a subscription to keywords inside issue body. If the match is
@@ -75,7 +83,7 @@
 }
 ```
 
-Note: security rules prevent editing subscriptions by anybody - so they
+Note: security rules prevent editing keyword 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 1b22136..812d72f 100644
--- a/github-label-notifier/functions/lib/subscriptions_db.dart
+++ b/github-label-notifier/functions/lib/subscriptions_db.dart
@@ -49,7 +49,10 @@
     return _makeRegExp().firstMatch(body)?.group(0);
   }
 
-  RegExp _makeRegExp() => RegExp(keywords.join('|'), caseSensitive: false);
+  RegExp _makeRegExp() {
+    final keywordsAlt = keywords.map((kw) => '$kw').join('|');
+    return RegExp('(?<=\\b|_)($keywordsAlt)(?=\\b|_)', caseSensitive: false);
+  }
 
   static final _keywordRegExp = RegExp(r'^[\w_/]+$');
   static bool _isOk(String keyword) => _keywordRegExp.hasMatch(keyword);
@@ -60,7 +63,7 @@
     String repositoryName) async {
   final sanitizedRepositoryName = repositoryName.replaceAll('/', r'$');
   final subscriptions = await _firestore
-      .document('github-label-subscriptions/$sanitizedRepositoryName')
+      .document('github-keyword-subscriptions/$sanitizedRepositoryName')
       .get();
 
   if (!subscriptions.exists) {
diff --git a/github-label-notifier/functions/node/index.test.dart b/github-label-notifier/functions/node/index.test.dart
index c9e4337..cbb6d2a 100644
--- a/github-label-notifier/functions/node/index.test.dart
+++ b/github-label-notifier/functions/node/index.test.dart
@@ -55,8 +55,8 @@
       ],
     }));
 
-    await subscriptions
-        .document('dart-lang\$webhook-test')
+    await firestore
+        .document('github-keyword-subscriptions/dart-lang\$webhook-test')
         .setData(DocumentData.fromMap({
           'keywords': [
             'jit',
@@ -164,7 +164,10 @@
       };
 
   Map<String, dynamic> makeIssueOpenedEvent(
-          {int number = 1, String repositoryName = 'dart-lang/webhook-test'}) =>
+          {int number = 1,
+          String repositoryName = 'dart-lang/webhook-test',
+          String body =
+              'This is an amazing ../third_party/dart/runtime/vm solution'}) =>
       {
         'action': 'opened',
         'issue': {
@@ -172,7 +175,7 @@
               'https://github.com/dart-lang/webhook-test/issues/${number}',
           'number': number,
           'title': 'TEST ISSUE TITLE',
-          'body': 'This is an amazing aot solution',
+          'body': body,
           'user': {
             'login': 'hest',
             'html_url': 'https://github.com/hest',
@@ -285,7 +288,7 @@
         ]));
   });
 
-  test('ok - single keyword subscriber', () async {
+  test('ok - issue opened', () async {
     final rs = await sendEvent(body: makeIssueOpenedEvent());
     expect(rs.statusCode, equals(HttpStatus.ok));
     expect(sendgridRequests.length, equals(1));
@@ -307,7 +310,40 @@
     expect(
         rq.body['content']
             .firstWhere((c) => c['type'] == 'text/plain')['value'],
-        contains('Matches keyword: aot'));
+        contains('Matches keyword: third_party/dart'));
+  });
+
+  test('ok - issue opened - test underscore as word boundary',
+      () async {
+    final rs = await sendEvent(body: makeIssueOpenedEvent(body: 'xyz, something_jit_something_else, foobar'));
+    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: jit'));
+  });
+
+  test('ok - issue opened - no matching keyword',
+      () async {
+    final rs = await sendEvent(body: makeIssueOpenedEvent(body: 'xyz, somethingjitsomething_else, foobar'));
+    expect(rs.statusCode, equals(HttpStatus.ok));
+    expect(sendgridRequests.length, equals(0));
   });
 }
 
diff --git a/github-label-notifier/ui/README.deploy.md b/github-label-notifier/ui/README.deploy.md
index a9f298b..21a2d79 100644
--- a/github-label-notifier/ui/README.deploy.md
+++ b/github-label-notifier/ui/README.deploy.md
@@ -17,12 +17,4 @@
 
 ## Deployment
 
-To build and deploy, use the following commands:
-
-pub get
-
-webdev build --output web:build/web -r
-
-rm -rf build/web/packages
-
-firebase deploy -P dart-ci --only hosting:dart-github-notifier
+To build and deploy, use `deploy.sh` script.
diff --git a/github-label-notifier/ui/deploy.sh b/github-label-notifier/ui/deploy.sh
new file mode 100755
index 0000000..c06660c
--- /dev/null
+++ b/github-label-notifier/ui/deploy.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# 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.
+
+set -e
+
+webdev build --output web:build/web -r
+rm -rf build/web/packages
+firebase deploy -P dart-ci --only hosting:dart-github-notifier
diff --git a/github-label-notifier/ui/lib/app_component.dart b/github-label-notifier/ui/lib/app_component.dart
index 5b2f7f9..d813f55 100644
--- a/github-label-notifier/ui/lib/app_component.dart
+++ b/github-label-notifier/ui/lib/app_component.dart
@@ -58,12 +58,22 @@
   List<String> repoLabels = [];
   String selectedLabel = '';
 
+  // Configuration of the keyword subscription to flutter/flutter repository.
+  String flutterKeywordLabel = '';
+  List<String> flutterKeywords = [];
+
   AppComponent(this._subscriptionsService, this._githubService);
 
   @override
   Future<Null> ngOnInit() async {
     _subscriptionsService.onAuth.listen((_) async {
       subscriptions = await _subscriptionsService.getSubscriptions();
+
+      final subscription = await _subscriptionsService.getKeywordSubscription('flutter/flutter');
+      if (subscription != null) {
+        flutterKeywordLabel = subscription.label;
+        flutterKeywords = subscription.keywords;
+      }
     });
     fetchLabelsForSelectedRepository();
   }
diff --git a/github-label-notifier/ui/lib/app_component.html b/github-label-notifier/ui/lib/app_component.html
index e7259e3..01ac468 100644
--- a/github-label-notifier/ui/lib/app_component.html
+++ b/github-label-notifier/ui/lib/app_component.html
@@ -6,44 +6,50 @@
   LOGIN
 </material-button>
 
-<div class="mdc-card mdc-card--outlined" *ngIf="isLoggedIn">
-  <div class="subscribe-row">
-    <div *ngIf="subscriptions.isEmpty">{{userEmail}} has no subscriptions</div>
-    <div *ngIf="subscriptions.isNotEmpty">{{userEmail}} subscribed to:
-      <ul class="repo-list">
-          <li *ngFor="let repo of subscriptions.entries; trackBy: trackByKey">
-            <div>{{repo.key}}</div>
-            <ul class="label-list">
-              <li *ngFor="let label of repo.value">
-                <div>
-                  {{label}}
-                  <material-button icon (trigger)="unsubscribeFrom(repo.key, label)">
-                    <material-icon icon="delete" size="small" baseline></material-icon>
-                  </material-button>
-                </div>
-              </li>
-            </ul>
-          </li>
-      </ul>
+<div *ngIf="isLoggedIn">
+  <div class="mdc-card mdc-card--outlined" *ngIf="isLoggedIn">
+    <div class="card-row">
+      <div *ngIf="subscriptions.isEmpty">{{userEmail}} has no subscriptions</div>
+      <div *ngIf="subscriptions.isNotEmpty">{{userEmail}} subscribed to:
+        <ul class="repo-list">
+            <li *ngFor="let repo of subscriptions.entries; trackBy: trackByKey">
+              <div>{{repo.key}}</div>
+              <ul class="label-list">
+                <li *ngFor="let label of repo.value">
+                  <div>
+                    {{label}}
+                    <material-button icon (trigger)="unsubscribeFrom(repo.key, label)">
+                      <material-icon icon="delete" size="small" baseline></material-icon>
+                    </material-button>
+                  </div>
+                </li>
+              </ul>
+            </li>
+        </ul>
+      </div>
+    </div>
+    <div class="card-row centered">
+      <material-dropdown-select
+        [options]="supportedRepositories"
+        [(selection)]="selectedRepository"
+        buttonText="{{selectedRepository}}"
+        [deselectOnActivate]="false">
+      </material-dropdown-select>
+      <material-auto-suggest-input
+        [selectionOptions]="repoLabels"
+        [(inputText)]="selectedLabel"
+        label="label name">
+      </material-auto-suggest-input>
+      <material-button
+        raised
+        class="subscribe-button blue"
+        (trigger)="subscribe()">
+        subscribe
+      </material-button>
     </div>
   </div>
-  <div class="subscribe-row centered">
-    <material-dropdown-select
-      [options]="supportedRepositories"
-      [(selection)]="selectedRepository"
-      buttonText="{{selectedRepository}}"
-      [deselectOnActivate]="false">
-    </material-dropdown-select>
-    <material-auto-suggest-input
-      [selectionOptions]="repoLabels"
-      [(inputText)]="selectedLabel"
-      label="label name">
-    </material-auto-suggest-input>
-    <material-button
-      raised
-      class="subscribe-button blue"
-      (trigger)="subscribe()">
-      subscribe
-    </material-button>
+
+  <div class="mdc-card mdc-card--outlined margin-before" *ngIf="flutterKeywordLabel.isNotEmpty">
+    <p class="card-row centered">Subscribing to label <b>{{flutterKeywordLabel}}</b> in <b>flutter/flutter</b> repository would also subscribe you to the following keywords: <b><span *ngFor="let keyword of flutterKeywords; let lastKeyword=last">{{keyword}}<span *ngIf="!lastKeyword">,&#32;</span></span></b></p>
   </div>
 </div>
\ No newline at end of file
diff --git a/github-label-notifier/ui/lib/app_component.scss b/github-label-notifier/ui/lib/app_component.scss
index 533adca..8f26d84 100644
--- a/github-label-notifier/ui/lib/app_component.scss
+++ b/github-label-notifier/ui/lib/app_component.scss
@@ -17,7 +17,7 @@
     @include button-dense-theme('.subscribe-button');
 }
 
-.subscribe-row {
+.card-row {
     padding: 10px;
 }
 
@@ -26,6 +26,10 @@
     margin-right: auto;
 }
 
+.margin-before {
+    margin-top: 1em;
+}
+
 ul.repo-list {
     li {
         list-style-type: none;
diff --git a/github-label-notifier/ui/lib/src/services/subscription_service.dart b/github-label-notifier/ui/lib/src/services/subscription_service.dart
index d6f1d81..d809a8b 100644
--- a/github-label-notifier/ui/lib/src/services/subscription_service.dart
+++ b/github-label-notifier/ui/lib/src/services/subscription_service.dart
@@ -72,6 +72,29 @@
     return groupedByRepo;
   }
 
+  /// Fetch configuration of keyword subscription for the specific repository.
+  Future<KeywordSubscription> getKeywordSubscription(
+      String repositoryName) async {
+    final app = await _ensureApp();
+    if (!isLoggedIn) {
+      return null;
+    }
+
+    final sanitizedRepositoryName = repositoryName.replaceAll('/', r'$');
+    final snapshot = await app
+        .firestore()
+        .doc('github-keyword-subscriptions/$sanitizedRepositoryName')
+        .get();
+
+    if (!snapshot.exists) {
+      return null;
+    }
+
+    return KeywordSubscription(
+        label: snapshot.get('label'),
+        keywords: snapshot.get('keywords').cast<String>());
+  }
+
   /// Adds a subscription to a label for the current user.
   Future<void> subscribeTo(String repositoryName, String labelName) async {
     final labelId =
@@ -156,3 +179,14 @@
 
   String toId() => '$repositoryName:$labelName';
 }
+
+/// Represents subscription to particular keywords in a repository.
+///
+/// Whenever a new issue is opened that contains one of the keywords
+/// in the body notifier will notify users subscribed to the specified label.
+class KeywordSubscription {
+  final String label;
+  final List<String> keywords;
+
+  KeywordSubscription({this.label, this.keywords});
+}