diff --git a/utils/git_log_review_commits.py b/utils/git_log_review_commits.py
new file mode 100755
index 0000000000000000000000000000000000000000..960f2606a8c19df0ff89083155d6b273931da533
--- /dev/null
+++ b/utils/git_log_review_commits.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+
+# ***** BEGIN GPL LICENSE BLOCK ***** #
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# Contributor(s): Campbell Barton
+#
+# ***** END GPL LICENSE BLOCK *****
+
+# <pep8 compliant>
+
+"""
+Example usage:
+
+   ./git_log_review_commits.py --source=../../.. --range=HEAD~40..HEAD --filter=BUGFIX
+"""
+
+
+class _Getch:
+    """
+    Gets a single character from standard input.
+    Does not echo to the screen.
+    """
+    def __init__(self):
+        try:
+            self.impl = _GetchWindows()
+        except ImportError:
+            self.impl = _GetchUnix()
+
+    def __call__(self):
+        return self.impl()
+
+
+class _GetchUnix:
+    def __init__(self):
+        import tty
+        import sys
+
+    def __call__(self):
+        import sys
+        import tty
+        import termios
+        fd = sys.stdin.fileno()
+        old_settings = termios.tcgetattr(fd)
+        try:
+            tty.setraw(sys.stdin.fileno())
+            ch = sys.stdin.read(1)
+        finally:
+            termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+        return ch
+
+
+class _GetchWindows:
+    def __init__(self):
+        import msvcrt
+
+    def __call__(self):
+        import msvcrt
+        return msvcrt.getch()
+
+
+getch = _Getch()
+# ------------------------------------------------------------------------------
+# Pretty Printing
+
+USE_COLOR = True
+
+if USE_COLOR:
+    color_codes = {
+        'black':         '\033[0;30m',
+        'bright_gray':   '\033[0;37m',
+        'blue':          '\033[0;34m',
+        'white':         '\033[1;37m',
+        'green':         '\033[0;32m',
+        'bright_blue':   '\033[1;34m',
+        'cyan':          '\033[0;36m',
+        'bright_green':  '\033[1;32m',
+        'red':           '\033[0;31m',
+        'bright_cyan':   '\033[1;36m',
+        'purple':        '\033[0;35m',
+        'bright_red':    '\033[1;31m',
+        'yellow':        '\033[0;33m',
+        'bright_purple': '\033[1;35m',
+        'dark_gray':     '\033[1;30m',
+        'bright_yellow': '\033[1;33m',
+        'normal':        '\033[0m',
+    }
+
+    def colorize(msg, color=None):
+        return (color_codes[color] + msg + color_codes['normal'])
+else:
+    def colorize(msg, color=None):
+        return msg
+bugfix = ""
+# avoid encoding issues
+import os
+import sys
+import io
+
+sys.stdin = os.fdopen(sys.stdin.fileno(), "rb")
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='surrogateescape', line_buffering=True)
+sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='surrogateescape', line_buffering=True)
+
+
+def print_commit(c):
+    print("------------------------------------------------------------------------------")
+    print(colorize("{{GitCommit|%s}}" % c.sha1.decode(), color='green'), end=" ")
+    # print("Author: %s" % colorize(c.author, color='bright_blue'))
+    print(colorize(c.author, color='bright_blue'))
+    print()
+    print(colorize(c.body, color='normal'))
+    print()
+    print(colorize("Files: (%d)" % len(c.files_status), color='yellow'))
+    for f in c.files_status:
+        print(colorize("  %s %s" % (f[0].decode('ascii'), f[1].decode('ascii')), 'yellow'))
+    print()
+
+
+def argparse_create():
+    import argparse
+
+    # When --help or no args are given, print this help
+    usage_text = "Review revisions."
+
+    epilog = "This script is typically used to help write release notes"
+
+    parser = argparse.ArgumentParser(description=usage_text, epilog=epilog)
+
+    parser.add_argument("--source", dest="source_dir",
+            metavar='PATH', required=True,
+            help="Path to git repository")
+    parser.add_argument("--range", dest="range_sha1",
+            metavar='SHA1_RANGE', required=True,
+            help="Range to use, eg: 169c95b8..HEAD")
+    parser.add_argument("--author", dest="author",
+            metavar='AUTHOR', type=str, required=False,
+            help=("Method to filter commits in ['BUGFIX', todo]"))
+    parser.add_argument("--filter", dest="filter_type",
+            metavar='FILTER', type=str, required=False,
+            help=("Method to filter commits in ['BUGFIX', todo]"))
+
+    return parser
+
+
+def main():
+    ACCEPT_FILE = "review_accept.txt"
+    REJECT_FILE = "review_reject.txt"
+
+    # ----------
+    # Parse Args
+
+    args = argparse_create().parse_args()
+
+    from git_log import GitCommit, GitCommitIter
+
+
+    # --------------
+    # Filter Commits
+
+    def match(c):
+        # filter_type
+        if not args.filter_type:
+            pass
+        elif args.filter_type == 'BUGFIX':
+            first_line = c.body.strip().split("\n")[0]
+            assert(len(first_line))
+            if any(w for w in first_line.split() if w.lower().startswith(("fix", "bugfix", "bug-fix"))):
+                pass
+            else:
+                return False
+        else:
+            raise Exception
+
+        # author
+        if not args.author:
+            pass
+        elif args.author != c.author:
+            return False
+
+
+        return True
+
+    commits = [c for c in GitCommitIter(args.source_dir, args.range_sha1) if match(c)]
+
+    # oldest first
+    commits.reverse()
+
+    tot_accept = 0
+    tot_reject = 0
+
+    def exit_message():
+        print("  Written",
+              colorize(ACCEPT_FILE, color='green'), "(%d)" % tot_accept,
+              colorize(REJECT_FILE, color='red'), "(%d)" % tot_reject,
+              )
+
+    for i, c in enumerate(commits):
+        if os.name == "posix":
+            # also clears scrollback
+            os.system("tput reset")
+        else:
+            print('\x1b[2J')  # clear
+
+        sha1 = c.sha1
+
+        # diff may scroll off the screen, thats OK
+        os.system("git --git-dir %s show %s --format=%%n" % (c._git_dir, sha1.decode('ascii')))
+        print("")
+        print_commit(c)
+        sys.stdout.flush()
+        # print(ch)
+        while True:
+            print("Space=" + colorize("Accept", 'green'),
+                  "Enter=" + colorize("Skip", 'red'),
+                  "Ctrl+C or Q=" + colorize("Quit", color='white'),
+                  "[%d of %d]" % (i + 1, len(commits)),
+                  "(+%d | -%d)" % (tot_accept, tot_reject),
+                  )
+            ch = getch()
+
+            if ch == b'\x03' or ch == b'q':
+                # Ctrl+C
+                exit_message()
+                print("Goodbye!")
+                return
+
+            elif ch == b' ':
+                log_filepath = ACCEPT_FILE
+                tot_accept += 1
+                break
+            elif ch == b'\r':
+                log_filepath = REJECT_FILE
+                tot_reject += 1
+                break
+            else:
+                print("Unknown input %r" % ch)
+
+        with open(log_filepath, 'ab') as f:
+            f.write(sha1 + b'\n')
+
+    exit_message()
+
+
+if __name__ == "__main__":
+    main()