├── .flake8 ├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── gitmine ├── __init__.py ├── commands │ ├── __init__.py │ ├── config.py │ ├── get.py │ └── go.py ├── constants.py ├── endpoints.py ├── gitmine.py ├── models │ ├── __init__.py │ └── github_elements.py ├── paths.py ├── utils.py └── version │ └── __init__.py ├── mypy.ini ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── data ├── issues.json └── prs.json ├── test_constants.py ├── test_get.py ├── test_gitmine.py └── test_utils.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | # __init__.py file imports raise warnings about unused imports 4 | __init__.py 5 | ignore = 6 | # False positives on imports used only in generic type annotations 7 | F401 8 | # Black recommends disabling 9 | E203 10 | W503 11 | # the next two are hanging indent errors. We exclude these because pylint 12 | # already catches them and in a few places we need to manually suppress 13 | # them to avoid fighting with PyCharm. We'd rather just add one 14 | # suppression comment. 15 | E128 16 | E131 17 | # isort handles this now 18 | E402 19 | # already covered by PyLint and gives false positives for typing.overload 20 | F811 21 | # Let Black handle line length 22 | max-line-length = 300 23 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .mypy_cache/ 3 | __pycache__/ 4 | *.egg-info/ 5 | .vscode/ 6 | .pytest_cache/ 7 | gitmine.key 8 | build/ 9 | dist/ 10 | .coverage -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.7 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 19.10b0 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/pre-commit/mirrors-mypy 13 | rev: v0.782 14 | hooks: 15 | - id: mypy 16 | args: [--no-strict-optional, --ignore-missing-imports] 17 | - repo: https://github.com/pre-commit/mirrors-isort 18 | rev: v5.3.0 19 | hooks: 20 | - id: isort -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.7" 4 | 5 | cache: 6 | pip: true 7 | 8 | install: 9 | - pip install -r requirements-dev.txt 10 | - pip install -r requirements.txt 11 | - pip install -e . 12 | 13 | script: 14 | - echo "python version $(python --version) running" 15 | - echo "pip version $(pip --version) running" 16 | - make check 17 | - pytest tests/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing guide 2 | *** 3 | 4 | #### To install requirements 5 | `make install` 6 | 7 | #### To fix requirements 8 | `make reqs-fix` 9 | 10 | #### To fix imports, formatting 11 | `make black-fix` 12 | 13 | #### To check - must run before pushing 14 | `make check` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joe Cummings, Alexis Baudron 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | @echo "an explicit target is required" 3 | 4 | SHELL=/usr/bin/env bash 5 | 6 | PYTHON_FILES=gitmine/*.py gitmine/commands/*.py gitmine/models/*.py 7 | 8 | install: 9 | pip install -r requirements.txt 10 | pip install -r dev-requirements.txt 11 | 12 | lint: 13 | pylint $(PYTHON_FILES) 14 | 15 | docstyle: 16 | pydocstyle --convention=google $(PYTHON_FILES) 17 | 18 | mypy: 19 | mypy $(PYTHON_FILES) 20 | 21 | flake8: 22 | flake8 $(PYTHON_FILES) 23 | 24 | SORT=LC_ALL=C sort --key=1,1 --key=3V --field-separator="=" 25 | 26 | reqs-fix: 27 | $(SORT) --output=requirements.txt requirements.txt 28 | $(SORT) --output=requirements-dev.txt requirements-dev.txt 29 | 30 | reqs-check: 31 | $(SORT) --check requirements.txt 32 | $(SORT) --check requirements-dev.txt 33 | 34 | black-fix: 35 | isort $(PYTHON_FILES) 36 | black --config pyproject.toml $(PYTHON_FILES) 37 | 38 | black-check: 39 | isort --check $(PYTHON_FILES) 40 | black --config pyproject.toml --check $(PYTHON_FILES) 41 | 42 | check: reqs-check black-check flake8 mypy lint 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/joecummings/gitmine.svg?branch=master)](https://travis-ci.com/joecummings/gitmine) 2 | 3 | # gitmine 4 | 5 | Stop getting buried under countless Github Issues and PRs. Organize, reference, and close with ease. 6 | 7 | Based on the amazing work done by Github itself on [hub](https://github.com/github/hub) and [Github CLI](https://cli.github.com/) (released while we were working on this project. Must've been on to something). I view this as a natural extension of the `gh` command for easy access to Issues and PRs from anywhere in your command line. 8 | 9 | #### Who is this for? 10 | 11 | I work on a lot of projects across many repositories. It was a pain in the a$$ to keep track of all the Issues I was assigned and PRs I had to review. Some tools provided a quick way to see Issues from one repository, but I needed a quick way to view, organize and open from any of these projects, thus `gitmine` was born. 12 | 13 | If you aren't a heavy user of Github or maybe only have one repository you focus on, this is probably more than you need. 14 | 15 | ## Usage 16 | 17 | ### Command Line Arguments 18 | ``` 19 | Usage: gitmine [OPTIONS] COMMAND [ARGS]... 20 | 21 | Simple command-line tool for querying assigned Issues and PR reviews from 22 | Github. 23 | 24 | Options: 25 | -v, --verbose Give more output. Option is additive, and can be used up to 26 | three times. 27 | --help Show this message and exit. 28 | 29 | Commands: 30 | config Set or Access Github Config information. 31 | get Get assigned Github Issues and/or Github PRs. 32 | go Open a browser page for the given repositiory / issue. 33 | ``` 34 | 35 | ### Config 36 | 37 | If you already have the Github CLI installed and setup, congrats! You can skip this section. `gitmine` automatically piggy-backs on Github CLI's config to access your Github information. 38 | To use `gitmine` you will first need to generate a Personal Access Token. You can follow the instructions [here](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 39 | 40 | ``` 41 | gitmine config username ~git-username~ 42 | gitmine config token ~git-token~ 43 | ``` 44 | 45 | ## Installation 46 | 47 | #### From PyPi 48 | ``` 49 | pip install gitmine 50 | ``` 51 | #### From source 52 | ``` 53 | pip install 'git+https://github.com/joecummings/gitmine.git' 54 | ``` 55 | 56 | ## Contributing 57 | 58 | See [Contributing.md](Contributing.md) 59 | 60 | ## Common Errors and FAQ's 61 | 62 | * Running `gitmine get prs` with the wrong username will not error but return nothing 63 | -------------------------------------------------------------------------------- /gitmine/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joecummings/gitmine/eccfcffdbfe56f2c276b62bfb238a52a99eb8778/gitmine/__init__.py -------------------------------------------------------------------------------- /gitmine/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joecummings/gitmine/eccfcffdbfe56f2c276b62bfb238a52a99eb8778/gitmine/commands/__init__.py -------------------------------------------------------------------------------- /gitmine/commands/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from typing import Dict, Optional 4 | 5 | import click 6 | import yaml 7 | 8 | from gitmine.paths import GH_CREDENTIALS_PATH, GHP_CREDENTIALS_DIR, GHP_CREDENTIALS_PATH 9 | 10 | logger = logging.getLogger() 11 | 12 | 13 | class GithubConfig: 14 | """Github Config object, holds information about username and bearer token""" 15 | 16 | def __init__(self) -> None: 17 | self.token = "" 18 | self.username = "" 19 | 20 | def get_value(self, prop: str) -> Optional[str]: 21 | if prop == "token": 22 | return self.token 23 | elif prop == "username": 24 | return self.username 25 | raise click.BadArgumentUsage(message=f"Unknown property specified: {prop}") 26 | 27 | def set_prop(self, prop: str, value: str) -> None: 28 | if prop == "token": 29 | self.token = value 30 | elif prop == "username": 31 | self.username = value 32 | else: 33 | raise click.BadArgumentUsage(message=f"Unknown property specified: {prop}") 34 | 35 | def config_as_dict(self) -> Dict[str, Dict[str, str]]: 36 | return {"github.com": {"username": self.username, "token": self.token}} 37 | 38 | @staticmethod 39 | def load_config_from_yaml(path_to_yaml_file: Path) -> "GithubConfig": 40 | with open(path_to_yaml_file, "r", encoding="utf-8") as handle: 41 | gh_yaml = yaml.load(handle, Loader=yaml.FullLoader) 42 | github_config = GithubConfig() 43 | 44 | if gh_yaml: 45 | gh_yaml_github = gh_yaml["github.com"] 46 | try: 47 | username = gh_yaml_github["username"] 48 | token = gh_yaml_github["token"] 49 | except KeyError: 50 | # Copying from GH credentials 51 | username = gh_yaml_github["user"] 52 | token = gh_yaml_github["oauth_token"] 53 | 54 | github_config.set_prop("username", username) 55 | github_config.set_prop("token", token) 56 | 57 | return github_config 58 | 59 | 60 | def config_command(ctx: click.Context, prop: str, value: str) -> None: 61 | """Implementation of the *config* command""" 62 | if not value and prop: 63 | click.echo(ctx.obj.get_value(prop)) 64 | 65 | elif value: 66 | ctx.obj.set_prop(prop, value) 67 | 68 | if not GHP_CREDENTIALS_PATH.exists(): 69 | GHP_CREDENTIALS_PATH.touch() 70 | 71 | ghp_config = GithubConfig.load_config_from_yaml(GHP_CREDENTIALS_PATH) 72 | ghp_config.set_prop(prop, value) 73 | set_config(ghp_config.config_as_dict()) 74 | 75 | logger.info(f"Config {prop} {value} written at {GHP_CREDENTIALS_PATH}") 76 | click.echo(value) 77 | 78 | 79 | def set_config(config_dict: Dict[str, Dict[str, str]]) -> None: 80 | """Set config file based on dictionary of config values.""" 81 | with open(GHP_CREDENTIALS_PATH, "w+", encoding="utf-8") as handle: 82 | yaml.dump(config_dict, handle) 83 | 84 | 85 | def get_or_create_github_config() -> GithubConfig: 86 | """Get Github Config info if it's already been written to disk, 87 | otherwise create an empty config to be filled in later. 88 | Create a credentials folder if it does not exist. 89 | """ 90 | if not GHP_CREDENTIALS_DIR.exists(): 91 | GHP_CREDENTIALS_DIR.mkdir(parents=True) 92 | 93 | if GHP_CREDENTIALS_PATH.exists(): 94 | return GithubConfig.load_config_from_yaml(GHP_CREDENTIALS_PATH) 95 | elif GH_CREDENTIALS_PATH.exists(): 96 | gh_config = GithubConfig.load_config_from_yaml(GH_CREDENTIALS_PATH) 97 | set_config(gh_config.config_as_dict()) # copy to our own credentials 98 | return gh_config 99 | 100 | return GithubConfig() 101 | -------------------------------------------------------------------------------- /gitmine/commands/get.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import logging 3 | import re 4 | import threading 5 | from typing import Any, Mapping 6 | 7 | import click 8 | import requests 9 | from tabulate import tabulate 10 | 11 | from gitmine.constants import ISSUE, PULL_REQUEST 12 | from gitmine.endpoints import ISSUES_ENDPOINT, REPOS_ENDPOINT, SEARCH_ENDPOINT, USER_ENDPOINT 13 | from gitmine.models.github_elements import GithubElement, RepoDict, Repository 14 | from gitmine.utils import SafeSession, safe_request 15 | 16 | logger = logging.getLogger() 17 | thread_local = threading.local() 18 | 19 | 20 | def get_session() -> Any: 21 | if not hasattr(thread_local, "session"): 22 | thread_local.session = SafeSession() 23 | return thread_local.session 24 | 25 | 26 | def get_prs(ctx: click.Context, color: bool, headers: Mapping[str, str]) -> RepoDict: 27 | """Get all Github PRs assigned to user.""" 28 | username = ctx.obj.get_value("username") 29 | logger.debug(f"Fetching PRs for {username} from github.com \n") 30 | url = SEARCH_ENDPOINT.copy() 31 | query_params = " ".join(["is:open", "is:pr", f"review-requested:{username}"]) 32 | url.add(path="/issues", args={"q": query_params}) 33 | response = safe_request(requests.get, url, headers, {}) 34 | prs = response.json()["items"] 35 | 36 | repositories = RepoDict() 37 | for pr in prs: 38 | url = pr["html_url"] 39 | repo_name = re.findall(r"github.com/(.+?)/pull", url)[0] 40 | repositories[repo_name].add_pr( 41 | GithubElement.from_dict(pr, elem_type=PULL_REQUEST, color_coded=color) 42 | ) 43 | 44 | return repositories 45 | 46 | 47 | def get_unassigned_issues( 48 | asc: bool, color: bool, repo_name: str, headers: Mapping[str, str] 49 | ) -> RepoDict: 50 | """Get all Github Issues that are unnassigned from the repos in which user is a collaborator.""" 51 | 52 | def get_collaborator_repos() -> Any: 53 | """Get all Github repos where user is classified as a collaborator.""" 54 | params = {"affiliation": "collaborator"} 55 | url = USER_ENDPOINT.copy() 56 | url.path /= "repos" 57 | response = safe_request(requests.get, url, headers, params) 58 | return response.json() 59 | 60 | collaborator_repos = get_collaborator_repos() 61 | if repo_name: 62 | collaborator_repos = filter(lambda x: x["full_name"] == repo_name, collaborator_repos) 63 | params = {"direction": "asc" if asc else "desc", "assignee": "none"} 64 | 65 | def get_issues_by_repo(repo: Mapping[str, Any]) -> Repository: 66 | """Get all Github Issues in a repo specified by params.""" 67 | 68 | session = get_session() 69 | url = REPOS_ENDPOINT.copy() 70 | url.path = url.path / repo["full_name"] / "issues" 71 | with session.safe_get(url, headers=headers, params=params) as response: 72 | repo_name = repo["full_name"] 73 | repo_class = Repository(name=repo_name) 74 | for issue in response.json(): 75 | repo_class.add_issue( 76 | GithubElement.from_dict(issue, elem_type=ISSUE, color_coded=color) 77 | ) 78 | return repo_class 79 | 80 | with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: 81 | repositories = RepoDict( 82 | # https://www.gitmemory.com/issue/python/mypy/7217/512213750 83 | Repository, # type: ignore 84 | { 85 | repo.name: repo 86 | for repo in filter( 87 | lambda x: x.has_issues(), 88 | executor.map(get_issues_by_repo, collaborator_repos), 89 | ) 90 | }, 91 | ) 92 | 93 | return repositories 94 | 95 | 96 | def get_issues( 97 | unassigned: bool, asc: bool, color: bool, repo_name: str, headers: Mapping[str, str] 98 | ) -> RepoDict: 99 | """Get all Github Issues assigned to user.""" 100 | 101 | if unassigned: 102 | click.echo("Hang on, getting unassigned issues for you...") 103 | return get_unassigned_issues(asc, color, repo_name, headers) 104 | 105 | if repo_name: 106 | url = REPOS_ENDPOINT.copy() 107 | url.path = url.path / repo_name / "issues" 108 | else: 109 | url = ISSUES_ENDPOINT.copy() 110 | 111 | params = {"direction": "asc" if asc else "desc"} 112 | logger.debug("Fetching issues from github.com \n") 113 | response = safe_request(requests.get, url, headers, params) 114 | 115 | repositories = RepoDict() 116 | for issue in response.json(): 117 | name = repo_name or issue["repository"]["full_name"] 118 | repositories[name].add_issue( 119 | GithubElement.from_dict(issue, elem_type=ISSUE, color_coded=color) 120 | ) 121 | 122 | return repositories 123 | 124 | 125 | def echo_info(repos: RepoDict, elem: str) -> None: 126 | """Print issues/prs in the following format: 127 | 128 | repo-title 129 | #issue-number issue-title 130 | ... 131 | #pr-number pr-title 132 | """ 133 | 134 | if not repos: 135 | click.echo(f"No {elem} found! Keep up the good work.") 136 | return 137 | 138 | all_repos = [] 139 | for repo in repos.values(): 140 | all_repos.extend(repo.format_for_table(elem)) 141 | click.echo_via_pager(tabulate(all_repos, tablefmt="plain")) 142 | 143 | 144 | def get_command( 145 | ctx: click.Context, 146 | spec: str, 147 | color: bool, 148 | asc: bool, 149 | repo_name: str = "", 150 | unassigned: bool = False, 151 | ) -> None: 152 | """Implementation of the *get* command.""" 153 | 154 | logger.info( 155 | f"""Getting {spec} for {ctx.obj.get_value('username')} 156 | from github.com with parameters: color={str(color)}, ascending={str(asc)} \n""" 157 | ) 158 | headers = {"Authorization": f"Bearer {ctx.obj.get_value('token')}"} 159 | 160 | if spec == "all": 161 | res = get_issues(unassigned, asc, color, repo_name, headers=headers) 162 | echo_info(res, "issues") 163 | click.echo("* " * 20) 164 | res = get_prs(ctx, color, headers=headers) 165 | echo_info(res, "prs") 166 | elif spec == "issues": 167 | res = get_issues(unassigned, asc, color, repo_name, headers=headers) 168 | echo_info(res, "issues") 169 | elif spec == "prs": 170 | res = get_prs(ctx, color, headers=headers) 171 | echo_info(res, "prs") 172 | else: 173 | raise click.BadArgumentUsage(message=f"Unkown spec: {spec}") 174 | -------------------------------------------------------------------------------- /gitmine/commands/go.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | import click 5 | 6 | logger = logging.getLogger() 7 | 8 | 9 | def go_command(repo: str, number: Optional[int]) -> None: 10 | """Implementation of the *go* command.""" 11 | logger.info(f"Launching browser session at repo: {repo}, issue: {number}") 12 | url = f"https://github.com/{repo}/issues/{number if number else ''}" 13 | click.launch(url) 14 | -------------------------------------------------------------------------------- /gitmine/constants.py: -------------------------------------------------------------------------------- 1 | OK_DELTA = 7 2 | WARNING_DELTA = 14 3 | 4 | MAX_ELEMS_TO_STDOUT = 20 5 | 6 | PULL_REQUEST = "PullRequest" 7 | ISSUE = "Issue" 8 | 9 | # Output colors 10 | REPO_NAME_COLOR = "bright_white" 11 | ELEM_NUM_COLOR = "magenta" 12 | LABELS_COLOR = "cyan" 13 | OK_DELTA_COLOR = "green" 14 | WARNING_DELTA_COLOR = "yellow" 15 | DANGER_DELTA_COLOR = "red" 16 | -------------------------------------------------------------------------------- /gitmine/endpoints.py: -------------------------------------------------------------------------------- 1 | from furl import furl 2 | 3 | BASE_GH_API = "https://api.github.com" 4 | ISSUES_ENDPOINT = furl(BASE_GH_API, path="/issues") 5 | REPOS_ENDPOINT = furl(BASE_GH_API, path="/repos") 6 | SEARCH_ENDPOINT = furl(BASE_GH_API, path="/search") 7 | USER_ENDPOINT = furl(BASE_GH_API, path="/user") 8 | -------------------------------------------------------------------------------- /gitmine/gitmine.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional, TypeVar 2 | 3 | import click 4 | 5 | from gitmine.commands.config import config_command, get_or_create_github_config 6 | from gitmine.commands.get import get_command 7 | from gitmine.commands.go import go_command 8 | from gitmine.utils import set_verbosity 9 | from gitmine.version import __version__ 10 | 11 | 12 | @click.group() 13 | @click.version_option(__version__) 14 | @click.pass_context 15 | def gitmine(ctx: click.Context) -> None: 16 | """Simple CLI for querying assigned Issues and PR reviews from Github.""" 17 | # Set the context object 18 | ctx.obj = get_or_create_github_config() 19 | 20 | 21 | _verbose_cmd = [ 22 | click.option( 23 | "-v", 24 | "--verbose", 25 | count=True, 26 | help="Give more output. Option is additive, and can be used up to two times.", 27 | ) 28 | ] 29 | 30 | T = TypeVar("T") 31 | 32 | 33 | def add_options( 34 | options: List[T], 35 | ) -> Callable[[Callable[..., None]], Callable[..., None]]: 36 | def _add_options(func: Callable[..., None]) -> Callable[..., None]: 37 | for option in reversed(options): 38 | func = option(func) # type: ignore 39 | return func 40 | 41 | return _add_options 42 | 43 | 44 | @gitmine.command() 45 | @click.argument("prop", nargs=1, required=True, type=click.Choice(["username", "token"])) 46 | @click.argument("value", nargs=1, required=False, type=click.STRING) 47 | @add_options(_verbose_cmd) 48 | @click.pass_context 49 | def config( 50 | ctx: click.Context, 51 | prop: str, 52 | value: str, 53 | verbose: int, 54 | ) -> None: 55 | """Set or Access Github Config information. Currently, config requires a Github username and Bearer token. 56 | 57 | [username|token] is the property to be set if *value* is also provided. If not, will return the current value of *prop* if it exists.\n 58 | VALUE is the value of property to be set. 59 | """ 60 | set_verbosity(verbose) 61 | config_command(ctx, prop, value) 62 | 63 | 64 | @gitmine.command() 65 | @click.option( 66 | "--color/--no-color", 67 | default=True, 68 | help="Color code Issues/PRs according to elapsed time.", 69 | ) 70 | @click.option( 71 | "--asc/--desc", 72 | default=False, 73 | help="Print Issues/PRs in ascending/descending order of elapsed time.", 74 | ) 75 | @click.option( 76 | "--repo", 77 | "-r", 78 | type=click.STRING, 79 | help="Specify a repo from which to get Issues / PRs.", 80 | ) 81 | @click.option( 82 | "--unassigned", 83 | "-u", 84 | is_flag=True, 85 | default=False, 86 | help="Get all unassigned Issues / PRs from your repositories.", 87 | ) 88 | @click.argument("spec", nargs=1, required=True, type=click.Choice(["issues", "prs", "all"])) 89 | @add_options(_verbose_cmd) 90 | @click.pass_context 91 | def get( 92 | ctx: click.Context, 93 | spec: str, 94 | color: bool, 95 | asc: bool, 96 | repo: str, 97 | unassigned: bool, 98 | verbose: int, 99 | ) -> None: 100 | """Get assigned Github Issues and/or Github PRs. 101 | 102 | [issues|prs|all] is what information to pull. 103 | """ 104 | set_verbosity(verbose) 105 | get_command(ctx, spec, color, asc, repo, unassigned) 106 | 107 | 108 | @gitmine.command() 109 | @click.argument("repo", nargs=1, required=True, type=click.STRING) 110 | @click.argument("number", nargs=1, required=False, type=click.INT) 111 | @add_options(_verbose_cmd) 112 | @click.pass_context 113 | def go( 114 | ctx: click.Context, # pylint: disable=unused-argument 115 | repo: str, 116 | number: Optional[int], 117 | verbose: int, 118 | ) -> None: 119 | """Open a browser page for the given repositiory / issue. 120 | 121 | REPO is the full name of the repository to query.\n 122 | NUMBER is the issue number of the repository to query. If this is not provided, will open a page to the main page of the repository. 123 | """ 124 | set_verbosity(verbose) 125 | go_command(repo, number) 126 | -------------------------------------------------------------------------------- /gitmine/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joecummings/gitmine/eccfcffdbfe56f2c276b62bfb238a52a99eb8778/gitmine/models/__init__.py -------------------------------------------------------------------------------- /gitmine/models/github_elements.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import datetime, timedelta 3 | from typing import Any, List, Mapping, Optional 4 | 5 | import click 6 | 7 | from gitmine.constants import ( 8 | DANGER_DELTA_COLOR, 9 | ELEM_NUM_COLOR, 10 | LABELS_COLOR, 11 | OK_DELTA, 12 | OK_DELTA_COLOR, 13 | REPO_NAME_COLOR, 14 | WARNING_DELTA, 15 | WARNING_DELTA_COLOR, 16 | ) 17 | 18 | 19 | class GithubElement: 20 | """Container for Github Issue or Pull Request.""" 21 | 22 | def __init__( 23 | self, 24 | elem_type: str, 25 | title: str, 26 | number: int, 27 | url: str, 28 | created_at: datetime, 29 | color_coded: bool, 30 | labels: Optional[List[Mapping[str, Any]]] = None, 31 | ) -> None: 32 | self.elem_type = elem_type 33 | self.title = title 34 | self.number = number 35 | self.url = url 36 | self.labels = labels 37 | self.created_at = created_at 38 | self.color_coded = color_coded 39 | 40 | def get_formatted_args_for_table(self) -> List[Optional[str]]: 41 | """Format arguments for Tabulate table. 42 | 43 | Returns: 44 | List comprised of Issue/PR number, name, labels, and date 45 | """ 46 | issue_num_with_color = click.style(f"#{self.number}", fg=ELEM_NUM_COLOR) 47 | 48 | date = click.style( 49 | f"{self._get_elapsed_time().days} days ago", 50 | fg=self._elapsed_time_to_color(self._get_elapsed_time()), 51 | dim=True, 52 | ) 53 | return [issue_num_with_color, self.title, self._parse_labels_for_repr(), date] 54 | 55 | def _elapsed_time_to_color(self, time: timedelta) -> str: 56 | """Return a color for how much time has elapsed since the Issue/PR was opened. 57 | 58 | Depends on the colors specified by OK_DELTA, WARNING_DELTA, and DANGER_DELTA. 59 | """ 60 | if not self.color_coded: 61 | return "white" 62 | 63 | if time < timedelta(days=OK_DELTA): 64 | return OK_DELTA_COLOR 65 | if time < timedelta(days=WARNING_DELTA): 66 | return WARNING_DELTA_COLOR 67 | return DANGER_DELTA_COLOR 68 | 69 | def _parse_labels_for_repr(self) -> str: 70 | """Parses Issue/PR labels as one string in parens.""" 71 | if self.labels: 72 | label_names = [label["name"] for label in self.labels] 73 | all_names = ", ".join(label_names) 74 | if all_names: 75 | return str(click.style(f"({all_names})", fg=LABELS_COLOR, dim=True)) 76 | return "" 77 | 78 | def _get_elapsed_time(self) -> timedelta: 79 | return datetime.now() - self.created_at 80 | 81 | @classmethod 82 | def from_dict( 83 | cls, obj: Mapping[str, Any], *, elem_type: str, color_coded: bool = False 84 | ) -> "GithubElement": 85 | """Creates a GithubElement from a JSON Github API response.""" 86 | return cls( 87 | elem_type=elem_type, 88 | title=obj["title"], 89 | number=obj["number"], 90 | labels=obj["labels"], 91 | url=obj["html_url"], 92 | created_at=datetime.strptime(obj["created_at"], "%Y-%m-%dT%H:%M:%SZ"), 93 | color_coded=color_coded, 94 | ) 95 | 96 | 97 | class Repository: 98 | """Container class for a Github Repository""" 99 | 100 | def __init__(self, name: str) -> None: 101 | self.name = name 102 | self.issues: List[GithubElement] = [] 103 | self.prs: List[GithubElement] = [] 104 | 105 | def add_issue(self, issue: GithubElement) -> None: 106 | self.issues.append(issue) 107 | 108 | def add_pr(self, pr: GithubElement) -> None: 109 | self.prs.append(pr) 110 | 111 | def has_issues(self) -> bool: 112 | return bool(self.issues) 113 | 114 | def has_prs(self) -> bool: 115 | return bool(self.prs) 116 | 117 | def format_for_table(self, elem: str) -> List[List[Optional[str]]]: 118 | res = list([[None, click.style(self.name, fg=REPO_NAME_COLOR, bold=True), None]]) 119 | if elem == "issues": 120 | for issue in self.issues: 121 | res.append(issue.get_formatted_args_for_table()) 122 | elif elem == "prs": 123 | for pr in self.prs: 124 | res.append(pr.get_formatted_args_for_table()) 125 | # Append row of None's for space between Repos 126 | res.append([None, None, None]) 127 | return res 128 | 129 | 130 | class RepoDict(defaultdict): # type: ignore 131 | """Extends *defaultdict* to be able to access a key as input""" 132 | 133 | def __missing__(self, key: str) -> Repository: 134 | self[key] = Repository(name=key) 135 | return self[key] # type: ignore 136 | 137 | def total_num_of_issues(self) -> int: 138 | return sum(len(r.issues) for r in self.values()) 139 | 140 | def total_num_of_prs(self) -> int: 141 | return sum(len(r.prs) for r in self.values()) 142 | -------------------------------------------------------------------------------- /gitmine/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | GH_CREDENTIALS_PATH = Path.home() / ".config" / "gh" / "hosts.yml" 4 | GHP_CREDENTIALS_DIR = Path.home() / ".config" / "ghp" 5 | GHP_CREDENTIALS_PATH = GHP_CREDENTIALS_DIR / "hosts.yml" 6 | -------------------------------------------------------------------------------- /gitmine/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, Mapping 3 | 4 | import click 5 | from furl import furl 6 | import requests 7 | from requests import Response, Session 8 | 9 | 10 | def set_verbosity(verbose: int) -> None: 11 | """Sets the Log Level given a verbose number.""" 12 | if verbose == 1: 13 | click.echo("Logging level set to INFO.") 14 | logging.basicConfig(level=logging.INFO) 15 | elif verbose >= 2: 16 | click.echo("Logging level set to DEBUG.") 17 | logging.basicConfig(level=logging.DEBUG) 18 | 19 | 20 | def safe_request( 21 | request_func: Callable[..., Response], 22 | url: furl, 23 | headers: Mapping[str, str], 24 | params: Mapping[str, str], 25 | ) -> Response: 26 | """Wrapper around request to safely return ConnectionError's and bad responses.""" 27 | try: 28 | response = request_func(url, params=params, headers=headers) 29 | except requests.exceptions.ConnectionError as e: 30 | raise click.ClickException(e) 31 | 32 | if response.status_code == 401: 33 | message = "Unauthorized Error 401: Bad Credentials" 34 | raise click.ClickException(message) 35 | 36 | elif response.status_code != 200: 37 | message = f"Error encountered with status code: {response.status_code}" 38 | raise click.ClickException(message) 39 | 40 | return response 41 | 42 | 43 | class SafeSession(Session): 44 | """Wrapper around Requests Session obj to safely query API.""" 45 | 46 | def safe_get( 47 | self, url: furl, *, headers: Mapping[str, str], params: Mapping[str, str] 48 | ) -> Response: 49 | return safe_request(self.get, url, headers, params) 50 | -------------------------------------------------------------------------------- /gitmine/version/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.11" 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | show_error_codes = True 3 | ignore_missing_imports = True 4 | follow_imports = silent 5 | strict = True 6 | # Disable specific strict options: 7 | disallow_untyped_calls = False 8 | disallow_untyped_decorators = False 9 | no_implicit_reexport = False 10 | # End disabling specific strict options 11 | warn_unreachable = True 12 | # For pydantic 13 | plugins = pydantic.mypy 14 | 15 | [pydantic-mypy] 16 | init_forbid_extra = True 17 | init_typed = True 18 | warn_required_dynamic_aliases = True 19 | warn_untyped_fields = True -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 100 3 | target-version = ['py37'] 4 | 5 | [tool.isort] 6 | py_version = "37" 7 | line_length = 100 8 | atomic = true 9 | combine_as_imports = true 10 | force_sort_within_sections = true 11 | profile = "black" 12 | 13 | [tool.pylint.basic] 14 | # Required to make packages with Cython extensions work 15 | extension-pkg-whitelist = "pydantic" 16 | # Allows shorter names than the default regex, 17 | # which is in pylint.checkers.base.SnakeCaseStyle.DEFAULT_NAME_RGX 18 | argument-rgx = "(([a-z][a-z0-9_]*)|(_[a-z0-9_]*)|([A-Z]))$" 19 | variable-rgx = "(([a-z][a-z0-9_]*)|(_[a-z0-9_]*)|([A-Z]))$" 20 | function-rgx = "(([a-z][a-z0-9_]*)|(_[a-z0-9_]*)|([A-Z]))$" 21 | 22 | [tool.pylint.format] 23 | # Let Black handle line length 24 | max-line-length = 300 25 | 26 | [tool.pylint.messages_control] 27 | # Most of these are disabled to prevent issues with dependencies being difficult to inspect 28 | # pylint FAQ recommends disabling: 29 | # wrong-import-order when using isort 30 | # missing-module-docstring,missing-class-docstring,missing-function-docstring when using pydocstyle 31 | disable = """ 32 | R,fixme,no-member,unsupported-membership-test,unsubscriptable-object 33 | unsupported-assignment-operation,not-an-iterable,too-many-lines,wrong-import-order,bad-continuation, 34 | wrong-import-position,missing-module-docstring,missing-class-docstring,missing-function-docstring,logging-fstring-interpolation, 35 | invalid-name 36 | """ 37 | 38 | [tool.pylint.reports] 39 | score = false -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==24.3.0 2 | flake8==3.9.2 3 | isort==5.9.3 4 | mypy==0.910 5 | pydantic==1.10.13 6 | pydocstyle==6.1.1 7 | pylint==2.10.2 8 | pytest==6.2.4 9 | typed-ast==1.4.3 10 | types-PyYAML==5.4.6 11 | types-requests==2.25.6 12 | types-tabulate==0.8.2 13 | typing-extensions==3.10.0.0 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML==5.4 2 | click==7.1.2 3 | colorama==0.4.3 4 | furl==2.1.2 5 | requests==2.32.2 6 | tabulate==0.8.9 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | from gitmine.version import __version__ 4 | 5 | long_descr = open("README.md").read() 6 | 7 | with open("requirements.txt", encoding="utf-8") as f: 8 | all_reqs = f.read().split("\n") 9 | 10 | install_requires = [ 11 | x.strip() 12 | for x in all_reqs 13 | if ("git+" not in x) and (not x.startswith("#")) and (not x.startswith("-")) 14 | ] 15 | 16 | setup( 17 | name="gitmine", 18 | packages=find_packages(), 19 | entry_points={"console_scripts": ["gitmine = gitmine.gitmine:gitmine"]}, 20 | install_requires=install_requires, 21 | include_package_data=True, 22 | python_requires=">=3.6", 23 | version=__version__, 24 | license="MIT", 25 | url="https://github.com/joecummings/gitmine", 26 | description="Simple command-line app for querying assigned Issues and PRs from Github.", 27 | long_description=long_descr, 28 | long_description_content_type="text/markdown", 29 | author="Joe Cummings, Alexis Baudron", 30 | author_email="jrcummings27@gmail.com", 31 | ) 32 | -------------------------------------------------------------------------------- /tests/data/issues.json: -------------------------------------------------------------------------------- 1 | { 2 | "print_output": 3 | { 4 | "11": "a/E3D\\x1b[31m#2\\x1b[0m Update CMake file to move executable to working dir\\x1b[31m#11\\x1b[0m ReadMe Updates - ongoing task list", 5 | "10": "a/E3D\\x1b[31m#11\\x1b[0m ReadMe Updates - ongoing task list\\x1b[31m#2\\x1b[0m Update CMake file to move executable to working dir", 6 | "01": "a/E3D\\x1b[37m#2\\x1b[0m Update CMake file to move executable to working dir\\x1b[37m#11\\x1b[0m ReadMe Updates - ongoing task list", 7 | "00": "a/E3D\\x1b[37m#11\\x1b[0m ReadMe Updates - ongoing task list\\x1b[37m#2\\x1b[0m Update CMake file to move executable to working dir" 8 | }, 9 | "issues": 10 | [ 11 | { 12 | "url": "https://api.github.com/repos/a/E3D/issues/11", 13 | "repository_url": "https://api.github.com/repos/a/E3D", 14 | "labels_url": "https://api.github.com/repos/a/E3D/issues/11/labels{/name}", 15 | "comments_url": "https://api.github.com/repos/a/E3D/issues/11/comments", 16 | "events_url": "https://api.github.com/repos/a/E3D/issues/11/events", 17 | "html_url": "https://github.com/a/E3D/issues/11", 18 | "id": 666562540, 19 | "node_id": "MDU6SXNzdWU2NjY1NjI1NDA=", 20 | "number": 11, 21 | "title": "ReadMe Updates - ongoing task list", 22 | "user": { 23 | "login": "a", 24 | "id": 24878639, 25 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 26 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 27 | "gravatar_id": "", 28 | "url": "https://api.github.com/users/a", 29 | "html_url": "https://github.com/a", 30 | "followers_url": "https://api.github.com/users/a/followers", 31 | "following_url": "https://api.github.com/users/a/following{/other_user}", 32 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 33 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 34 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 35 | "organizations_url": "https://api.github.com/users/a/orgs", 36 | "repos_url": "https://api.github.com/users/a/repos", 37 | "events_url": "https://api.github.com/users/a/events{/privacy}", 38 | "received_events_url": "https://api.github.com/users/a/received_events", 39 | "type": "User", 40 | "site_admin": "False" 41 | }, 42 | "labels": [ 43 | { 44 | "id": 2011714398, 45 | "node_id": "MDU6TGFiZWwyMDExNzE0Mzk4", 46 | "url": "https://api.github.com/repos/a/E3D/labels/documentation", 47 | "name": "documentation", 48 | "color": "0075ca", 49 | "default": "True", 50 | "description": "Improvements or additions to documentation" 51 | } 52 | ], 53 | "state": "open", 54 | "locked": "False", 55 | "assignee": { 56 | "login": "a", 57 | "id": 24878639, 58 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 59 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 60 | "gravatar_id": "", 61 | "url": "https://api.github.com/users/a", 62 | "html_url": "https://github.com/a", 63 | "followers_url": "https://api.github.com/users/a/followers", 64 | "following_url": "https://api.github.com/users/a/following{/other_user}", 65 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 66 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 67 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 68 | "organizations_url": "https://api.github.com/users/a/orgs", 69 | "repos_url": "https://api.github.com/users/a/repos", 70 | "events_url": "https://api.github.com/users/a/events{/privacy}", 71 | "received_events_url": "https://api.github.com/users/a/received_events", 72 | "type": "User", 73 | "site_admin": "False" 74 | }, 75 | "assignees": [ 76 | { 77 | "login": "a", 78 | "id": 24878639, 79 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 80 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 81 | "gravatar_id": "", 82 | "url": "https://api.github.com/users/a", 83 | "html_url": "https://github.com/a", 84 | "followers_url": "https://api.github.com/users/a/followers", 85 | "following_url": "https://api.github.com/users/a/following{/other_user}", 86 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 87 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 88 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 89 | "organizations_url": "https://api.github.com/users/a/orgs", 90 | "repos_url": "https://api.github.com/users/a/repos", 91 | "events_url": "https://api.github.com/users/a/events{/privacy}", 92 | "received_events_url": "https://api.github.com/users/a/received_events", 93 | "type": "User", 94 | "site_admin": "False" 95 | } 96 | ], 97 | "milestone": "None", 98 | "comments": 0, 99 | "created_at": "2020-07-27T20:25:30Z", 100 | "updated_at": "2020-07-27T20:25:30Z", 101 | "closed_at": "None", 102 | "author_association": "OWNER", 103 | "active_lock_reason": "None", 104 | "repository": { 105 | "id": 258538849, 106 | "node_id": "MDEwOlJlcG9zaXRvcnkyNTg1Mzg4NDk=", 107 | "name": "E3D", 108 | "full_name": "a/E3D", 109 | "private": "True", 110 | "owner": { 111 | "login": "a", 112 | "id": 24878639, 113 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 114 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 115 | "gravatar_id": "", 116 | "url": "https://api.github.com/users/a", 117 | "html_url": "https://github.com/a", 118 | "followers_url": "https://api.github.com/users/a/followers", 119 | "following_url": "https://api.github.com/users/a/following{/other_user}", 120 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 121 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 122 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 123 | "organizations_url": "https://api.github.com/users/a/orgs", 124 | "repos_url": "https://api.github.com/users/a/repos", 125 | "events_url": "https://api.github.com/users/a/events{/privacy}", 126 | "received_events_url": "https://api.github.com/users/a/received_events", 127 | "type": "User", 128 | "site_admin": "False" 129 | }, 130 | "html_url": "https://github.com/a/E3D", 131 | "description": "None", 132 | "fork": "False", 133 | "url": "https://api.github.com/repos/a/E3D", 134 | "forks_url": "https://api.github.com/repos/a/E3D/forks", 135 | "keys_url": "https://api.github.com/repos/a/E3D/keys{/key_id}", 136 | "collaborators_url": "https://api.github.com/repos/a/E3D/collaborators{/collaborator}", 137 | "teams_url": "https://api.github.com/repos/a/E3D/teams", 138 | "hooks_url": "https://api.github.com/repos/a/E3D/hooks", 139 | "issue_events_url": "https://api.github.com/repos/a/E3D/issues/events{/number}", 140 | "events_url": "https://api.github.com/repos/a/E3D/events", 141 | "assignees_url": "https://api.github.com/repos/a/E3D/assignees{/user}", 142 | "branches_url": "https://api.github.com/repos/a/E3D/branches{/branch}", 143 | "tags_url": "https://api.github.com/repos/a/E3D/tags", 144 | "blobs_url": "https://api.github.com/repos/a/E3D/git/blobs{/sha}", 145 | "git_tags_url": "https://api.github.com/repos/a/E3D/git/tags{/sha}", 146 | "git_refs_url": "https://api.github.com/repos/a/E3D/git/refs{/sha}", 147 | "trees_url": "https://api.github.com/repos/a/E3D/git/trees{/sha}", 148 | "statuses_url": "https://api.github.com/repos/a/E3D/statuses/{sha}", 149 | "languages_url": "https://api.github.com/repos/a/E3D/languages", 150 | "stargazers_url": "https://api.github.com/repos/a/E3D/stargazers", 151 | "contributors_url": "https://api.github.com/repos/a/E3D/contributors", 152 | "subscribers_url": "https://api.github.com/repos/a/E3D/subscribers", 153 | "subscription_url": "https://api.github.com/repos/a/E3D/subscription", 154 | "commits_url": "https://api.github.com/repos/a/E3D/commits{/sha}", 155 | "git_commits_url": "https://api.github.com/repos/a/E3D/git/commits{/sha}", 156 | "comments_url": "https://api.github.com/repos/a/E3D/comments{/number}", 157 | "issue_comment_url": "https://api.github.com/repos/a/E3D/issues/comments{/number}", 158 | "contents_url": "https://api.github.com/repos/a/E3D/contents/{+path}", 159 | "compare_url": "https://api.github.com/repos/a/E3D/compare/{base}...{head}", 160 | "merges_url": "https://api.github.com/repos/a/E3D/merges", 161 | "archive_url": "https://api.github.com/repos/a/E3D/{archive_format}{/ref}", 162 | "downloads_url": "https://api.github.com/repos/a/E3D/downloads", 163 | "issues_url": "https://api.github.com/repos/a/E3D/issues{/number}", 164 | "pulls_url": "https://api.github.com/repos/a/E3D/pulls{/number}", 165 | "milestones_url": "https://api.github.com/repos/a/E3D/milestones{/number}", 166 | "notifications_url": "https://api.github.com/repos/a/E3D/notifications{?since,all,participating}", 167 | "labels_url": "https://api.github.com/repos/a/E3D/labels{/name}", 168 | "releases_url": "https://api.github.com/repos/a/E3D/releases{/id}", 169 | "deployments_url": "https://api.github.com/repos/a/E3D/deployments", 170 | "created_at": "2020-04-24T14:42:28Z", 171 | "updated_at": "2020-07-28T21:12:23Z", 172 | "pushed_at": "2020-07-28T21:12:23Z", 173 | "git_url": "git://github.com/a/E3D.git", 174 | "ssh_url": "git@github.com:a/E3D.git", 175 | "clone_url": "https://github.com/a/E3D.git", 176 | "svn_url": "https://github.com/a/E3D", 177 | "homepage": "None", 178 | "size": 562694, 179 | "stargazers_count": 0, 180 | "watchers_count": 0, 181 | "language": "Jupyter Notebook", 182 | "has_issues": "True", 183 | "has_projects": "True", 184 | "has_downloads": "True", 185 | "has_wiki": "True", 186 | "has_pages": "False", 187 | "forks_count": 0, 188 | "mirror_url": "None", 189 | "archived": "False", 190 | "disabled": "False", 191 | "open_issues_count": 5, 192 | "license": "None", 193 | "forks": 0, 194 | "open_issues": 5, 195 | "watchers": 0, 196 | "default_branch": "master" 197 | }, 198 | "body": "- [ ] Update env name\r\n- [ ] environment.yml updates\r\n- [ ] v2e acknowledgements\r\n", 199 | "performed_via_github_app": "None" 200 | }, 201 | { 202 | "url": "https://api.github.com/repos/a/E3D/issues/2", 203 | "repository_url": "https://api.github.com/repos/a/E3D", 204 | "labels_url": "https://api.github.com/repos/a/E3D/issues/2/labels{/name}", 205 | "comments_url": "https://api.github.com/repos/a/E3D/issues/2/comments", 206 | "events_url": "https://api.github.com/repos/a/E3D/issues/2/events", 207 | "html_url": "https://github.com/a/E3D/issues/2", 208 | "id": 662324571, 209 | "node_id": "MDU6SXNzdWU2NjIzMjQ1NzE=", 210 | "number": 2, 211 | "title": "Update CMake file to move executable to working dir", 212 | "user": { 213 | "login": "a", 214 | "id": 24878639, 215 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 216 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 217 | "gravatar_id": "", 218 | "url": "https://api.github.com/users/a", 219 | "html_url": "https://github.com/a", 220 | "followers_url": "https://api.github.com/users/a/followers", 221 | "following_url": "https://api.github.com/users/a/following{/other_user}", 222 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 223 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 224 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 225 | "organizations_url": "https://api.github.com/users/a/orgs", 226 | "repos_url": "https://api.github.com/users/a/repos", 227 | "events_url": "https://api.github.com/users/a/events{/privacy}", 228 | "received_events_url": "https://api.github.com/users/a/received_events", 229 | "type": "User", 230 | "site_admin": "False" 231 | }, 232 | "labels": [ 233 | 234 | ], 235 | "state": "open", 236 | "locked": "False", 237 | "assignee": { 238 | "login": "a", 239 | "id": 24878639, 240 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 241 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 242 | "gravatar_id": "", 243 | "url": "https://api.github.com/users/a", 244 | "html_url": "https://github.com/a", 245 | "followers_url": "https://api.github.com/users/a/followers", 246 | "following_url": "https://api.github.com/users/a/following{/other_user}", 247 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 248 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 249 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 250 | "organizations_url": "https://api.github.com/users/a/orgs", 251 | "repos_url": "https://api.github.com/users/a/repos", 252 | "events_url": "https://api.github.com/users/a/events{/privacy}", 253 | "received_events_url": "https://api.github.com/users/a/received_events", 254 | "type": "User", 255 | "site_admin": "False" 256 | }, 257 | "assignees": [ 258 | { 259 | "login": "a", 260 | "id": 24878639, 261 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 262 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 263 | "gravatar_id": "", 264 | "url": "https://api.github.com/users/a", 265 | "html_url": "https://github.com/a", 266 | "followers_url": "https://api.github.com/users/a/followers", 267 | "following_url": "https://api.github.com/users/a/following{/other_user}", 268 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 269 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 270 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 271 | "organizations_url": "https://api.github.com/users/a/orgs", 272 | "repos_url": "https://api.github.com/users/a/repos", 273 | "events_url": "https://api.github.com/users/a/events{/privacy}", 274 | "received_events_url": "https://api.github.com/users/a/received_events", 275 | "type": "User", 276 | "site_admin": "False" 277 | } 278 | ], 279 | "milestone": "None", 280 | "comments": 0, 281 | "created_at": "2020-07-20T22:21:35Z", 282 | "updated_at": "2020-07-20T22:21:35Z", 283 | "closed_at": "None", 284 | "author_association": "OWNER", 285 | "active_lock_reason": "None", 286 | "repository": { 287 | "id": 258538849, 288 | "node_id": "MDEwOlJlcG9zaXRvcnkyNTg1Mzg4NDk=", 289 | "name": "E3D", 290 | "full_name": "a/E3D", 291 | "private": "True", 292 | "owner": { 293 | "login": "a", 294 | "id": 24878639, 295 | "node_id": "MDQ6VXNlcjI0ODc4NjM5", 296 | "avatar_url": "https://avatars0.githubusercontent.com/u/24878639?v=4", 297 | "gravatar_id": "", 298 | "url": "https://api.github.com/users/a", 299 | "html_url": "https://github.com/a", 300 | "followers_url": "https://api.github.com/users/a/followers", 301 | "following_url": "https://api.github.com/users/a/following{/other_user}", 302 | "gists_url": "https://api.github.com/users/a/gists{/gist_id}", 303 | "starred_url": "https://api.github.com/users/a/starred{/owner}{/repo}", 304 | "subscriptions_url": "https://api.github.com/users/a/subscriptions", 305 | "organizations_url": "https://api.github.com/users/a/orgs", 306 | "repos_url": "https://api.github.com/users/a/repos", 307 | "events_url": "https://api.github.com/users/a/events{/privacy}", 308 | "received_events_url": "https://api.github.com/users/a/received_events", 309 | "type": "User", 310 | "site_admin": "False" 311 | }, 312 | "html_url": "https://github.com/a/E3D", 313 | "description": "None", 314 | "fork": "False", 315 | "url": "https://api.github.com/repos/a/E3D", 316 | "forks_url": "https://api.github.com/repos/a/E3D/forks", 317 | "keys_url": "https://api.github.com/repos/a/E3D/keys{/key_id}", 318 | "collaborators_url": "https://api.github.com/repos/a/E3D/collaborators{/collaborator}", 319 | "teams_url": "https://api.github.com/repos/a/E3D/teams", 320 | "hooks_url": "https://api.github.com/repos/a/E3D/hooks", 321 | "issue_events_url": "https://api.github.com/repos/a/E3D/issues/events{/number}", 322 | "events_url": "https://api.github.com/repos/a/E3D/events", 323 | "assignees_url": "https://api.github.com/repos/a/E3D/assignees{/user}", 324 | "branches_url": "https://api.github.com/repos/a/E3D/branches{/branch}", 325 | "tags_url": "https://api.github.com/repos/a/E3D/tags", 326 | "blobs_url": "https://api.github.com/repos/a/E3D/git/blobs{/sha}", 327 | "git_tags_url": "https://api.github.com/repos/a/E3D/git/tags{/sha}", 328 | "git_refs_url": "https://api.github.com/repos/a/E3D/git/refs{/sha}", 329 | "trees_url": "https://api.github.com/repos/a/E3D/git/trees{/sha}", 330 | "statuses_url": "https://api.github.com/repos/a/E3D/statuses/{sha}", 331 | "languages_url": "https://api.github.com/repos/a/E3D/languages", 332 | "stargazers_url": "https://api.github.com/repos/a/E3D/stargazers", 333 | "contributors_url": "https://api.github.com/repos/a/E3D/contributors", 334 | "subscribers_url": "https://api.github.com/repos/a/E3D/subscribers", 335 | "subscription_url": "https://api.github.com/repos/a/E3D/subscription", 336 | "commits_url": "https://api.github.com/repos/a/E3D/commits{/sha}", 337 | "git_commits_url": "https://api.github.com/repos/a/E3D/git/commits{/sha}", 338 | "comments_url": "https://api.github.com/repos/a/E3D/comments{/number}", 339 | "issue_comment_url": "https://api.github.com/repos/a/E3D/issues/comments{/number}", 340 | "contents_url": "https://api.github.com/repos/a/E3D/contents/{+path}", 341 | "compare_url": "https://api.github.com/repos/a/E3D/compare/{base}...{head}", 342 | "merges_url": "https://api.github.com/repos/a/E3D/merges", 343 | "archive_url": "https://api.github.com/repos/a/E3D/{archive_format}{/ref}", 344 | "downloads_url": "https://api.github.com/repos/a/E3D/downloads", 345 | "issues_url": "https://api.github.com/repos/a/E3D/issues{/number}", 346 | "pulls_url": "https://api.github.com/repos/a/E3D/pulls{/number}", 347 | "milestones_url": "https://api.github.com/repos/a/E3D/milestones{/number}", 348 | "notifications_url": "https://api.github.com/repos/a/E3D/notifications{?since,all,participating}", 349 | "labels_url": "https://api.github.com/repos/a/E3D/labels{/name}", 350 | "releases_url": "https://api.github.com/repos/a/E3D/releases{/id}", 351 | "deployments_url": "https://api.github.com/repos/a/E3D/deployments", 352 | "created_at": "2020-04-24T14:42:28Z", 353 | "updated_at": "2020-07-28T21:12:23Z", 354 | "pushed_at": "2020-07-28T21:12:23Z", 355 | "git_url": "git://github.com/a/E3D.git", 356 | "ssh_url": "git@github.com:a/E3D.git", 357 | "clone_url": "https://github.com/a/E3D.git", 358 | "svn_url": "https://github.com/a/E3D", 359 | "homepage": "None", 360 | "size": 562694, 361 | "stargazers_count": 0, 362 | "watchers_count": 0, 363 | "language": "Jupyter Notebook", 364 | "has_issues": "True", 365 | "has_projects": "True", 366 | "has_downloads": "True", 367 | "has_wiki": "True", 368 | "has_pages": "False", 369 | "forks_count": 0, 370 | "mirror_url": "None", 371 | "archived": "False", 372 | "disabled": "False", 373 | "open_issues_count": 5, 374 | "license": "None", 375 | "forks": 0, 376 | "open_issues": 5, 377 | "watchers": 0, 378 | "default_branch": "master" 379 | }, 380 | "body": "", 381 | "performed_via_github_app": "None" 382 | }]} -------------------------------------------------------------------------------- /tests/data/prs.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joecummings/gitmine/eccfcffdbfe56f2c276b62bfb238a52a99eb8778/tests/data/prs.json -------------------------------------------------------------------------------- /tests/test_constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | GITHUB_CREDENTIALS_PATH_COPY = Path(__file__).parent / Path(".gitmine_credentials") 4 | KEY_PATH_COPY = Path(__file__).parent / Path("gitmine.key") 5 | 6 | TEST_ISSUES_PATH = Path(__file__).parent / Path("data/issues.json") 7 | TEST_PRS_PATH = Path(__file__).parent / Path("data/prs.json ") 8 | -------------------------------------------------------------------------------- /tests/test_get.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict 3 | 4 | import click 5 | import pytest 6 | from click.testing import CliRunner 7 | from test_constants import TEST_ISSUES_PATH, TEST_PRS_PATH 8 | 9 | from gitmine.commands.get import echo_info 10 | from gitmine.gitmine import gitmine # gitmine? 11 | 12 | runner = CliRunner() 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def get_configuration(): 17 | data = {} 18 | 19 | with open(TEST_ISSUES_PATH, "r") as issues_handle: 20 | issue_data = json.load(issues_handle) 21 | data["issue"] = issue_data["issues"] 22 | data["issue_output"] = issue_data["print_output"] 23 | 24 | with open(TEST_PRS_PATH, "r") as prs_handle: 25 | issue_data = json.load(prs_handle) 26 | 27 | yield data 28 | 29 | 30 | def base_runner(options): 31 | command = ["get", *options] 32 | return runner.invoke(gitmine, command) 33 | 34 | 35 | def test_get_none(): 36 | result = base_runner([""]) 37 | assert result.exit_code == 2 38 | 39 | 40 | def test_get_bad_argument(): 41 | result = base_runner(["banana-boat"]) 42 | assert result.exit_code == 2 43 | 44 | 45 | def test_get_bad_argument2(): 46 | result = base_runner(["banana-boat"]) 47 | assert result.exit_code == 2 48 | 49 | 50 | def test_get_issues(capsys): 51 | pass 52 | # with open(TEST_ISSUES_PATH, "r") as issues_handle: 53 | # json_dict = json.load(issues_handle) 54 | # issues = json_dict["issues"] 55 | # print_output = json_dict["print_output"]["11"] 56 | # print_issues(issues, color=False, asc=True) 57 | # captured = capsys.readouterr() 58 | # captured_out = captured.out.replace("\n", "").split("#") # Don't care about newlines 59 | # for issue in captured_out: 60 | # assert issue in print_output 61 | 62 | 63 | def test_get_issues_spec_repo(): 64 | # specify repository to pull from and make sure only those get returned 65 | pass 66 | 67 | 68 | def test_get_issues_option_asc_nocolor(): 69 | pass 70 | 71 | 72 | def test_get_issues_opetion_desc_color(): 73 | pass 74 | 75 | 76 | def test_get_issues_option_desc_nocolorr(): 77 | pass 78 | 79 | 80 | def test_get_all(): 81 | pass 82 | 83 | 84 | def test_get_bad_credentials(): 85 | pass 86 | -------------------------------------------------------------------------------- /tests/test_gitmine.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | 3 | from gitmine.commands.config import get_or_create_github_config 4 | from gitmine.gitmine import config, get, gitmine, go 5 | 6 | runner = CliRunner() 7 | 8 | def test_gitmine_without_command(): 9 | result = runner.invoke(gitmine, []) 10 | assert result.exit_code == 0 11 | 12 | 13 | def test_gitmine_verbose(): 14 | with runner.isolated_filesystem(): 15 | result = runner.invoke(gitmine, ["config", "username", "abc", "-v"]) 16 | assert result.exit_code == 0 17 | assert "Logging level set to INFO." in result.output 18 | 19 | 20 | def test_gitmine_vverbose(): 21 | with runner.isolated_filesystem(): 22 | result = runner.invoke(gitmine, ["config", "username", "abc", "-vv"]) 23 | assert result.exit_code == 0 24 | assert "Logging level set to DEBUG." in result.output 25 | 26 | 27 | def test_get_version(): 28 | from gitmine.version import __version__ 29 | 30 | result = runner.invoke(gitmine, ["--version"]) 31 | assert result.output == f"gitmine, version {__version__}\n" 32 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joecummings/gitmine/eccfcffdbfe56f2c276b62bfb238a52a99eb8778/tests/test_utils.py --------------------------------------------------------------------------------