Add functions to check Gerrit change id/number

We check whether a string is a gerrit number, a change-ID or a full
change-ID in many places. We should have functions to do this in one
place.

BUG=chromium:352899
TEST=`bulidbot/run_tests` passes

Change-Id: I75c7c9a700ef45670484d59cd04c1709a876b7d2
Reviewed-on: https://chromium-review.googlesource.com/190980
Reviewed-by: Yu-Ju Hong <yjhong@chromium.org>
Commit-Queue: Yu-Ju Hong <yjhong@chromium.org>
Tested-by: Yu-Ju Hong <yjhong@chromium.org>
diff --git a/buildbot/validation_pool.py b/buildbot/validation_pool.py
index cd84df5..f8ac406 100644
--- a/buildbot/validation_pool.py
+++ b/buildbot/validation_pool.py
@@ -502,7 +502,7 @@
     existing = self._lookup_cache[
         cros_patch.FormatChangeId(
             change.change_id, force_internal=change.internal, strict=False)]
-    if query.isdigit() and existing is not None:
+    if cros_patch.IsGerritNumber(query) and existing is not None:
       if (not parent_lookup or existing.project == change.project and
           existing.tracking_branch == change.tracking_branch):
         key = cros_patch.FormatGerritNumber(
diff --git a/lib/gerrit.py b/lib/gerrit.py
index ded8176..2584c42 100644
--- a/lib/gerrit.py
+++ b/lib/gerrit.py
@@ -197,7 +197,7 @@
     if current_patch:
       o_params.extend(['CURRENT_COMMIT', 'CURRENT_REVISION'])
 
-    if change and change.isdigit() and not query_kwds:
+    if change and cros_patch.IsGerritNumber(change) and not query_kwds:
       if dryrun:
         cros_build_lib.Info('Would have run gob_util.GetChangeDetail(%s, %s)',
                             self.host, change)
diff --git a/lib/patch.py b/lib/patch.py
index 063caa9..5d7224b 100644
--- a/lib/patch.py
+++ b/lib/patch.py
@@ -26,10 +26,58 @@
 
 
 _MAXIMUM_GERRIT_NUMBER_LENGTH = 6
+_GERRIT_CHANGE_ID_PREFIX = 'I'
+_GERRIT_CHANGE_ID_LENGTH = 40
+_GERRIT_CHANGE_ID_TOTAL_LENGTH = (_GERRIT_CHANGE_ID_LENGTH +
+                                  len(_GERRIT_CHANGE_ID_PREFIX))
 REPO_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_\-]*(/[a-zA-Z0-9_-]+)*$')
 BRANCH_NAME_RE = re.compile(r'^(refs/heads/)?[a-zA-Z0-9_][a-zA-Z0-9_\-]*$')
 
 
+def IsGerritNumber(text):
+  """Returns True if |text| conforms to the Gerrit change number format.
+
+  Args:
+    text: The string to check.
+  """
+  return text.isdigit() and len(text) <= _MAXIMUM_GERRIT_NUMBER_LENGTH
+
+
+def IsChangeID(text, strict=False):
+  """Returns True if |text| conforms to the change-ID format.
+
+  Change-ID is a string that starts with I/i. E.g.
+    I47ea30385af60ae4cc2acc5d1a283a46423bc6e1
+
+  Args:
+    text: The string to check.
+    strict: If True, then it's an error if the text holds an internally
+      formatted ChangeId.  Generally this is only useful if you're working w/
+      a direct git commit message and are trying to ensure the message is
+      gerrit compatible.
+  """
+  if strict and (not text.startswith(_GERRIT_CHANGE_ID_PREFIX) or
+                 len(text) != _GERRIT_CHANGE_ID_TOTAL_LENGTH):
+    return False
+
+  return ((text.startswith(_GERRIT_CHANGE_ID_PREFIX) or
+           text.startswith(_GERRIT_CHANGE_ID_PREFIX.lower())) and
+          git.IsSHA1(text[len(_GERRIT_CHANGE_ID_PREFIX):].lower()))
+
+
+def IsFullChangeID(text):
+  """Returns True if |text| conforms to the full change-ID format.
+
+  Full change-ID format: project~branch~change-id. E.g.
+    chromiumos/chromite~master~I47ea30385af60ae4cc2acc5d1a283a46423bc6e1
+
+  Args:
+    text: The string to check.
+  """
+  fields = text.split('~')
+  return len(fields) == 3 and IsChangeID(fields[2])
+
+
 class PatchException(Exception):
   """Base exception class all patch exception derive from."""
 
