Script to abandon stale changes from the review server

Fetches a list of open changes that have not been updated since a
given age (default 6 months), and then abandons them.

Assumes that the user's credentials are in the .netrc file.  Supports
either basic or digest authentication.

Example to abandon changes that have not been updated for 3 years:

 ./abandon_stale --gerrit-url http://review.example.com/ --age 3years

Supports dry-run mode to only list the stale changes but not actually
abandon them.

Requires pygerrit (https://github.com/sonyxperiadev/pygerrit).

Change-Id: Ie0edb54847f9f2ab8204647e17e3893ed0a057ea
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
new file mode 100755
index 0000000..f118346
--- /dev/null
+++ b/contrib/abandon_stale.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# The MIT License
+#
+# Copyright 2014 Sony Mobile Communications. All rights reserved.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+""" Script to abandon stale changes from the review server.
+
+Fetches a list of open changes that have not been updated since a
+given age in months or years (default 6 months), and then abandons them.
+
+Assumes that the user's credentials are in the .netrc file.  Supports
+either basic or digest authentication.
+
+Example to abandon changes that have not been updated for 3 months:
+
+  ./abandon_stale --gerrit-url http://review.example.com/ --age 3months
+
+Supports dry-run mode to only list the stale changes but not actually
+abandon them.
+
+Requires pygerrit (https://github.com/sonyxperiadev/pygerrit).
+
+"""
+
+import logging
+import optparse
+import re
+import sys
+
+from pygerrit.rest import GerritRestAPI
+from pygerrit.rest.auth import HTTPBasicAuthFromNetrc, HTTPDigestAuthFromNetrc
+
+
+def _main():
+    parser = optparse.OptionParser()
+    parser.add_option('-g', '--gerrit-url', dest='gerrit_url',
+                      metavar='URL',
+                      default=None,
+                      help='gerrit server URL')
+    parser.add_option('-b', '--basic-auth', dest='basic_auth',
+                      action='store_true',
+                      help='use HTTP basic authentication instead of digest')
+    parser.add_option('-n', '--dry-run', dest='dry_run',
+                      action='store_true',
+                      help='enable dry-run mode: show stale changes but do '
+                           'not abandon them')
+    parser.add_option('-a', '--age', dest='age',
+                      metavar='AGE',
+                      default="6months",
+                      help='age of change since last update '
+                           '(default: %default)')
+    parser.add_option('-m', '--message', dest='message',
+                      metavar='STRING', default=None,
+                      help='Custom message to append to abandon message')
+    parser.add_option('--exclude-branch', dest='exclude_branches',
+                      metavar='BRANCH_NAME',
+                      default=[],
+                      action='append',
+                      help='Do not abandon changes on given branch')
+    parser.add_option('--exclude-project', dest='exclude_projects',
+                      metavar='PROJECT_NAME',
+                      default=[],
+                      action='append',
+                      help='Do not abandon changes on given project')
+    parser.add_option('--owner', dest='owner',
+                      metavar='USERNAME',
+                      default=None,
+                      action='store',
+                      help='Only abandon changes owned by the given user')
+    parser.add_option('-v', '--verbose', dest='verbose',
+                      action='store_true',
+                      help='enable verbose (debug) logging')
+
+    (options, _args) = parser.parse_args()
+
+    level = logging.DEBUG if options.verbose else logging.INFO
+    logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
+                        level=level)
+
+    if not options.gerrit_url:
+        logging.error("Gerrit URL is required")
+        return 1
+
+    pattern = re.compile(r"^([\d]+)(months|years)")
+    match = pattern.match(options.age)
+    if not match:
+        logging.error("Invalid age: %s", options.age)
+        return 1
+    message = "Abandoning after %s %s or more of inactivity." % \
+        (match.group(1), match.group(2))
+
+    if options.basic_auth:
+        auth_type = HTTPBasicAuthFromNetrc
+    else:
+        auth_type = HTTPDigestAuthFromNetrc
+
+    try:
+        auth = auth_type(url=options.gerrit_url)
+        gerrit = GerritRestAPI(url=options.gerrit_url, auth=auth)
+    except Exception as e:
+        logging.error(e)
+        return 1
+
+    logging.info(message)
+    try:
+        stale_changes = []
+        offset = 0
+        step = 500
+        query_terms = ["status:new", "age:%s" % options.age] + \
+                      ["-branch:%s" % b for b in options.exclude_branches] + \
+                      ["-project:%s" % p for p in options.exclude_projects]
+        if options.owner:
+            query_terms += ["owner:%s" % options.owner]
+        query = "%20".join(query_terms)
+        while True:
+            q = query + "&n=%d&S=%d" % (step, offset)
+            logging.debug("Query: %s", q)
+            url = "/changes/?q=" + q
+            result = gerrit.get(url)
+            logging.debug("%d changes", len(result))
+            if not result:
+                break
+            stale_changes += result
+            last = result[-1]
+            if "_more_changes" in last:
+                logging.debug("More...")
+                offset += step
+            else:
+                break
+    except Exception as e:
+        logging.error(e)
+        return 1
+
+    abandoned = 0
+    errors = 0
+    abandon_message = message
+    if options.message:
+        abandon_message += "\n\n" + options.message
+    for change in stale_changes:
+        number = change["_number"]
+        try:
+            owner = change["owner"]["name"]
+        except:
+            owner = "Unknown"
+        subject = change["subject"]
+        if len(subject) > 70:
+            subject = subject[:65] + " [...]"
+        change_id = change["id"]
+        logging.info("%s (%s): %s", number, owner, subject)
+        if options.dry_run:
+            continue
+
+        try:
+            gerrit.post("/changes/" + change_id + "/abandon",
+                        data='{"message" : "%s"}' % abandon_message)
+            abandoned += 1
+        except Exception as e:
+            errors += 1
+            logging.error(e)
+    logging.info("Total %d stale open changes", len(stale_changes))
+    if not options.dry_run:
+        logging.info("Abandoned %d changes. %d errors.", abandoned, errors)
+
+if __name__ == "__main__":
+    sys.exit(_main())