[result feed] Allow pasted links to GitHub issues in our orgs

Allow comments to be created by pasting a link to a GitHub issue
in the dart-lang, google, or flutter organizations' repos.
These are converted to GitHub-style short links. The short links
can also be entered directly in the comment.

Change-Id: Ide24b2a310db514d802a1009af55131f4ba39f90
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/143742
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/results_feed/lib/src/components/try_results_component.html b/results_feed/lib/src/components/try_results_component.html
index b53854b..182864b 100644
--- a/results_feed/lib/src/components/try_results_component.html
+++ b/results_feed/lib/src/components/try_results_component.html
@@ -26,8 +26,10 @@
     </simple-html>
   </div>
   <div *ngIf="approving">
-    Comment: (type #4567 for GitHub SDK issue 4567,
-    or co19 #123 for a co19 issue. Use \n for newline.)<br>
+    Comment: Use GitHub short links like #456 (dart-lang sdk issue 456),
+    flutter/engine#93, or google/quiver-dart#45. You can also paste
+    URLs of GitHub issues in dart-lang, flutter, or google directly.
+    <br>
     <material-input
         multiline
         style="width: 80%"
diff --git a/results_feed/lib/src/formatting.dart b/results_feed/lib/src/formatting.dart
index 3e6f2a0..b4dc6a9 100644
--- a/results_feed/lib/src/formatting.dart
+++ b/results_feed/lib/src/formatting.dart
@@ -2,6 +2,8 @@
 // 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 'package:intl/intl.dart';
 
 int _year = DateTime.now().year;
@@ -26,3 +28,35 @@
 
 // Checkmark followed by a space. Used for approved results.
 String checkmark = '\u2714 ';
+
+// Format comments and change Github short issue links to HTML links.
+const organizations = ['dart-lang', 'google', 'flutter'];
+final organization = organizations.join('|');
+const repo = '[\\w-]+';
+const issue = '\\d+';
+
+final pastedIssueUrlMatcher =
+    RegExp('\\bhttps://github.com/($organization)/($repo)/issues/($issue)\\b');
+// We allow short links of the form "organization/repo#number",
+// "repo#number", and "#number".  Organization and repo default
+// to dart-lang and sdk.
+final shortLinkMatcher = RegExp('(\\b(($organization)/)?($repo))?#($issue)');
+
+String formatComment(String comment) => comment == null
+    ? null
+    : HtmlEscape(HtmlEscapeMode.element)
+        .convert(comment)
+        .replaceAll('\n', '<br>')
+        .replaceAllMapped(pastedIssueUrlMatcher,
+            (match) => ' ${match[1]}/${match[2]}#${match[3]} ')
+        .replaceAllMapped(
+            shortLinkMatcher,
+            (match) => '<a target="_blank" rel="noopener" '
+                'href="https://${[
+                  'github.com',
+                  match[3] ?? 'dart-lang',
+                  match[4] ?? 'sdk',
+                  'issues',
+                  match[5]
+                ].join('/')}">'
+                '${match[0]}</a>');
diff --git a/results_feed/lib/src/model/comment.dart b/results_feed/lib/src/model/comment.dart
index dd7a675..ad26e2b 100644
--- a/results_feed/lib/src/model/comment.dart
+++ b/results_feed/lib/src/model/comment.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:firebase/firestore.dart' show DocumentSnapshot;
+import '../formatting.dart';
 
 class Comment implements Comparable {
   final String id;
@@ -38,7 +39,7 @@
       this.patchset)
       : results = List<String>.from(_results),
         tryResults = List<String>.from(_tryResults) {
-    commentHtml = createCommentHtml();
+    commentHtml = formatComment(comment);
   }
 
   Comment.fromDocument(DocumentSnapshot document)
@@ -56,30 +57,12 @@
         pinnedIndex = document.get('pinned_index'),
         review = document.get('review'),
         patchset = document.get('patchset') {
-    commentHtml = createCommentHtml();
+    commentHtml = formatComment(comment);
   }
 
   String approvedText() =>
       (approved == null) ? '' : approved ? 'approved' : 'disapproved';
 
-  static const repositories = ['sdk', 'co19'];
-  // Matches a repository or nothing, followed by #[digits][word break].
-  static final issueMatcher =
-      RegExp('(${repositories.join("|")})?\\s*#(\\d+)\\b');
-
-  String createCommentHtml() {
-    final result = comment
-        ?.replaceAll('<', '&lt;')
-        ?.replaceAll('\\n', '<br>')
-        ?.replaceAllMapped(
-            issueMatcher,
-            (match) =>
-                '<a target="_blank" rel="noopener" href="https://github.com/'
-                'dart-lang/${match[1] ?? "sdk"}/issues/${match[2]}">'
-                '${match[0]}</a>');
-    return result;
-  }
-
   @override
   int compareTo(Object other) => created.compareTo((other as Comment).created);
 }
diff --git a/results_feed/test/formatting_test.dart b/results_feed/test/formatting_test.dart
index e4af604..7f8a30e 100644
--- a/results_feed/test/formatting_test.dart
+++ b/results_feed/test/formatting_test.dart
@@ -21,4 +21,131 @@
     expect(formattedDate(DateTime.parse('2018-11-13T15:07:55.123')),
         '15:07 Tue Nov 13, 2018');
   });