@@ -195,10 +243,10 @@
       as a fallback when a Change-Id could not be parsed.
   """
   s = "%x" % (random.randint(0, 2 ** 160),)
-  s = s.rjust(40, '0')
+  s = s.rjust(_GERRIT_CHANGE_ID_LENGTH, '0')
   if unusable:
     return 'Fake-ID %s' % s
-  return 'I%s' % s
+  return '%s%s' % (_GERRIT_CHANGE_ID_PREFIX, s)
 
 
 class PatchCache(object):
@@ -297,24 +345,12 @@
   """
   original_text = text
   prefix, text = _StripPrefix(text, strict, force_external, force_internal)
-  if text[0] not in 'iI' or len(text) > 41:
+  if not IsChangeID(text, strict=strict):
     raise ValueError("FormatChangeId invoked w/ a malformed Change-Id: %r" %
                      (original_text,))
-  elif strict:
-    if text[0] == 'i':
-      raise ValueError("FormatChangeId invoked w/ a malformed Change-Id, "
-                       "leading 'i' char needs to be I: %r" % (original_text,))
-    elif len(text) != 41:
-      raise ValueError("FormatChangeId invoked w/ a malformed Change-Id, "
-                       "value isn't 41 characters: %r" % (original_text,))
 
-  # Drop the leading I/i.
-  text = text[1:].lower()
-  if not git.IsSHA1(text, False):
-    raise ValueError("FormatChangeId invoked w/ a non hex ChangeId value: %s"
-                     % original_text)
-
-  return '%sI%s' % (prefix, text)
+  return '%s%s%s' % (prefix, _GERRIT_CHANGE_ID_PREFIX,
+                     text[len(_GERRIT_CHANGE_ID_PREFIX):].lower())
 
 
 def FormatSha1(text, force_internal=False, force_external=False, strict=False):
