├── requirements.txt ├── .gitignore ├── test.env ├── test.sh ├── lib ├── output.py ├── text.py └── parser.py ├── action.yml ├── test_parser.py ├── README.md └── gpt.py /requirements.txt: -------------------------------------------------------------------------------- 1 | openai -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.pyc 3 | test_code -------------------------------------------------------------------------------- /test.env: -------------------------------------------------------------------------------- 1 | OPENAI_API_SECRET=op://h7a2hwbolvnsnxwyi7dzknimza/ktim6hhyqzvvvv42efku65qhh4/api_secret -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Enter issue text" 4 | 5 | read issue_text 6 | issue_number=1 7 | 8 | export GITHUB_OUTPUT=./test_output.txt 9 | python gpt.py "$OPENAI_API_SECRET" "$issue_number" "$issue_text" ./test_code -------------------------------------------------------------------------------- /lib/output.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def print_github_log_message(role, text): 5 | if role.lower() == 'assistant': 6 | prefix = '[Assistant]' 7 | color = '\033[1;34m' # Blue 8 | elif role.lower() == 'user': 9 | prefix = '[User]' 10 | color = '\033[1;32m' # Green 11 | else: 12 | raise ValueError( 13 | "Invalid role. Role must be either 'assistant' or 'user'.") 14 | 15 | reset_color = '\033[0m' # Reset color 16 | separator = '-' * 80 17 | 18 | print(f"{color}{separator}\n{prefix}\n{text}\n{separator}{reset_color}", flush=True) 19 | 20 | 21 | def set_output(name, value): 22 | with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: 23 | fh.write(f"{name}={value}\n") 24 | -------------------------------------------------------------------------------- /lib/text.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | 4 | 5 | def toRealPath(base_path, filename): 6 | # Get rid of \r 7 | filename = filename.replace("\r", "") 8 | 9 | # Trim whitespace 10 | filename = filename.strip() 11 | 12 | # Trim the leading dot if it exists 13 | if filename.startswith("."): 14 | filename = filename[1:] 15 | 16 | # Trim leading slash if it exists 17 | if filename.startswith("/"): 18 | filename = filename[1:] 19 | 20 | return os.path.join(base_path, filename) 21 | 22 | 23 | def trimCodeBlocks(text): 24 | return text.strip("```") 25 | 26 | 27 | def format_file(file): 28 | try: 29 | subprocess.run( 30 | ["prettier", "--write", file], 31 | capture_output=True, 32 | check=True, 33 | text=True, 34 | ) 35 | except subprocess.CalledProcessError as e: 36 | error_message = ( 37 | f"An error occurred while formatting {file} with Prettier:\n" 38 | f"Output: {e.output}\n" 39 | f"Error: {e.stderr}\n" 40 | ) 41 | return error_message 42 | 43 | return None 44 | 45 | 46 | def format_code_with_line_numbers(code): 47 | lines = code.split('\n') 48 | max_line_number_length = len(str(len(lines))) 49 | formatted_lines = [] 50 | 51 | for index, line in enumerate(lines, start=1): 52 | line_number = f"{index:>{max_line_number_length}}" 53 | formatted_line = f"{line_number} | {line}" 54 | formatted_lines.append(formatted_line) 55 | 56 | formatted_code = '```\n' + '\n'.join(formatted_lines) + '\n```' 57 | return formatted_code 58 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "GPT Developer" 2 | description: "An AI developer doing the work for you. It performs code changes based on GitHub issues. Warning: Experimental!" 3 | icon: "code" 4 | color: "blue" 5 | inputs: 6 | openai_api_key: 7 | description: "OpenAI API key" 8 | required: true 9 | openai_model: 10 | description: "OpenAI chat model to use, e.g. gpt-3.5-turbo or gpt-4. Defaults to gpt-3.5-turbo" 11 | required: false 12 | issue_number: 13 | description: "So GPT can reference the issue number in the commit message" 14 | required: true 15 | issue_text: 16 | description: "So GPT knows what needs to be done" 17 | required: true 18 | path: 19 | description: "Path to the file that needs to be changed" 20 | required: true 21 | outputs: 22 | commit_message: 23 | description: "The commit message" 24 | value: ${{ steps.gpt.outputs.commit_message }} 25 | comment_message: 26 | description: "The exit message, in case GPT thinks it needs to stop and let you know" 27 | value: ${{ steps.gpt.outputs.comment_message }} 28 | runs: 29 | using: "composite" 30 | steps: 31 | - name: Install Python 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: 3.11 35 | - name: "Install Python dependencies" 36 | run: | 37 | pip install openai 38 | shell: bash 39 | - name: "Install Prettier" 40 | run: | 41 | npm install -g prettier 42 | shell: bash 43 | - name: "Let GPT do the work" 44 | id: gpt 45 | run: | 46 | python $GITHUB_ACTION_PATH/gpt.py "${{ inputs.openai_api_key }}" "${{ inputs.issue_number }}" "${{ inputs.issue_text }}" "${{ inputs.path }}" 47 | shell: bash 48 | env: 49 | GITHUB_ACTION_PATH: ${{ github.action_path }} 50 | -------------------------------------------------------------------------------- /test_parser.py: -------------------------------------------------------------------------------- 1 | from lib.parser import parse_commands 2 | 3 | 4 | def test_parser(): 5 | print('Test 1: Check if log commands are parsed correctly') 6 | message = ''' 7 | Hello, this is a log message. 8 | 9 | read file1.html, file2.html 10 | ''' 11 | commands = parse_commands(message) 12 | assert commands[0]['command'] == 'log' and commands[0]['contents'].strip( 13 | ) == 'Hello, this is a log message.' 14 | assert commands[1]['command'] == 'read' and commands[1]['arg'] == 'file1.html, file2.html' 15 | 16 | print('Test 2: Check if missing arguments for multiline commands are detected') 17 | message = ''' 18 | write Delimiter 19 | This should fail due to a missing filename. 20 | Delimiter 21 | ''' 22 | try: 23 | commands = parse_commands(message) 24 | assert False, "Exception not raised for missing argument in multiline command" 25 | except Exception as e: 26 | assert str(e) == "Missing argument for command `write`." 27 | 28 | print('Test 3: Check if unclosed delimiter is detected') 29 | message = ''' 30 | write file1.html Delimiter 31 | Content without closing delimiter. 32 | ''' 33 | try: 34 | commands = parse_commands(message) 35 | assert False, "Exception not raised for unclosed delimiter" 36 | except Exception as e: 37 | # Assert that e contains the correct error message 38 | assert str(e).startswith( 39 | "Command `write` was not closed with delimiter `Delimiter`.") 40 | 41 | print('Test 4: Check if multiline commands are parsed correctly') 42 | message = ''' 43 | write file1.html Delimiter 44 | This is the content of the file. 45 | Delimiter 46 | ''' 47 | commands = parse_commands(message) 48 | assert commands[0]['command'] == 'write' and commands[0]['arg'] == 'file1.html' and commands[0]['contents'].strip( 49 | ) == 'This is the content of the file.' 50 | 51 | print('Test 5: Check if singleline commands are parsed correctly') 52 | message = '''commit This is a commit message''' 53 | commands = parse_commands(message) 54 | assert commands[0]['command'] == 'commit' and commands[0]['arg'] == 'This is a commit message' 55 | 56 | print('Test 6: Issuing only a read command should work just fine') 57 | message = "read file1.html" 58 | 59 | commands = parse_commands(message) 60 | assert commands[0]['command'] == 'read' and commands[0]['arg'] == 'file1.html' 61 | 62 | print('Test 7: Test the commit command') 63 | 64 | message = "commit This is a commit message" 65 | commands = parse_commands(message) 66 | assert commands[0]['command'] == 'commit' and commands[0]['arg'] == 'This is a commit message' 67 | 68 | print("All tests passed") 69 | 70 | 71 | if __name__ == '__main__': 72 | test_parser() 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPT Developer 2 | 3 | This is a GitHub Action that asks ChatGPT to edit your code for you. 4 | 5 | ## Quick Start 6 | 7 | 1. Go to the project template: https://github.com/skaramicke/gpt-developer-project-template 8 | 2. Click the `Use this template` button 9 | 3. Create a new repository from the template 10 | 4. Add your OpenAI API key as a secret to the repository with the name `OPENAI_API_SECRET` 11 | 5. Create an issue with the label `gpt-developer` and the action will run and update the code according to the title and description of the issue. 12 | 13 | ## Inputs 14 | 15 | `openai_api_key` 16 | **Required** Your OpenAI API key. You can get one from https://beta.openai.com/account/api-keys. 17 | 18 | `issue_number` 19 | **Required** The number of the issue to comment on. 20 | 21 | `issue_text` 22 | **Required** The text to run through ChatGPT. 23 | 24 | ## Outputs 25 | 26 | `comment_message` 27 | If the AI decides it can't do anything with the issue text, it will issue an exit message and stop the process. 28 | 29 | `commit_message` 30 | When the AI is done with the code, it will commit the changes with this commit message. 31 | 32 | A closing statement for the issue number is added no matter what the AI decides to write. 33 | 34 | ## Example usage 35 | 36 | 1. Create a github workflow `.github/workflows/gpt-developer.yml`: 37 | 38 | ```yaml 39 | name: GPT Developer 40 | 41 | on: 42 | issues: 43 | types: 44 | - labeled 45 | 46 | jobs: 47 | update-code-on-issue: 48 | if: github.event.label.name == 'gpt-developer' 49 | name: Update the code according to issue text 50 | runs-on: ubuntu-latest 51 | permissions: 52 | issues: write 53 | contents: write 54 | steps: 55 | - uses: ben-z/actions-comment-on-issue@1.0.2 56 | with: 57 | message: "I'm working on it! - GPT Developer" 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Checkout code 61 | uses: actions/checkout@v2 62 | 63 | - name: Update code 64 | uses: skaramicke/gpt-developer@v1.4 65 | id: gpt 66 | with: 67 | openai_api_key: ${{ secrets.OPENAI_API_SECRET }} 68 | openai_model: gpt-4 69 | issue_number: ${{ github.event.issue.number }} 70 | issue_text: "${{ github.event.issue.title}}, ${{ github.event.issue.body }} - created by ${{ github.event.issue.user.login }}" 71 | path: ${{ github.workspace }} 72 | 73 | - uses: ben-z/actions-comment-on-issue@1.0.2 74 | if: ${{ steps.gpt.outputs.comment_message != '' }} 75 | with: 76 | message: ${{ steps.gpt.outputs.comment_message }} 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | - name: Commit changes 80 | if: ${{ steps.gpt.outputs.commit_message != '' }} 81 | uses: stefanzweifel/git-auto-commit-action@v4 82 | with: 83 | commit_message: ${{ steps.gpt.outputs.commit_message }} - gpt-developer 84 | 85 | - uses: ben-z/actions-comment-on-issue@1.0.2 86 | if: ${{ steps.gpt.outputs.commit_message != '' }} 87 | with: 88 | message: 'I''ve updated the code with this commit message: "${{ steps.gpt.outputs.commit_message }}" - GPT Developer' 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | ``` 91 | 92 | 2. Create an issue with the label `gpt-developer` and the action will run and update the code according to the title and description of the issue. 93 | 3. ??? 94 | 4. Profit!!1 95 | -------------------------------------------------------------------------------- /lib/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | multiline_commands = {'write', 'comment'} 4 | singleline_commands = {'read', 'remove', 'commit', 'exit'} 5 | 6 | 7 | def documentation(): 8 | return '''Commands: 9 | read: Read files 10 | read 11 | write: Write contents to a file a file 12 | write < 14 | EOF 15 | remove: Remove files 16 | remove 17 | commit: Set the commit message 18 | commit 19 | comment: Set the comment message 20 | comment < 22 | EOF 23 | exit: Exit the program 24 | exit 25 | ''' 26 | 27 | 28 | def parse_commands(message): 29 | command_objects = [] 30 | 31 | # Split the message into lines 32 | lines = message.split('\n') 33 | current_command = 'log' 34 | current_arg = '' 35 | current_contents = [] 36 | command_end_delimiter = '' 37 | 38 | for line in lines: 39 | first_word = line.split(' ')[0] 40 | 41 | # Check if it's time to switch commands 42 | if command_end_delimiter == '' and first_word in multiline_commands: 43 | if current_command == 'log': 44 | text = ("\n".join(current_contents)).strip() 45 | if text != '': 46 | command_objects.append({ 47 | 'command': current_command, 48 | 'contents': text 49 | }) 50 | current_command = first_word 51 | arguments = line.split(' ')[1:] 52 | if len(arguments) > 1: 53 | # Strip whitespaces from arg 54 | current_arg = arguments[0].strip() 55 | if not current_arg: # Check if arg is empty after stripping 56 | raise Exception( 57 | f"Missing argument for command `{current_command}`.") 58 | command_end_delimiter = ' '.join(arguments[1:]).strip() 59 | else: 60 | current_arg = '' 61 | command_end_delimiter = arguments[0].strip() 62 | current_contents = [] 63 | 64 | # ChatGPT-4 is very keen on using < 5 else "gpt-3.5-turbo" 16 | 17 | # List complete filenames recursively 18 | files = [] 19 | for root, dirs, filenames in os.walk(path): 20 | for filename in filenames: 21 | # skip .git and .github directories 22 | if ".git" in root or ".github" in root: 23 | continue 24 | 25 | # remove the path variable from the filename 26 | files.append(os.path.join(root, filename).replace(path, ".")) 27 | 28 | files = ", ".join(files) 29 | 30 | commands_doc = documentation() 31 | 32 | prompt = f"""Issue #{issue_number}: {issue_text} 33 | {commands_doc} 34 | Existing files: {files} 35 | You are interacting with software. Solve the issue detailed above using the commands documented above. You use commands to `read`, `write`, `remove` files in the code and then `comment` on the issue or `commit` the changes. `exit` to stop. 36 | You need to use the commands. The text you write is being parsed by a custom software that executes the commands. There's no human on the other end. 37 | """ 38 | 39 | messages = [ 40 | {"role": "user", "content": prompt}, 41 | ] 42 | print_github_log_message("user", prompt) 43 | 44 | while True: 45 | 46 | completions = openai.ChatCompletion.create( 47 | model=model, 48 | messages=messages, 49 | ) 50 | 51 | response = completions["choices"][0]["message"]["content"] 52 | 53 | print_github_log_message("assistant", response) 54 | messages.append({"role": "assistant", "content": response}) 55 | 56 | user_message = '' 57 | 58 | try: 59 | commands = parse_commands(response) 60 | 61 | if len([command for command in commands if command["command"] != "log"]) == 0: 62 | user_message += f"No commands detected in ```{response}```. You can ONLY use commands. The text you write is being parsed by a custom software that executes the commands. There's no human on the other end. If you are done processing the issue, use the command `exit`.\n{commands_doc}" 63 | 64 | for command in commands: 65 | if command["command"] == "exit": 66 | break 67 | 68 | # Print all commands except log, since the AI is not aware of the log command. 69 | if command["command"] != "log": 70 | user_message += f'# {command["command"]}\n' 71 | 72 | if command["command"] == "comment": 73 | set_output("comment_message", command["contents"]) 74 | user_message += f'comment stored: {command["contents"]}' 75 | 76 | if command["command"] == "commit": 77 | set_output("commit_message", 78 | command["arg"] + " - Closes #" + issue_number) 79 | user_message += f'commit message stored: {command["arg"]}' 80 | 81 | if command["command"] == "read": 82 | files = command["arg"].split(",") 83 | 84 | file_contents = "" 85 | for filename in files: 86 | file_path = toRealPath(path, filename) 87 | with open(file_path, "r") as f: 88 | code = format_code_with_line_numbers(f.read()) 89 | file_contents += f"`{filename}`:\n{code}\n" 90 | 91 | user_message += f"{file_contents}\n" 92 | 93 | if command["command"] == "write": 94 | 95 | filename = command["arg"] 96 | file_path = toRealPath(path, filename) 97 | 98 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 99 | 100 | with open(file_path, "w") as f: 101 | f.write(command["contents"]) 102 | 103 | formatting_errors = format_file(file_path) 104 | 105 | with open(file_path, "r") as f: 106 | code = format_code_with_line_numbers(f.read()) 107 | if formatting_errors: 108 | user_message += f"wrote file `{filename}`:\n{code}\nPlease fix the formatting errors:\n{formatting_errors}\n" 109 | else: 110 | user_message += f"wrote file `{filename}`:\n{code}\n" 111 | 112 | if command["command"] == "remove": 113 | files = command["arg"].split(",") 114 | for filename in files: 115 | file_path = toRealPath(path, filename) 116 | if os.path.exists(file_path): 117 | os.remove(file_path) 118 | user_message += f"removed {filename}\n" 119 | else: 120 | user_message += f"{filename} does not exist\n" 121 | 122 | except Exception as e: 123 | # Create an error string where any occurrence of `path` has been replaced with a period 124 | error = str(e).replace(path, ".") 125 | user_message += f"error: {error}" 126 | pass 127 | 128 | user_message = user_message.strip() 129 | if user_message != "": 130 | print_github_log_message("user", user_message) 131 | messages.append({"role": "user", "content": user_message}) 132 | else: 133 | break 134 | 135 | 136 | print("done") 137 | --------------------------------------------------------------------------------