├── 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 |
Cross-platform CLI tool to generate your Github profile's stats and summary.
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |