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)