├── git_reviewers ├── py.typed ├── __init__.py ├── tests │ ├── __init__.py │ ├── fixtures.py │ └── test.py └── reviewers.py ├── MANIFEST.in ├── install.sh ├── LICENSE ├── .drone.yml ├── .gitignore ├── pyproject.toml ├── README.md └── CHANGELOG.md /git_reviewers/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_reviewers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_reviewers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGELOG.md 3 | recursive-exclude git_reviewers/tests * 4 | -------------------------------------------------------------------------------- /git_reviewers/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | from typing import Dict, Any 4 | 5 | PHAB_DEFAULT: Dict[str, Dict[str, Any]] = { 6 | 'response': { 7 | 'data': [], 8 | }, 9 | } 10 | 11 | PHAB_ACTIVATED = copy.deepcopy(PHAB_DEFAULT) 12 | PHAB_ACTIVATED['response']['data'] = \ 13 | [{'fields': {'roles': ['activated']}}] 14 | 15 | PHAB_DISABLED = copy.deepcopy(PHAB_DEFAULT) 16 | PHAB_DISABLED['response']['data'] = \ 17 | [{'fields': {'roles': ['disabled']}}] 18 | 19 | PHAB_DEFAULT_DATA = json.dumps(PHAB_DEFAULT).encode("utf-8") 20 | PHAB_ACTIVATED_DATA = json.dumps(PHAB_ACTIVATED).encode("utf-8") 21 | PHAB_DISABLED_DATA = json.dumps(PHAB_DISABLED).encode("utf-8") 22 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Installs this repository so that you can run `git reviewers` from anywhere in 4 | # your filesystem 5 | 6 | # Check if python 3 is installed 7 | if ! command -v python3 > /dev/null 2>&1; then 8 | echo "You must install python 3 to use git reviewers" 9 | echo "On OSX, use 'brew install python3'" 10 | echo "On Ubuntu/Debian, use 'sudo apt install python3'" 11 | fi 12 | 13 | if [ -z "$1" ]; then 14 | BROWSE_PY_LOCATION="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 15 | BROWSE_PY_LOCATION="$BROWSE_PY_LOCATION"/git_reviewers 16 | else 17 | # Used for homebrew 18 | BROWSE_PY_LOCATION=$1 19 | fi 20 | 21 | git config --global \ 22 | alias.reviewers \ 23 | "!$BROWSE_PY_LOCATION/reviewers.py" 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Albert Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | type: docker 3 | name: test 4 | 5 | steps: 6 | - name: Test Python 7 | image: python:3.14 8 | commands: 9 | - curl https://qlty.sh | sh 10 | - pip install -e .[test] 11 | - ruff check . 12 | - mypy . 13 | - coverage run -m unittest 14 | - coverage report -m 15 | - coverage lcov -o .coverage.lcov 16 | - ~/.qlty/bin/qlty coverage publish .coverage.lcov --override-commit-sha "$DRONE_COMMIT_SHA" --override-branch "$DRONE_BRANCH" --override-build-id "$DRONE_BUILD_NUMBER" 17 | environment: 18 | QLTY_COVERAGE_TOKEN: qltcp_nTYXby3yhuZud40N 19 | 20 | - name: Test Python Packaging 21 | image: python:3.14 22 | commands: 23 | - pip install twine build 24 | - python -m build 25 | - twine check --strict dist/* 26 | 27 | - name: Upload Python 28 | depends_on: 29 | - Test Python 30 | - Test Python Packaging 31 | environment: 32 | TWINE_USERNAME: 33 | from_secret: twine_username 34 | TWINE_PASSWORD: 35 | from_secret: twine_password 36 | image: python:3.14 37 | commands: 38 | - pip install twine build 39 | - python -m build 40 | - twine upload dist/* 41 | when: 42 | event: 43 | - tag 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "git-reviewers" 3 | authors = [ 4 | {name = "Albert Wang", email = "git@albertyw.com"}, 5 | ] 6 | description = "Suggest reviewers for your git branch" 7 | requires-python = ">=3.7" 8 | keywords = ["git", "code", "review", "reviewer", "log", "history"] 9 | license = "MIT" 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Intended Audience :: Developers", 13 | "Natural Language :: English", 14 | "Topic :: Software Development :: Version Control", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Programming Language :: Python :: 3.14", 24 | "Typing :: Typed", 25 | ] 26 | dependencies = [] 27 | dynamic = ["version", "readme"] 28 | 29 | [project.optional-dependencies] 30 | test = [ 31 | # Testing 32 | "coverage==7.12.0", # Test coverage 33 | "ruff==0.14.8", # Python linting 34 | "mypy==1.19.0", # Static typing 35 | ] 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/albertyw/git-reviewers" 39 | 40 | [project.scripts] 41 | git_reviewers = "git_reviewers.reviewers:main" 42 | 43 | [tool.setuptools.dynamic] 44 | version = {attr = "git_reviewers.reviewers.__version__"} 45 | readme = {file = "README.md", content-type="text/markdown"} 46 | 47 | [options.package_data] 48 | git_reviewers = ["py.typed"] 49 | 50 | [tool.ruff] 51 | lint.select = ["E", "F", "W", "A", "B", "COM", "N", "PLC", "PLE", "PLW"] 52 | lint.ignore = ["B010"] 53 | 54 | [tool.mypy] 55 | strict = true 56 | ignore_missing_imports = true 57 | exclude = [ 58 | "build", 59 | ] 60 | 61 | [tool.coverage.run] 62 | source = [ 63 | ".", 64 | ] 65 | omit = [ 66 | ".virtualenv", 67 | ] 68 | 69 | [tool.coverage.report] 70 | exclude_lines = [ 71 | "pragma: no cover", 72 | 'if __name__ == "__main__":', 73 | ] 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | git-reviewers 2 | ============= 3 | 4 | [![PyPI](https://img.shields.io/pypi/v/git-reviewers.svg)](https://pypi.org/project/git-reviewers/) 5 | [![Python Versions](https://img.shields.io/pypi/pyversions/git-reviewers.svg)](https://pypi.org/project/git-reviewers/) 6 | 7 | 8 | [![Build Status](https://drone.albertyw.com/api/badges/albertyw/git-reviewers/status.svg)](https://drone.albertyw.com/albertyw/git-reviewers) 9 | [![Maintainability](https://qlty.sh/gh/albertyw/projects/git-reviewers/maintainability.svg)](https://qlty.sh/gh/albertyw/projects/git-reviewers) 10 | [![Code Coverage](https://qlty.sh/gh/albertyw/projects/git-reviewers/coverage.svg)](https://qlty.sh/gh/albertyw/projects/git-reviewers) 11 | 12 | Intelligently find code reviewers. 13 | See also, [git-browse](https://github.com/albertyw/git-browse). 14 | 15 | Installation 16 | ------------ 17 | 18 | ### Homebrew (preferred for MacOS) 19 | 20 | If you use Homebrew, you can install git-reviewers through the 21 | [homebrew-albertyw tap](https://github.com/albertyw/homebrew-albertyw): 22 | 23 | 24 | ```bash 25 | brew install albertyw/albertyw/git-reviewers 26 | ``` 27 | 28 | ### Manual 29 | 30 | If you don't use Homebrew, first clone this repository to somewhere on your system 31 | (perhaps in your [dotfiles](https://github.com/albertyw/dotfiles) 32 | repository), then run `/install.sh`. 33 | 34 | After installation, you can modify any default flags for git-reviewers 35 | in `~/.gitconfig` 36 | 37 | Usage 38 | ----- 39 | 40 | ``` 41 | usage: reviewers.py [-h] [-v] [--verbose] [-i IGNORE] [-j JSON] [-c] 42 | 43 | Suggest reviewers for your diff. https://github.com/albertyw/git-reviewers 44 | 45 | optional arguments: 46 | -h, --help show this help message and exit 47 | -v, --version show program's version number and exit 48 | --verbose verbose mode 49 | -i IGNORE, --ignore IGNORE 50 | ignore a list of reviewers (comma separated) 51 | -j JSON, --json JSON json file to read configs from, overridden by CLI 52 | flags 53 | -c, --copy Copy the list of reviewers to clipboard, if available 54 | -b BASE_BRANCH, --base-branch BASE_BRANCH 55 | Compare against a base branch (default: master) 56 | ``` 57 | 58 | Finders 59 | ------- 60 | 61 | `git-reviewers` is componsed of a set of strategies for generating lists of 62 | reviewers, or Finders. They return a weighted set of reviewers which is then 63 | sorted and recommended to you. They include: 64 | 65 | - `FindLogReviewers` - Generate a list of reviewers based on committers to 66 | your committed (but not merged with master) files 67 | - `FindHistoricalReviewers` - Generate reviewers based on the repository 68 | committers as a whole 69 | - `FindArcCommitReviewers` - Generate reviewers based on arc commit messages 70 | for files which you have modified on your branch 71 | 72 | Configuration 73 | ------------- 74 | 75 | `git-reviewers` supports reading configuration from a configuration file 76 | with the `--json` flag. The configuration file accepts json with the 77 | following fields (all fields optional): 78 | 79 | ```json 80 | { 81 | "verbose": false, 82 | "copy": false, 83 | "ignore": ["a", "b", "c"], 84 | "base_branch": "master" 85 | } 86 | ``` 87 | 88 | `git-reviewers` will also by default search for and load a json 89 | configuration file at `~/.git/reviewers`. 90 | 91 | Development 92 | ----------- 93 | 94 | ```bash 95 | pip install -e .[test] 96 | ruff check . 97 | mypy . 98 | coverage run -m unittest 99 | coverage report -m 100 | ``` 101 | 102 | Publishing 103 | ---------- 104 | 105 | ```bash 106 | pip install twine 107 | python -m build 108 | twine upload dist/* 109 | ``` 110 | 111 | Need to also update [albertyw/homebrew-albertyw](https://github.com/albertyw/homebrew-albertyw). 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | ============ 3 | 4 | v0.13.7 (2024-11-10) 5 | -------------------- 6 | 7 | - Officially support python 3.13 8 | - Update dependencies 9 | - Fix python lint issues 10 | 11 | 12 | v0.13.6 (2024-04-23) 13 | -------------------- 14 | 15 | - Officially support python 3.12 16 | - Update dependencies 17 | - Switch from setuptools to pyproject 18 | 19 | 20 | v0.13.5 (2023-07-10) 21 | -------------------- 22 | 23 | - Update dependencies 24 | - Refactor development and CI configuration 25 | 26 | 27 | v0.13.4 (2022-11-05) 28 | -------------------- 29 | 30 | - Update tests 31 | - Update dependencies 32 | - Support python 3.10 and 3.11; drop support for python 3.5 and 3.6 33 | 34 | 35 | v0.13.3 (2020-01-03) 36 | -------------------- 37 | 38 | - Catch arcanist errors 39 | - Export python typing from package 40 | - Update dependencies 41 | - Officially support python 3.9 42 | 43 | 44 | v0.13.2 (2019-09-04) 45 | -------------------- 46 | 47 | - Convert README to markdown 48 | - Fix validation of package `long_description` 49 | 50 | 51 | v0.13.1 (2019-09-04) 52 | -------------------- 53 | 54 | - Cleanup and minor fixes 55 | - Readme updates 56 | - Update dependencies 57 | 58 | 59 | v0.13.0 (2019-03-25) 60 | -------------------- 61 | 62 | - Add ability to configure base branch with `-b` 63 | - Optimizes git shortlog to be faster 64 | - Dependency updates 65 | 66 | 67 | v0.12.2 (2019-02-22) 68 | -------------------- 69 | 70 | - Optimizes FindHistoricalReviewers to look over the entire repository at once 71 | (Fixes https://github.com/albertyw/git-reviewers/issues/40) 72 | 73 | 74 | v0.12.1 (2019-02-05) 75 | -------------------- 76 | 77 | - Fixed a bug where too many usernames would cause arc to lock up querying phabricator 78 | - Updated README 79 | - Removed support for python 3.4 and 3.5 80 | - Dependency updates 81 | 82 | 83 | v0.12.0 (2019-02-03) 84 | -------------------- 85 | 86 | - Changed entrypoint from git_reviewers to git-reviewers 87 | - Added support for reading the default config file of the current user 88 | - Refactors to reading configs 89 | - Backfilled some mypy type annotations 90 | - Updated dependencies 91 | 92 | 93 | v0.11.1 (2018-12-26) 94 | -------------------- 95 | 96 | - Make phabricator user activation check faster 97 | - Add documentation in readme about configuration file 98 | - Fix package description syntax 99 | - Dependency updates 100 | 101 | 102 | v0.11.0 (2018-11-26) 103 | -------------------- 104 | 105 | - Add json config files 106 | - Updates to test dependencies 107 | 108 | 109 | v0.10.0 (2018-10-14) 110 | -------------------- 111 | 112 | - Add ability to look at entire repository history when computing reviewers 113 | 114 | 115 | v0.9.0 (2018-07-03) 116 | ------------------- 117 | 118 | - Add verbose mode 119 | - Fixes for copying data to clipboard 120 | - Update test dependencies 121 | 122 | 123 | v0.8.0 (2018-03-27) 124 | ------------------- 125 | 126 | - Refactors and optimizations 127 | 128 | 129 | v0.7.0 (2018-03-24) 130 | ------------------- 131 | 132 | - Add homebrew 133 | - Be able to work with deleted files 134 | - Prune users that are disabled in phabricator 135 | - Update test dependencies 136 | 137 | 138 | v0.6.1 (2017-12-25) 139 | ------------------- 140 | 141 | - Make install.sh accept a path for reviewers.py 142 | 143 | 144 | v0.6.0 (2017-12-17) 145 | ------------------- 146 | 147 | - Remove `FindDiffLogReviewers` because it overlaps with `FindLogReviewers` 148 | - Remove `--path` from installation script and readme 149 | - Update readme 150 | - Update mypy dependency 151 | 152 | 153 | v0.5.0 (2017-11-18) 154 | ------------------- 155 | 156 | - Add `--copy` flag to copy reviewers to OS clipboard 157 | - Various refactors and minor fixes 158 | 159 | 160 | v0.4.0 (2017-11-13) 161 | ------------------- 162 | 163 | - Remove no-op `--path` argument 164 | - Add an `--ignore` argument for ignoring possible reviewers 165 | - Weight reviewers by the frequency they show up in previous git history 166 | 167 | 168 | v0.3.1 (2017-11-12) 169 | ------------------- 170 | 171 | - Limit to 7 reviewers 172 | - Support weighting/sorting reviewers 173 | - Add PEP-484 type annotations for mypy 174 | 175 | 176 | v0.3.0 (2017-10-30) 177 | ------------------- 178 | 179 | - Get reviewers based on arcanist reviewers in commit messages 180 | - Remove uber-specific logic 181 | - Add `-v` to read git-reviewer version 182 | 183 | 184 | v0.2.0 (2017-10-23) 185 | ------------------- 186 | 187 | - Be able to gather reviewers based on the changed files compared to master branch 188 | - Updated testing dependencies 189 | - Lots of refactors, fixes 190 | 191 | 192 | v0.1.2 (2017-10-20) 193 | ------------------- 194 | 195 | - Lots of refactors, fixes 196 | 197 | 198 | v0.1.1 (2017-10-17) 199 | ------------------- 200 | 201 | - Reformat README to RST 202 | - Add installation script 203 | 204 | 205 | v0.1.0 (2017-06-24) 206 | ------------------- 207 | 208 | - Make uber behavior be toggleable and off by default 209 | - Select reviewers based on the files that were changed 210 | 211 | 212 | v0.0.2 (2017-06-20) 213 | ------------------- 214 | 215 | - Add initial support for finding repository committers as a list of reviewers 216 | -------------------------------------------------------------------------------- /git_reviewers/reviewers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | from collections import Counter 5 | import json 6 | import os 7 | import pathlib 8 | import subprocess 9 | import sys 10 | 11 | import typing # NOQA 12 | from typing import List, Tuple 13 | 14 | if sys.version_info < (3, 0): # NOQA pragma: no cover 15 | raise SystemError("Must be using Python 3") 16 | 17 | __version__ = '0.13.7' 18 | STRIP_DOMAIN_USERNAMES = ['uber.com'] 19 | REVIEWERS_LIMIT = 7 20 | 21 | 22 | class FindReviewers(): 23 | def __init__(self, config): # type: (Config) -> None 24 | self.config = config 25 | 26 | def get_reviewers(self): # type: () -> typing.Counter[str] 27 | """ 28 | All review classes should implement this and return a list of strings 29 | representing reviewers 30 | """ 31 | raise NotImplementedError() 32 | 33 | def run_command(self, command: List[str]) -> List[str]: 34 | """ Wrapper for running external subprocesses """ 35 | process = subprocess.run(command, stdout=subprocess.PIPE, check=False) 36 | data = process.stdout.decode("utf-8").strip() 37 | if data: 38 | return data.split('\n') 39 | return [] 40 | 41 | def extract_username_from_email(self, email: str) -> str: 42 | """ Given an email, extract the username for that email """ 43 | domain = email[email.find('@')+1:] 44 | if domain in STRIP_DOMAIN_USERNAMES: 45 | return email[:email.find('@')] 46 | return email 47 | 48 | def check_phabricator_activated( 49 | self, username: str, 50 | ) -> subprocess.Popen[bytes]: 51 | """ Check whether a phabricator user has been activated by """ 52 | phab_command = ['arc', 'call-conduit', 'user.search'] 53 | request = '{"constraints": {"usernames": ["%s"]}}' % username 54 | process = subprocess.Popen( 55 | phab_command, 56 | stdin=subprocess.PIPE, 57 | stdout=subprocess.PIPE) 58 | process.communicate(input=request.encode("utf-8")) 59 | return process 60 | 61 | def parse_phabricator(self, username, process): 62 | # type: (str, subprocess.Popen[bytes]) -> str 63 | stdout, stderr = process.communicate() 64 | if process.returncode != 0: 65 | print("stdout: %s" % stdout.decode("utf-8")) 66 | print("stderr: %s" % stderr.decode("utf-8")) 67 | raise RuntimeError("Arc not able to call conduit") 68 | output_str = stdout.decode("utf-8").strip() 69 | phab_output = json.loads(output_str) 70 | data = phab_output['response']['data'] 71 | if not data: 72 | return username 73 | roles = data[0]['fields']['roles'] 74 | if 'disabled' in roles: 75 | return '' 76 | return username 77 | 78 | def filter_phabricator_activated(self, all_users: List[str]) -> List[str]: 79 | limited_users = all_users[:REVIEWERS_LIMIT] 80 | username_processes = [ 81 | (x, self.check_phabricator_activated(x)) for x in limited_users 82 | ] 83 | usernames = [self.parse_phabricator(*x) for x in username_processes] 84 | usernames = [x for x in usernames if x] 85 | if len(usernames) < REVIEWERS_LIMIT: 86 | for username in all_users[REVIEWERS_LIMIT:]: 87 | check_proc = self.check_phabricator_activated(username) 88 | parsed_username = self.parse_phabricator(username, check_proc) 89 | if parsed_username: 90 | usernames.append(parsed_username) 91 | if len(usernames) >= REVIEWERS_LIMIT: 92 | break 93 | return usernames 94 | 95 | 96 | class FindFileLogReviewers(FindReviewers): 97 | def extract_username_from_shortlog(self, shortlog: str) -> Tuple[str, int]: 98 | """ Given a line from a git shortlog, extract the username """ 99 | shortlog = shortlog.strip() 100 | email = shortlog[shortlog.rfind("<")+1:] 101 | email = email[:email.find(">")] 102 | username = self.extract_username_from_email(email) 103 | count = int(shortlog.split("\t")[0]) 104 | return username, count 105 | 106 | def get_log_reviewers_from_file(self, file_paths): 107 | # type: (List[str]) -> typing.Counter[str] 108 | """ Find the reviewers based on the git log for a file """ 109 | git_shortlog_command = ['git', 'shortlog', '-sne'] 110 | if file_paths: 111 | git_shortlog_command += ['--'] + file_paths 112 | git_shortlog = self.run_command(git_shortlog_command) 113 | users = dict( 114 | self.extract_username_from_shortlog(shortlog) 115 | for shortlog 116 | in git_shortlog 117 | ) 118 | users = { 119 | reviewer: count for (reviewer, count) 120 | in users.items() if reviewer 121 | } 122 | return Counter(users) 123 | 124 | def get_changed_files(self) -> List[str]: 125 | raise NotImplementedError() 126 | 127 | def get_reviewers(self): # type: () -> typing.Counter[str] 128 | """ Find the reviewers based on the git log of the diffed files """ 129 | changed_files = self.get_changed_files() 130 | reviewers = self.get_log_reviewers_from_file(changed_files) 131 | return reviewers 132 | 133 | 134 | class FindLogReviewers(FindFileLogReviewers): 135 | def get_changed_files(self) -> List[str]: 136 | """ Find the changed files between current status and master """ 137 | branch = self.config.base_branch 138 | git_diff_files_command = ['git', 'diff', branch, '--name-only'] 139 | git_diff_files = self.run_command(git_diff_files_command) 140 | return git_diff_files 141 | 142 | 143 | class FindHistoricalReviewers(FindFileLogReviewers): 144 | def get_reviewers(self): # type: () -> typing.Counter[str] 145 | reviewers = self.get_log_reviewers_from_file([]) 146 | return reviewers 147 | 148 | 149 | class FindArcCommitReviewers(FindLogReviewers): 150 | """ 151 | Get reviewers based on arc commit messages, which list which users 152 | have approved past diffs 153 | """ 154 | def get_log_reviewers_from_file(self, file_paths): 155 | # type: (List[str]) -> typing.Counter[str] 156 | command = ['git', 'log', '--all', '--'] + file_paths 157 | git_commit_messages = self.run_command(command) 158 | reviewers_identifier = 'Reviewed By: ' 159 | reviewers = Counter() # type: typing.Counter[str] 160 | for raw_line in git_commit_messages: 161 | if reviewers_identifier not in raw_line: 162 | continue 163 | line = raw_line.replace(reviewers_identifier, '') 164 | line_reviewers = line.split(', ') 165 | line_reviewers = [r.strip() for r in line_reviewers] 166 | reviewers.update(line_reviewers) 167 | return reviewers 168 | 169 | 170 | def show_reviewers(reviewer_list, copy_clipboard): 171 | # type: (List[str], bool) -> None 172 | """ Output the reviewers to stdout and optionally to OS clipboard """ 173 | reviewer_string = ", ".join(reviewer_list) 174 | print(reviewer_string) 175 | 176 | if not copy_clipboard: 177 | return 178 | try: 179 | p = subprocess.Popen( 180 | ['pbcopy', 'w'], 181 | stdin=subprocess.PIPE, close_fds=True, 182 | ) 183 | p.communicate(input=reviewer_string.encode('utf-8')) 184 | except FileNotFoundError: 185 | pass 186 | 187 | 188 | def get_reviewers(config): # type: (Config) -> List[str] 189 | """ Main function to get reviewers for a repository """ 190 | phabricator = False 191 | finders = [ 192 | FindLogReviewers, 193 | FindHistoricalReviewers, 194 | FindArcCommitReviewers, 195 | ] 196 | reviewers = Counter() # type: typing.Counter[str] 197 | for finder in finders: 198 | finder_reviewers = finder(config).get_reviewers() 199 | if config.verbose: 200 | print( 201 | "Reviewers from %s: %s" % 202 | (finder.__name__, dict(finder_reviewers)), 203 | ) 204 | reviewers.update(finder_reviewers) 205 | if finder == FindArcCommitReviewers and finder_reviewers: 206 | phabricator = True 207 | 208 | most_common = [x[0] for x in reviewers.most_common()] 209 | most_common = [x for x in most_common if x not in config.ignores] 210 | if phabricator: 211 | most_common = FindArcCommitReviewers(config) \ 212 | .filter_phabricator_activated(most_common) 213 | reviewers_list = most_common[:REVIEWERS_LIMIT] 214 | return reviewers_list 215 | 216 | 217 | class Config(): 218 | DEFAULT_GLOBAL_JSON = ".git/reviewers" 219 | VERBOSE_DEFAULT = None 220 | IGNORES_DEFAULT = '' 221 | JSON_DEFAULT = '' 222 | COPY_DEFAULT = None 223 | BASE_BRANCH_DEFAULT = 'master' 224 | 225 | def __init__(self) -> None: 226 | self.verbose = False 227 | self.ignores: List[str] = [] 228 | self.json = '' 229 | self.copy = False 230 | self.base_branch = 'master' 231 | 232 | @staticmethod 233 | def default_global_json(): 234 | # type: () -> str 235 | """ 236 | Return the path to the default config file for the current user 237 | """ 238 | home_dir = str(pathlib.Path.home()) 239 | json_path = os.path.join(home_dir, Config.DEFAULT_GLOBAL_JSON) 240 | return json_path 241 | 242 | def read_configs(self, args): 243 | # type: (argparse.Namespace) -> None 244 | """ Read config data """ 245 | self.read_from_json(Config.default_global_json()) 246 | self.read_from_json(args.json) 247 | self.read_from_args(args) 248 | 249 | def read_from_json(self, args_json): 250 | # type: (str) -> None 251 | """ Read configs from the json config file """ 252 | self.json = args_json 253 | try: 254 | with open(self.json, 'r') as config_handle: 255 | config_data = config_handle.read() 256 | config = json.loads(config_data) 257 | except (FileNotFoundError, json.decoder.JSONDecodeError): 258 | return 259 | if not isinstance(config, dict): 260 | return 261 | 262 | self.verbose = config.get('verbose', self.verbose) 263 | self.copy = config.get('copy', self.copy) 264 | self.ignores += config.get('ignore', self.ignores) 265 | self.base_branch = config.get('base_branch', self.base_branch) 266 | 267 | def read_from_args(self, args): 268 | # type: (argparse.Namespace) -> None 269 | """ Parse configs by joining config file against argparse """ 270 | if args.verbose != Config.VERBOSE_DEFAULT: 271 | self.verbose = args.verbose 272 | if args.copy != Config.VERBOSE_DEFAULT: 273 | self.copy = args.copy 274 | if args.ignore != Config.IGNORES_DEFAULT: 275 | self.ignores += args.ignore.split(',') 276 | if args.base_branch != Config.BASE_BRANCH_DEFAULT: 277 | self.base_branch = args.base_branch 278 | 279 | 280 | def main() -> None: 281 | """ Main entrypoint function to receive CLI arguments """ 282 | description = "Suggest reviewers for your diff.\n" 283 | description += "https://github.com/albertyw/git-reviewers" 284 | parser = argparse.ArgumentParser(description=description) 285 | parser.add_argument( 286 | '-v', '--version', action='version', version=__version__, 287 | ) 288 | parser.add_argument( 289 | '--verbose', 290 | default=Config.VERBOSE_DEFAULT, action='store_true', 291 | help='verbose mode', 292 | ) 293 | parser.add_argument( 294 | '-i', '--ignore', 295 | default=Config.IGNORES_DEFAULT, 296 | help='ignore a list of reviewers (comma separated)', 297 | ) 298 | parser.add_argument( 299 | '-j', '--json', 300 | default=Config.JSON_DEFAULT, 301 | help='json file to read configs from, overridden by CLI flags', 302 | ) 303 | parser.add_argument( 304 | '-c', '--copy', 305 | default=Config.COPY_DEFAULT, action='store_true', 306 | help='Copy the list of reviewers to clipboard, if available', 307 | ) 308 | parser.add_argument( 309 | '-b', '--base-branch', 310 | default=Config.BASE_BRANCH_DEFAULT, 311 | help='Compare against a base branch (default: master)', 312 | ) 313 | args = parser.parse_args() 314 | config = Config() 315 | config.read_configs(args) 316 | reviewers_list = get_reviewers(config) 317 | show_reviewers(reviewers_list, config.copy) 318 | 319 | 320 | if __name__ == "__main__": 321 | main() 322 | -------------------------------------------------------------------------------- /git_reviewers/tests/test.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | import os 3 | import json 4 | import sys 5 | import tempfile 6 | import typing # NOQA 7 | import unittest 8 | from unittest.mock import patch, MagicMock 9 | 10 | from git_reviewers import reviewers 11 | from git_reviewers.tests.fixtures import \ 12 | PHAB_DEFAULT_DATA, \ 13 | PHAB_ACTIVATED_DATA, \ 14 | PHAB_DISABLED_DATA 15 | 16 | directory = os.path.dirname(os.path.realpath(__file__)) 17 | BASE_DIRECTORY = os.path.normpath(os.path.join(directory, '..', '..')) 18 | 19 | 20 | class TestFindReviewers(unittest.TestCase): 21 | def setUp(self) -> None: 22 | self.finder = reviewers.FindReviewers(reviewers.Config()) 23 | self.orig_reviewers_limit = reviewers.REVIEWERS_LIMIT 24 | 25 | def tearDown(self) -> None: 26 | reviewers.REVIEWERS_LIMIT = self.orig_reviewers_limit 27 | 28 | def test_get_reviewers(self) -> None: 29 | with self.assertRaises(NotImplementedError): 30 | self.finder.get_reviewers() 31 | 32 | @patch('subprocess.run') 33 | def test_run_command(self, mock_run: MagicMock) -> None: 34 | mock_run().stdout = b'asdf' 35 | data = self.finder.run_command(['ls']) 36 | self.assertEqual(data, ['asdf']) 37 | 38 | @patch('subprocess.run') 39 | def test_run_command_empty_response(self, mock_run: MagicMock) -> None: 40 | mock_run().stdout = b'' 41 | data = self.finder.run_command([':']) 42 | self.assertEqual(data, []) 43 | 44 | def check_extract_username(self, email: str, expected_user: str) -> None: 45 | user = self.finder.extract_username_from_email(email) 46 | self.assertEqual(user, expected_user) 47 | 48 | def test_extract_username_from_generic_email(self) -> None: 49 | self.check_extract_username('asdf@gmail.com', 'asdf@gmail.com') 50 | 51 | def test_extract_uber_username_from_email(self) -> None: 52 | self.check_extract_username('asdf@uber.com', 'asdf') 53 | 54 | @patch('subprocess.Popen') 55 | def test_check_phabricator_activated(self, mock_popen: MagicMock) -> None: 56 | mock_popen().communicate.return_value = [PHAB_ACTIVATED_DATA, ''] 57 | activated = self.finder.check_phabricator_activated('asdf') 58 | self.assertTrue(activated) 59 | 60 | @patch('subprocess.Popen') 61 | def test_check_phabricator_activated_none( 62 | self, mock_popen: MagicMock, 63 | ) -> None: 64 | mock_popen().communicate.return_value = [PHAB_DEFAULT_DATA, ''] 65 | activated = self.finder.check_phabricator_activated('asdf') 66 | self.assertTrue(activated) 67 | 68 | def test_filter_phabricator_activated(self) -> None: 69 | users = ['a', 'b', 'c', 'd'] 70 | reviewers.REVIEWERS_LIMIT = 2 71 | self.mock_check_count = 0 72 | 73 | def mock_check(u: str) -> int: 74 | self.assertEqual(u, users[self.mock_check_count]) 75 | self.mock_check_count += 1 76 | return self.mock_check_count - 1 77 | self.mock_parse_count = 0 78 | 79 | def mock_parse(u: str, p: int) -> str: 80 | self.assertEqual(u, users[self.mock_parse_count]) 81 | self.assertEqual(p, self.mock_parse_count) 82 | parse_return = '' 83 | if self.mock_parse_count in [0, 2]: 84 | parse_return = u 85 | self.mock_parse_count += 1 86 | return parse_return 87 | self.finder.check_phabricator_activated = mock_check # type: ignore 88 | self.finder.parse_phabricator = mock_parse # type: ignore 89 | filtered_usernames = self.finder.filter_phabricator_activated(users) 90 | self.assertEqual(self.mock_check_count, 3) 91 | self.assertEqual(self.mock_parse_count, 3) 92 | self.assertEqual(filtered_usernames, ['a', 'c']) 93 | 94 | 95 | class TestFindLogReviewers(unittest.TestCase): 96 | def setUp(self) -> None: 97 | self.finder = reviewers.FindFileLogReviewers(reviewers.Config()) 98 | 99 | def check_extract_username_from_shortlog( 100 | self, shortlog: str, email: str, weight: int, 101 | ) -> None: 102 | user_data = self.finder.extract_username_from_shortlog(shortlog) 103 | self.assertEqual(user_data, (email, weight)) 104 | 105 | def test_gets_generic_emails(self) -> None: 106 | shortlog = ' 3\tAlbert Wang \n' 107 | self.check_extract_username_from_shortlog( 108 | shortlog, 109 | 'example@gmail.com', 110 | 3, 111 | ) 112 | 113 | def test_gets_uber_emails(self) -> None: 114 | shortlog = ' 3\tAlbert Wang \n' 115 | self.check_extract_username_from_shortlog(shortlog, 'albertyw', 3) 116 | 117 | def test_gets_user_weight(self) -> None: 118 | shortlog = ' 2\tAlbert Wang \n' 119 | self.check_extract_username_from_shortlog(shortlog, 'albertyw', 2) 120 | 121 | def test_get_changed_files(self) -> None: 122 | with self.assertRaises(NotImplementedError): 123 | self.finder.get_changed_files() 124 | 125 | @patch('subprocess.run') 126 | def test_gets_reviewers(self, mock_run: MagicMock) -> None: 127 | changed_files = ['README.rst'] 128 | self.finder.get_changed_files = ( # type: ignore 129 | MagicMock(return_value=changed_files) 130 | ) 131 | process = MagicMock() 132 | git_shortlog = b' 3\tAlbert Wang \n' 133 | git_shortlog += b'3\tAlbert Wang \n' 134 | process.stdout = git_shortlog 135 | mock_run.return_value = process 136 | users = self.finder.get_reviewers() 137 | reviewers = Counter({'albertyw': 3, 'example@gmail.com': 3}) 138 | self.assertEqual(users, reviewers) 139 | 140 | 141 | class TestLogReviewers(unittest.TestCase): 142 | def setUp(self) -> None: 143 | self.finder = reviewers.FindLogReviewers(reviewers.Config()) 144 | 145 | def test_get_changed_files(self) -> None: 146 | changed_files = ['README.rst', 'pyproject.toml'] 147 | self.finder.run_command = ( # type: ignore 148 | MagicMock(return_value=changed_files) 149 | ) 150 | files = self.finder.get_changed_files() 151 | self.assertEqual(files, ['README.rst', 'pyproject.toml']) 152 | 153 | 154 | class TestHistoricalReviewers(unittest.TestCase): 155 | def setUp(self) -> None: 156 | self.finder = reviewers.FindHistoricalReviewers(reviewers.Config()) 157 | 158 | def test_get_reviewers(self) -> None: 159 | counter = Counter() # type: typing.Counter[str] 160 | mock_get_log_reviewers = MagicMock(return_value=counter) 161 | self.finder.get_log_reviewers_from_file = ( # type: ignore 162 | mock_get_log_reviewers 163 | ) 164 | reviewers = self.finder.get_reviewers() 165 | self.assertEqual(counter, reviewers) 166 | 167 | 168 | class TestFindArcCommitReviewers(unittest.TestCase): 169 | def setUp(self) -> None: 170 | self.finder = reviewers.FindArcCommitReviewers(reviewers.Config()) 171 | 172 | def test_no_reviewers(self) -> None: 173 | log = ['asdf'] 174 | self.finder.run_command = MagicMock(return_value=log) # type: ignore 175 | reviewers = self.finder.get_log_reviewers_from_file(['file']) 176 | self.assertEqual(reviewers, Counter()) 177 | 178 | def test_reviewers(self) -> None: 179 | log = ['asdf', ' Reviewed By: asdf, qwer'] 180 | self.finder.run_command = MagicMock(return_value=log) # type: ignore 181 | reviewers = self.finder.get_log_reviewers_from_file(['file']) 182 | self.assertEqual(reviewers, Counter({'asdf': 1, 'qwer': 1})) 183 | 184 | def test_multiple_reviews(self) -> None: 185 | log = ['asdf', ' Reviewed By: asdf, qwer', 'Reviewed By: asdf'] 186 | self.finder.run_command = MagicMock(return_value=log) # type: ignore 187 | reviewers = self.finder.get_log_reviewers_from_file(['file']) 188 | self.assertEqual(reviewers, Counter({'asdf': 2, 'qwer': 1})) 189 | 190 | 191 | class TestShowReviewers(unittest.TestCase): 192 | @patch('builtins.print') 193 | def test_show_reviewers(self, mock_print: MagicMock) -> None: 194 | usernames = ['asdf', 'albertyw'] 195 | reviewers.show_reviewers(usernames, False) 196 | mock_print.assert_called_with('asdf, albertyw') 197 | 198 | @patch('subprocess.Popen') 199 | def test_copy_reviewers(self, mock_popen: MagicMock) -> None: 200 | usernames = ['asdf', 'albertyw'] 201 | reviewers.show_reviewers(usernames, True) 202 | self.assertTrue(mock_popen.called) 203 | 204 | @patch('subprocess.Popen') 205 | def test_copy_reviewers_no_pbcopy(self, mock_popen: MagicMock) -> None: 206 | usernames = ['asdf', 'albertyw'] 207 | mock_popen.side_effect = FileNotFoundError 208 | reviewers.show_reviewers(usernames, True) 209 | 210 | 211 | class TestGetReviewers(unittest.TestCase): 212 | @patch('builtins.print') 213 | def test_verbose_reviewers(self, mock_print: MagicMock) -> None: 214 | config = reviewers.Config() 215 | config.verbose = True 216 | counter = Counter({'asdf': 1, 'qwer': 1}) 217 | get_reviewers = ( 218 | 'git_reviewers.reviewers.' 219 | 'FindFileLogReviewers.get_reviewers' 220 | ) 221 | run_command = 'git_reviewers.reviewers.FindReviewers.run_command' 222 | with patch.object(sys, 'argv', ['reviewers.py', '--verbose']): 223 | with patch(get_reviewers) as mock_get_reviewers: 224 | with patch(run_command) as mock_run_command: 225 | with patch('subprocess.Popen') as mock_popen: 226 | mock_popen().returncode = 0 227 | mock_popen().communicate.return_value = \ 228 | [PHAB_ACTIVATED_DATA, b''] 229 | mock_run_command.return_value = [] 230 | mock_get_reviewers.return_value = counter 231 | reviewers.get_reviewers(config) 232 | self.assertEqual(len(mock_print.call_args), 2) 233 | self.assertEqual( 234 | mock_print.call_args[0][0], 235 | 'Reviewers from FindArcCommitReviewers: %s' % 236 | "{'asdf': 1, 'qwer': 1}", 237 | ) 238 | 239 | 240 | class TestConfig(unittest.TestCase): 241 | def setUp(self) -> None: 242 | self.config = reviewers.Config() 243 | self.config_file = tempfile.NamedTemporaryFile('w') 244 | self.mock_args = MagicMock() 245 | self.mock_args.verbose = None 246 | self.mock_args.ignore = '' 247 | self.mock_args.json = '' 248 | self.mock_args.copy = None 249 | 250 | def tearDown(self) -> None: 251 | self.config_file.close() 252 | 253 | def test_default_global_json(self) -> None: 254 | expected_path = os.path.expanduser("~") + "/.git/reviewers" 255 | json_path = reviewers.Config.default_global_json() 256 | self.assertEqual(json_path, expected_path) 257 | 258 | def test_read_configs_args(self) -> None: 259 | self.mock_args.verbose = True 260 | self.config.read_configs(self.mock_args) 261 | self.assertTrue(self.config.verbose) 262 | self.assertEqual(self.config.ignores, []) 263 | self.assertFalse(self.config.copy) 264 | 265 | def test_read_configs_copy(self) -> None: 266 | self.mock_args.copy = True 267 | self.config.read_configs(self.mock_args) 268 | self.assertTrue(self.config.copy) 269 | 270 | def test_read_json(self) -> None: 271 | self.mock_args.ignore = 'a,b' 272 | self.mock_args.json = self.config_file.name 273 | config_file_data = {'verbose': True, 'ignore': ['c', 'd']} 274 | self.config_file.write(json.dumps(config_file_data)) 275 | self.config_file.flush() 276 | self.config.read_configs(self.mock_args) 277 | self.assertTrue(self.config.verbose) 278 | self.assertEqual(set(self.config.ignores), set(['a', 'b', 'c', 'd'])) 279 | 280 | def test_read_malformed_json(self) -> None: 281 | self.mock_args.ignore = 'a,b' 282 | self.mock_args.json = self.config_file.name 283 | self.config_file.write('') 284 | self.config_file.flush() 285 | self.config.read_configs(self.mock_args) 286 | self.assertEqual(set(self.config.ignores), set(['a', 'b'])) 287 | 288 | def test_read_unusable(self) -> None: 289 | self.mock_args.ignore = 'a,b' 290 | self.mock_args.json = self.config_file.name 291 | self.config_file.write("[]") 292 | self.config_file.flush() 293 | self.config.read_configs(self.mock_args) 294 | self.assertEqual(set(self.config.ignores), set(['a', 'b'])) 295 | 296 | 297 | class TestMain(unittest.TestCase): 298 | @patch('builtins.print') 299 | def test_main(self, mock_print: MagicMock) -> None: 300 | with patch.object(sys, 'argv', ['reviewers.py']): 301 | reviewers.main() 302 | self.assertTrue(mock_print.called) 303 | 304 | @patch('argparse.ArgumentParser._print_message') 305 | def test_version(self, mock_print: MagicMock) -> None: 306 | with patch.object(sys, 'argv', ['reviewers.py', '-v']): 307 | with self.assertRaises(SystemExit): 308 | reviewers.main() 309 | self.assertTrue(mock_print.called) 310 | version = reviewers.__version__ + "\n" 311 | self.assertEqual(mock_print.call_args[0][0], version) 312 | 313 | @patch('builtins.print') 314 | def test_ignore_reviewers(self, mock_print: MagicMock) -> None: 315 | counter = Counter({'asdf': 1, 'qwer': 1}) 316 | get_reviewers = ( 317 | 'git_reviewers.reviewers.' 318 | 'FindFileLogReviewers.get_reviewers' 319 | ) 320 | run_command = 'git_reviewers.reviewers.FindReviewers.run_command' 321 | with patch.object(sys, 'argv', ['reviewers.py', '-i', 'asdf']): 322 | with patch(get_reviewers) as mock_get_reviewers: 323 | with patch(run_command) as mock_run_command: 324 | with patch('subprocess.Popen') as mock_popen: 325 | mock_popen().returncode = 0 326 | mock_popen().communicate.return_value = \ 327 | [PHAB_ACTIVATED_DATA, b''] 328 | mock_run_command.return_value = [] 329 | mock_get_reviewers.return_value = counter 330 | reviewers.main() 331 | self.assertEqual(mock_print.call_args[0][0], 'qwer') 332 | 333 | @patch('builtins.print') 334 | def test_phabricator_disabled_reviewers( 335 | self, mock_print: MagicMock, 336 | ) -> None: 337 | counter = Counter({'asdf': 1, 'qwer': 1}) 338 | get_reviewers = ( 339 | 'git_reviewers.reviewers.' 340 | 'FindFileLogReviewers.get_reviewers' 341 | ) 342 | run_command = 'git_reviewers.reviewers.FindReviewers.run_command' 343 | with patch.object(sys, 'argv', ['reviewers.py']): 344 | with patch(get_reviewers) as mock_get_reviewers: 345 | with patch(run_command) as mock_run_command: 346 | with patch('subprocess.Popen') as mock_popen: 347 | mock_popen().returncode = 0 348 | mock_popen().communicate.return_value = \ 349 | [PHAB_DISABLED_DATA, b''] 350 | mock_run_command.return_value = [] 351 | mock_get_reviewers.return_value = counter 352 | reviewers.main() 353 | self.assertEqual(mock_print.call_args[0][0], '') 354 | --------------------------------------------------------------------------------