Add abandon_stale script

Taken from gerrit master at 26db03b. It's more convenient to have
this in a standalone project rather than core gerrit.

Change-Id: Ifaa5d12cee20a24fe0d7cfc1f910240e39825f59
diff --git a/abandon_stale.py b/abandon_stale.py
new file mode 100755
index 0000000..2e01131
--- /dev/null
+++ b/abandon_stale.py
@@ -0,0 +1,225 @@
+#!/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
+days, months or years (default 6 months), and then abandons them.
+
+Requires the user's credentials for the Gerrit server to be declared 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.
+
+See the --help output for more information about options.
+
+Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2) to be installed
+and available for import.
+
+"""
+
+import logging
+import optparse
+import re
+import sys
+
+from pygerrit2.rest import GerritRestAPI
+from pygerrit2.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='(deprecated) use HTTP basic authentication instead'
+                      ' of digest')
+    parser.add_option('-d', '--digest-auth', dest='digest_auth',
+                      action='store_true',
+                      help='use HTTP digest authentication instead of basic')
+    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('-t', '--test', dest='testmode', action='store_true',
+                      help='test mode: query changes with the `test-abandon` '
+                           'topic and ignore age option')
+    parser.add_option('-a', '--age', dest='age',
+                      metavar='AGE',
+                      default="6months",
+                      help='age of change since last update in days, months'
+                           ' or years (default: %default)')
+    parser.add_option('-m', '--message', dest='message',
+                      metavar='STRING', default=None,
+                      help='custom message to append to abandon message')
+    parser.add_option('--branch', dest='branches', metavar='BRANCH_NAME',
+                      default=[], action='append',
+                      help='abandon changes only on the given branch')
+    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('--project', dest='projects', metavar='PROJECT_NAME',
+                      default=[], action='append',
+                      help='abandon changes only on the given project')
+    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('--exclude-wip', dest='exclude_wip',
+                      action='store_true',
+                      help='Exclude changes that are Work-in-Progress')
+    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
+
+    if options.testmode:
+        message = "Abandoning in test mode"
+    else:
+        pattern = re.compile(r"^([\d]+)(month[s]?|year[s]?|week[s]?)")
+        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.digest_auth:
+        auth_type = HTTPDigestAuthFromNetrc
+    else:
+        auth_type = HTTPBasicAuthFromNetrc
+
+    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
+        if options.testmode:
+            query_terms = ["status:new", "owner:self", "topic:test-abandon"]
+        else:
+            query_terms = ["status:new", "age:%s" % options.age]
+        if options.exclude_wip:
+            query_terms += ["-is:wip"]
+        if options.branches:
+            query_terms += ["branch:%s" % b for b in options.branches]
+        elif options.exclude_branches:
+            query_terms += ["-branch:%s" % b for b in options.exclude_branches]
+        if options.projects:
+            query_terms += ["project:%s" % p for p in options.projects]
+        elif options.exclude_projects:
+            query_terms = ["-project:%s" % p for p in options.exclude_projects]
+        if options.owner and not options.testmode:
+            query_terms += ["owner:%s" % options.owner]
+        query = "%20".join(query_terms)
+        while True:
+            q = query + "&o=DETAILED_ACCOUNTS&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"]
+        project = ""
+        if len(options.projects) != 1:
+            project = "%s: " % change["project"]
+        owner = ""
+        if options.verbose:
+            try:
+                o = change["owner"]["name"]
+            except KeyError:
+                o = "Unknown"
+            owner = " (%s)" % o
+        subject = change["subject"]
+        if len(subject) > 70:
+            subject = subject[:65] + " [...]"
+        change_id = change["id"]
+        logging.info("%s%s: %s%s", number, owner, project, subject)
+        if options.dry_run:
+            continue
+
+        try:
+            gerrit.post("/changes/" + change_id + "/abandon",
+                        json={"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())