+
+  test('Format comment', () {
+    const unformatted = 'This comment has\n'
+        'newlines and\n'
+        'escaped newlines and '
+        'a < sign and an '
+        '& ampersand';
+    const formatted = 'This comment has<br>'
+        'newlines and<br>'
+        'escaped newlines and '
+        'a &lt; sign and an '
+        '&amp; ampersand';
+    expect(formatComment(unformatted), formatted);
+  });
+
+  test('Format comment with GitHub short issue links', () {
+    const unformatted = '#012 This comment has\n'
+        'a short issue link #1234 and \n'
+        'one after a newline \n'
+        '#987 and a link with a repo dart_style#567 '
+        'and one with the dart-lang org dart-lang/dart_ci#890 '
+        'and one with the google org google/a-repo#000 '
+        'and one with the flutter org flutter/engine#92text afterward '
+        'but the following are invalid and should\n'
+        'not be changed to html:\n'
+        'one with no number # '
+        'one across a newline sdk#\n34 '
+        'one with an org and repo but no number google/sdk#deadbeef '
+        'The following get converted, but with the default org:\n'
+        'one from a different org private/dart_clone#358 '
+        'one with an org but no repo dart-lang/#1234 '
+        'one with a slash but no org /dart-pad#567';
+    const formatted = '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/012">#012</a> '
+        'This comment has<br>'
+        'a short issue link '
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/1234">#1234</a> '
+        'and <br>one after a newline <br>'
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/987">#987</a> and '
+        'a link with a repo '
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/dart_style/issues/567">'
+        'dart_style#567</a> '
+        'and one with the dart-lang org '
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/dart_ci/issues/890">'
+        'dart-lang/dart_ci#890</a> '
+        'and one with the google org '
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/google/a-repo/issues/000">'
+        'google/a-repo#000</a> '
+        'and one with the flutter org '
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/flutter/engine/issues/92">'
+        'flutter/engine#92</a>text afterward '
+        'but the following are invalid and should<br>'
+        'not be changed to html:<br>'
+        'one with no number # '
+        'one across a newline sdk#<br>34 '
+        'one with an org and repo but no number google/sdk#deadbeef '
+        'The following get converted, but with the default org:<br>'
+        'one from a different org private/'
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/dart_clone/issues/358">'
+        'dart_clone#358</a> '
+        'one with an org but no repo dart-lang/'
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/1234">'
+        '#1234</a> '
+        'one with a slash but no org /'
+        '<a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/dart-pad/issues/567">'
+        'dart-pad#567</a>';
+    expect(formatComment(unformatted), formatted);
+  });
+
+  test('Format comment with pasted GitHub issue URL', () {
+    const unformatted = 'This comment has a pasted issue URL\n'
+        'https://github.com/dart-lang/sdk/issues/4132\n'
+        'and one from a different repo '
+        '\nhttps://github.com/dart-lang/dart_ci/issues/4321\n'
+        'and one from flutter '
+        '\nhttps://github.com/flutter/engine/issues/3241 '
+        'here are links with text around them '
+        'link https://github.com/dart-lang/sdk/issues/4132 here'
+        'link:https://github.com/dart-lang/sdk/issues/4132<here>'
+        'These links should not be converted:\n'
+        'myhttps://github.com/dart-lang/sdk/issues/4132 '
+        'link https://github.com/dart-lang/sdk/issues/4132foo here'
+        'link https://github.com/dart-clone/sdk/issues/4132 here'
+        'link https://github.com/google//issues/4132 here'
+        'link https://github.com/dart-lang/sdk/issues/ here'
+        'link https://github.com/dart-lang/sdk/issues/new here'
+        'link https://gitfoohub.com/dart-lang/sdk/issues/4132 here'
+        'link https://github.com/dart-lang/s.dk/issues/1234 here';
+    const formatted = 'This comment has a pasted issue URL<br>'
+        ' <a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/4132">'
+        'dart-lang/sdk#4132</a> <br>'
+        'and one from a different repo '
+        '<br> <a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/dart_ci/issues/4321">'
+        'dart-lang/dart_ci#4321</a> <br>'
+        'and one from flutter '
+        '<br> <a target="_blank" rel="noopener" '
+        'href="https://github.com/flutter/engine/issues/3241">'
+        'flutter/engine#3241</a>  '
+        'here are links with text around them '
+        'link  <a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/4132">'
+        'dart-lang/sdk#4132</a>  here'
+        'link: <a target="_blank" rel="noopener" '
+        'href="https://github.com/dart-lang/sdk/issues/4132">'
+        'dart-lang/sdk#4132</a> &lt;here&gt;'
+        'These links should not be converted:<br>'
+        'myhttps://github.com/dart-lang/sdk/issues/4132 '
+        'link https://github.com/dart-lang/sdk/issues/4132foo here'
+        'link https://github.com/dart-clone/sdk/issues/4132 here'
+        'link https://github.com/google//issues/4132 here'
+        'link https://github.com/dart-lang/sdk/issues/ here'
+        'link https://github.com/dart-lang/sdk/issues/new here'
+        'link https://gitfoohub.com/dart-lang/sdk/issues/4132 here'
+        'link https://github.com/dart-lang/s.dk/issues/1234 here';
+    expect(formatComment(unformatted), formatted);
+  });
 }
diff --git a/results_feed/web/main.dart b/results_feed/web/main.dart
index d706a9f..5fa424d 100644
--- a/results_feed/web/main.dart
+++ b/results_feed/web/main.dart
@@ -9,11 +9,13 @@
 import 'package:dart_results_feed/src/services/firestore_service.dart';
 import 'package:dart_results_feed/src/components/routing_wrapper_component.template.dart'
     as ng;
+import 'package:dart_results_feed/src/formatting.dart' as formatting;
 import 'main.template.dart' as self;
 
-// Allow links from comments to GitHub issues in the dart-lang organization.
+// Allow links from comments to GitHub issues in allowed organizations.
 List<Uri> getUriWhitelist() => List.unmodifiable(<Uri>[
-      Uri.https('github.com', 'dart-lang/'),
+  for (final organization in formatting.organizations)
+      Uri.https('github.com', '$organization/')
     ]);
 
 // Use for local testing