[
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintainted in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n\n# Mac\n.DS_Store\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Python: Current File\",\n            \"type\": \"python\",\n            \"request\": \"launch\",\n            \"program\": \"${file}\",\n            \"console\": \"integratedTerminal\",\n            \"justMyCode\": true\n        }\n    ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.tabCompletion\": \"on\",\n  \"diffEditor.codeLens\": true,\n  \"python.analysis.typeCheckingMode\": \"basic\",\n  \"python.formatting.provider\": \"black\"\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Abi Raja\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# aicommit - AI-generated Git commit messages\n\nA simple CLI tool that generates 5 commit message suggestions for the changes in your current Git repo. After you pick and edit the commit message you want, it commits the changes.\n\n![CleanShot 2023-01-05 at 15 55 47](https://user-images.githubusercontent.com/23818/211055859-7fa8b320-e2d6-41c4-ac29-7f441364666d.gif)\n\n### Installation\n\n**`pip install aicommit`**\n\nOn first run, it will prompt you for your OpenAI API key. Sign up for OpenAI if you haven't. Grab your API key by going to the dropdown on the top right, selecting \"View API Keys\" and creating a new key. Copy this key.\n\n**NOTE:** it commits all changes, untracked and unstaged, in your current repo.\n\n# Feedback/thoughts\n\nPing me on [Twitter](https://twitter.com/_abi_)\n\n## scan_repo\n\n`scan_repo` runs through all the commits in your repository to generate a CSV with AI-suggested commit messages side-by-side with your original commit messages. [Read more about this tool here](https://abiraja.substack.com/p/ai-generated-git-commit-messages)\n\nTo run scan_repo, copy `.env.example` to `.env` and add your OPENAI_KEY.\n\nTo update the repo it runs on, modify the `GITHUB_REPO_URL` variable at the top of `scan_repo.py`\n\n\n# Publishing to pip\n\nVersion bump and clear out `dist/`\n\n```\npython3 -m build\ntwine check dist/*\ntwine upload dist/* --verbose\n```\n"
  },
  {
    "path": "autocommit/__init__.py",
    "content": ""
  },
  {
    "path": "autocommit/commit.py",
    "content": "from __future__ import print_function, unicode_literals\nfrom PyInquirer import prompt as py_inquirer_prompt, style_from_dict, Token\nimport subprocess\n\nimport keyring\n\nimport os\nimport re\nimport markdown\n\nfrom pydantic import BaseModel\nfrom langchain.llms import OpenAI\nfrom langchain.prompts import BasePromptTemplate\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nOPENAI_KEY = os.getenv(\"OPENAI_KEY\")\n\n\nclass CustomPromptTemplate(BasePromptTemplate, BaseModel):\n    template: str\n\n    def format(self, **kwargs) -> str:\n        c_kwargs = {k: v for k, v in kwargs.items()}\n        return self.template.format(**c_kwargs)\n\n\nprompt = CustomPromptTemplate(\n    input_variables=[\"diff\"],\n    template=\"\"\"\n    What follows \"-------\" is a git diff for a potential commit.\n    Reply with a markdown unordered list of 5 possible, different Git commit messages \n    (a Git commit message should be concise but also try to describe \n    the important changes in the commit), order the list by what you think \n    would be the best commit message first, and don't include any other text \n    but the 5 messages in your response.\n    ------- \n    {diff}\n    -------\n\"\"\",\n)\n\n\ndef generate_suggestions(diff, openai_api_key=OPENAI_KEY):\n\n    llm = OpenAI(\n        temperature=0.2,\n        openai_api_key=openai_api_key,\n        max_tokens=100,\n        model_name=\"text-davinci-003\",\n    )  # type: ignore\n\n    # query OpenAI\n    formattedPrompt = prompt.format(diff=diff)\n    response = llm(formattedPrompt)\n\n    # Convert the markdown string to HTML\n    html = markdown.markdown(response)\n\n    # Use a regular expression to extract the list items from the HTML\n    items = re.findall(r\"<li>(.*?)</li>\", html)\n    return items\n\n\nSERVICE_ID = \"auto-commit-cli\"\n\n\ndef prompt_for_openai_api_key():\n    questions = [\n        {\n            \"type\": \"input\",\n            \"name\": \"openai_api_key\",\n            \"message\": \"Please enter your OpenAI API key:\",\n        }\n    ]\n    answers = py_inquirer_prompt(questions)\n    openai_api_key = answers[\"openai_api_key\"]\n    keyring.set_password(SERVICE_ID, \"user\", openai_api_key)\n    return openai_api_key\n\n\ndef main():\n    # Prompt for OpenAI API key if it's not set\n    openai_api_key = keyring.get_password(SERVICE_ID, \"user\")\n    if openai_api_key is None:\n        openai_api_key = prompt_for_openai_api_key()\n\n    #  Get the diff including untracked files (see https://stackoverflow.com/a/52093887)\n    git_command = \"git --no-pager diff; for next in $( git ls-files --others --exclude-standard ) ; do git --no-pager diff --no-index /dev/null $next; done;\"\n\n    # Windows support\n    if os.name == \"nt\":\n        git_command = \"\"\"git diff && for /f \"delims=\" %a in ('git ls-files --others --exclude-standard') do (git diff --no-index /dev/null %a)\"\"\"\n\n    output = subprocess.run(\n        git_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE\n    )\n\n    if len(output.stderr) > 0:\n        error = output.stderr.decode(\"utf-8\")\n        print(\"There was an error retrieving the current diff: \")\n        if error.startswith(\"warning: Not a git repository\"):\n            print(\"You're not inside a git repo. Please run it from inside a git repo.\")\n        else:\n            print(error)\n        exit(-1)\n\n    diff = output.stdout.decode(\"utf8\")\n    # Trim the diff\n    diff = diff.strip()\n\n    if len(diff) == 0:\n        print(\"Diff is empty. Nothing to commit.\")\n        exit(0)\n\n    suggestions = []\n\n    try:\n        suggestions = generate_suggestions(diff[:7000], openai_api_key=openai_api_key)\n    except Exception as e:\n        print(\"There was an error generating suggestions from OpenAI: \")\n        # Prompt for OpenAI API key if it's incorrect\n        if \"Incorrect API key provided\" in str(e):\n            openai_api_key = prompt_for_openai_api_key()\n            print(\"Please re-run the command now.\")\n        else:\n            print(e)\n        exit(-1)\n\n    if len(suggestions) == 0:\n        print(\"No suggestions found.\")\n        exit(0)\n\n    # Prompt user with commit messages and choices and allow edits\n    custom_style = style_from_dict(\n        {\n            Token.Separator: \"#6C6C6C\",\n            Token.QuestionMark: \"#000000\",\n            Token.Selected: \"#FFFF00 bold\",\n            Token.Pointer: \"#FF9D00 bold\",\n            Token.Instruction: \"\",\n            Token.Answer: \"#EA9104 bold\",\n            Token.Question: \"\",\n        }\n    )\n\n    questions = [\n        {\n            \"type\": \"list\",\n            \"name\": \"commit_message\",\n            \"message\": \"Commit message suggestions:\",\n            \"choices\": [f\"{i + 1}. {item}\" for i, item in enumerate(suggestions)],\n            \"filter\": lambda val: val[3:],\n        }\n    ]\n    answers = py_inquirer_prompt(questions, style=custom_style)\n    answers = py_inquirer_prompt(\n        [\n            {\n                \"type\": \"input\",\n                \"name\": \"final_commit_message\",\n                \"message\": \"Confirm or edit the commit message:\",\n                \"default\": answers.get(\"commit_message\"),\n            },\n        ]\n    )\n\n    # Commit the changes\n    git_command = (\n        'git add -A; git commit -m \"' + answers.get(\"final_commit_message\") + '\"'\n    )\n\n    # Windows support\n    if os.name == \"nt\":\n        git_command = (\n            'git add -A && git commit -m \"' + answers.get(\"final_commit_message\") + '\"'\n        )\n\n    output = subprocess.run(\n        git_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE\n    )\n\n    if len(output.stderr) > 0:\n        error = output.stderr.decode(\"utf-8\")\n        print(\"There was an error committing:\")\n        print(error)\n    else:\n        print(\"Commit successful with message:\", answers.get(\"final_commit_message\"))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "autocommit/llm.py",
    "content": "import os\nimport re\nimport markdown\n\nfrom pydantic import BaseModel\nfrom langchain.llms import OpenAI\nfrom langchain.prompts import BasePromptTemplate\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nOPENAI_KEY = os.getenv(\"OPENAI_KEY\")\n\n\nclass CustomPromptTemplate(BasePromptTemplate, BaseModel):\n    template: str\n\n    def format(self, **kwargs) -> str:\n        c_kwargs = {k: v for k, v in kwargs.items()}\n        return self.template.format(**c_kwargs)\n\n\nprompt = CustomPromptTemplate(\n    input_variables=[\"diff\"], template=\"\"\"\n    What follows \"-------\" is a git diff for a potential commit.\n    Reply with a markdown unordered list of 5 possible, different Git commit messages \n    (a Git commit message should be concise but also try to describe \n    the important changes in the commit), order the list by what you think \n    would be the best commit message first, and don't include any other text \n    but the 5 messages in your response.\n    ------- \n    {diff}\n    -------\n\"\"\")\n\n\ndef generate_suggestions(diff, openai_api_key=OPENAI_KEY):\n\n    llm = OpenAI(temperature=0.2, openai_api_key=openai_api_key,\n                 max_tokens=100, model_name=\"text-davinci-003\")  # type: ignore\n\n    # query OpenAI\n    formattedPrompt = prompt.format(diff=diff)\n    response = llm(formattedPrompt)\n\n    # Convert the markdown string to HTML\n    html = markdown.markdown(response)\n\n    # Use a regular expression to extract the list items from the HTML\n    items = re.findall(r'<li>(.*?)</li>', html)\n    return items\n"
  },
  {
    "path": "null",
    "content": ""
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"aicommit\"\nversion = \"0.1.0\"\nauthors = [\n  { name=\"Abi Raja\", email=\"abimanyuraja@gmail.com\" },\n]\ndescription = \"Generate AI powered commit messages\"\nreadme = \"README.md\"\nrequires-python = \">=3.7\"\ndependencies = [\n    'async-timeout ~= 4.0',\n    'black ~= 22.12.0',\n    'certifi ~= 2022.12.7',\n    'cffi ~= 1.15.1',\n    'charset-normalizer ~= 2.1.1',\n    'click ~= 8.1.3',\n    'et-xmlfile ~= 1.1.0',\n    'gitdb ~= 4.0.10',\n    'GitPython ~= 3.1.29',\n    'idna ~= 3.4',\n    'importlib-metadata ~= 5.2.0',\n    'jaraco.classes ~= 3.2.3',\n    'keyring ~= 23.13.1',\n    'langchain ~= 0.0.45',\n    'Markdown ~= 3.4.1',\n    'more-itertools ~= 9.0.0',\n    'mypy-extensions ~= 0.4.3',\n    'numpy ~= 1.24.0',\n    'openai ~= 0.25.0',\n    'openpyxl ~= 3.0.10',\n    'pandas ~= 1.5.2',\n    'pandas-stubs ~= 1.5.2.221213',\n    'pathspec ~= 0.10.3',\n    'platformdirs ~= 2.6.0',\n    'prompt-toolkit ~= 1.0.14',\n    'pycparser ~= 2.21',\n    'pydantic ~= 1.10.2',\n    'pygit2 ~= 1.11.1',\n    'Pygments ~= 2.13.0',\n    'PyInquirer ~= 1.0.3',\n    'python-dateutil ~= 2.8.2',\n    'python-dotenv ~= 0.21.0',\n    'pytz ~= 2022.7',\n    'PyYAML ~= 6.0',\n    'redis ~= 4.4.0',\n    'regex ~= 2022.10.31',\n    'requests ~= 2.28.1',\n    'six ~= 1.16.0',\n    'smmap ~= 5.0.0',\n    'SQLAlchemy ~= 1.4.45',\n    'tomli ~= 2.0.1',\n    'tqdm ~= 4.64.1',\n    'types-pytz ~= 2022.7.0.0',\n    'typing_extensions ~= 4.4.0',\n    'urllib3 ~= 1.26.13',\n    'wcwidth ~= 0.2.5',\n    'zipp ~= 3.11.0'\n]\n\n[project.urls]\n\"Homepage\" = \"https://github.com/abi/autocommit\"\n\"Bug Tracker\" = \"https://github.com/abi/autocommit/issues\"\n\n[project.scripts]\naicommit = \"autocommit.commit:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n"
  },
  {
    "path": "requirements.txt",
    "content": "async-timeout==4.0.2\nblack==22.12.0\ncertifi==2022.12.7\ncffi==1.15.1\ncharset-normalizer==2.1.1\nclick==8.1.3\net-xmlfile==1.1.0\ngitdb==4.0.10\nGitPython==3.1.29\nidna==3.4\nimportlib-metadata==5.2.0\njaraco.classes==3.2.3\nkeyring==23.13.1\nlangchain==0.0.45\nMarkdown==3.4.1\nmore-itertools==9.0.0\nmypy-extensions==0.4.3\nnumpy==1.24.0\nopenai==0.25.0\nopenpyxl==3.0.10\npandas==1.5.2\npandas-stubs==1.5.2.221213\npathspec==0.10.3\nplatformdirs==2.6.0\nprompt-toolkit==1.0.14\npycparser==2.21\npydantic==1.10.2\npygit2==1.11.1\nPygments==2.13.0\nPyInquirer==1.0.3\npython-dateutil==2.8.2\npython-dotenv==0.21.0\npytz==2022.7\nPyYAML==6.0\nredis==4.4.0\nregex==2022.10.31\nrequests==2.28.1\nsix==1.16.0\nsmmap==5.0.0\nSQLAlchemy==1.4.45\ntomli==2.0.1\ntqdm==4.64.1\ntypes-pytz==2022.7.0.0\ntyping_extensions==4.4.0\nurllib3==1.26.13\nwcwidth==0.2.5\nzipp==3.11.0\n"
  },
  {
    "path": "scan_repo.py",
    "content": "from collections import namedtuple\nimport os\nimport shutil\nimport csv\nimport sys\nfrom dotenv import load_dotenv\nimport git\nimport pygit2\n\nfrom autocommit.llm import generate_suggestions\n\nload_dotenv()\n\nGIT_REPO_URL = os.getenv(\"GIT_REPO_URL\")\nif GIT_REPO_URL is None:\n    raise ValueError(\"GIT_REPO_URL is not set\")\n\ntemp_repo_dir = \"/tmp/ai-commit-msg-repo\"\n\n# Delete the directory if it exists\nif os.path.exists(temp_repo_dir):\n    shutil.rmtree(temp_repo_dir)\n\n# Clone the repository\ngit.Repo.clone_from(GIT_REPO_URL, temp_repo_dir)\n\n# Open the repository\nrepo = pygit2.Repository(temp_repo_dir)\ncommits = repo.walk(repo.head.target, pygit2.GIT_SORT_TIME)\n\n# Iterate over the commits and organize the data we need\ncommit_objects = []\nCommitObject = namedtuple(\"CommitObject\", [\"sha\", \"message\", \"diff\"])\nfor commit in commits:\n    if len(commit.parents) > 0:\n        diff = repo.diff(commit.parents[0], commit).patch\n    else:\n        diff = \"\"\n    commit_objects.append(CommitObject(commit.id, commit.message, diff))\n\nfiltered_commit_objects = commit_objects\n\nwriter = csv.writer(sys.stdout, quoting=csv.QUOTE_MINIMAL)\n\nfor commit in filtered_commit_objects:\n    message = commit.message.replace(\"\\n\", \";\")\n\n    # Skip merge commits\n    if message.startswith(\"Merge\"):\n        continue\n\n    # text-davinci-003 supports 4000 tokens. Let's use upto 3500 tokens for the prompt.\n    # 3500 tokens = 14,000 characters (https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them)\n    # But in practice, > 7000 seems to exceed the limit\n    suggestions = generate_suggestions(commit.diff[:7000])\n\n    # generate CSV row\n    for item in suggestions:\n        # Only inlude SHA & original message for the first row\n        if item == suggestions[0]:\n            writer.writerow([commit.sha, message, item])\n        else:\n            writer.writerow([\"\", \"\", item])\n"
  }
]