Merge branch 'main' into avm99963-monorail

Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266

GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/redirect/__init__.py b/redirect/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/redirect/__init__.py
diff --git a/redirect/redirect.py b/redirect/redirect.py
new file mode 100644
index 0000000..10398f6
--- /dev/null
+++ b/redirect/redirect.py
@@ -0,0 +1,94 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Redirect Middleware for Monorail.
+
+Handles traffic redirection before hitting main monorail app.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import flask
+from redirect import redirect_utils
+from redirect import redirectissue
+
+
+class RedirectMiddleware(object):
+
+  def __init__(self, main_app, redirect_app):
+    self._main_app = main_app
+    self._redirect_app = redirect_app
+
+  def __call__(self, environ, start_response):
+    # Run the redirect app first.
+    response = flask.Response.from_app(self._redirect_app, environ)
+    if response.status_code == 404:
+      # If it returns 404, run the main app.
+      return self._main_app(environ, start_response)
+    # Otherwise, return the response from the redirect app.
+    app_iter, status, headers = response.get_wsgi_response(environ)
+    start_response(status, headers)
+    return app_iter
+
+
+def GenerateRedirectApp():
+  redirect_app = flask.Flask(__name__)
+
+  def PreCheckHandler():
+    # Should not redirect away from monorail if param set.
+    r = flask.request
+    no_redirect = 'no_tracker_redirect' in r.args
+    if no_redirect:
+      flask.abort(404)
+  redirect_app.before_request(PreCheckHandler)
+
+  def IssueList(project_name):
+    redirect_url = redirect_utils.GetRedirectURL(project_name)
+    if redirect_url:
+      query_string = redirect_utils.GetSearchQuery(
+          project_name, flask.request.args)
+      return flask.redirect(redirect_url + '/issues?' + query_string)
+    flask.abort(404)
+
+  redirect_app.route('/p/<string:project_name>/')(IssueList)
+  redirect_app.route('/p/<string:project_name>/issues/')(IssueList)
+  redirect_app.route('/p/<string:project_name>/issues/list')(IssueList)
+  redirect_app.route('/p/<string:project_name>/issues/list_new')(IssueList)
+
+  def IssueDetail(project_name):
+    local_id = flask.request.args.get('id', type=int)
+    if not local_id:
+      flask.abort(404)
+
+    redirect_url = _GenerateIssueDetailRedirectURL(local_id, project_name)
+    if redirect_url:
+      return flask.render_template('redirect.html', base_url=redirect_url)
+    flask.abort(404)
+  redirect_app.route('/p/<string:project_name>/issues/detail')(IssueDetail)
+
+  def IssueCreate(project_name):
+    redirect_url = redirect_utils.GetRedirectURL(project_name)
+    if redirect_url:
+      query_string = redirect_utils.GetNewIssueParams(
+          flask.request.args, project_name)
+      return flask.redirect(redirect_url + '/new?' + query_string)
+    flask.abort(404)
+  redirect_app.route('/p/<string:project_name>/issues/entry')(IssueCreate)
+  redirect_app.route('/p/<string:project_name>/issues/entry_new')(IssueCreate)
+
+  return redirect_app
+
+
+def _GenerateIssueDetailRedirectURL(local_id, project_name):
+  redirect_base_url = redirect_utils.GetRedirectURL(project_name)
+  if not redirect_base_url:
+    return None
+
+  if local_id > redirect_utils.MAX_MONORAIL_ISSUE_ID:
+    return redirect_base_url + '/' + str(local_id)
+
+  tracker_id = redirectissue.RedirectIssue.Get(project_name, local_id)
+  if tracker_id:
+    return redirect_base_url + '/' + tracker_id
+  return None
diff --git a/redirect/redirect_custom_value.py b/redirect/redirect_custom_value.py
new file mode 100644
index 0000000..c644de8
--- /dev/null
+++ b/redirect/redirect_custom_value.py
@@ -0,0 +1,25 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from google.appengine.ext import ndb
+
+
+class RedirectCustomValue(ndb.Model):
+  """Represents a project custome value redirect information."""
+  ProjectName = ndb.StringProperty()
+  MonorailType = ndb.StringProperty()
+  MonorailValue = ndb.StringProperty()
+  RedirectType = ndb.StringProperty()
+  RedirectValue = ndb.StringProperty()
+
+  @classmethod
+  def Get(cls, project, custom_type, value):
+    # TODO(b/283983843): add function to handle multiple values.
+    entity = cls.query(
+        RedirectCustomValue.ProjectName == project,
+        RedirectCustomValue.MonorailType == custom_type,
+        RedirectCustomValue.MonorailValue == value).get()
+    if not entity:
+      return None, None
+    return entity.RedirectType, entity.RedirectValue
diff --git a/redirect/redirect_project_template.py b/redirect/redirect_project_template.py
new file mode 100644
index 0000000..66f29ca
--- /dev/null
+++ b/redirect/redirect_project_template.py
@@ -0,0 +1,21 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from google.appengine.ext import ndb
+
+
+class RedirectProjectTemplate(ndb.Model):
+  """Represents a template redirect information."""
+  ProjectName = ndb.StringProperty()
+  MonorailTemplateName = ndb.StringProperty()
+  RedirectComponentID = ndb.StringProperty()
+  RedirectTemplateID = ndb.StringProperty()
+
+  @classmethod
+  def Get(cls, project, template_name):
+    key = project + ':' + template_name
+    entity = ndb.Key('RedirectProjectTemplate', key).get()
+    if not entity:
+      return None, None
+    return entity.RedirectComponentID, entity.RedirectTemplateID
diff --git a/redirect/redirect_utils.py b/redirect/redirect_utils.py
new file mode 100644
index 0000000..151c4b5
--- /dev/null
+++ b/redirect/redirect_utils.py
@@ -0,0 +1,139 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""Utils for redirect."""
+import urllib
+from werkzeug.datastructures import MultiDict
+from redirect import redirect_project_template
+
+from tracker import tracker_constants
+from tracker import tracker_bizobj
+from redirect import redirect_custom_value
+
+PROJECT_REDIRECT_MAP = {
+    'pigweed': 'https://issues.pigweed.dev',
+    'git': 'https://git.issues.gerritcodereview.com',
+    'gerrit': 'https://issues.gerritcodereview.com',
+    'skia': 'http://issues.skia.org',
+    'fuchsia': 'https://issues.fuchsia.dev',
+}
+
+MAX_MONORAIL_ISSUE_ID = 10000000
+
+TRACKER_SEARCH_KEY_MAP = {
+    'cc': 'cc',
+    'owner': 'assignee',
+    'commentby': 'commenter',
+    'reporter': 'reporter',
+    'is': 'is',
+}
+
+VALID_IS_SEARCH_VALUE = ['open', 'starred']
+
+
+def GetRedirectURL(project_name):
+  return PROJECT_REDIRECT_MAP.get(project_name, None)
+
+
+def GetNewIssueParams(params: MultiDict, project_name: str):
+  new_issue_params = {}
+
+  # Get component and template id.
+  template_name = params.get('template', type=str, default='default')
+  redirect_component_id, redirect_template_id = (
+    redirect_project_template.RedirectProjectTemplate.Get(
+    project_name, template_name))
+  if redirect_component_id:
+    new_issue_params['component'] = redirect_component_id
+  if redirect_template_id:
+    new_issue_params['template'] = redirect_template_id
+
+  if params.get('summary', type=str):
+    new_issue_params['title'] = params.get('summary', type=str)
+
+  if (params.get('description', type=str) or params.get('comment', type=str)):
+    new_issue_params['description'] = (
+        params.get('description', type=str) or params.get('comment', type=str))
+
+  if params.get('cc', type=str):
+    new_issue_params['cc'] = params.get('cc', type=str)
+
+  if params.get('owner', type=str):
+    new_issue_params['assignee'] = params.get('owner', type=str).split('@')[0]
+
+  # TODO(b/283983843): redirect when custom field settled. (components)
+  return urllib.parse.urlencode(new_issue_params)
+
+
+def GetSearchQuery(project_name, params):
+  search_conds = []
+
+  # can param is the default search query used in monorail.
+  # Each project can customize the canned queries.
+  # (eg.can=41013401 in Monorail is the Triage Queue.)
+  # For redirect we will just support the build in can query as the first step.
+  # TODO(b/283983843): support customized can query as needed.
+  can_param = params.get(
+      'can', type=int, default=tracker_constants.OPEN_ISSUES_CAN)
+  # TODO(b/283983843): move the BuiltInQuery to redirect folder.
+  default_search_string = tracker_bizobj.GetBuiltInQuery(can_param)
+  for cond in default_search_string.split(' '):
+    search_conds.append(cond)
+
+  # q param is the user defined search query.
+  if params.get('q', type=str):
+    search_string = urllib.parse.unquote(params.get('q', type=str))
+    for cond in search_string.split(' '):
+      search_conds.append(cond)
+
+  query_string = ''
+  for cond in search_conds:
+    condition_pair = _ConvertSearchCondition(project_name, cond)
+    if condition_pair:
+      (k, v) = condition_pair
+      query_string += ' {0}:{1}'.format(k, v)
+  return urllib.parse.urlencode({'q': query_string.strip()})
+
+
+# Convert monorail search conditions to tracker search conditions.
+def _ConvertSearchCondition(project_name, cond):
+  cond_pair = []
+  # In monorail the search condition can be either ':' or '='.
+  if ':' in cond:
+    cond_pair = cond.split(':')
+  if '=' in cond:
+    cond_pair = cond.split('=')
+
+  if len(cond_pair) != 2:
+    return None
+  # '-' stand for NOT.
+  pre = '-' if cond_pair[0].startswith('-') else ''
+  key_val = cond_pair[0][1:] if cond_pair[0].startswith('-') else cond_pair[0]
+
+  k, v = _GenerateTrackerSearchKeyValuePair(project_name, key_val, cond_pair[1])
+  if not k or not v:
+    return None
+
+  return pre + k, v
+
+
+# Convert the search value to tracker search format.
+def _GenerateTrackerSearchKeyValuePair(project_name, key, value):
+  if len(value) == 0:
+    return None, None
+  # Find the related search filter from datastore.
+  new_key, new_value = redirect_custom_value.RedirectCustomValue.Get(
+      project_name, key, value)
+  if new_key and new_value:
+    return new_key, new_value
+
+  # If the value is not store in datastore check the general filter set.
+  new_key = TRACKER_SEARCH_KEY_MAP.get(key, None)
+  if not new_key:
+    return None, None
+
+  if new_key == 'is':
+    return new_key, value if value in VALID_IS_SEARCH_VALUE else None
+
+  new_value = value.replace(',', '|')
+  return new_key, '({})'.format(new_value)
diff --git a/redirect/redirectissue.py b/redirect/redirectissue.py
new file mode 100644
index 0000000..00410a1
--- /dev/null
+++ b/redirect/redirectissue.py
@@ -0,0 +1,20 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from google.appengine.ext import ndb
+
+
+class RedirectIssue(ndb.Model):
+  """Represents a issue redirect information."""
+  ProjectName = ndb.StringProperty()
+  MonorailLocalID = ndb.StringProperty()
+  RedirectID = ndb.StringProperty()
+
+  @classmethod
+  def Get(cls, project, issue_local_id):
+    key = project + ':' + str(issue_local_id)
+    redirect_issue_entity = ndb.Key('RedirectIssue', key).get()
+    if not redirect_issue_entity:
+      return None
+    return redirect_issue_entity.RedirectID
diff --git a/redirect/templates/redirect.html b/redirect/templates/redirect.html
new file mode 100644
index 0000000..7494735
--- /dev/null
+++ b/redirect/templates/redirect.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<script>
+  const commentRegexp = /#c([0-9]+)/;
+  const url = {{ base_url|tojson }}
+  let hash = window.location.hash;
+
+  // If a monorail style comment is specified then convert it to an
+  // issue tracker style comment. Increment it by one because issue
+  // tracker considers the description as the first comment.
+  const matches = hash.match(commentRegexp);
+  if (matches) {
+    let commentNum = parseInt(matches[1]);
+    hash = hash.replace("#c" + commentNum, "#comment" + (commentNum+1));
+  }
+  window.location = url + hash;
+</script>
diff --git a/redirect/test/__init__.py b/redirect/test/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/redirect/test/__init__.py
diff --git a/redirect/test/redirect_custom_value_test.py b/redirect/test/redirect_custom_value_test.py
new file mode 100644
index 0000000..d3d70ed
--- /dev/null
+++ b/redirect/test/redirect_custom_value_test.py
@@ -0,0 +1,62 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import unittest
+
+from google.appengine.ext import ndb
+from google.appengine.ext import testbed
+from redirect import redirect_custom_value
+
+
+class TestRedirectCustomValue(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_datastore_v3_stub()
+    self.testbed.init_memcache_stub()
+    ndb.get_context().clear_cache()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testGetRedirectCustomValue(self):
+    redirectCustomValue = redirect_custom_value.RedirectCustomValue
+    redirectCustomValue(
+        ProjectName='a',
+        MonorailType='test',
+        MonorailValue='a',
+        RedirectType='t',
+        RedirectValue='v').put()
+
+    (t, v) = redirectCustomValue.Get('a', 'test', 'a')
+    self.assertEqual(t, 't')
+    self.assertEqual(v, 'v')
+
+  def testGetRedirectCustomValueWithoutValue(self):
+    redirectCustomValue = redirect_custom_value.RedirectCustomValue
+
+    (t, v) = redirectCustomValue.Get('a', 'test', 'a')
+    self.assertEqual(t, None)
+    self.assertEqual(v, None)
+
+  def testGetRedirectCustomValueOnlyReturnTheFirstMatch(self):
+    # There should be only one match in db.
+    # This may change if we decided to support mutiple value mapping.
+    redirectCustomValue = redirect_custom_value.RedirectCustomValue
+    redirectCustomValue(
+        ProjectName='a',
+        MonorailType='test',
+        MonorailValue='a',
+        RedirectType='t1',
+        RedirectValue='v1').put()
+    redirectCustomValue(
+        ProjectName='a',
+        MonorailType='test',
+        MonorailValue='a',
+        RedirectType='t2',
+        RedirectValue='v2').put()
+    (t, v) = redirectCustomValue.Get('a', 'test', 'a')
+    self.assertEqual(t, 't1')
+    self.assertEqual(v, 'v1')
diff --git a/redirect/test/redirect_project_template_test.py b/redirect/test/redirect_project_template_test.py
new file mode 100644
index 0000000..13995c7
--- /dev/null
+++ b/redirect/test/redirect_project_template_test.py
@@ -0,0 +1,42 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import unittest
+
+from google.appengine.ext import ndb
+from google.appengine.ext import testbed
+from redirect import redirect_project_template
+
+
+class TestRedirectCustomValue(unittest.TestCase):
+
+  def setUp(self):
+    self.testbed = testbed.Testbed()
+    self.testbed.activate()
+    self.testbed.init_datastore_v3_stub()
+    self.testbed.init_memcache_stub()
+    ndb.get_context().clear_cache()
+
+  def tearDown(self):
+    self.testbed.deactivate()
+
+  def testGetRedirectProjectTemplate(self):
+    redirectProjectTemplate = redirect_project_template.RedirectProjectTemplate
+    redirectProjectTemplate(
+        ProjectName='a',
+        MonorailTemplateName='default template',
+        RedirectComponentID='123',
+        RedirectTemplateID='456',
+        id='a:default template').put()
+
+    (t, v) = redirectProjectTemplate.Get('a', 'default template')
+    self.assertEqual(t, '123')
+    self.assertEqual(v, '456')
+
+  def testGetRedirectProjectTemplateWithoutValue(self):
+    redirectProjectTemplate = redirect_project_template.RedirectProjectTemplate
+
+    (t, v) = redirectProjectTemplate.Get('a', 'default template')
+    self.assertEqual(t, None)
+    self.assertEqual(v, None)
diff --git a/redirect/test/redirect_test.py b/redirect/test/redirect_test.py
new file mode 100644
index 0000000..d4af8e8
--- /dev/null
+++ b/redirect/test/redirect_test.py
@@ -0,0 +1,57 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import unittest
+from redirect import redirect
+from mock import patch
+class TestRedirectApp(unittest.TestCase):
+
+  def setUp(self):
+    self.app = redirect.GenerateRedirectApp()
+    self.app.config['TESTING'] = True
+
+  def testNoRedirectIssueList(self):
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/list')
+    self.assertEqual(response.status_code, 404)
+
+  @patch("redirect.redirect_utils.GetRedirectURL")
+  @patch("redirect.redirect_utils.GetSearchQuery")
+  def testRedirectIssueList(self, fake_get_url, fake_get_search_query):
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/list')
+    self.assertEqual(response.status_code, 302)
+
+  def testNoRedirectCreateIssue(self):
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/entry')
+    self.assertEqual(response.status_code, 404)
+
+  @patch("redirect.redirect_utils.GetRedirectURL")
+  def testRedirectCreateIssue(self, fake_get_url):
+    fake_get_url.return_value = "test"
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/entry')
+    self.assertEqual(response.status_code, 302)
+
+  def testNoRedirectIssueDetail(self):
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/detail?id=1')
+    self.assertEqual(response.status_code, 404)
+
+  @patch("redirect.redirect_utils.GetRedirectURL")
+  @patch("redirect.redirectissue.RedirectIssue.Get")
+  def testRedirectIssueDetail(self, fake_get_url, fake_redirectIssue):
+    fake_get_url.return_value = "test"
+    fake_redirectIssue.return_value = "1"
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/detail?id=1')
+    self.assertEqual(response.status_code, 200)
+
+  @patch("redirect.redirect_utils.GetRedirectURL")
+  def testRedirectIssueDetail(self, fake_get_url):
+    fake_get_url.return_value = "test"
+    client = self.app.test_client()
+    response = client.get('/p/project1/issues/detail?id=10000001')
+    self.assertEqual(response.status_code, 200)
diff --git a/redirect/test/redirect_utils_test.py b/redirect/test/redirect_utils_test.py
new file mode 100644
index 0000000..2ea65ce
--- /dev/null
+++ b/redirect/test/redirect_utils_test.py
@@ -0,0 +1,67 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import unittest
+import werkzeug
+
+from mock import patch
+
+from redirect import redirect_utils
+from mock import patch
+
+
+class TestRedirectUtils(unittest.TestCase):
+
+  @patch("redirect.redirect_project_template.RedirectProjectTemplate.Get")
+  def testNewIssueParams(self, fake_redirectProjectTemplate):
+    fake_redirectProjectTemplate.return_value = None, None
+    params = werkzeug.datastructures.MultiDict(
+        [
+            ('summary', 'this is a summary'),
+            ('owner', 'test@google.com'),
+            ('description', 'task'),
+            ('cc', 'c1@google.com,c2@google.com'),
+        ])
+    expected = ('title=this+is+a+summary&description=task&'
+                'cc=c1%40google.com%2Cc2%40google.com&assignee=test')
+
+    get = redirect_utils.GetNewIssueParams(params, 'project')
+    self.assertEqual(expected, get)
+
+  @patch("redirect.redirect_project_template.RedirectProjectTemplate.Get")
+  def testNewIssueParamsWithComponent(self, fake_redirectProjectTemplate):
+    fake_redirectProjectTemplate.return_value = '1', '2'
+    params = werkzeug.datastructures.MultiDict(
+        [('summary', 'this is a summary'), ('owner', 'test@google.com')])
+    expected = 'component=1&template=2&title=this+is+a+summary&assignee=test'
+
+    get = redirect_utils.GetNewIssueParams(params, 'project')
+    self.assertEqual(expected, get)
+
+  @patch("redirect.redirect_project_template.RedirectProjectTemplate.Get")
+  def testNewIssueParamsWithNoValidValue(self, fake_redirectProjectTemplate):
+    fake_redirectProjectTemplate.return_value = None, None
+    params = werkzeug.datastructures.MultiDict([('test', 'this is a test')])
+    expected = ''
+    get = redirect_utils.GetNewIssueParams(params, 'project')
+    self.assertEqual(expected, get)
+
+  @patch("redirect.redirect_custom_value.RedirectCustomValue.Get")
+  def testGetSearchQuery(self, fake_redirectcustomevalue):
+    fake_redirectcustomevalue.return_value = None, None
+    params = werkzeug.datastructures.MultiDict(
+        [('q', 'owner%3Ame%20has%3ARollout-Type')])
+    expected = 'q=is%3Aopen+assignee%3A%28me%29'
+
+    get = redirect_utils.GetSearchQuery('project', params)
+    self.assertEqual(expected, get)
+
+  @patch("redirect.redirect_custom_value.RedirectCustomValue.Get")
+  def testGetSearchQueryWithCanValue(self, fake_redirectcustomevalue):
+    fake_redirectcustomevalue.return_value = None, None
+    params = werkzeug.datastructures.MultiDict([('can', 4)])
+    expected = 'q=is%3Aopen+reporter%3A%28me%29'
+
+    get = redirect_utils.GetSearchQuery('project', params)
+    self.assertEqual(expected, get)