@@ -366,15 +402,12 @@
   """
   original_text = text
   prefix, text = _StripPrefix(text, strict, force_external, force_internal)
-  if not text.isdigit():
-    raise ValueError("FormatSha1 invoked w/ a value that isn't a number: %r" %
-                     (original_text,))
+  if not IsGerritNumber(text):
+    raise ValueError(
+        "FormatGerritNumber invoked w/ a value that isn't a number or longer "
+        "than %i digits: %r" % (_MAXIMUM_GERRIT_NUMBER_LENGTH, original_text,))
   # Force an int conversion to strip any leading zeroes.
   text = str(int(text))
-  if len(text) > _MAXIMUM_GERRIT_NUMBER_LENGTH:
-    raise ValueError("FormatSha1 invoked w/ a value longer than %i digits: %r" %
-                     (_MAXIMUM_GERRIT_NUMBER_LENGTH, original_text,))
-
   return '%s%s' % (prefix, text)
 
 
@@ -452,19 +485,19 @@
   if text.startswith(constants.INTERNAL_CHANGE_PREFIX):
     text = text[len(constants.INTERNAL_CHANGE_PREFIX):]
 
-  if len(text.split('~')) == 3:
+  if IsFullChangeID(text):
     if not full_changeid:
       raise ValueError(
           "FormatPatchDep: Fully-qualified change-id is not allowed in this "
           "context: %r" % (original_text,))
     target = FormatFullChangeId
-  elif text[0:1] in 'Ii':
+  elif IsChangeID(text):
     if not changeId:
       raise ValueError(
           "FormatPatchDep: ChangeId isn't allowed in this context: %r"
           % (original_text,))
     target = FormatChangeId
-  elif text.isdigit() and len(text) <= _MAXIMUM_GERRIT_NUMBER_LENGTH:
+  elif IsGerritNumber(text):
     if not gerrit_number:
       raise ValueError(
           "FormatPatchDep: Gerrit number isn't allowed in this context: %r"
@@ -507,7 +540,9 @@
   # ensuring CQ's internals can do the translation (almost can now,
   # but will fail in the case of a CQ-DEPEND on a change w/in the
   # same pool).
-  _STRICT_VALID_CHANGE_ID_RE = re.compile(r'^I[0-9a-fA-F]{40}$')
+  pattern = (r'^'+ re.escape(_GERRIT_CHANGE_ID_PREFIX) + 'r[0-9a-fA-F]{' +
+             re.escape(str(_GERRIT_CHANGE_ID_LENGTH)) + r'}$')
+  _STRICT_VALID_CHANGE_ID_RE = re.compile(pattern)
   _GIT_CHANGE_ID_RE = re.compile(r'^Change-Id:[\t ]*(\w+)\s*$',
                                  re.I | re.MULTILINE)
 
diff --git a/lib/patch_unittest.py b/lib/patch_unittest.py
index dc13999..75ac5a8 100755
--- a/lib/patch_unittest.py
+++ b/lib/patch_unittest.py
@@ -51,6 +51,49 @@
 GERRIT_ABANDONED_CHANGEID = '2'
 
 
+class TestChangeNumberOrID(cros_test_lib.TestCase):
+  """Tests that we can determine distinguish change number/ID."""
+  def TestGerritNumber(self):
+    """Tests that we can tell a Gerrit number."""
+    text = '12345'
+    self.assertTrue(cros_patch.IsGerritNumber(text))
+    self.assertFalse(cros_patch.IsChangeID(text))
+    self.assertFalse(cros_patch.IsFullChangeID(text))
+
+    text = '12345678'
+    self.assertFalse(cros_patch.IsGerritNumber(text))
+    self.assertFalse(cros_patch.IsChangeID(text))
+    self.assertFalse(cros_patch.IsFullChangeID(text))
+
+  def TestChangeID(self):
+    """Tests that we can tell a change-ID."""
+    text = 'I47ea30385af60ae4cc2acc5d1a283a46423bc6e1'
+    self.assertFalse(cros_patch.IsGerritNumber(text))
+    self.assertTrue(cros_patch.IsChangeID(text))
+    self.assertFalse(cros_patch.IsFullChangeID(text))
+
+    text = 'i47ea30385af60ae4cc2acc5d1a283a46423bc6e1'
+    self.assertFalse(cros_patch.IsGerritNumber(text))
+    self.assertTrue(cros_patch.IsChangeID(text))
+    self.assertFalse(cros_patch.IsChangeID(text, strict=True))
+    self.assertFalse(cros_patch.IsFullChangeID(text))
+
+    # Change-ID too short.
+    text = 'I47ea30385af60ae4cc2acc5d1a2'
+    self.assertFalse(cros_patch.IsGerritNumber(text))
+    self.assertFalse(cros_patch.IsChangeID(text))
+    self.assertFalse(cros_patch.IsChangeID(text, strict=True))
+    self.assertFalse(cros_patch.IsFullChangeID(text))
+
+  def TestFullChangeID(self):
+    """Tests that we can tell a full change-ID."""
+    text = ('chromiumos/chromite~master~'
+            'I47ea30385af60ae4cc2acc5d1a283a46423bc6e1')
+    self.assertFalse(cros_patch.IsGerritNumber(text))
+    self.assertFalse(cros_patch.IsChangeID(text))
+    self.assertTrue(cros_patch.IsFullChangeID(text))
+
+
 class TestGitRepoPatch(cros_test_lib.TempDirTestCase):
   """Unittests for git patch related methods."""