├── tests ├── .gitignore ├── repo1.tar.gz ├── repo2.tar.gz ├── stats_1.csv ├── tests_commons.py ├── conftest.py ├── test_ignore_file_types.py ├── basic.py ├── test_obfuscation.py ├── test_cli_stats.py ├── test_max_changes_per_file.py ├── test_cli.py └── test_collapse_changes.py ├── scripts ├── install-dependencies.sh ├── run-tests.sh ├── cli.sh └── test-publish-pypi.sh ├── run-cli.sh ├── src ├── __init__.py ├── generators │ ├── BashGenerator.py │ ├── LuaGenerator.py │ ├── PyGenerator.py │ ├── JsGenerator.py │ ├── RubyGenerator.py │ ├── SwiftGenerator.py │ ├── TsGenerator.py │ ├── SqlGenerator.py │ ├── KotlinGenerator.py │ ├── TerraformGenerator.py │ ├── PhpGenerator.py │ ├── JsonGenerator.py │ ├── CGenerator.py │ ├── ScalaGenerator.py │ ├── JavaGenerator.py │ ├── CppGenerator.py │ ├── HtmlGenerator.py │ ├── Generator.py │ ├── CssGenerator.py │ └── __init__.py ├── commons.py ├── Content.py ├── README.md ├── Committer.py ├── Stats.py ├── cli.py ├── ImporterFromStats.py └── ImporterFromRepository.py ├── Pipfile ├── git-import-contributions.rb ├── setup.py ├── .github └── workflows │ ├── tests.yaml │ └── pypi.yaml ├── LICENSE ├── .gitignore ├── README.md └── Pipfile.lock /tests/.gitignore: -------------------------------------------------------------------------------- 1 | ./mockrepo 2 | ./mockrepoc 3 | ./repo1 4 | ./repo2 -------------------------------------------------------------------------------- /scripts/install-dependencies.sh: -------------------------------------------------------------------------------- 1 | pipenv install 2 | pipenv install --dev -------------------------------------------------------------------------------- /scripts/run-tests.sh: -------------------------------------------------------------------------------- 1 | export PYTHONPATH=".:$PYTHONPATH" 2 | pipenv run pytest -------------------------------------------------------------------------------- /run-cli.sh: -------------------------------------------------------------------------------- 1 | export PYTHONPATH=".:$PYTHONPATH" 2 | pipenv run python src/cli.py "$@" -------------------------------------------------------------------------------- /scripts/cli.sh: -------------------------------------------------------------------------------- 1 | export PYTHONPATH=".:$PYTHONPATH" 2 | pipenv run python src/cli.py "$@" -------------------------------------------------------------------------------- /tests/repo1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitryartuh/Contributions-Importer-For-Github/HEAD/tests/repo1.tar.gz -------------------------------------------------------------------------------- /tests/repo2.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitryartuh/Contributions-Importer-For-Github/HEAD/tests/repo2.tar.gz -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from .Content import * 4 | from .ImporterFromRepository import * 5 | from .Committer import * 6 | from .generators import * 7 | -------------------------------------------------------------------------------- /tests/stats_1.csv: -------------------------------------------------------------------------------- 1 | contributions,date 2 | 4,2024-08-05 3 | 2,2024-08-09 4 | 0,2024-08-10 5 | 0,2024-08-11 6 | 5,2024-08-12 7 | 10,2024-08-13 8 | 4,2024-08-14 9 | 3,2024-08-15 -------------------------------------------------------------------------------- /scripts/test-publish-pypi.sh: -------------------------------------------------------------------------------- 1 | rm -rf dist build *.egg-info 2 | pipenv run python setup.py sdist bdist_wheel 3 | ls dist/ 4 | pipenv run twine upload --repository testpypi dist/* 5 | pipenv run pip install --index-url https://test.pypi.org/simple/ git-import-contributions 6 | -------------------------------------------------------------------------------- /src/generators/BashGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class BashGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('echo "' + self.random_string(5) + '"') 12 | -------------------------------------------------------------------------------- /src/generators/LuaGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class LuaGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append("print '" + self.random_string(5) + "'") 12 | -------------------------------------------------------------------------------- /src/generators/PyGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class PyGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('print("' + self.random_string(5) + '")') 12 | -------------------------------------------------------------------------------- /src/generators/JsGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class JsGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('console.log("' + self.random_string(5) + '")') 12 | -------------------------------------------------------------------------------- /src/generators/RubyGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class RubyGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('puts("' + self.random_string(5) + '")') 12 | -------------------------------------------------------------------------------- /src/generators/SwiftGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class SwiftGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('print("' + self.random_string(5) + '")') 12 | -------------------------------------------------------------------------------- /src/generators/TsGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class TsGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('console.log("' + self.random_string(5) + '")') 12 | -------------------------------------------------------------------------------- /src/generators/SqlGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class SqlGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append("SELECT * from " + self.random_string(5) + ";") 12 | -------------------------------------------------------------------------------- /src/generators/KotlinGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class KotlinGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('println("' + self.random_string(5) + '")') 12 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | gitpython = "==3.1.42" 8 | 9 | [dev-packages] 10 | pytest = "*" 11 | setuptools = "*" 12 | importlib-metadata = "*" 13 | wheel = "*" 14 | twine = "*" 15 | 16 | [requires] 17 | python_version = "3" 18 | -------------------------------------------------------------------------------- /src/generators/TerraformGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class TerraformGenerator(Generator): 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def insert(self, content, num): 10 | for i in range(num): 11 | content.append('resource "random_string" "' + self.random_string(5) + '" { length = 10 }') 12 | -------------------------------------------------------------------------------- /tests/tests_commons.py: -------------------------------------------------------------------------------- 1 | from git import Repo 2 | import time 3 | 4 | REPOS_PATHS = ['tests/repo1', 'tests/repo2'] 5 | MOCK_REPO_PATH = 'tests/mockrepo' 6 | 7 | def import_commits(repo_path: str): 8 | repo = Repo(repo_path) 9 | commits_list = [] 10 | 11 | for commit in repo.iter_commits(repo.head.ref.name): 12 | commit_date = commit.committed_date 13 | date_iso_format = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(commit_date)) 14 | commits_list.append([date_iso_format, commit.message.strip()]) 15 | 16 | return commits_list 17 | -------------------------------------------------------------------------------- /src/generators/PhpGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class PhpGenerator(Generator): 5 | 6 | min_content_size = 2 7 | 8 | def __init__(self): 9 | pass 10 | 11 | def insert(self, content, num): 12 | if len(content) <= self.min_content_size: 13 | content.clear() 14 | content.append('') 16 | for i in range(num): 17 | content.insert(-1, ' echo "' + self.random_string(5) + '";') 18 | 19 | def delete(self, content, num): 20 | for i in range(min(num, len(content) - self.min_content_size)): 21 | content.pop(-2) 22 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import tarfile 2 | import shutil 3 | from tests_commons import REPOS_PATHS 4 | 5 | 6 | def pytest_sessionstart(session): 7 | for repo in REPOS_PATHS: 8 | with tarfile.open(f"{repo}.tar.gz") as tar: 9 | shutil.rmtree(repo, ignore_errors=True) 10 | tar.extractall('tests') 11 | 12 | 13 | def pytest_sessionfinish(session, exitstatus): 14 | for repo in REPOS_PATHS: 15 | shutil.rmtree(repo, ignore_errors=True) 16 | shutil.rmtree('tests/mockrepo', ignore_errors=True) 17 | shutil.rmtree('tests/mockrepo_c', ignore_errors=True) 18 | shutil.rmtree('tests/mockrepo_stats', ignore_errors=True) 19 | -------------------------------------------------------------------------------- /src/generators/JsonGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | import random 3 | 4 | 5 | class JsonGenerator(Generator): 6 | 7 | min_content_size = 3 8 | 9 | def __init__(self): 10 | pass 11 | 12 | def insert(self, content, num): 13 | if len(content) <= self.min_content_size: 14 | content.clear() 15 | content.append('{') 16 | content.append(' "last-prop": 42') 17 | content.append('}') 18 | for i in range(num): 19 | content.insert(-2, ' "' + self.random_string(5) + '": ' + 20 | str(int(random.random() * 100)) + ',') 21 | 22 | def delete(self, content, num): 23 | for i in range(min(num, len(content) - self.min_content_size)): 24 | content.pop(-3) 25 | -------------------------------------------------------------------------------- /src/generators/CGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class CGenerator(Generator): 5 | 6 | min_content_size = 5 7 | 8 | def __init__(self): 9 | pass 10 | 11 | def insert(self, content, num): 12 | if len(content) <= self.min_content_size: 13 | content.clear() 14 | content.append('# include ') 15 | content.append('') 16 | content.append('int main() {') 17 | content.append('return 0;') 18 | content.append('}') 19 | for i in range(num): 20 | content.insert(-2, ' printf("' + self.random_string(5) + '");') 21 | 22 | def delete(self, content, num): 23 | for i in range(min(num, len(content) - self.min_content_size)): 24 | content.pop(-3) 25 | -------------------------------------------------------------------------------- /src/generators/ScalaGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class ScalaGenerator(Generator): 5 | 6 | min_content_size = 4 7 | 8 | def __init__(self): 9 | pass 10 | 11 | def insert(self, content, num): 12 | if len(content) <= self.min_content_size: 13 | content.clear() 14 | content.append('object Main' + self.random_string(5) + ' {') 15 | content.append(' def main(args: Array[String]): Unit = {') 16 | content.append(' }') 17 | content.append('}') 18 | for i in range(num): 19 | content.insert(-2, ' println("' + self.random_string(5) + '");') 20 | 21 | def delete(self, content, num): 22 | for i in range(min(num, len(content) - self.min_content_size)): 23 | content.pop(-3) 24 | -------------------------------------------------------------------------------- /src/generators/JavaGenerator.py: -------------------------------------------------------------------------------- 1 | 2 | from . import Generator 3 | 4 | 5 | class JavaGenerator(Generator): 6 | 7 | min_content_size = 4 8 | 9 | def __init__(self): 10 | pass 11 | 12 | def insert(self, content, num): 13 | if len(content) <= self.min_content_size: 14 | content.clear() 15 | content.append('public class C' + self.random_string(5) + ' {') 16 | content.append(' public static void main() {') 17 | content.append(' }') 18 | content.append('}') 19 | for i in range(num): 20 | content.insert(-2, ' System.out.println("' + self.random_string(5) + '");') 21 | 22 | def delete(self, content, num): 23 | for i in range(min(num, len(content) - self.min_content_size)): 24 | content.pop(-3) 25 | -------------------------------------------------------------------------------- /src/generators/CppGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | 3 | 4 | class CppGenerator(Generator): 5 | min_content_size = 6 6 | 7 | def __init__(self): 8 | pass 9 | 10 | def insert(self, content, num): 11 | if len(content) <= self.min_content_size: 12 | content.clear() 13 | content.append('# include ') 14 | content.append('using namespace std;') 15 | content.append('') 16 | content.append('int main() {') 17 | content.append('return 0;') 18 | content.append('}') 19 | for i in range(num): 20 | content.insert(-2, ' cout << "' + self.random_string(5) + '";') 21 | 22 | def delete(self, content, num): 23 | for i in range(min(num, len(content) - self.min_content_size)): 24 | content.pop(-3) 25 | -------------------------------------------------------------------------------- /src/generators/HtmlGenerator.py: -------------------------------------------------------------------------------- 1 | 2 | from . import Generator 3 | import random 4 | 5 | 6 | class HtmlGenerator(Generator): 7 | 8 | min_content_size = 6 9 | 10 | def __init__(self): 11 | pass 12 | 13 | def insert(self, content, num): 14 | if len(content) <= self.min_content_size: 15 | content.clear() 16 | content.append('') 17 | content.append('') 18 | content.append('') 19 | content.append('') 20 | content.append('') 21 | content.append('') 22 | for i in range(num): 23 | content.insert(-2, '
' + self.random_phrase(random.random() * 10 + 1) + '
') 24 | 25 | def delete(self, content, num): 26 | for i in range(min(num, len(content) - self.min_content_size)): 27 | content.pop(-3) 28 | -------------------------------------------------------------------------------- /git-import-contributions.rb: -------------------------------------------------------------------------------- 1 | class GitImportContributions < Formula 2 | desc "Tool to import contributions into a Git repository from stats or other repositories" 3 | homepage "https://github.com/miromannino/Contributions-Importer-For-Github" 4 | url "https://github.com/miromannino/Contributions-Importer-For-Github/archive/refs/tags/2.0.tar.gz" 5 | sha256 "99df08c418e843c793718a8ea9de24b515e3fa3f9ea4887f5c1f609d4307ab95" 6 | license "MIT" 7 | 8 | depends_on "python@3.9" 9 | 10 | def install 11 | system "pip3", "install", "--prefix=#{prefix}", "gitpython" 12 | system "python3", *Language::Python.setup_install_args(prefix) 13 | end 14 | 15 | test do 16 | output = shell_output("#{bin}/git-import-contributions --help", 2) 17 | assert_match "Unified CLI for Contribution Importer", output 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/commons.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import git 3 | import re 4 | 5 | Author = namedtuple("Author", ["name", "email"]) 6 | 7 | 8 | def is_valid_git_repo(path): 9 | try: 10 | _ = git.Repo(path).git_dir # Attempt to access the .git directory 11 | return True 12 | except git.exc.InvalidGitRepositoryError: 13 | return False 14 | except git.exc.NoSuchPathError: 15 | return False 16 | 17 | 18 | def extract_name_email(author_string): 19 | """ 20 | Extracts the name and email from a string in the format 'My Name '. 21 | Returns: 22 | Author: An object with 'name' and 'email' fields, or None if the format is invalid. 23 | """ 24 | match = re.match(r"^(.+?)\s*<([^<>]+)>$", author_string) 25 | if match: 26 | name, email = match.groups() 27 | return Author(name=name.strip(), email=email.strip()) 28 | return None 29 | -------------------------------------------------------------------------------- /tests/test_ignore_file_types.py: -------------------------------------------------------------------------------- 1 | import git 2 | from src import * 3 | import shutil 4 | from tests.tests_commons import REPOS_PATHS, MOCK_REPO_PATH 5 | import pytest 6 | 7 | 8 | def test_ignore_file_types(): 9 | repos = [git.Repo(repo_path) for repo_path in REPOS_PATHS] 10 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 11 | mock_repo = git.Repo.init(MOCK_REPO_PATH) 12 | 13 | ignored_filetypes = ['.csv', '.txt', '.pdf', '.xsl', '.sql'] 14 | 15 | importer = ImporterFromRepository(repos, mock_repo) 16 | importer.set_ignored_file_types(ignored_filetypes) 17 | importer.set_keep_commit_messages(True) 18 | importer.import_repository() 19 | 20 | # check that there are no files of the ignored categories 21 | for file in mock_repo.git.ls_files().split('\n'): 22 | for ignored_filetype in ignored_filetypes: 23 | assert not file.endswith(ignored_filetype) 24 | -------------------------------------------------------------------------------- /tests/basic.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import git 3 | from src import * 4 | 5 | repos_path = [ 6 | '/path/to/Project1', 7 | '/path/to/Project2', 8 | '/path/to/Project3', 9 | ] 10 | repos = [] 11 | for repo_path in repos_path: 12 | repos.append(git.Repo(repo_path)) 13 | 14 | mock_repo_path = '/path/to/destination/private/repo' 15 | mock_repo = git.Repo.init(mock_repo_path) 16 | 17 | importer = ImporterFromRepository(repos, mock_repo) 18 | importer.set_author(['your.email@domain.com', 'your.other.email@domain.com']) 19 | importer.set_commit_max_amount_changes(15) 20 | importer.set_changes_commits_max_time_backward(60 * 60 * 24 * 30) 21 | importer.set_max_changes_per_file(60) 22 | importer.ignore_file_types(['.csv', '.txt', '.pdf', '.xsl', '.sql']) 23 | importer.set_collapse_multiple_changes_to_one(True) 24 | importer.set_keep_commit_messages(True) 25 | importer.import_repository() 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='git-import-contributions', 8 | version='2.0.0-rc1', 9 | packages=find_packages(), 10 | include_package_data=True, 11 | install_requires=[ 12 | 'gitpython' 13 | ], 14 | entry_points={ 15 | 'console_scripts': [ 16 | 'git-import-contributions=src.cli:main', 17 | ], 18 | }, 19 | author='Miro Mannino', 20 | description='This tool helps users to import contributions to GitHub from private git repositories, or from public repositories that are not hosted in GitHub', 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | url='https://github.com/miromannino/Contributions-Importer-For-Github', 24 | ) 25 | -------------------------------------------------------------------------------- /src/generators/Generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | class Generator: 5 | 6 | def __init__(self): 7 | pass 8 | 9 | def random_string(self, length=10): 10 | return ''.join([chr(int(random.random() * (ord('z') - ord('a'))) + ord('a')) 11 | for c in range(length)]) 12 | 13 | def random_phrase(self, length=10, word_length=10): 14 | return ' '.join([self.random_string(length=int(word_length)) for _ in range(int(length))]) 15 | 16 | ''' insert num lines of code/text inside content. 17 | content is a list of strings that represent the file ''' 18 | 19 | def insert(self, content, num): 20 | for i in range(num): 21 | content.append(self.random_phrase(random.random() * 10 + 1)) 22 | 23 | ''' delete num lines of code/text from content. 24 | content is a list of strings that represent the file ''' 25 | 26 | def delete(self, content, num): 27 | for i in range(min(num, len(content))): 28 | content.pop() 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] 16 | python-version: [3.11] 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v3 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Set up Git 30 | run: | 31 | git config --global user.name "GitHub Actions" 32 | git config --global user.email "user@example.com" 33 | 34 | - name: Install dependencies 35 | run: | 36 | pip install pipenv 37 | pipenv install --dev 38 | 39 | - name: Run tests 40 | run: | 41 | export PYTHONPATH=".:$PYTHONPATH" 42 | pipenv run pytest 43 | -------------------------------------------------------------------------------- /src/generators/CssGenerator.py: -------------------------------------------------------------------------------- 1 | from . import Generator 2 | import random 3 | 4 | 5 | class CssGenerator(Generator): 6 | 7 | properties = ['line-height', 'border-top-width', 'border-bottom-width', 'border-left-width', 'border-right-width', 8 | 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', 'padding-top', 'padding-bottom', 9 | 'padding-left', 'padding-right', 'font-size'] 10 | 11 | min_content_size = 2 12 | 13 | def __init__(self): 14 | pass 15 | 16 | def insert(self, content, num): 17 | if len(content) <= self.min_content_size: 18 | content.clear() 19 | content.append('.' + self.random_string(5) + ' { ') 20 | content.append('}') 21 | for i in range(num): 22 | content.insert(-1, ' ' + self.properties[int(random.random() * len(self.properties))] 23 | + ': ' + str(int(random.random() * 100)) + 'px;') 24 | 25 | def delete(self, content, num): 26 | for i in range(min(num, len(content) - self.min_content_size)): 27 | content.pop(-2) 28 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | id-token: write 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: 3.9 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install pipenv 29 | pipenv install --dev 30 | 31 | - name: Build the package 32 | run: | 33 | rm -rf dist build *.egg-info 34 | pipenv run python setup.py sdist bdist_wheel 35 | 36 | - name: Publish to PyPI 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | 39 | - name: Test package installation 40 | run: | 41 | pipenv run pip install --no-deps git-import-contributions 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Miro Mannino 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 | -------------------------------------------------------------------------------- /src/Content.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import pathlib 5 | from .generators import available_generators 6 | 7 | 8 | class Content: 9 | FILENAME = 'content' 10 | 11 | def __init__(self, folder_path): 12 | self.contents = {} 13 | self.folder_path = folder_path 14 | self.load() 15 | 16 | def loadFile(self, ext, path): 17 | if ext not in available_generators: 18 | return 19 | with open(path, 'r') as t: 20 | lines = [] 21 | for l in t: 22 | lines.append(l.replace('\n', '')) 23 | self.contents[ext] = lines 24 | 25 | def load(self): 26 | for path in os.listdir(self.folder_path): 27 | full_path = os.path.join(self.folder_path, path) 28 | if os.path.isfile(full_path): 29 | self.loadFile(pathlib.Path(full_path).suffix, full_path) 30 | 31 | def save(self): 32 | for k, v in self.contents.items(): 33 | full_path = os.path.join(self.folder_path, Content.FILENAME + k) 34 | with open(full_path, 'w') as f: 35 | for l in v: 36 | f.write(l) 37 | f.write('\n') 38 | 39 | def get_files(self): 40 | return map(lambda fn: Content.FILENAME + fn, self.contents.keys()) 41 | 42 | def get(self, ext): 43 | if ext not in self.contents: 44 | self.contents[ext] = [] 45 | return self.contents[ext] 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # pycharm 104 | .idea/* 105 | .DS_Store 106 | -------------------------------------------------------------------------------- /tests/test_obfuscation.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import git 3 | from src import * 4 | import shutil 5 | from tests.tests_commons import import_commits, REPOS_PATHS, MOCK_REPO_PATH 6 | import pytest 7 | 8 | 9 | def test_obfuscation(): 10 | repos = [git.Repo(repo_path) for repo_path in REPOS_PATHS] 11 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 12 | mock_repo = git.Repo.init(MOCK_REPO_PATH) 13 | importer = ImporterFromRepository(repos, mock_repo) 14 | importer.set_keep_commit_messages(False) 15 | importer.import_repository() 16 | 17 | repos_commits = [] 18 | for repo_path in REPOS_PATHS: 19 | repos_commits += import_commits(repo_path) 20 | repos_commits.sort(key=lambda c: c[0]) 21 | 22 | mock_commits = import_commits(MOCK_REPO_PATH) 23 | mock_commits.sort(key=lambda c: c[0]) 24 | 25 | # test that these are the same list 26 | assert len(repos_commits) == len(mock_commits) 27 | for i in range(len(repos_commits)): 28 | assert repos_commits[i][0] == mock_commits[i][0] 29 | assert repos_commits[i][1] != mock_commits[i][1] 30 | 31 | 32 | def test_no_obfuscation(): 33 | repos = [git.Repo(repo_path) for repo_path in REPOS_PATHS] 34 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 35 | mock_repo = git.Repo.init(MOCK_REPO_PATH) 36 | importer = ImporterFromRepository(repos, mock_repo) 37 | importer.set_keep_commit_messages(True) 38 | importer.import_repository() 39 | 40 | repos_commits = [] 41 | for repo_path in REPOS_PATHS: 42 | repos_commits += import_commits(repo_path) 43 | repos_commits.sort(key=lambda c: c[0]) 44 | 45 | mock_commits = import_commits(MOCK_REPO_PATH) 46 | mock_commits.sort(key=lambda c: c[0]) 47 | 48 | # test that these are the same list 49 | assert len(repos_commits) == len(mock_commits) 50 | for i in range(len(repos_commits)): 51 | assert repos_commits[i][0] == mock_commits[i][0] 52 | assert repos_commits[i][1] == mock_commits[i][1] 53 | -------------------------------------------------------------------------------- /tests/test_cli_stats.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import git 3 | import os 4 | import pytest 5 | import csv 6 | from tests.tests_commons import MOCK_REPO_PATH 7 | 8 | MOCK_REPO_PATH_STATS = f"{MOCK_REPO_PATH}_stats" 9 | 10 | def test_cli_stats(): 11 | cli_command = [ 12 | "python", "src/cli.py", 13 | "stats", 14 | "--csv", "tests/stats_1.csv", 15 | "--mock_repo", MOCK_REPO_PATH_STATS, 16 | "--generator", ".ts", 17 | "--author", "Test Name " 18 | ] 19 | 20 | subprocess.run(cli_command, check=True) 21 | 22 | # Extract the expected dates from the CSV file 23 | unique_csv_dates = set([]) 24 | expanded_csv_dates = [] 25 | with open("tests/stats_1.csv", newline="") as csvfile: 26 | reader = csv.DictReader(csvfile) 27 | for row in reader: 28 | contributions = int(row["contributions"]) 29 | if contributions > 0: 30 | unique_csv_dates.add(row["date"]) 31 | for _ in range(contributions): 32 | expanded_csv_dates.append(row["date"]) 33 | 34 | # Validate that the mock repository contains commits with dates matching the CSV 35 | mock_repo = git.Repo(MOCK_REPO_PATH_STATS) 36 | unique_commit_dates = set([ 37 | commit.committed_datetime.strftime("%Y-%m-%d") 38 | for commit in mock_repo.iter_commits() 39 | ]) 40 | assert sorted(unique_commit_dates) == sorted(unique_csv_dates), ( 41 | f"Commit dates do not match. Expected: {sorted(unique_csv_dates)}, Got: {sorted(unique_commit_dates)}" 42 | ) 43 | 44 | # Validates that the amount of commits is as described in the csv 45 | mock_repo = git.Repo(MOCK_REPO_PATH_STATS) 46 | commit_dates = [ 47 | commit.committed_datetime.strftime("%Y-%m-%d") 48 | for commit in mock_repo.iter_commits() 49 | ] 50 | assert sorted(commit_dates) == sorted(expanded_csv_dates), ( 51 | f"Commit dates do not match. Expected: {sorted(expanded_csv_dates)}, Got: {sorted(commit_dates)}" 52 | ) 53 | -------------------------------------------------------------------------------- /src/generators/__init__.py: -------------------------------------------------------------------------------- 1 | from .Generator import Generator 2 | from .JsGenerator import JsGenerator 3 | from .JavaGenerator import JavaGenerator 4 | from .CssGenerator import CssGenerator 5 | from .CppGenerator import CppGenerator 6 | from .CGenerator import CGenerator 7 | from .PyGenerator import PyGenerator 8 | from .RubyGenerator import RubyGenerator 9 | from .JsonGenerator import JsonGenerator 10 | from .KotlinGenerator import KotlinGenerator 11 | from .LuaGenerator import LuaGenerator 12 | from .PhpGenerator import PhpGenerator 13 | from .HtmlGenerator import HtmlGenerator 14 | from .BashGenerator import BashGenerator 15 | from .SqlGenerator import SqlGenerator 16 | from .ScalaGenerator import ScalaGenerator 17 | from .SwiftGenerator import SwiftGenerator 18 | from .TerraformGenerator import TerraformGenerator 19 | from .TsGenerator import TsGenerator 20 | 21 | available_generators = { 22 | '.md': Generator, 23 | '.txt': Generator, 24 | '.tex': Generator, 25 | '.js': JsGenerator, 26 | '.java': JavaGenerator, 27 | '.css': CssGenerator, 28 | '.scss': CssGenerator, 29 | '.cpp': CppGenerator, 30 | '.c': CGenerator, 31 | '.py': PyGenerator, 32 | '.json': JsonGenerator, 33 | '.kt': KotlinGenerator, 34 | '.lua': LuaGenerator, 35 | '.php': PhpGenerator, 36 | '.html': HtmlGenerator, 37 | '.sh': BashGenerator, 38 | '.sql': SqlGenerator, 39 | '.scala': ScalaGenerator, 40 | '.swift': SwiftGenerator, 41 | '.ts': TsGenerator, 42 | '.tsx': TsGenerator, 43 | '.rb': RubyGenerator, 44 | '.tf': TerraformGenerator 45 | } 46 | 47 | 48 | def apply_generator(content, stats): 49 | for ext, num in stats.deletions.items(): 50 | if ext in available_generators: 51 | gen = available_generators[ext]() 52 | gen.delete(content.get(ext), num) 53 | for ext, num in stats.insertions.items(): 54 | if ext in available_generators: 55 | gen = available_generators[ext]() 56 | gen.insert(content.get(ext), num) 57 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | **This is a mock repository.** 2 | 3 | This repository aims to report in GitHub contributions coming from other platforms. 4 | 5 | It has been automatically created using Miro Mannino's [Contributions Importer for GitHub](https://github.com/miromannino/contributions-importer-for-github) 6 | 7 | ## Notice 8 | 9 | The content of this repository contains mock code. This prevents private source code from being leaked. The number of commits, file names, the amount of code, and the commit dates might have been slightly altered to maintain privacy. 10 | 11 | Notice that the statistics coming from this repository are not in any way complete. Commits only come from other selected git repositories. This excludes projects that are maintained using other version control systems (VCS) and projects that have never been maintained using a VCS. 12 | 13 | ## Reasons 14 | 15 | GitHub shows contributions statistics of its users. There are [several reasons](https://github.com/isaacs/github/issues/627) why this feature could be debatable. 16 | 17 | Moreover, this mechanism only rewards developers who work in companies that host projects on GitHub. 18 | Considering the undeniable popularity of GitHub, developers that use other platforms are disadvantaged. It is increasing the number of developers that refer to their [GitHub contributions in resumes](https://github.com/resume/resume.github.com). Similarly, recruiters [may use GitHub to find talent](https://www.socialtalent.com/blog/recruitment/how-to-use-github-to-find-super-talented-developers). 19 | 20 | In more extreme cases, some developers decided to boycott GitHub's lock-in system and developed tools that can alter GitHub's contribution graph with fake commits: [Rockstar](https://github.com/avinassh/rockstar) and [Vanity text for GitHub](https://github.com/ihabunek/github-vanity) are good examples. 21 | 22 | Instead, [Contributions Importer for GitHub](https://github.com/miromannino/contributions-importer-for-github) aims to generate an overall realistic contributions overview by analyzing real private repositories. 23 | 24 | -------------------------------------------------------------------------------- /tests/test_max_changes_per_file.py: -------------------------------------------------------------------------------- 1 | import git 2 | from src import * 3 | import shutil 4 | from tests.tests_commons import import_commits, REPOS_PATHS, MOCK_REPO_PATH 5 | import pytest 6 | 7 | 8 | def test_max_changes_per_file(): 9 | repos = [git.Repo(repo_path) for repo_path in REPOS_PATHS] 10 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 11 | mock_repo = git.Repo.init(MOCK_REPO_PATH) 12 | 13 | shutil.rmtree(MOCK_REPO_PATH + '_c', ignore_errors=True) 14 | mock_repo_collapsed = git.Repo.init(MOCK_REPO_PATH + '_c') 15 | importer = ImporterFromRepository(repos, mock_repo_collapsed) 16 | importer.set_max_changes_per_file(1) 17 | importer.set_keep_commit_messages(True) 18 | importer.import_repository() 19 | 20 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 21 | mock_repo = git.Repo.init(MOCK_REPO_PATH) 22 | importer = ImporterFromRepository(repos, mock_repo) 23 | importer.set_max_changes_per_file(1) 24 | importer.set_keep_commit_messages(True) 25 | importer.import_repository() 26 | 27 | files_collapsed = os.listdir(MOCK_REPO_PATH + '_c') 28 | files_collapsed = list(filter(lambda f: os.path.isfile(f), files_collapsed)) 29 | files_collapsed.remove("README.md") 30 | files_non_collapsed = os.listdir(MOCK_REPO_PATH) 31 | files_non_collapsed = list(filter(lambda f: os.path.isfile(f), files_non_collapsed)) 32 | files_non_collapsed.remove("README.md") 33 | assert len(files_collapsed) == len(files_non_collapsed) 34 | 35 | for file in files_collapsed: 36 | assert file in files_non_collapsed 37 | lines_of_code_collapsed = 0 38 | lines_of_code_non_collapsed = 0 39 | with open(os.path.join(MOCK_REPO_PATH + '_c', file), 'r') as f: 40 | lines_of_code_collapsed = len(f.readlines()) 41 | with open(os.path.join(MOCK_REPO_PATH, file), 'r') as f: 42 | lines_of_code_non_collapsed = len(f.readlines()) 43 | print( 44 | "Checking", 45 | file, 46 | "collapsed:", 47 | lines_of_code_collapsed, 48 | "non_collapsed:", 49 | lines_of_code_non_collapsed) 50 | assert lines_of_code_collapsed < lines_of_code_non_collapsed 51 | -------------------------------------------------------------------------------- /src/Committer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from typing import Optional 4 | import git 5 | import time 6 | import os 7 | import shutil 8 | from .commons import Author 9 | 10 | 11 | class Committer: 12 | 13 | def __init__(self, mock_repo_path, content): 14 | self.mock_repo_path = mock_repo_path 15 | self.content = content 16 | self.mock_repo = self._initialize_repo() 17 | 18 | def _initialize_repo(self): 19 | """Ensure the mock repository exists and is initialized.""" 20 | if not os.path.exists(self.mock_repo_path): 21 | os.makedirs(self.mock_repo_path) 22 | try: 23 | return git.Repo(self.mock_repo_path) 24 | except git.exc.InvalidGitRepositoryError: 25 | return git.Repo.init(self.mock_repo_path) 26 | 27 | def _check_readme(self): 28 | readme_path = os.path.dirname(__file__) + '/README.md' 29 | mockrepo_readme_path = self.mock_repo_path + '/README.md' 30 | shutil.copyfile(readme_path, mockrepo_readme_path) 31 | self.mock_repo.git.add('README.md') 32 | 33 | def get_last_commit_date(self): 34 | ''' returns the last commit date in ms from epoch''' 35 | last_commit_date = 0 36 | for b in self.mock_repo.branches: 37 | for c in self.mock_repo.iter_commits(b.name): 38 | if c.committed_date > last_commit_date: 39 | last_commit_date = c.committed_date 40 | return last_commit_date 41 | 42 | def commit(self, date: int, message: str, author: Optional[Author] = None): 43 | ''' performs the commit. date is in seconds from epoch ''' 44 | self._check_readme() 45 | for file in self.content.get_files(): 46 | self.mock_repo.git.add(file) 47 | date_iso_format = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(date)) 48 | if author: 49 | if isinstance(author, list): 50 | author = author[0] 51 | os.environ["GIT_AUTHOR_NAME"] = author.name 52 | os.environ["GIT_AUTHOR_EMAIL"] = author.email 53 | os.environ['GIT_AUTHOR_DATE'] = date_iso_format 54 | os.environ['GIT_COMMITTER_DATE'] = date_iso_format 55 | try: 56 | self.mock_repo.git.commit('-m', message, '--allow-empty') 57 | except git.exc.GitError as e: 58 | print('Error in commit: ' + str(e)) 59 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import git 3 | import os 4 | import pytest 5 | from tests.tests_commons import REPOS_PATHS, MOCK_REPO_PATH 6 | 7 | 8 | def test_cli_collapse_changes(): 9 | cli_command_collapsed = [ 10 | "python", "src/cli.py", 11 | "repo", 12 | "--repos", *REPOS_PATHS, 13 | "--mock_repo", MOCK_REPO_PATH + "_c", 14 | "--author", "Test Name ", 15 | "--collapse-multiple-changes", 16 | "--keep-commit-messages" 17 | ] 18 | 19 | cli_command_non_collapsed = [ 20 | "python", "src/cli.py", 21 | "repo", 22 | "--repos", *REPOS_PATHS, 23 | "--mock_repo", MOCK_REPO_PATH, 24 | "--author", "Test Name ", 25 | "--keep-commit-messages" 26 | ] 27 | 28 | subprocess.run(cli_command_collapsed, check=True) 29 | subprocess.run(cli_command_non_collapsed, check=True) 30 | 31 | # Get lists of files in each mock repo, excluding README.md 32 | files_collapsed = [ 33 | f for f in os.listdir(MOCK_REPO_PATH + "_c") 34 | if os.path.isfile(os.path.join(MOCK_REPO_PATH + "_c", f)) and not f.endswith(".md") 35 | ] 36 | files_non_collapsed = [ 37 | f for f in os.listdir(MOCK_REPO_PATH) 38 | if os.path.isfile(os.path.join(MOCK_REPO_PATH, f)) and not f.endswith(".md") 39 | ] 40 | 41 | # Assert same number of files 42 | assert len(files_collapsed) == len(files_non_collapsed) 43 | 44 | # Validate line count 45 | for file in files_collapsed: 46 | assert file in files_non_collapsed 47 | with open(os.path.join(MOCK_REPO_PATH + "_c", file), "r") as collapsed_file: 48 | lines_collapsed = len(collapsed_file.readlines()) 49 | with open(os.path.join(MOCK_REPO_PATH, file), "r") as non_collapsed_file: 50 | lines_non_collapsed = len(non_collapsed_file.readlines()) 51 | assert lines_collapsed < lines_non_collapsed 52 | 53 | 54 | def test_cli_filter_by_author(): 55 | cli_command = [ 56 | "python", "src/cli.py", 57 | "repo", 58 | "--repos", *REPOS_PATHS, 59 | "--mock_repo", MOCK_REPO_PATH, 60 | "--author", "Test Name " 61 | ] 62 | 63 | subprocess.run(cli_command, check=True) 64 | 65 | # Validate that the mock repository contains commits only by the specified author 66 | mock_repo = git.Repo(MOCK_REPO_PATH) 67 | for commit in mock_repo.iter_commits(): 68 | assert "test@example.com" in commit.author.email 69 | -------------------------------------------------------------------------------- /tests/test_collapse_changes.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import git 4 | import pytest 5 | from src.ImporterFromRepository import ImporterFromRepository 6 | from tests.tests_commons import REPOS_PATHS, MOCK_REPO_PATH 7 | 8 | 9 | @pytest.fixture 10 | def setup_mock_repos(): 11 | # Cleanup existing mock repos 12 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 13 | shutil.rmtree(MOCK_REPO_PATH + "_c", ignore_errors=True) 14 | yield 15 | # Cleanup after the test 16 | shutil.rmtree(MOCK_REPO_PATH, ignore_errors=True) 17 | shutil.rmtree(MOCK_REPO_PATH + "_c", ignore_errors=True) 18 | 19 | 20 | def test_collapse_changes_smaller_filesizes(setup_mock_repos): 21 | # Initialize repositories 22 | repos = [git.Repo(repo_path) for repo_path in REPOS_PATHS] 23 | 24 | # Test with collapse enabled 25 | mock_repo_collapsed = git.Repo.init(MOCK_REPO_PATH + "_c") 26 | importer_collapsed = ImporterFromRepository(repos, mock_repo_collapsed) 27 | importer_collapsed.set_collapse_multiple_changes_to_one(True) 28 | importer_collapsed.set_keep_commit_messages(True) 29 | importer_collapsed.import_repository() 30 | 31 | # Test with collapse disabled 32 | mock_repo = git.Repo.init(MOCK_REPO_PATH) 33 | importer_non_collapsed = ImporterFromRepository(repos, mock_repo) 34 | importer_non_collapsed.set_collapse_multiple_changes_to_one(False) 35 | importer_non_collapsed.set_keep_commit_messages(True) 36 | importer_non_collapsed.import_repository() 37 | 38 | # Get lists of files in each mock repo, excluding README.md 39 | files_collapsed = [ 40 | f for f in os.listdir(MOCK_REPO_PATH + "_c") 41 | if os.path.isfile(os.path.join(MOCK_REPO_PATH + "_c", f)) and not f.endswith(".md") 42 | ] 43 | files_non_collapsed = [ 44 | f for f in os.listdir(MOCK_REPO_PATH) 45 | if os.path.isfile(os.path.join(MOCK_REPO_PATH, f)) and not f.endswith(".md") 46 | ] 47 | 48 | # Assert same number of files 49 | assert len(files_collapsed) == len(files_non_collapsed) 50 | 51 | # Compare line counts for each file 52 | for file in files_collapsed: 53 | assert file in files_non_collapsed 54 | with open(os.path.join(MOCK_REPO_PATH + "_c", file), "r") as collapsed_file: 55 | lines_collapsed = len(collapsed_file.readlines()) 56 | with open(os.path.join(MOCK_REPO_PATH, file), "r") as non_collapsed_file: 57 | lines_non_collapsed = len(non_collapsed_file.readlines()) 58 | assert lines_collapsed < lines_non_collapsed 59 | -------------------------------------------------------------------------------- /src/Stats.py: -------------------------------------------------------------------------------- 1 | class Stats: 2 | """ 3 | A class that represents statistics for code changes. 4 | 5 | Attributes: 6 | insertions (dict): A dictionary that stores the number of insertions per file extension. 7 | deletions (dict): A dictionary that stores the number of deletions per file extension. 8 | max_changes_per_file (int): The maximum number of changes allowed per file. 9 | """ 10 | 11 | def __init__(self, max_changes_per_file=-1): 12 | self.insertions = {} 13 | self.deletions = {} 14 | self.max_changes_per_file = max_changes_per_file 15 | 16 | def add_insertions(self, ext: str, num: int): 17 | """ 18 | Adds the number of insertions for a specific file extension. 19 | 20 | Args: 21 | ext (str): The file extension. 22 | num (int): The number of insertions. 23 | """ 24 | if ext not in self.insertions: 25 | self.insertions[ext] = num 26 | else: 27 | self.insertions[ext] = self.insertions[ext] + num 28 | if 0 < self.max_changes_per_file < self.insertions[ext]: 29 | self.insertions[ext] = self.max_changes_per_file 30 | 31 | def add_deletions(self, ext, num): 32 | """ 33 | Adds the number of deletions for a specific file extension. 34 | 35 | Args: 36 | ext (str): The file extension. 37 | num (int): The number of deletions. 38 | """ 39 | if ext not in self.deletions: 40 | self.deletions[ext] = num 41 | else: 42 | self.deletions[ext] = self.deletions[ext] + num 43 | if 0 < self.max_changes_per_file < self.deletions[ext]: 44 | self.deletions[ext] = self.max_changes_per_file 45 | 46 | def iterate_insertions(self, max_changes=-1): 47 | """ 48 | Iterates over the insertions in the Stats object, breaking them down into smaller chunks. 49 | 50 | Args: 51 | max_changes (int, optional): The maximum number of changes allowed in each chunk. Defaults to -1, which means no limit. 52 | 53 | Yields: 54 | Stats: A new Stats object with a subset of the insertions. 55 | """ 56 | if max_changes <= 0: 57 | yield self 58 | return 59 | broken_stats = Stats() 60 | acc = 0 61 | for k, v in self.insertions.items(): 62 | while v > 0: 63 | changes = min(max_changes - acc, v) 64 | v -= changes 65 | broken_stats.insertions[k] = changes 66 | acc += changes 67 | if acc >= max_changes: 68 | yield broken_stats 69 | broken_stats.insertions = {} 70 | acc = 0 71 | for k, v in self.deletions.items(): 72 | while v > 0: 73 | changes = min(max_changes - acc, v) 74 | v -= changes 75 | broken_stats.deletions[k] = changes 76 | acc += changes 77 | if acc >= max_changes: 78 | yield broken_stats 79 | broken_stats.deletions = {} 80 | acc = 0 81 | if len(broken_stats.insertions) > 0 or len(broken_stats.deletions) > 0: 82 | yield broken_stats 83 | 84 | def __str__(self): 85 | """ 86 | Returns a string representation of the Stats object. 87 | """ 88 | return 'insertions: ' + str(self.insertions) \ 89 | + ' deletions: ' + str(self.deletions) 90 | -------------------------------------------------------------------------------- /src/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import datetime 4 | import git 5 | from src.ImporterFromStats import ImporterFromStats 6 | from src.ImporterFromRepository import ImporterFromRepository 7 | 8 | 9 | def handle_stats_action(args): 10 | manager = ImporterFromStats( 11 | mock_repo_path=args.mock_repo, 12 | generator_type=args.generator, 13 | max_commits_per_day=args.max_commits_per_day, 14 | ) 15 | 16 | if args.author: 17 | manager.set_author(args.author) 18 | 19 | manager.process_csv(args.csv) 20 | 21 | 22 | def handle_repo_action(args): 23 | repos = [git.Repo(repo_path) for repo_path in args.repos] 24 | mock_repo = git.Repo.init(args.mock_repo) 25 | importer = ImporterFromRepository(repos, mock_repo) 26 | 27 | if args.author: 28 | importer.set_author(args.author) 29 | if args.max_commits_per_day: 30 | importer.set_max_commits_per_day([int(v) for v in args.max_commits_per_day]) 31 | if args.commit_max_amount_changes is not None: 32 | importer.set_commit_max_amount_changes(args.commit_max_amount_changes) 33 | if args.changes_commits_max_time_backward is not None: 34 | importer.set_changes_commits_max_time_backward(args.changes_commits_max_time_backward) 35 | if args.ignored_file_types: 36 | importer.set_ignored_file_types(args.ignored_file_types) 37 | if args.ignore_before_date: 38 | ignore_date = datetime.datetime.strptime(args.ignore_before_date, "%Y-%m-%d") 39 | importer.set_ignore_before_date(ignore_date.timestamp()) 40 | importer.set_collapse_multiple_changes_to_one(args.collapse_multiple_changes) 41 | importer.set_keep_commit_messages(args.keep_commit_messages) 42 | importer.set_start_from_last(args.start_from_last) 43 | 44 | importer.import_repository() 45 | 46 | 47 | def main(): 48 | parser = argparse.ArgumentParser(description="Unified CLI for Contribution Importer") 49 | 50 | subparsers = parser.add_subparsers(dest="action", required=True) 51 | 52 | # region 'stats' action 53 | stats_parser = subparsers.add_parser( 54 | "stats", 55 | help="Generate commits based on contribution stats." 56 | ) 57 | stats_parser.add_argument( 58 | "--csv", 59 | required=True, 60 | help="Path to the CSV file containing contributions data." 61 | ) 62 | stats_parser.add_argument( 63 | "--mock_repo", 64 | required=True, 65 | help="Path to the mock repository." 66 | ) 67 | stats_parser.add_argument( 68 | "--generator", 69 | required=True, 70 | help="File type for the generator (e.g., '.ts')." 71 | ) 72 | stats_parser.add_argument( 73 | "--max-commits-per-day", 74 | type=int, 75 | default=10, 76 | help="Maximum number of commits per day (default: 10)." 77 | ) 78 | stats_parser.add_argument( 79 | "--author", 80 | nargs="+", 81 | help="Emails of the author to filter commits by." 82 | ) 83 | # endregion 84 | 85 | # region 'repo' action 86 | repo_parser = subparsers.add_parser( 87 | "repo", 88 | help="Import contributions from repositories." 89 | ) 90 | repo_parser.add_argument( 91 | "--repos", 92 | nargs="+", 93 | help="Paths to the repositories to import from." 94 | ) 95 | repo_parser.add_argument( 96 | "--mock_repo", 97 | help="Path to the mock repository." 98 | ) 99 | repo_parser.add_argument( 100 | "--author", 101 | nargs="+", 102 | help="Emails of the author to filter commits by." 103 | ) 104 | repo_parser.add_argument( 105 | "--max-commits-per-day", 106 | nargs=2, 107 | type=int, 108 | help="Max commits per day as a range (min max)." 109 | ) 110 | repo_parser.add_argument( 111 | "--commit-max-amount-changes", 112 | type=int, 113 | help="Max number of changes allowed per commit." 114 | ) 115 | repo_parser.add_argument( 116 | "--changes-commits-max-time-backward", 117 | type=int, 118 | help="Max time backward for splitting commits (in seconds)." 119 | ) 120 | repo_parser.add_argument( 121 | "--ignored-file-types", 122 | nargs="+", 123 | help="List of file types to ignore (e.g., .csv, .txt)." 124 | ) 125 | repo_parser.add_argument( 126 | "--ignore-before-date", 127 | type=str, 128 | help="Ignore commits before this date (YYYY-MM-DD)." 129 | ) 130 | repo_parser.add_argument( 131 | "--collapse-multiple-changes", 132 | action="store_true", 133 | help="Collapse multiple changes into one." 134 | ) 135 | repo_parser.add_argument( 136 | "--keep-commit-messages", 137 | action="store_true", 138 | help="Keep original commit messages." 139 | ) 140 | repo_parser.add_argument( 141 | "--start-from-last", 142 | action="store_true", 143 | help="Start importing from the last commit." 144 | ) 145 | # endregion 146 | 147 | args = parser.parse_args() 148 | 149 | if args.action == "stats": 150 | handle_stats_action(args) 151 | elif args.action == "repo": 152 | handle_repo_action(args) 153 | 154 | 155 | if __name__ == "__main__": 156 | print(sys.argv) 157 | main() 158 | -------------------------------------------------------------------------------- /src/ImporterFromStats.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import datetime as dt 3 | import git 4 | import time 5 | import random 6 | 7 | from .commons import extract_name_email 8 | from .Committer import Committer 9 | from .Content import Content 10 | from .generators import apply_generator 11 | from .Stats import Stats 12 | 13 | DEFAULT_TIME_RANGE = (9, 18) 14 | DEFAULT_MAX_COMMITS_PER_DAY = 10 15 | DEFAULT_MAX_CHANGES_PER_FILE = 10 16 | DEFAULT_JITTER = (((DEFAULT_TIME_RANGE[1] - DEFAULT_TIME_RANGE[0] 17 | ) / DEFAULT_MAX_COMMITS_PER_DAY) * 60 * 60) / 2 18 | 19 | 20 | class ImporterFromStats: 21 | 22 | def __init__( 23 | self, mock_repo_path, 24 | generator_type, 25 | max_changes_per_file=DEFAULT_MAX_CHANGES_PER_FILE, 26 | max_commits_per_day=DEFAULT_MAX_COMMITS_PER_DAY, 27 | commit_time_range=DEFAULT_TIME_RANGE, 28 | author=None, 29 | jitter=DEFAULT_JITTER): 30 | """ 31 | :param mock_repo_path: Path to the mock repository. 32 | :param generator_type: File type for the generator (e.g., ".ts"). 33 | :param max_changes_per_file: Number of max changes per file on each commit 34 | :param max_commits_per_day: max commits per day. 35 | """ 36 | self.repo = git.Repo.init(mock_repo_path) 37 | self.content = Content(self.repo.working_tree_dir) 38 | self.committer = Committer(mock_repo_path, self.content) 39 | self.generator_type = generator_type 40 | self.max_changes_per_file = max_changes_per_file 41 | self.max_commits_per_day = max_commits_per_day 42 | self.commit_time_range = commit_time_range 43 | self.author = author 44 | self.jitter = jitter 45 | 46 | def set_author(self, author: str | list): 47 | """ 48 | Set author from a string "Some Name " or a list of such strings. 49 | """ 50 | if isinstance(author, list): 51 | self.author = [extract_name_email(a) for a in author if extract_name_email(a) is not None] 52 | else: 53 | self.author = extract_name_email(author) 54 | 55 | def count_commits_for_date(self, date): 56 | """ 57 | Count the number of commits for a given date. 58 | :param date: Date in 'YYYY-MM-DD' format. 59 | :return: Number of commits for the given date. 60 | """ 61 | try: 62 | log_output = self.repo.git.log('--pretty=format:%ad', '--date=short') 63 | committed_dates = log_output.split('\n') 64 | return committed_dates.count(date) 65 | except BaseException: 66 | return 0 67 | 68 | def parse_date(self, date_str): 69 | try: 70 | date = dt.datetime.strptime(date_str, "%Y-%m-%d") 71 | except ValueError: 72 | raise ValueError(f"Invalid date format: {date_str}. Expected 'YYYY-MM-DD'.") 73 | return date 74 | 75 | def process_csv(self, file_path): 76 | """ 77 | Process a CSV file and create commits based on the contributions data. 78 | :param file_path: Path to the CSV file. 79 | """ 80 | with open(file_path, "r") as file: 81 | reader = csv.reader(file) 82 | next(reader) # Skip the header 83 | 84 | # Read all rows to calculate max_commits_in_stats 85 | rows = [row for row in reader] 86 | max_commits_in_stats = max(int(row[0]) for row in rows) 87 | 88 | for row in rows: 89 | target_commits = int(row[0]) # Target number of commits for the date 90 | date_str = row[1] 91 | print(target_commits, "commits at", date_str) 92 | date = self.parse_date(date_str) 93 | 94 | # Normalize the number of commits for the day 95 | scaled_commits = int( 96 | (target_commits / max_commits_in_stats) * self.max_commits_per_day 97 | ) 98 | scaled_commits = max( 99 | 0, min( 100 | scaled_commits, self.max_commits_per_day)) 101 | 102 | # Count existing commits for the date 103 | existing_commits = self.count_commits_for_date(date_str) 104 | 105 | # Calculate missing commits 106 | missing_commits = scaled_commits - existing_commits 107 | if missing_commits > 0: 108 | print(f"Creating {missing_commits} commits for {date_str}") 109 | self.create_commits(date, missing_commits) 110 | else: 111 | print(f"No additional commits needed for {date_str}") 112 | 113 | def create_commits(self, date, count): 114 | """ 115 | Create the specified number of commits for a given date, generating content using apply_generator. 116 | :param date: Date for the commits (datetime object). 117 | :param count: Number of commits to create. 118 | """ 119 | # Define the commit time hours (for example working hours) 120 | start_time = dt.datetime.combine(date, dt.time(self.commit_time_range[0], 0, 0)) 121 | end_time = dt.datetime.combine(date, dt.time(self.commit_time_range[1], 0, 0)) 122 | 123 | # Calculate the time interval between commits 124 | working_seconds = (end_time - start_time).total_seconds() 125 | interval = working_seconds // count if count > 0 else 0 126 | 127 | current_time = start_time 128 | for i in range(count): 129 | # Generate stats and apply generator 130 | stats = Stats() 131 | stats.insertions[self.generator_type] = random.randint(1, self.max_changes_per_file) 132 | stats.deletions[self.generator_type] = random.randint(1, self.max_changes_per_file) 133 | apply_generator(self.content, stats) 134 | self.content.save() 135 | 136 | # Convert current_time to a Unix timestamp 137 | commit_date = int(time.mktime(current_time.timetuple()) + random.randint(0, int(self.jitter))) 138 | 139 | # Commit changes 140 | message = ( 141 | f"Add code in files of type: {','.join(stats.insertions.keys())}\n" 142 | f"Remove code in files of type: {','.join(stats.deletions.keys())}" 143 | ) 144 | self.committer.commit(commit_date, message, self.author) 145 | print( 146 | f"Commit created for {current_time.strftime('%Y-%m-%d')} with message:\n{message}") 147 | 148 | # Increment current_time by the interval 149 | current_time += dt.timedelta(seconds=interval) 150 | -------------------------------------------------------------------------------- /src/ImporterFromRepository.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import pathlib 4 | from random import random 5 | 6 | import time 7 | 8 | from src.commons import extract_name_email 9 | 10 | from .Committer import Committer 11 | from .Content import Content 12 | from .generators import apply_generator 13 | from .Stats import Stats 14 | 15 | 16 | class ImporterFromRepository: 17 | 18 | def __init__(self, repos, mock_repo): 19 | 20 | # Maximum amount in the past that the commit can be shifted for. The values are in seconds. 21 | self.commit_time_max_past = 0 22 | 23 | # The maximum number of changes (line of code changed, added or removed) that a commit can have. Commits with 24 | # many changes are disadvantaged in GitHub. Most likely these large commits could have been split in many 25 | # smaller ones. GitHub users that know how contributions are calculated are prone to do several smaller commits 26 | # instead, while in private repository this could not be necessary, especially in smaller teams. 27 | # The default is -1, and it is to indicate no limits. 28 | self.commit_max_changes = -1 29 | 30 | # Maximum number of changes per file. By default for each change (line of code changed, added or removed) a 31 | # line of mock code is changed. This would limit the number of generated mock code for extreme cases where too 32 | # many lines of codes are changes (e.g. SQL database dump). 33 | self.max_changes_per_file = 100 34 | 35 | # If commit_max_changes is a positive number, a commit could be break in several ones. 36 | # In that case this value decides how long these commits could go in the past. The idea 37 | # is that a big commit is likely composed by several features that could have been 38 | # committed in different commits. These changes would have been some time before the actual 39 | # big commit. The time is in seconds. 40 | self.changes_commits_max_time_backward = 60 * 60 * 24 * 4 # 4 days as default 41 | 42 | # It allows the importer to collapse several lines of changes to just one per commit, 43 | # and one per type of file. This allows avoiding excessive growth of files size. 44 | self.collapse_multiple_changes_to_one = True 45 | 46 | # It allows some types of files to be ignored. For example ['.csv', 47 | # '.txt', '.pdf', '.log', '.sql', '.json'] 48 | self.ignored_file_types = [] 49 | 50 | # In case the settings above are too crazy it doesn't commit too much (the 51 | # array is to have a random value instead of a specific one) 52 | self.max_commits_per_day = [10, 15] 53 | 54 | # Ignore all the commits before this date, in order to analyze same repositories over time 55 | self.ignore_before_date = None 56 | 57 | # Ignore all the commits before last commit 58 | self.start_from_last = False 59 | 60 | # Author to analyze. If None commits from any author will be imported. Author is given as email 61 | # This could be an array of email in case, depending on the repository, 62 | # the author has different emails. 63 | self.author = None 64 | 65 | # Keep the original commit message if true 66 | self.keep_commit_messages = False 67 | 68 | self.repos = repos 69 | self.mock_repo = mock_repo 70 | self.content = Content(mock_repo.working_tree_dir) 71 | self.committer = Committer(mock_repo.working_tree_dir, self.content) 72 | 73 | def import_repository(self): 74 | commits_for_last_day = 0 75 | 76 | if self.start_from_last: 77 | last_committed_date = self.committer.get_last_commit_date() 78 | print('\nStarting from last commit: ' + 79 | time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_committed_date))) 80 | else: 81 | print('\nStarting') 82 | last_committed_date = 0 83 | 84 | author_emails = ([a.email for a in self.author] if isinstance(self.author, list) else [ 85 | self.author.email]) if self.author else [] 86 | 87 | for c in self.get_all_commits(last_committed_date + 1): 88 | print('Analyze commit at ' + 89 | time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(c.committed_date))) 90 | print(' msg:" ' + c.message + '"') 91 | 92 | if len(author_emails) > 0 and c.author.email not in author_emails: 93 | print(' Commit skipped because the author is: ' + c.author.email) 94 | continue 95 | 96 | committed_date = c.committed_date 97 | if self.commit_time_max_past > 0: 98 | committed_date -= int(random() * self.commit_time_max_past) 99 | print(' Commit date changed to: ' + 100 | time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(c.committed_date))) 101 | 102 | stats = Stats(self.max_changes_per_file) 103 | self.get_changes(c, stats) 104 | print(' Commit changes: ' + str(stats)) 105 | for broken_stats in stats.iterate_insertions(self.commit_max_changes): 106 | if self.collapse_multiple_changes_to_one: 107 | for k in broken_stats.insertions.keys(): 108 | broken_stats.insertions[k] = 1 109 | for k in broken_stats.deletions.keys(): 110 | broken_stats.deletions[k] = 1 111 | print(' Apply changes: ' + str(broken_stats)) 112 | apply_generator(self.content, broken_stats) 113 | self.content.save() 114 | break_committed_date = committed_date 115 | if broken_stats != stats: 116 | max_past = self.changes_commits_max_time_backward 117 | if last_committed_date != 0: 118 | max_past = min(break_committed_date - last_committed_date, max_past) 119 | break_committed_date -= int(random() * (max_past / 3) + (max_past / 3 * 2)) 120 | if time.strftime("%Y-%m-%d", time.localtime(last_committed_date) 121 | ) == time.strftime("%Y-%m-%d", time.localtime(break_committed_date)): 122 | commits_for_last_day += 1 123 | if commits_for_last_day > random( 124 | ) * (self.max_commits_per_day[1] - self.max_commits_per_day[0]) + self.max_commits_per_day[0]: 125 | print(' Commit skipped because the maximum amount of commit for ' + 126 | time.strftime("%Y-%m-%d", time.localtime(last_committed_date)) + ' exceeded') 127 | continue 128 | else: 129 | commits_for_last_day = 1 130 | print(' Commit at: ' + 131 | time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(break_committed_date))) 132 | print("keep commit messages" + str(self.keep_commit_messages)) 133 | if self.keep_commit_messages: 134 | message = c.message 135 | else: 136 | message = 'add code in files types: ' + ','.join(broken_stats.insertions.keys()) + \ 137 | '\nremove code in files types: ' + ','.join(broken_stats.deletions.keys()) 138 | self.committer.commit(break_committed_date, message, self.author) 139 | last_committed_date = break_committed_date 140 | 141 | def get_all_commits(self, ignore_before_date): 142 | ''' iter commits coming from any branch''' 143 | commits = [] 144 | s = set() # to remove duplicated commits from other branches 145 | for repo in self.repos: 146 | for b in repo.branches: 147 | for c in repo.iter_commits(b.name): 148 | if c.committed_date < ignore_before_date or ( 149 | self.ignore_before_date is not None and c.committed_date < self.ignore_before_date): 150 | continue 151 | if c.hexsha not in s: 152 | s.add(c.hexsha) 153 | commits.append(c) 154 | commits.sort(key=lambda c: c.committed_date) 155 | return commits 156 | 157 | def get_changes(self, commit, stats): 158 | ''' for a specific commit it gets all the changed files ''' 159 | for k, v in commit.stats.files.items(): 160 | ext = pathlib.Path(k).suffix 161 | if ext in self.ignored_file_types: 162 | continue 163 | if v['insertions'] > 0: 164 | stats.add_insertions(ext, v['insertions']) 165 | if v['deletions'] > 0: 166 | stats.add_deletions(ext, v['deletions']) 167 | 168 | def set_commit_time_max_past(self, value): 169 | self.commit_time_max_past = value 170 | 171 | def set_commit_max_amount_changes(self, max_amount): 172 | self.commit_max_changes = max_amount 173 | 174 | def set_changes_commits_max_time_backward(self, max_amount): 175 | self.changes_commits_max_time_backward = max_amount 176 | 177 | def set_collapse_multiple_changes_to_one(self, value): 178 | self.collapse_multiple_changes_to_one = value 179 | 180 | def set_ignored_file_types(self, file_types): 181 | self.ignored_file_types = file_types 182 | 183 | def set_max_changes_per_file(self, value): 184 | self.max_changes_per_file = value 185 | 186 | def set_max_commits_per_day(self, value): 187 | self.max_commits_per_day = value 188 | 189 | def set_ignore_before_date(self, value): 190 | self.ignore_before_date = value 191 | 192 | def set_start_from_last(self, value): 193 | self.start_from_last = value 194 | 195 | def set_author(self, author: str | list): 196 | """ 197 | Set author from a string "Some Name " or a list of such strings. 198 | """ 199 | if isinstance(author, list): 200 | self.author = [extract_name_email(a) for a in author if extract_name_email(a) is not None] 201 | else: 202 | self.author = extract_name_email(author) 203 | 204 | def set_keep_commit_messages(self, value): 205 | self.keep_commit_messages = value 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contributions Importer for GitHub 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/miromannino/Contributions-Importer-For-Github/blob/main/LICENSE) [![pypi version](https://img.shields.io/pypi/v/git-import-contributions.svg)](https://pypi.org/project/git-import-contributions/) [![Build and Tests](https://github.com/miromannino/Contributions-Importer-For-Github/actions/workflows/tests.yaml/badge.svg)](https://github.com/miromannino/Contributions-Importer-For-Github/actions/workflows/tests.yaml) 4 | 5 | This tool helps users to import contributions to GitHub from private git repositories, or from public repositories that are not hosted in GitHub. 6 | 7 |

8 | 9 |

10 | 11 | ## How it Works 12 | 13 | In its simplest case, this tool copies all commits from a source git repository to a mock git repository. Each copied commit will report the same commit date, but the original code is not copied, nor is the commit message. 14 | 15 |

16 | 17 |

18 | 19 | _Contributions Importer_ will create instead mock code to report which languages have been used in the source repository. 20 | 21 | You can also have multiple source git repositories as well to report activities from several private git repositories. 22 | 23 | ## Reasons 24 | 25 | GitHub shows contributions statistics of its users. There are [several reasons](https://github.com/isaacs/github/issues/627) why this feature could be debatable. 26 | 27 | Moreover, this mechanism only rewards developers who work on GitHub-maintained repositories. 28 | 29 | Considering the undeniable popularity of GitHub, developers that use other platforms are disadvantaged. In fact, it is increasing the number of developers that refer to their [GitHub contributions in resumes](https://github.com/resume/resume.github.com). Similarly, recruiters [may use GitHub to find talent](https://www.socialtalent.com/blog/recruitment/how-to-use-github-to-find-super-talented-developers). 30 | 31 | In more extreme cases, some developers decided to boycott GitHub's lock-in system and developed tools that can alter GitHub's contribution graph with fake commits: [Rockstar](https://github.com/avinassh/rockstar) and [Vanity text for GitHub](https://github.com/ihabunek/github-vanity) are good examples. 32 | 33 | Instead, [Contributions Importer for GitHub](https://github.com/miromannino/contributions-importer-for-github) aims to generate an overall realistic activity overview. 34 | 35 | ### Installation 36 | 37 | To install using `pip`: 38 | 39 | ```bash 40 | pip install git-import-contributions 41 | ``` 42 | 43 | Using `brew`: 44 | 45 | ```bash 46 | brew install git-import-contributions 47 | ``` 48 | 49 | ### Usage 50 | 51 | The `git-import-contributions` CLI provides an interface for importing contributions into a mock Git repository using data from either a CSV file or other repositories. This tool is designed for developers and operates exclusively via the command line. 52 | 53 | The `git-import-contributions` CLI has two main modes of operation: **stats** and **repo**. 54 | 55 | #### General Syntax 56 | 57 | ```bash 58 | git-import-contributions [options] 59 | ``` 60 | 61 | ### Actions 62 | 63 | #### 1. Stats Mode 64 | 65 | Generates commits in a mock repository based on a CSV file containing contribution statistics. 66 | 67 | **Command:** 68 | 69 | ```bash 70 | git-import-contributions stats \ 71 | --csv \ 72 | --mock_repo \ 73 | --generator 74 | ``` 75 | 76 | **Options:** 77 | 78 | - `--csv `: Path to the CSV file containing contribution statistics. 79 | - `--mock_repo `: Path to the mock Git repository. 80 | - `--generator `: Type of generator to use for file creation (e.g., `.ts`). 81 | 82 | **Other Optional Options:** 83 | 84 | - `--max-commits-per-day `: Maximum number of commits per day (default: 10). 85 | - `--author `: Filter commits by the specified author(s) (optional). Accepts multiple email addresses. 86 | 87 | **Example:** 88 | 89 | ```bash 90 | git-import-contributions stats --csv data.csv --mock_repo mock-repo --generator .py --max-commits-per-day 5 --author "example@example.com" 91 | ``` 92 | 93 | #### 2. Repo Mode 94 | 95 | Imports contributions into a mock repository by analyzing one or more existing repositories. 96 | 97 | **Command:** 98 | 99 | ```bash 100 | git-import-contributions repo \ 101 | --repos \ 102 | --mock_repo 103 | ``` 104 | 105 | **Options:** 106 | 107 | - `--repos `: Paths to the repositories to analyze (required). Accepts multiple paths. 108 | - `--mock_repo `: Path to the mock Git repository (required). 109 | 110 | **Other Optional Options:** 111 | 112 | - `--author `: Filter commits by the specified author(s) (optional). Accepts multiple email addresses. 113 | - `--max-commits-per-day `: Set a range for the number of commits per day (optional). 114 | - `--commit-max-amount-changes `: Limit the number of changes per commit (optional). 115 | - `--changes-commits-max-time-backward `: Maximum time backward for splitting large commits (optional). 116 | - `--ignored-file-types `: List of file types to ignore (e.g., `.csv`, `.txt`) (optional). 117 | - `--ignore-before-date `: Ignore commits before this date (optional). 118 | - `--collapse-multiple-changes`: Collapse multiple changes into one per type of file (optional). 119 | - `--keep-commit-messages`: Keep original commit messages instead of using mocked ones (optional). 120 | - `--start-from-last`: Start importing from the last commit in the mock repository (optional). 121 | 122 | **Example:** 123 | 124 | ```bash 125 | git-import-contributions repo --repos repo1 repo2 --mock_repo mock-repo --author "dev@example.com" --max-commits-per-day 5 10 --ignore-before-date 2020-01-01 126 | ``` 127 | 128 | ### Advanced Features 129 | 130 | The `repo` mode supports additional options to control how contributions are imported: 131 | 132 | 1. **Masking Commit Time** 133 | Commit times can be randomized using `--changes-commits-max-time-backward`. 134 | 135 | 2. **Limiting Changes per Commit** 136 | Use `--commit-max-amount-changes` to set a cap on the number of changes in a single commit. 137 | 138 | 3. **Incremental Imports** 139 | Use `--start-from-last` to import contributions incrementally starting from the most recent commit in the mock repository 140 | 141 | 4. **Ignoring Commits Before a Date** 142 | Use `--ignore-before-date` to skip commits older than a specific date. 143 | 144 | ### Help 145 | 146 | To view the full list of commands and options: 147 | 148 | ```bash 149 | git-import-contributions --help 150 | ``` 151 | 152 | For specific actions: 153 | 154 | ```bash 155 | git-import-contributions stats --help 156 | git-import-contributions repo --help 157 | ``` 158 | 159 | ### Other good tutorials about this project 160 | 161 | - [How I Restored My Git Contributions](https://medium.com/@razan.joc/how-i-restored-my-git-contributions-7ddb27f06d4e) by Rajan Joshi 162 | - [Import Contributions from Bitbucket to GitHub](https://medium.com/@danielnmai/import-contributions-from-bitbucket-to-github-afd9160eaf6d) by Daniel Mai 163 | 164 | ## Contributing 165 | 166 | We welcome contributions from the community. Please fork the repository, create a new branch, and submit a pull request with your changes. 167 | 168 | Ensure all tests pass and update documentation as needed. 169 | 170 | ### Code style 171 | 172 | Regarding code styles like indentation and whitespace, **follow the conventions you see used in the source already.** 173 | 174 | A pep8 auto formatter is used with the following settings: 175 | 176 | ```ini 177 | [pep8] 178 | indent-size = 2 179 | ignore = E121 180 | max-line-length = 100 181 | aggressive = true 182 | ``` 183 | 184 | It can also be configured in VSCode `settings.json` with: 185 | 186 | ```json 187 | "autopep8.args": [ 188 | "--indent-size=2", 189 | "--ignore=E121", 190 | "--max-line-length=100", 191 | "--aggressive" 192 | ] 193 | ``` 194 | 195 | ### Submitting pull requests 196 | 197 | - Create a new branch; avoid working directly in the `master` branch. 198 | - Write failing tests for the changes you plan to implement. 199 | - Make the necessary changes to fix the issues. 200 | - Ensure all tests, including the new ones, pass successfully. 201 | - Update the documentation to reflect any modifications. 202 | - Push your changes to your fork and submit a pull request. 203 | 204 | ### Use from source 205 | 206 | Make sure you have first of all `pipenv` installed and install all required dependencies: 207 | 208 | ```bash 209 | ./scripts/install-dependencies.sh 210 | ``` 211 | 212 | You can then use the CLI with: 213 | 214 | ```bash 215 | ./scripts/cli.sh --help 216 | ``` 217 | 218 | ### Tests 219 | 220 | In order to run tests: 221 | 222 | Make sure you have first of all `pipenv` installed and install all required dependencies: 223 | 224 | ```bash 225 | ./scripts/install-dependencies.sh 226 | ``` 227 | 228 | Start tests with: 229 | 230 | ```bash 231 | ./scripts/run-tests.sh 232 | ``` 233 | 234 | ### Install from source 235 | 236 | To install from source using `pip`: 237 | 238 | ```bash 239 | pip install . 240 | ``` 241 | 242 | To uninstall 243 | 244 | ```bash 245 | pip uninstall git-import-contributions 246 | ``` 247 | 248 | To test Brew installation locally: 249 | 250 | ```bash 251 | brew install --build-from-source ./git-import-contributions.rb 252 | ``` 253 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "ce36f3ab63360cc0c32be563ac6319b5cbfa6c75333c32cb266ae2693c0971c9" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "gitdb": { 20 | "hashes": [ 21 | "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", 22 | "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" 23 | ], 24 | "markers": "python_version >= '3.7'", 25 | "version": "==4.0.12" 26 | }, 27 | "gitpython": { 28 | "hashes": [ 29 | "sha256:1bf9cd7c9e7255f77778ea54359e54ac22a72a5b51288c457c881057b7bb9ecd", 30 | "sha256:2d99869e0fef71a73cbd242528105af1d6c1b108c60dfabd994bf292f76c3ceb" 31 | ], 32 | "index": "pypi", 33 | "markers": "python_version >= '3.7'", 34 | "version": "==3.1.42" 35 | }, 36 | "smmap": { 37 | "hashes": [ 38 | "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", 39 | "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e" 40 | ], 41 | "markers": "python_version >= '3.7'", 42 | "version": "==5.0.2" 43 | } 44 | }, 45 | "develop": { 46 | "certifi": { 47 | "hashes": [ 48 | "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", 49 | "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" 50 | ], 51 | "markers": "python_version >= '3.6'", 52 | "version": "==2024.12.14" 53 | }, 54 | "charset-normalizer": { 55 | "hashes": [ 56 | "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", 57 | "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa", 58 | "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a", 59 | "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", 60 | "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b", 61 | "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", 62 | "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", 63 | "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", 64 | "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", 65 | "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", 66 | "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", 67 | "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", 68 | "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", 69 | "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", 70 | "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", 71 | "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", 72 | "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", 73 | "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", 74 | "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", 75 | "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", 76 | "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e", 77 | "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a", 78 | "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4", 79 | "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca", 80 | "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", 81 | "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", 82 | "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", 83 | "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", 84 | "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", 85 | "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", 86 | "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", 87 | "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", 88 | "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", 89 | "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", 90 | "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", 91 | "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd", 92 | "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c", 93 | "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", 94 | "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", 95 | "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", 96 | "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", 97 | "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824", 98 | "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", 99 | "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf", 100 | "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487", 101 | "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d", 102 | "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd", 103 | "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", 104 | "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534", 105 | "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", 106 | "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", 107 | "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", 108 | "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd", 109 | "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", 110 | "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9", 111 | "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", 112 | "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", 113 | "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d", 114 | "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", 115 | "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", 116 | "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", 117 | "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", 118 | "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", 119 | "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", 120 | "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8", 121 | "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", 122 | "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", 123 | "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", 124 | "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", 125 | "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", 126 | "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", 127 | "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", 128 | "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", 129 | "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", 130 | "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", 131 | "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", 132 | "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", 133 | "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e", 134 | "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6", 135 | "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", 136 | "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", 137 | "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e", 138 | "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", 139 | "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", 140 | "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c", 141 | "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", 142 | "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", 143 | "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089", 144 | "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", 145 | "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e", 146 | "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", 147 | "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616" 148 | ], 149 | "markers": "python_version >= '3.7'", 150 | "version": "==3.4.1" 151 | }, 152 | "docutils": { 153 | "hashes": [ 154 | "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", 155 | "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2" 156 | ], 157 | "markers": "python_version >= '3.9'", 158 | "version": "==0.21.2" 159 | }, 160 | "id": { 161 | "hashes": [ 162 | "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", 163 | "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658" 164 | ], 165 | "markers": "python_version >= '3.8'", 166 | "version": "==1.5.0" 167 | }, 168 | "idna": { 169 | "hashes": [ 170 | "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", 171 | "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 172 | ], 173 | "markers": "python_version >= '3.6'", 174 | "version": "==3.10" 175 | }, 176 | "iniconfig": { 177 | "hashes": [ 178 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 179 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 180 | ], 181 | "markers": "python_version >= '3.7'", 182 | "version": "==2.0.0" 183 | }, 184 | "jaraco.classes": { 185 | "hashes": [ 186 | "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", 187 | "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" 188 | ], 189 | "markers": "python_version >= '3.8'", 190 | "version": "==3.4.0" 191 | }, 192 | "jaraco.context": { 193 | "hashes": [ 194 | "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", 195 | "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" 196 | ], 197 | "markers": "python_version >= '3.8'", 198 | "version": "==6.0.1" 199 | }, 200 | "jaraco.functools": { 201 | "hashes": [ 202 | "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", 203 | "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649" 204 | ], 205 | "markers": "python_version >= '3.8'", 206 | "version": "==4.1.0" 207 | }, 208 | "keyring": { 209 | "hashes": [ 210 | "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", 211 | "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd" 212 | ], 213 | "markers": "python_version >= '3.9'", 214 | "version": "==25.6.0" 215 | }, 216 | "markdown-it-py": { 217 | "hashes": [ 218 | "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", 219 | "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" 220 | ], 221 | "markers": "python_version >= '3.8'", 222 | "version": "==3.0.0" 223 | }, 224 | "mdurl": { 225 | "hashes": [ 226 | "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", 227 | "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" 228 | ], 229 | "markers": "python_version >= '3.7'", 230 | "version": "==0.1.2" 231 | }, 232 | "more-itertools": { 233 | "hashes": [ 234 | "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", 235 | "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89" 236 | ], 237 | "markers": "python_version >= '3.9'", 238 | "version": "==10.6.0" 239 | }, 240 | "nh3": { 241 | "hashes": [ 242 | "sha256:09f037c02fc2c43b211ff1523de32801dcfb0918648d8e651c36ef890f1731ec", 243 | "sha256:0ae9cbd713524cdb81e64663d0d6aae26f678db9f2cd9db0bf162606f1f9f20c", 244 | "sha256:10317cd96fe4bbd4eb6b95f3920b71c902157ad44fed103fdcde43e3b8ee8be6", 245 | "sha256:181063c581defe683bd4bb78188ac9936d208aebbc74c7f7c16b6a32ae2ebb38", 246 | "sha256:1b9a8340a0aab991c68a5ca938d35ef4a8a3f4bf1b455da8855a40bee1fa0ace", 247 | "sha256:231addb7643c952cd6d71f1c8702d703f8fe34afcb20becb3efb319a501a12d7", 248 | "sha256:3eb04b9c3deb13c3a375ea39fd4a3c00d1f92e8fb2349f25f1e3e4506751774b", 249 | "sha256:47b2946c0e13057855209daeffb45dc910bd0c55daf10190bb0b4b60e2999784", 250 | "sha256:4fd2e9248725ebcedac3997a8d3da0d90a12a28c9179c6ba51f1658938ac30d0", 251 | "sha256:6ed834c68452a600f517dd3e1534dbfaff1f67f98899fecf139a055a25d99150", 252 | "sha256:76e2f603b30c02ff6456b233a83fc377dedab6a50947b04e960a6b905637b776", 253 | "sha256:813f1c8012dd64c990514b795508abb90789334f76a561fa0fd4ca32d2275330", 254 | "sha256:8698db4c04b140800d1a1cd3067fda399e36e1e2b8fc1fe04292a907350a3e9b", 255 | "sha256:92f3f1c4f47a2c6f3ca7317b1d5ced05bd29556a75d3a4e2715652ae9d15c05d", 256 | "sha256:9705c42d7ff88a0bea546c82d7fe5e59135e3d3f057e485394f491248a1f8ed5", 257 | "sha256:ac4d27dc836a476efffc6eb661994426b8b805c951b29c9cf2ff36bc9ad58bc5", 258 | "sha256:ce3731c8f217685d33d9268362e5b4f770914e922bba94d368ab244a59a6c397", 259 | "sha256:d2a176fd4306b6f0f178a3f67fac91bd97a3a8d8fafb771c9b9ef675ba5c8886", 260 | "sha256:da87573f03084edae8eb87cfe811ec338606288f81d333c07d2a9a0b9b976c0b", 261 | "sha256:ddefa9fd6794a87e37d05827d299d4b53a3ec6f23258101907b96029bfef138a", 262 | "sha256:e1061a4ab6681f6bdf72b110eea0c4e1379d57c9de937db3be4202f7ad6043db", 263 | "sha256:e1f7370b4e14cc03f5ae141ef30a1caf81fa5787711f80be9081418dd9eb79d2", 264 | "sha256:eb4254b1dac4a1ee49919a5b3f1caf9803ea8dada1816d9e8289e63d3cd0dd9a", 265 | "sha256:f7d564871833ddbe54df3aa59053b1110729d3a800cb7628ae8f42adb3d75208" 266 | ], 267 | "markers": "python_version >= '3.8'", 268 | "version": "==0.2.20" 269 | }, 270 | "packaging": { 271 | "hashes": [ 272 | "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", 273 | "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" 274 | ], 275 | "markers": "python_version >= '3.8'", 276 | "version": "==24.2" 277 | }, 278 | "pluggy": { 279 | "hashes": [ 280 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 281 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 282 | ], 283 | "markers": "python_version >= '3.8'", 284 | "version": "==1.5.0" 285 | }, 286 | "pygments": { 287 | "hashes": [ 288 | "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", 289 | "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" 290 | ], 291 | "markers": "python_version >= '3.8'", 292 | "version": "==2.19.1" 293 | }, 294 | "pytest": { 295 | "hashes": [ 296 | "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", 297 | "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761" 298 | ], 299 | "index": "pypi", 300 | "markers": "python_version >= '3.8'", 301 | "version": "==8.3.4" 302 | }, 303 | "readme-renderer": { 304 | "hashes": [ 305 | "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", 306 | "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1" 307 | ], 308 | "markers": "python_version >= '3.9'", 309 | "version": "==44.0" 310 | }, 311 | "requests": { 312 | "hashes": [ 313 | "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", 314 | "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 315 | ], 316 | "markers": "python_version >= '3.8'", 317 | "version": "==2.32.3" 318 | }, 319 | "requests-toolbelt": { 320 | "hashes": [ 321 | "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", 322 | "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" 323 | ], 324 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 325 | "version": "==1.0.0" 326 | }, 327 | "rfc3986": { 328 | "hashes": [ 329 | "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", 330 | "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" 331 | ], 332 | "markers": "python_version >= '3.7'", 333 | "version": "==2.0.0" 334 | }, 335 | "rich": { 336 | "hashes": [ 337 | "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", 338 | "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90" 339 | ], 340 | "markers": "python_full_version >= '3.8.0'", 341 | "version": "==13.9.4" 342 | }, 343 | "setuptools": { 344 | "hashes": [ 345 | "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", 346 | "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" 347 | ], 348 | "index": "pypi", 349 | "markers": "python_version >= '3.9'", 350 | "version": "==75.8.0" 351 | }, 352 | "twine": { 353 | "hashes": [ 354 | "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", 355 | "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd" 356 | ], 357 | "index": "pypi", 358 | "markers": "python_version >= '3.8'", 359 | "version": "==6.1.0" 360 | }, 361 | "urllib3": { 362 | "hashes": [ 363 | "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", 364 | "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d" 365 | ], 366 | "markers": "python_version >= '3.9'", 367 | "version": "==2.3.0" 368 | }, 369 | "wheel": { 370 | "hashes": [ 371 | "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", 372 | "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248" 373 | ], 374 | "index": "pypi", 375 | "markers": "python_version >= '3.8'", 376 | "version": "==0.45.1" 377 | } 378 | } 379 | } 380 | --------------------------------------------------------------------------------