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())