├── CODEOWNERS ├── ghs ├── __init__.py ├── check_config.py ├── es_queries.py ├── fetchers.py ├── utils.py ├── viewer.py └── ghs.py ├── setup.cfg ├── Dockerfile ├── .flake8 ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── setup.py ├── CONTRIBUTING.md ├── .gitignore └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @makkoncept 2 | -------------------------------------------------------------------------------- /ghs/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.4" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | indent-size = 4 3 | max-line-length = 100 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.0a5-alpine 2 | COPY . /ghs 3 | WORKDIR /ghs 4 | RUN pip install . 5 | CMD ghs --help 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | # don't traverse git directory 4 | .git, 5 | # don't traverse cached files 6 | __pycache__, 7 | # don't traverse venv files 8 | bin, 9 | lib, 10 | share, 11 | local, 12 | # don't traverse autogenerated scripts 13 | migrations 14 | max-line-length = 130 15 | 16 | # Specify a list of codes to ignore. 17 | ignore = 18 | E203, F632, E711, E501, E722, E741, W503, F541 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 HackerRank 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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI Workflow 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | 10 | jobs: 11 | test: 12 | name: GHS Test 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: 18 | - "3.7" 19 | - "3.8" 20 | - "3.9" 21 | - "3.10" 22 | 23 | steps: 24 | - name: Checkout infrastructure 25 | uses: actions/checkout@v3 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Upgrade pip version 33 | run: | 34 | python3 -m pip install --upgrade pip 35 | 36 | - name: Install the project 37 | run: | 38 | pip3 install . 39 | 40 | - name: Lint with flake8 41 | run: | 42 | pip3 install flake8 43 | flake8 . 44 | 45 | - name: Check Black formatting 46 | uses: psf/black@stable 47 | with: 48 | options: "--check --verbose" 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from setuptools import setup 4 | 5 | import ghs 6 | 7 | HERE = pathlib.Path(__file__).parent 8 | 9 | README = (HERE / "README.md").read_text() 10 | 11 | 12 | setup( 13 | name="ghs", 14 | version=ghs.__version__, 15 | description="Get you Github profile's stats and summary.", 16 | packages=["ghs"], 17 | include_package_data=True, 18 | long_description=README, 19 | long_description_content_type="text/markdown", 20 | url="http://github.com/interviewstreet/ghs", 21 | license="MIT", 22 | author="Hackerrank", 23 | author_email="pypi@hackerrank.com", 24 | keywords=["github", "cli", "utility", "command", "console"], 25 | install_requires=[ 26 | "requests", 27 | "retry_requests", 28 | "python-dateutil", 29 | "termcolor", 30 | "colorama", 31 | "pyperclip", 32 | "halo", 33 | ], 34 | entry_points={"console_scripts": ["ghs=ghs.ghs:main_proxy"]}, 35 | classifiers=[ 36 | "Environment :: Console", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: MIT License", 39 | "Natural Language :: English", 40 | "Operating System :: OS Independent", 41 | "Programming Language :: Python :: 3", 42 | "Topic :: Utilities", 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing Guidelines 2 | 3 | Hi! Thanks for your interest in helping to make this project more awesome by contributing. 4 | 5 | Contribution can be anything like (but not limited to) improving documentation, reporting bugs, feature requests, contributing code. 6 | 7 | ### Reporting Bugs 8 | 9 | 1. First make sure that the bug has not already been [reported](https://github.com/interviewstreet/ghs). 10 | 2. Create a [bug report](https://github.com/interviewstreet/ghs/issues/new). 11 | 3. It would be helpful for us if the issue: 12 | - can be descriptive. 13 | - have information like Operating system, ghs version, etc. 14 | - have steps to reproduce the error. 15 | - have error stacktrace. 16 | 17 | ### Feature Requests 18 | 19 | 1. First make sure that the feature has not already been [requested](https://github.com/interviewstreet/ghs). 20 | 2. Create a [feature request](https://github.com/interviewstreet/ghs/issues/new). 21 | 22 | ### Contributing code 23 | 24 | 1. Pick up an [issue](https://github.com/interviewstreet/ghs/issues) (or [create one](https://github.com/interviewstreet/ghs/issues/new)) on which you want to work. You 25 | can take help of labels to filter them down. 26 | 2. Tell beforehand that you are working on the issue. This helps in making sure that multiple contributors are not working on the same issue. 27 | 28 | ### Development 29 | 30 | 1. Fork the repository and clone it locally. 31 | 2. Please create a separate branch for each issue that you're working on. Do not make changes to the default branch (e.g. master) of your fork. 32 | 3. Make changes to the code and 33 | 4. Install the cli [via source code](https://github.com/interviewstreet/ghs#using-source-code) and check/confirm your changes. 34 | 5. Commit the changes to the issue branch you created. Please Write descriptive commits. 35 | 6. Push the changes to your fork and submit a Pull Request with the issue reference. 36 | 37 | ### Coding Style 38 | 39 | This project uses the [Black](https://black.readthedocs.io/en/stable/) code formatter for formatting the code. [Flake8](https://flake8.pycqa.org/en/latest/) is used for linting the code and validating the style and structure. 40 | -------------------------------------------------------------------------------- /ghs/check_config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import requests 4 | 5 | import colorama 6 | from halo import Halo 7 | from requests import Session 8 | from retry_requests import retry 9 | from termcolor import cprint 10 | 11 | from ghs.es_queries import viewer_query 12 | 13 | colorama.init() 14 | home_dir = os.path.expanduser("~") 15 | config = configparser.ConfigParser() 16 | spinner = Halo(text="Checking if the token is valid", spinner="dots") 17 | my_session = retry(Session(), retries=2, backoff_factor=10) 18 | 19 | 20 | def config_dir_path(): 21 | return os.path.join(home_dir, ".ghs") 22 | 23 | 24 | def config_file_path(): 25 | return os.path.join(config_dir_path(), "ghs.config") 26 | 27 | 28 | class ValidationException(Exception): 29 | pass 30 | 31 | 32 | def check_config_dir(spnr): 33 | try: 34 | path = config_dir_path() 35 | # makes path recursively. returns None if already exist. 36 | os.makedirs(path, exist_ok=True) 37 | if not os.path.isfile(os.path.join(path, "ghs.config")): 38 | spnr.stop() 39 | return create_config_file() 40 | except IOError: 41 | print("Error occured while creating config files.") 42 | 43 | return True 44 | 45 | 46 | def create_config_file(): 47 | cprint( 48 | "Creating config file", 49 | color="green", 50 | ) 51 | 52 | return save_token() 53 | 54 | 55 | # TODO: move this to fetchers after resolving circular imports 56 | def fetch_token_scopes(headers): 57 | resp = requests.get(f"https://api.github.com/rate_limit", headers=headers) 58 | 59 | if "X-OAuth-Scopes" in resp.headers.keys(): 60 | scopes = resp.headers["X-OAuth-Scopes"] 61 | return scopes.split(", ") 62 | else: 63 | return None 64 | 65 | 66 | def validate_token_scopes(headers): 67 | required_scopes = ["read:user", "repo", "read:packages"] 68 | token_scopes = fetch_token_scopes(headers) 69 | if token_scopes is None or not set(required_scopes).issubset(token_scopes): 70 | raise ValidationException( 71 | f"Error: The token does not have valid scopes. \n Required scopes: {required_scopes}. \n Provided token scopes: {token_scopes} " 72 | ) 73 | 74 | 75 | def save_token(): 76 | pat = input("please enter your github pat: ") 77 | 78 | headers = { 79 | "Authorization": f"token {pat}", 80 | "User-Agent": "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)", 81 | } 82 | 83 | spinner.start() 84 | with my_session.post( 85 | "https://api.github.com/graphql", 86 | json={"query": viewer_query()}, 87 | headers=headers, 88 | ) as result: 89 | request = result 90 | spinner.stop() 91 | 92 | if request.status_code == 200: 93 | result = request.json() 94 | username = result["data"]["viewer"]["login"] 95 | validate_token_scopes(headers) 96 | print(f"Saving the token for {username} in ~/.ghs/ghs.config") 97 | config["TOKEN"] = {"pat": pat} 98 | with open(config_file_path(), "w") as f: 99 | config.write(f) 100 | return True 101 | elif request.status_code == 401: 102 | raise ValidationException("The PAT is not valid") 103 | else: 104 | raise ValidationException("Error in saving the pat") 105 | 106 | 107 | def get_saved_token(): 108 | config.read(config_file_path()) 109 | return config["TOKEN"]["pat"] 110 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # Mac related file 148 | .DS_Store 149 | 150 | # PyCharm 151 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 152 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 153 | # and can be added to the global gitignore or merged into this file. For a more nuclear 154 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 155 | #.idea/ -------------------------------------------------------------------------------- /ghs/es_queries.py: -------------------------------------------------------------------------------- 1 | def general_stats_query(username): 2 | query = """{ 3 | search(query: USERNAME, type: USER, first: 10) { 4 | nodes { 5 | ... on User { 6 | id 7 | email 8 | name 9 | pullRequests(first: 1) { 10 | totalCount 11 | } 12 | repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { 13 | totalCount 14 | } 15 | openIssues: issues(states: OPEN) { 16 | totalCount 17 | } 18 | closedIssues: issues(states: CLOSED) { 19 | totalCount 20 | } 21 | packages(first: 100) { 22 | nodes { 23 | statistics { 24 | downloadsTotalCount 25 | } 26 | name 27 | } 28 | } 29 | repositories( 30 | first: 100 31 | ownerAffiliations: OWNER 32 | orderBy: {field: STARGAZERS, direction: DESC} 33 | ) { 34 | totalCount 35 | nodes { 36 | stargazers { 37 | totalCount 38 | } 39 | releases { 40 | totalCount 41 | } 42 | packages { 43 | totalCount 44 | } 45 | forks { 46 | totalCount 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | """.replace( 55 | "USERNAME", '"{}"'.format(username) 56 | ) 57 | 58 | return query 59 | 60 | 61 | def contribution_years_query(username): 62 | query = """{ 63 | search(query: USERNAME, type: USER, first: 1) { 64 | nodes { 65 | ... on User { 66 | contributionsCollection { 67 | contributionYears 68 | } 69 | } 70 | } 71 | } 72 | }""".replace( 73 | "USERNAME", '"{}"'.format(username) 74 | ) 75 | 76 | return query 77 | 78 | 79 | def contribution_collection_query(username, date): 80 | query = """{ 81 | search(query: USERNAME, type: USER, first: 1) { 82 | nodes { 83 | ... on User { 84 | contributionsCollection(from: DATE) { 85 | totalCommitContributions 86 | totalPullRequestContributions 87 | totalPullRequestReviewContributions 88 | commitContributionsByRepository(maxRepositories: 100) { 89 | contributions(orderBy: {field: COMMIT_COUNT, direction: DESC}) { 90 | totalCount 91 | } 92 | repository { 93 | languages(orderBy: {field: SIZE, direction: DESC}, first: 3) { 94 | nodes { 95 | name 96 | } 97 | } 98 | name 99 | owner { 100 | login 101 | } 102 | stargazerCount 103 | forkCount 104 | isPrivate 105 | } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | """.replace( 113 | "USERNAME", '"{}"'.format(username) 114 | ).replace( 115 | "DATE", '"{}"'.format(date) 116 | ) 117 | 118 | return query 119 | 120 | 121 | def total_commit_query(NAME, OWNER): 122 | query = """ 123 | { 124 | repository(name: NAME, owner: OWNER) { 125 | object(expression: "master") { 126 | ... on Commit { 127 | id 128 | history { 129 | totalCount 130 | } 131 | } 132 | } 133 | } 134 | } 135 | """.replace( 136 | "NAME", '"{}"'.format(NAME) 137 | ).replace( 138 | "OWNER", '"{}"'.format(OWNER) 139 | ) 140 | 141 | return query 142 | 143 | 144 | def viewer_query(): 145 | query = """{ 146 | viewer { 147 | login 148 | } 149 | }""" 150 | 151 | return query 152 | 153 | 154 | def user_id_query(username): 155 | query = """{ 156 | search(query: NAME, type: USER, first: 1) { 157 | nodes { 158 | ... on User { 159 | id 160 | } 161 | } 162 | } 163 | } 164 | """.replace( 165 | "NAME", '"{}"'.format(username) 166 | ) 167 | 168 | return query 169 | -------------------------------------------------------------------------------- /ghs/fetchers.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests import Session 3 | from retry_requests import retry 4 | 5 | from ghs.es_queries import ( 6 | contribution_collection_query, 7 | contribution_years_query, 8 | general_stats_query, 9 | total_commit_query, 10 | user_id_query, 11 | ) 12 | from ghs.utils import get_headers 13 | 14 | my_session = retry(Session(), retries=2, backoff_factor=10) 15 | 16 | 17 | def fetch_oldest_contribution_year(username): 18 | with my_session.post( 19 | "https://api.github.com/graphql", 20 | json={"query": contribution_years_query(username)}, 21 | headers=get_headers(), 22 | ) as result: 23 | request = result 24 | 25 | if request.status_code == 200: 26 | result = request.json() 27 | return result["data"]["search"]["nodes"][0]["contributionsCollection"][ 28 | "contributionYears" 29 | ][-1] 30 | else: 31 | raise Exception(f"Query failed with status code: {request.status_code}") 32 | 33 | 34 | def fetch_contributors_count(repo_owner, repo_name): 35 | resp = requests.get( 36 | f"https://api.github.com/repos/{repo_owner}/{repo_name}/contributors?per_page=1&anon=true", 37 | headers=get_headers(), 38 | ) 39 | if "Link" in resp.headers.keys(): 40 | link = resp.headers["Link"] 41 | link = link.split(",")[1].strip() 42 | link = link[link.find("&page=") :] 43 | count = link[link.find("=") + 1 : link.find(">")] 44 | return count 45 | else: 46 | return None 47 | 48 | 49 | def fetch_contribution_collection(username, start_date): 50 | with my_session.post( 51 | "https://api.github.com/graphql", 52 | json={"query": contribution_collection_query(username, start_date)}, 53 | headers=get_headers(), 54 | ) as result: 55 | request = result 56 | 57 | if request.status_code == 200: 58 | result = request.json() 59 | else: 60 | raise Exception(f"Query failed with status code: {request.status_code}") 61 | 62 | contribution_collection = result["data"]["search"]["nodes"][0][ 63 | "contributionsCollection" 64 | ] 65 | repos = contribution_collection["commitContributionsByRepository"] 66 | total_commit_contributions = contribution_collection["totalCommitContributions"] 67 | total_pull_request_contributions = contribution_collection[ 68 | "totalPullRequestContributions" 69 | ] 70 | total_pull_request_review_contributions = contribution_collection[ 71 | "totalPullRequestReviewContributions" 72 | ] 73 | 74 | return [ 75 | repos, 76 | total_commit_contributions, 77 | total_pull_request_contributions, 78 | total_pull_request_review_contributions, 79 | ] 80 | 81 | 82 | def fetch_total_repo_commits(repo_name, repo_owner): 83 | with my_session.post( 84 | "https://api.github.com/graphql", 85 | json={"query": total_commit_query(repo_name, repo_owner)}, 86 | headers=get_headers(), 87 | ) as result: 88 | request = result 89 | 90 | if request.status_code == 200: 91 | result = request.json() 92 | 93 | if result["data"]["repository"]["object"] == None: 94 | return None 95 | 96 | return result["data"]["repository"]["object"]["history"]["totalCount"] 97 | 98 | 99 | def fetch_general_stats(username): 100 | with my_session.post( 101 | "https://api.github.com/graphql", 102 | json={"query": general_stats_query(username)}, 103 | headers=get_headers(), 104 | ) as result: 105 | request = result 106 | 107 | if request.status_code == 200: 108 | result = request.json() 109 | return result["data"]["search"]["nodes"][0] 110 | else: 111 | raise Exception(f"Query failed with status code: {request.status_code}") 112 | 113 | 114 | def fetch_user_id(username): 115 | with my_session.post( 116 | "https://api.github.com/graphql", 117 | json={"query": user_id_query(username)}, 118 | headers=get_headers(), 119 | ) as result: 120 | request = result 121 | 122 | if request.status_code == 200: 123 | result = request.json() 124 | if len(result["data"]["search"]["nodes"]) == 0: 125 | return None 126 | else: 127 | return result["data"]["search"]["nodes"][0]["id"] 128 | else: 129 | raise Exception(f"Query failed with status code: {request.status_code}") 130 | -------------------------------------------------------------------------------- /ghs/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | from dateutil.relativedelta import relativedelta 3 | from ghs.check_config import get_saved_token 4 | 5 | USER_AGENTS = [ 6 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/605.1.15 (KHTML, like Gecko)", 7 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko)", 8 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15", 9 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15", 10 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15", 11 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15", 12 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/605.1.15 (KHTML, like Gecko)", 13 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", 14 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.1 Safari/605.1.15", 15 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/603.3.8 (KHTML, like Gecko)", 16 | "Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:24.0) Gecko/20100101 Firefox/24.0", 17 | "Apache/2.4.34 (Ubuntu) OpenSSL/1.1.1 (internal dummy connection)", 18 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:49.0) Gecko/20100101 Firefox/49.0", 19 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:65.0) Gecko/20100101 Firefox/65.0", 20 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36", 21 | "Mozilla/5.0 (SMART-TV; Linux; Tizen 2.4.0) AppleWebkit/538.1 (KHTML, like Gecko) SamsungBrowser/1.1 TV Safari/538.1", 22 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36", 23 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/78.0.3904.70 Safari/537.36", 24 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:21.0) Gecko/20100101 Firefox/21.0", 25 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0", 26 | "Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/65.0.3325.181 Chrome/65.0.3325.181 Safari/537.36", 27 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.92 Safari/537.36", 28 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko; Google Web Preview) Chrome/41.0.2272.118 Safari/537.36", 29 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.101 Safari/537.36", 30 | "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:63.0) Gecko/20100101 Firefox/63.0", 31 | "Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.42 (KHTML, like Gecko) Chromium/25.0.1349.2 Chrome/25.0.1349.2 Safari/537.42", 32 | "Mozilla/5.0 (Windows NT 5.1; rv:7.0.1) Gecko/20100101 Firefox/7.0.1", 33 | "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)", 34 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", 35 | "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36", 36 | "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)", 37 | ] 38 | 39 | 40 | def get_random_user_agent(): 41 | return USER_AGENTS[random.randint(0, len(USER_AGENTS) - 1)] 42 | 43 | 44 | def get_headers(): 45 | headers = { 46 | "Authorization": f"token {get_saved_token()}", 47 | "User-Agent": get_random_user_agent(), 48 | } 49 | return headers 50 | 51 | 52 | def subtract_years(date, years): 53 | return format_date_object((date - relativedelta(years=years))) 54 | 55 | 56 | def format_date_object(date): 57 | return date.strftime("%Y-%m-%dT%H:%M:%SZ") 58 | 59 | 60 | def let_user_pick(options): 61 | print("Please choose:") 62 | for idx, element in enumerate(options): 63 | print("{}) {}".format(idx + 1, element)) 64 | i = input("Enter number: ") 65 | try: 66 | if 0 < int(i) <= len(options): 67 | return int(i) 68 | except: 69 | pass 70 | return None 71 | 72 | 73 | def parse_user_contribution_repos(repos, user_contribution_repos): 74 | for repo in repos[:5]: 75 | commits_count = repo["contributions"]["totalCount"] 76 | repo_name = repo["repository"]["name"] 77 | repo_owner = repo["repository"]["owner"]["login"] 78 | languages = repo["repository"]["languages"]["nodes"] 79 | is_private = repo["repository"]["isPrivate"] 80 | stargazer_count = repo["repository"]["stargazerCount"] 81 | fork_count = repo["repository"]["forkCount"] 82 | 83 | if repo_name in user_contribution_repos.keys(): 84 | user_contribution_repos[repo_name]["commits_count"] += commits_count 85 | else: 86 | user_contribution_repos[repo_name] = { 87 | "owner": repo_owner, 88 | "commits_count": commits_count, 89 | "languages": languages, 90 | "is_private": is_private, 91 | "stargazer_count": stargazer_count, 92 | "fork_count": fork_count, 93 | } 94 | 95 | return user_contribution_repos 96 | -------------------------------------------------------------------------------- /ghs/viewer.py: -------------------------------------------------------------------------------- 1 | import colorama 2 | from termcolor import cprint 3 | import datetime 4 | 5 | colorama.init() 6 | 7 | 8 | def render_general_stats(username, general_stats, spinner): 9 | total_prs = general_stats["pullRequests"]["totalCount"] 10 | total_contribution = general_stats["repositoriesContributedTo"]["totalCount"] 11 | total_repositories = general_stats["repositories"]["totalCount"] 12 | all_repositories = general_stats["repositories"]["nodes"] 13 | total_issues = ( 14 | general_stats["openIssues"]["totalCount"] 15 | + general_stats["closedIssues"]["totalCount"] 16 | ) 17 | 18 | total_stars, total_forks, total_releases, total_packages = [0, 0, 0, 0] 19 | for repo in all_repositories: 20 | total_stars += repo["stargazers"]["totalCount"] 21 | total_forks += repo["forks"]["totalCount"] 22 | total_packages += repo["packages"]["totalCount"] 23 | total_releases += repo["releases"]["totalCount"] 24 | 25 | spinner.stop() 26 | 27 | text = f"\nGithub stats of {username}\n" 28 | cprint(text, "cyan", end="") 29 | output_text = text 30 | 31 | text = f"\nTotal PRs: {total_prs}\nContributed to: {total_contribution}\nTotal Issues: {total_issues}\nTotal Repositories: {total_repositories}\nTotal Stars: {total_stars}\nTotal Forks: {total_forks}\nTotal Packages: {total_packages}\nTotal Releases: {total_releases}\n" 32 | print(text, end="") 33 | output_text += text 34 | 35 | if ( 36 | total_prs 37 | + total_contribution 38 | + total_issues 39 | + total_repositories 40 | + total_stars 41 | + total_forks 42 | + total_packages 43 | + total_releases 44 | == 0 45 | ): 46 | output_text += render_private_profile_warning(username) 47 | 48 | output_text += render_generated_on() 49 | 50 | return output_text 51 | 52 | 53 | def render_generated_on(): 54 | today = datetime.datetime.now().strftime("%d-%b-%Y") 55 | text = f"\ngenerated on: {today}" 56 | cprint(text, "green") 57 | return text 58 | 59 | 60 | def render_private_profile_warning(username): 61 | text = f"\nIt's possible that {username} has made their contributions private. Use {username}'s token token to get their correct contributions stats." 62 | cprint(text, "yellow") 63 | return text 64 | 65 | 66 | def _render_top_contribution(top_contribution_data): 67 | text = f"\nYour Top Contributions:\n" 68 | print(text) 69 | output_text = text 70 | count = 0 71 | for i in top_contribution_data.keys(): 72 | count += 1 73 | if count > 3: 74 | break 75 | repo_name = i 76 | repo_owner = top_contribution_data[i]["repo_owner"] 77 | total_commits = top_contribution_data[i]["total_commits"] 78 | contributors_count = top_contribution_data[i]["contributors_count"] 79 | individual_commit_countribution = top_contribution_data[i][ 80 | "individual_commit_contribution" 81 | ] 82 | languages = top_contribution_data[i]["languages"] 83 | is_private = top_contribution_data[i]["is_private"] 84 | stargazer_count = top_contribution_data[i]["stargazer_count"] 85 | fork_count = top_contribution_data[i]["fork_count"] 86 | 87 | text = f"{count}.) " 88 | print(text, end="") 89 | output_text += text 90 | 91 | if is_private: 92 | text = f"private repo belonging to {repo_owner}\n" 93 | cprint(text, "green", end="") 94 | output_text += text 95 | else: 96 | text = f"{repo_owner}/{repo_name}\n" 97 | cprint(text, "green", end="") 98 | output_text += text 99 | 100 | if total_commits is not None: 101 | text = f"\t * This repository has a total of {total_commits} commits{f' and {contributors_count} contributors' if contributors_count is not None else ''}.\n" 102 | print(text, end="") 103 | output_text += text 104 | if stargazer_count > 0: 105 | text = f"\t * It has {stargazer_count} stars{f' and {fork_count} forks' if fork_count > 0 else ''}.\n" 106 | print(text, end="") 107 | output_text += text 108 | 109 | text = f"\t * During this period you made {individual_commit_countribution} commits to this repo.\n\t * Top languages of the repo {languages}.\n" 110 | print(text, end="") 111 | output_text += text 112 | 113 | return output_text 114 | 115 | 116 | def render_user_summary( 117 | start_duration, 118 | end_duration, 119 | top_contribution_data, 120 | output_text, 121 | repos, 122 | total_commit_contributions, 123 | total_pull_request_contributions, 124 | total_pull_request_review_contributions, 125 | ): 126 | text = f"\n{start_duration} - {end_duration}\n" 127 | output_text += text 128 | cprint(text, color="cyan") 129 | if total_commit_contributions == 0: 130 | text = "No public code contribution during this period\n" 131 | print(text, end="") 132 | output_text += text 133 | else: 134 | text = "During this period, you made a total of " 135 | print(text, end="") 136 | output_text += text 137 | 138 | text = f"{total_pull_request_contributions} Pull requests" 139 | cprint(text, "yellow", end="") 140 | output_text += text 141 | 142 | text = " and " 143 | print(text, end="") 144 | output_text += text 145 | 146 | text = f"{total_commit_contributions} commits " 147 | cprint(text, "yellow", end="") 148 | output_text += text 149 | 150 | text = f"across {len(repos) if len(repos) < 100 else '100+'} repositories.\n" 151 | print(text, end="") 152 | output_text += text 153 | if total_pull_request_review_contributions > 0: 154 | text = "You also " 155 | print(text, end="") 156 | output_text += text 157 | 158 | text = ( 159 | f"reviewed {total_pull_request_review_contributions} pull requests.\n" 160 | ) 161 | cprint(text, "yellow", end="") 162 | output_text += text 163 | 164 | output_text += _render_top_contribution(top_contribution_data) 165 | 166 | return output_text 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

ghs

3 |

Cross-platform CLI tool to generate your Github profile's stats and summary.

4 |

5 | 6 | MIT License 7 | 8 | 9 | prs welcome 10 | 11 | 12 | platforms 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |

27 |

28 | 29 | # Preview 30 | 31 |

32 | 33 |

34 | 35 | Hop on to [examples](#examples) for other usecases. 36 | 37 | --- 38 | 39 | Jump to: 40 | 41 | - [Installation](#installation) 42 | - [Using pip](#using-pip) 43 | - [Using source code](#using-source-code) 44 | - [Docker](#docker) 45 | - [Github PAT](#github-pat) 46 | - [Usage](#usage) 47 | - [Examples](#examples) 48 | - [Installation Hiccups on Windows](#installation-hiccups-on-windows) 49 | - [Environment error](#could-not-install-package-due-to-environment-error) 50 | - [ghs command not found](#ghs-command-not-found-even-after-installing) 51 | - [How to contribute?](#how-to-contribute) 52 | - [Steps for pushing a new update](#steps-for-pushing-a-new-update) 53 | - [Changelog](#changelog) 54 | - [Privacy Notice](#privacy-notice) 55 | - [License](#license) 56 | 57 | ## Installation 58 | 59 | ### Using pip 60 | 61 | The stable version of this package is maintained on pypi, install using pip: 62 | 63 | ```bash 64 | pip install ghs 65 | ``` 66 | 67 | ### Using source code 68 | 69 | This can be useful when you want to do a code contribution to this project. You can test and verify your local changes before submitting a Pull Request. 70 | 71 | 1. Clone the repository 72 | 73 | ```bash 74 | git clone https://github.com/interviewstreet/ghs.git 75 | ``` 76 | 77 | 2. Navigate to the project root and create a virtual environment 78 | 79 | ```bash 80 | python -m venv venv 81 | ``` 82 | 83 | 3. Activate the virtual environment 84 | - For macOS and linux, run `source venv/bin/activate` 85 | - For windows, run `.\venv\Scripts\activate` 86 | 4. Install the cli by running the following command while you are in the project root 87 | 88 | ```bash 89 | pip install . 90 | ``` 91 | 92 | _Note_: You need to reinstall by running the pip command if you want the cli to pick up your code changes. 93 | 94 | ## Docker 95 | 96 | ```bash 97 | docker build -t ghs:latest . 98 | docker run -it ghs ghs --help 99 | ``` 100 | 101 | ## Github PAT 102 | 103 | Generate a Github personal access token (https://github.com/settings/tokens) and use the `ghs -t` command to save it in the config file. This will be used to make the API requests to Github. A happy side-effect of this is that your private contributions are also considered while generating the stats and the summary of your username. 104 | 105 | Please make sure that you give the following scopes to the token: 106 | 107 | - `repo` 108 | - `read:user` 109 | - `read:packages` 110 | 111 | PS: Your Github PAT is not compromised by ghs. Please read the [Privacy Notice](#privacy-notice) to know more. 112 | 113 | ## Usage 114 | 115 | ```bash 116 | ghs [options] 117 | ``` 118 | 119 | | Option | Description | 120 | | -------------------------- | ----------------------------------------------------------------------------------- | 121 | | `-v` `--version` | Print the cli version | 122 | | `-t` `--token-update` | Prompts the user for github PAT and saves it in the config file | 123 | | `-u ` | Print the general stats for the provided username | 124 | | `-s` `--summary` | Print the summary of the user. The username should be provided using the `-u` flag. | 125 | | `-c` `--copy-to-clipboard` | Copy the output to clipboard. Can be used with `-u` or `-s`. | 126 | | `-h` `--help` | Show the help message of the cli | 127 | 128 | ## Examples 129 | 130 | ### `ghs -u ` 131 | 132 | Prints the general Github stats for the given username. 133 | 134 |

135 | 136 |

137 | 138 | ### copy to clipboard 139 | 140 | Provide the `-c` flag to copy the output to your clipboard. 141 | 142 |

143 | 144 |

145 | 146 | ### Other options for summary 147 | 148 | In addition to getting the Github summary from the beginning, you can also get the summary of the last 12 months or you can provide your own custom duration. 149 | 150 |

151 | 152 |

153 | 154 | ## Installation hiccups on windows 155 | 156 | ### Could not install package due to Environment Error 157 | 158 | It can be solved by scoping the installation. Add the flag `--user` to the pip command (`pip install --user ghs`). 159 | 160 | Alternatively, you can install the tool inside a virtual environment 161 | 162 | ### ghs command not found even after installing 163 | 164 | Most likely the place where the command is installed is not in the system [PATH](). On windows, there are [a few places](https://stackoverflow.com/questions/25522743/where-does-pip-store-save-python-3-modules-packages-on-windows-8) where the packages might be installed. After confirming the location, [add that directory](https://www.computerhope.com/issues/ch000549.htm) to the PATH. 165 | 166 | ## How to contribute? 167 | 168 | Please see [Contributing guidelines](https://github.com/interviewstreet/ghs/blob/master/CONTRIBUTING.md) for more information. 169 | 170 | ## Steps for pushing a new update 171 | 172 | 1. Bump the version in `ghs/__init__.py` (we follow semantic versioning). 173 | 174 | 2. Create an annotated tag for this commit with the version name `git tag -a v1.2.3 -m "v1.2.3"`. You can use this to publish a new release on the project's github page and the same can be used for maintaining the changelog. 175 | 176 | 3. Make sure you have [twine](https://pypi.org/project/twine/) and [build](https://pypi.org/project/build/) installed. 177 | 178 | ``` 179 | pip install build twine 180 | ``` 181 | 182 | 3. Build the package 183 | 184 | ``` 185 | python -m build 186 | ``` 187 | 188 | This will create a source archive and a wheel inside the `dist` folder. You can inspect them to make sure that they contain the correct files. 189 | 190 | 4. Run twine sanity on the build files 191 | 192 | ``` 193 | twine check dist/* 194 | ``` 195 | 196 | 5. First push the package on [TestPyPi](https://test.pypi.org/) so that you can test the updates without affecting the real PyPI index 197 | 198 | ``` 199 | twine upload -r testpypi dist/* 200 | 201 | ``` 202 | 203 | > Get the credentials for hackerrank dev PyPI account from karthik. 204 | 205 | Twine will list the package url on TestPyPI. You can test and confirm your changes by installing the package. 206 | 207 | 6. Finally, run the following command to upload the package to PyPI 208 | 209 | ``` 210 | twine upload dist/* 211 | ``` 212 | 213 | > Get the credentials for hackerrank PyPI account from karthik. 214 | 215 | 7. Treat yourself with a scoop of tender coconut. 216 | 217 | ## Changelog 218 | 219 | You can checkout [Releases](https://github.com/interviewstreet/ghs/releases) for the changelog. 220 | 221 | ## Privacy Notice 222 | 223 | ghs does not collect any data. 224 | 225 | - It has no home server. The Github PAT is stored locally in your machine. 226 | - It doesn't embed any kind of analytic hooks in its code. 227 | 228 | The only time ghs connects to a remote server is when you want to generate the stats and summary of your github profile. The cli uses the [Github GraphQL](https://docs.github.com/en/graphql) and [Github Rest](https://docs.github.com/en/rest) APIs to do so. The data collected via the APIs is not sent anywhere. It's displayed in your terminal or copied to your clipboard (only if you explicitly tell the tool to do so by providing the `-c` or `--copy-to-clipboard` flag). 229 | 230 | ## License 231 | 232 | [MIT](https://github.com/interviewstreet/ghs/blob/master/LICENSE) © HackerRank 233 | -------------------------------------------------------------------------------- /ghs/ghs.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime 3 | import traceback 4 | 5 | import colorama 6 | import pyperclip 7 | from halo import Halo 8 | from termcolor import cprint 9 | 10 | from ghs.__init__ import __version__ 11 | from ghs.check_config import ValidationException, check_config_dir, save_token 12 | from ghs.fetchers import ( 13 | fetch_contribution_collection, 14 | fetch_contributors_count, 15 | fetch_general_stats, 16 | fetch_oldest_contribution_year, 17 | fetch_total_repo_commits, 18 | fetch_user_id, 19 | ) 20 | from ghs.utils import ( 21 | format_date_object, 22 | let_user_pick, 23 | parse_user_contribution_repos, 24 | subtract_years, 25 | ) 26 | from ghs.viewer import render_general_stats, render_generated_on, render_user_summary 27 | 28 | colorama.init() 29 | spinner = Halo(text="Loading", spinner="dots") 30 | 31 | 32 | def verify_github_username(username): 33 | if fetch_user_id(username) is None: 34 | raise ValidationException(f"Error: {username} is not a valid github username") 35 | spinner.stop() 36 | 37 | 38 | def general_stats(username): 39 | spinner.start() 40 | verify_github_username(username) 41 | spinner.start() 42 | general_stats = fetch_general_stats(username) 43 | return render_general_stats(username, general_stats, spinner) 44 | 45 | 46 | def validate_start_and_end_duration(start_duration, end_duration): 47 | if not start_duration.isdigit() or not end_duration.isdigit(): 48 | raise ValidationException("Error: duration not provided in the desired format") 49 | 50 | if int(end_duration) <= int(start_duration): 51 | raise ValidationException( 52 | "Error: ending year cannot be less than starting year" 53 | ) 54 | 55 | if int(end_duration) - int(start_duration) > 30: 56 | raise ValidationException("Error: the year gap is too big") 57 | 58 | 59 | def user_summary(username, durations, choice): 60 | text = "" 61 | if choice == 1: 62 | text = f"\nGithub summary of {username} for the past 12 months.\n" 63 | elif choice == 2: 64 | text = f"\nGithub summary of {username} since joining.\n" 65 | elif choice == 3: 66 | text = f"\nGithub summary of {username}\n" 67 | 68 | cprint(text, "magenta", end="") 69 | # output_text is used to collect the text in a single string 70 | # so that it can be copied to clipboard if the flag is provided 71 | output_text = text 72 | 73 | if choice != 2: 74 | spinner.start() 75 | verify_github_username(username) 76 | 77 | for duration in durations: 78 | spinner.start() 79 | duration = duration.strip() 80 | if choice == 1: 81 | start_duration, end_duration = duration.split("#") 82 | elif choice == 2 or choice == 3: 83 | try: 84 | start_duration, end_duration = duration.split("-") 85 | except Exception: 86 | raise ValidationException( 87 | "Error: duration not provided in the desired format" 88 | ) 89 | 90 | start_duration = start_duration.strip() 91 | end_duration = end_duration.strip() 92 | if end_duration == "present" and choice is not 1: 93 | end_duration = f"{datetime.date.today().year + 1}" 94 | 95 | ( 96 | total_commit_contributions, 97 | total_pull_request_contributions, 98 | total_pull_request_review_contributions, 99 | ) = [0, 0, 0] 100 | user_contribution_repos, top_contribution_data = [{}, {}] 101 | 102 | if choice == 1: 103 | contribution_collection = [ 104 | repos, 105 | total_commit_contributions, 106 | total_pull_request_contributions, 107 | total_pull_request_review_contributions, 108 | ] = fetch_contribution_collection(username, start_duration) 109 | 110 | user_contribution_repos = parse_user_contribution_repos( 111 | repos[:5], user_contribution_repos 112 | ) 113 | elif choice == 2 or choice == 3: 114 | validate_start_and_end_duration(start_duration, end_duration) 115 | 116 | for curr_year in range(int(start_duration), int(end_duration)): 117 | start_date = format_date_object(datetime.datetime(curr_year, 1, 1)) 118 | ( 119 | repos, 120 | commit_contributions, 121 | pull_request_contributions, 122 | pull_request_review_contributions, 123 | ) = fetch_contribution_collection(username, start_date) 124 | 125 | total_commit_contributions += commit_contributions 126 | total_pull_request_contributions += pull_request_contributions 127 | total_pull_request_review_contributions += ( 128 | pull_request_review_contributions 129 | ) 130 | 131 | user_contribution_repos = parse_user_contribution_repos( 132 | repos[:5], user_contribution_repos 133 | ) 134 | 135 | contribution_collection = [ 136 | repos, 137 | total_commit_contributions, 138 | total_pull_request_contributions, 139 | total_pull_request_review_contributions, 140 | ] 141 | 142 | commit_count_repo_name_mapping = {} 143 | for repo_name in user_contribution_repos.keys(): 144 | commit_count_repo_name_mapping[ 145 | user_contribution_repos[repo_name]["commits_count"] 146 | ] = repo_name 147 | 148 | sorted_commit_counts = sorted( 149 | list(commit_count_repo_name_mapping.keys()), reverse=True 150 | ) 151 | 152 | for commits_count in sorted_commit_counts: 153 | repo_name = commit_count_repo_name_mapping[commits_count] 154 | repo = user_contribution_repos[repo_name] 155 | repo_owner, languages, is_private, stargazer_count, fork_count = ( 156 | repo["owner"], 157 | repo["languages"], 158 | repo["is_private"], 159 | repo["stargazer_count"], 160 | repo["fork_count"], 161 | ) 162 | 163 | parsed_languages = [] 164 | for l in languages: 165 | parsed_languages.append(l["name"]) 166 | 167 | total_commits = fetch_total_repo_commits(repo_name, repo_owner) 168 | contributors_count = fetch_contributors_count(repo_owner, repo_name) 169 | 170 | top_contribution_data[repo_name] = { 171 | "repo_owner": repo_owner, 172 | "total_commits": total_commits, 173 | "contributors_count": contributors_count, 174 | "individual_commit_contribution": commits_count, 175 | "languages": parsed_languages, 176 | "is_private": is_private, 177 | "stargazer_count": stargazer_count, 178 | "fork_count": fork_count, 179 | } 180 | 181 | spinner.stop() 182 | if end_duration == f"{datetime.date.today().year + 1}": 183 | end_duration = "present" 184 | if choice == 1: 185 | start_duration = start_duration.split("T")[0] 186 | 187 | output_text = render_user_summary( 188 | start_duration, 189 | end_duration, 190 | top_contribution_data, 191 | output_text, 192 | *contribution_collection, 193 | ) 194 | 195 | output_text += render_generated_on() 196 | return output_text 197 | 198 | 199 | def main(): 200 | parser = argparse.ArgumentParser( 201 | description="Get stats and summary of your github profile." 202 | ) 203 | 204 | parser.add_argument( 205 | "-v", "--version", action="store_true", help="print cli version and exit" 206 | ) 207 | 208 | parser.add_argument( 209 | "-t", 210 | "--token-update", 211 | dest="token_update", 212 | action="store_true", 213 | help="update the token in config and exit", 214 | ) 215 | 216 | parser.add_argument( 217 | "-u", dest="username", metavar="", help="github username" 218 | ) 219 | 220 | parser.add_argument( 221 | "-s", 222 | "--summary", 223 | dest="summary", 224 | action="store_true", 225 | help="display the summary of user's github profile", 226 | ) 227 | 228 | parser.add_argument( 229 | "-c", 230 | "--copy-to-clipboard", 231 | dest="copy_to_clipboard", 232 | action="store_true", 233 | help="copy the output to clipboard", 234 | ) 235 | 236 | args = parser.parse_args() 237 | 238 | if args.version: 239 | print(__version__) 240 | exit(0) 241 | 242 | if args.token_update: 243 | if not check_config_dir(spinner): 244 | exit(1) 245 | else: 246 | save_token() 247 | exit(0) 248 | 249 | if args.username: 250 | if not check_config_dir(spinner): 251 | exit(1) 252 | if args.summary: 253 | choice = None 254 | while choice is None: 255 | choice = let_user_pick( 256 | [ 257 | "Generate the summary for the past 12 months", 258 | "Generate the summary from when you joined github", 259 | "Custom durations", 260 | ] 261 | ) 262 | 263 | if choice == 1: 264 | durations = f"{subtract_years(datetime.datetime.now(), 1)}#present" 265 | elif choice == 2: 266 | spinner.start() 267 | verify_github_username(args.username) 268 | year = fetch_oldest_contribution_year(args.username) 269 | durations = "" 270 | for val in list(range(year, datetime.date.today().year + 1)): 271 | if val == datetime.date.today().year: 272 | durations += f"{val}-present" 273 | else: 274 | durations += f"{val}-{val+1}," 275 | elif choice == 3: 276 | durations = input( 277 | "Enter year duration in this format -> 2017-2019,2019-2021,2021-present: " 278 | ) 279 | 280 | output_text = user_summary(args.username, durations.split(","), choice) 281 | if args.copy_to_clipboard: 282 | pyperclip.copy(output_text) 283 | else: 284 | output_text = general_stats(args.username) 285 | if args.copy_to_clipboard: 286 | pyperclip.copy(output_text) 287 | else: 288 | raise ValidationException("Error: username not provided") 289 | 290 | 291 | def main_proxy(): 292 | try: 293 | main() 294 | except ValidationException as e: 295 | spinner.stop() 296 | cprint(e, color="red", attrs=["bold"]) 297 | except KeyboardInterrupt: 298 | cprint("\nExiting", color="yellow") 299 | except Exception as e: 300 | spinner.stop() 301 | cprint(f"Error: {e}", color="red", attrs=["bold"]) 302 | traceback_str = "".join(traceback.format_tb(e.__traceback__)) 303 | print(f"Traceback: \n{traceback_str}") 304 | --------------------------------------------------------------------------------