├── .gitignore ├── .vscode └── settings.json ├── src ├── index.ts ├── helpers │ ├── corrections.ts │ ├── openai.ts │ ├── cli.ts │ ├── container.ts │ └── github.ts └── lib │ ├── buildPlan.ts │ └── build.ts ├── .env.example ├── tsconfig.json ├── package.json ├── LICENSE ├── scripts.ts ├── prompt.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /build/ 3 | /dist/ 4 | 5 | .vscode/ 6 | node_modules/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { cli } from "./helpers/cli"; 2 | import * as dotenv from "dotenv" 3 | 4 | dotenv.config(); 5 | 6 | 7 | (async () => { 8 | await cli(); 9 | })().catch((err: any) => { 10 | console.error(err) 11 | process.exit(1) 12 | }) -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Name and email that will be used for git commits. 2 | GIT_AUTHOR_NAME=Gitwit 3 | GIT_AUTHOR_EMAIL=git@gitwit.dev 4 | 5 | # Username and personal access token for accessing GitHub. 6 | # Get a token from here: https://github.com/settings/tokens 7 | GITHUB_USERNAME=gitwitdev 8 | GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 9 | 10 | # ChatGPT API key 11 | # Get a secret key from here: https://platform.openai.com/account/api-keys 12 | OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["esnext", "dom"], 5 | "esModuleInterop": true, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "outDir": "dist", 9 | "strict": true, 10 | "types": ["node"], 11 | "resolveJsonModule": true 12 | }, 13 | "include": ["*.ts", "src/lib/buildPlan.ts", "src/helpers/cli.ts", "src/helpers/container.ts", "src/helpers/corrections.ts", "src/helpers/github.ts", "src/helpers/openai.ts", "src/index.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitwit", 3 | "version": "0.1.7", 4 | "description": "", 5 | "main": "index.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "tsc --module commonjs && node dist/src/index.js" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "dockerode": "^3.3.5", 14 | "dotenv": "^16.0.3", 15 | "fs": "^0.0.1-security", 16 | "json5": "^2.2.3", 17 | "node-fetch": "^2.6.9", 18 | "openai": "^3.2.1", 19 | "tar": "^6.1.13" 20 | }, 21 | "devDependencies": { 22 | "@types/dockerode": "^3.3.16", 23 | "@types/node": "^18.15.11", 24 | "@types/node-fetch": "^2.6.3", 25 | "@types/tar": "^6.1.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 James Murdza 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/helpers/corrections.ts: -------------------------------------------------------------------------------- 1 | // Post-processing: 2 | 3 | function applyCorrections(buildScript: string) { 4 | 5 | // Detect commands like: echo -e "..." > ... 6 | const echoRedirection = /^((?echo -e "(?:\\.|[^\\"])*") > (?.*\/).*)$/mg; 7 | // Detect commands like: echo -e '...' > ... 8 | const echoRedirectionSingleQuote = /^((?echo -e '(?:\\.|[^\\'])*') > (?.*\/).*)$/mg; 9 | 10 | // These small corrections are necessary as they take into account the random characters created by terminal. 11 | return buildScript.replace(/^npx /mg, 'npx --yes ') 12 | .replace(/(^\`\`\`[a-z]*\n|\n\`\`\`$)/g, '') 13 | .replace(/^echo( -e)? /mg, 'echo -e ') 14 | .replace(/^npm install /mg, 'npm install --package-lock-only ') 15 | .replace(echoRedirection, 'mkdir -p $ && $1') 16 | .replace(echoRedirectionSingleQuote, 'mkdir -p $ && $1') 17 | .replace(/^git (remote|push|checkout|merge|branch) .*$/mg, '') 18 | .replace(/^(az|aws|systemctl) .*$/mg, '') 19 | .replace(/^git commit (.*)$/mg, 'git commit -a $1') 20 | .replace(/^sed -i '' /mg, 'sed -i '); 21 | } 22 | 23 | export { applyCorrections }; 24 | -------------------------------------------------------------------------------- /src/helpers/openai.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, OpenAIApi } from 'openai' 2 | 3 | // OpenAI API: 4 | 5 | export type Completion = { text: string; id: string; model: string } | { error: string }; 6 | 7 | async function simpleOpenAIRequest(prompt: string, config: any): Promise { 8 | 9 | const baseOptions = { 10 | headers: { 11 | "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`, 12 | ...(process.env.OPENAI_CACHE_ENABLED && { 13 | "Helicone-Cache-Enabled": "true", 14 | "Helicone-Cache-Bucket-Max-Size": "1", 15 | }), 16 | }, 17 | }; 18 | 19 | const configuration = new Configuration({ 20 | apiKey: process.env.OPENAI_API_KEY, 21 | basePath: process.env.OPENAI_BASE_URL, 22 | baseOptions: baseOptions, 23 | }) 24 | const openai = new OpenAIApi(configuration) 25 | 26 | try { 27 | const completion = await openai.createChatCompletion({ 28 | ...config, 29 | messages: [ 30 | { 31 | role: 'user', 32 | content: prompt, 33 | }, 34 | ], 35 | }) 36 | // When the API returns an error: 37 | const data: any = completion.data; 38 | if (data.error) { 39 | throw new Error(`OpenAI error: (${data.error.type}) ${data.error.message}`) 40 | } 41 | return { 42 | text: completion.data.choices[0]!.message!.content, 43 | id: completion.data.id, 44 | model: completion.data.model 45 | }; 46 | } catch (error: any) { 47 | // When any other error occurs: 48 | throw new Error(`Failed to make request. Error message: ${error.message}`); 49 | } 50 | } 51 | 52 | export { simpleOpenAIRequest } 53 | -------------------------------------------------------------------------------- /scripts.ts: -------------------------------------------------------------------------------- 1 | // Setup the git config. 2 | export const SETUP_GIT_CONFIG = ` 3 | git config --global user.email {GIT_AUTHOR_EMAIL} 4 | git config --global user.name {GIT_AUTHOR_NAME} 5 | git config --global init.defaultBranch main 6 | `; 7 | 8 | // Make a new directory. 9 | export const MAKE_PROJECT_DIR = ` 10 | cd ~ 11 | mkdir {REPO_NAME} 12 | cd {REPO_NAME} 13 | `; 14 | 15 | // Run the build script and change to the script's final directory. 16 | export const RUN_BUILD_SCRIPT = ` 17 | source /app/build.sh > /app/build.log 2>&1 18 | `; 19 | 20 | // Change to the top-level directory of the git repository. 21 | export const CD_GIT_ROOT = ` 22 | cd $(git rev-parse --show-toplevel) 23 | ` 24 | 25 | export const GET_BUILD_LOG = ` 26 | cat /app/build.log 27 | ` 28 | 29 | // Configure the git credentials. 30 | export const SETUP_GIT_CREDENTIALS = ` 31 | echo "https://{GITHUB_USERNAME}:{GITHUB_TOKEN}@github.com" >> ~/.git-credentials 32 | git config --global credential.helper store 33 | ` 34 | 35 | // Push the main branch to the remote repository. 36 | export const PUSH_TO_REPO = ` 37 | git branch -M main 38 | git remote add origin {PUSH_URL} 39 | git push -u origin main 40 | ` 41 | 42 | // Clone an existing repository. 43 | export const CLONE_PROJECT_REPO = ` 44 | cd ~ 45 | git clone https://{GITHUB_USERNAME}:{GITHUB_TOKEN}@github.com/{FULL_REPO_NAME}.git 46 | cd {REPO_NAME} 47 | ` 48 | 49 | // Get the contents of the repository. 50 | export const GET_FILE_LIST = ` 51 | cd ~/{REPO_NAME} 52 | find . -path "./.git" -prune -o -type f -print 53 | ` 54 | 55 | // Create a new git branch. 56 | export const CREATE_NEW_BRANCH = ` 57 | cd ~/{REPO_NAME} 58 | git checkout {SOURCE_BRANCH_NAME} 59 | git checkout -b {BRANCH_NAME} 60 | ` 61 | 62 | // Push the new branch to the remote repository. 63 | export const PUSH_BRANCH = ` 64 | git push -u origin {BRANCH_NAME} 65 | ` 66 | -------------------------------------------------------------------------------- /src/lib/buildPlan.ts: -------------------------------------------------------------------------------- 1 | import JSON5 from "json5" 2 | 3 | type BuildPlanItem = { 4 | filePath: string, 5 | action: "add" | "edit", 6 | description: string 7 | }; 8 | 9 | // A plan containing a list of files to edit or add in a repository. 10 | export class BuildPlan { 11 | items: BuildPlanItem[] = [] 12 | 13 | // Parse the output of a ChatGPT request into a BuildPlan object. 14 | constructor(inputText: string, files: string[]) { 15 | // Remove leading "./" from filenames. 16 | const fixPath = (file: string) => file.trim().replace(/^\.\//, ''); 17 | const fixedPaths = files.map(fixPath); 18 | console.log(fixedPaths) 19 | // Convert arrays into objects. 20 | this.items = JSON5.parse(inputText) 21 | .map(([filePath, action, description]: [string, string, string]) => { 22 | const fixedPath = fixPath(filePath); 23 | // Set to edit or new based on if the file exists in the repository. 24 | const exists = fixedPaths.includes(fixedPath); 25 | return { 26 | // Normalize filenames. 27 | filePath: fixedPath, 28 | action: exists ? "edit" : "add", 29 | description 30 | } 31 | }); 32 | return this; 33 | } 34 | 35 | // A string describing how each file will be changed. 36 | readableString = () => { 37 | const previewItemString = (item: BuildPlanItem) => `- ${item.action} ${item.filePath}: ${item.description}` 38 | return this.items.map(previewItemString).join("\n") 39 | } 40 | 41 | // A string containing the contents of each file that will be changed. 42 | readableContents = async (readFile: (input: string) => Promise) => { 43 | // Only include files that exist already. 44 | const existingFileItems = this.items.filter(item => item.action === "edit"); 45 | // Get the contents of each file as a string. 46 | const getFileContents = async ({ filePath }: BuildPlanItem) => { 47 | const fileContents = await readFile(filePath) 48 | const delimiter = "\n```\n" 49 | return `*${filePath}*:${delimiter}${fileContents}${delimiter}`; 50 | } 51 | const repositoryContents = await Promise.all( 52 | existingFileItems.map(getFileContents) 53 | ) 54 | return repositoryContents.join("\n") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/helpers/cli.ts: -------------------------------------------------------------------------------- 1 | import * as readline from "readline" 2 | import * as fs from "fs" 3 | import { Build } from "../lib/build" 4 | import { writeFile, readFile } from "fs/promises" 5 | 6 | function askQuestion(query: string): Promise { 7 | const rl = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout, 10 | }) 11 | 12 | return new Promise((resolve) => 13 | rl.question(query, (ans) => { 14 | rl.close() 15 | resolve(ans) 16 | }) 17 | ) 18 | } 19 | 20 | export async function cli(): Promise { 21 | if (!fs.existsSync("./build")) { 22 | fs.mkdirSync("./build") 23 | } 24 | 25 | const again = process.argv.includes("--again") // Use user input from the last run. 26 | const offline = process.argv.includes("--offline") // Use build script from the last run. 27 | const debug = process.argv.includes("--debug") // Leave the container running to debug. 28 | const branch = process.argv.includes("--branch") 29 | 30 | let userInput, suggestedName, sourceGitURL 31 | 32 | // Detect metadata from a previous run. 33 | if (offline || again) { 34 | ({ userInput, suggestedName, sourceGitURL } = JSON.parse( 35 | (await readFile("./build/info.json")).toString() 36 | )) 37 | } 38 | 39 | if (!userInput || !suggestedName) { 40 | if (branch) { 41 | sourceGitURL = await askQuestion("Source repository URL: ") 42 | } else { 43 | console.log("Let's cook up a new project!") 44 | } 45 | userInput = await askQuestion("What would you like to make? ") 46 | suggestedName = await askQuestion(branch ? "New branch name:" : "Repository name: ") 47 | await writeFile("./build/info.json", JSON.stringify({ userInput, suggestedName, sourceGitURL })) 48 | } 49 | 50 | let project = await Build.create({ 51 | buildType: branch ? "BRANCH" : "REPOSITORY", 52 | suggestedName, 53 | userInput, 54 | creator: process.env.GITHUB_USERNAME!, 55 | sourceGitURL 56 | }); 57 | 58 | if (offline) { 59 | const completionFile = await readFile("./build/completion.json"); 60 | const infoFile = await readFile("./build/info.json"); 61 | const text = completionFile.toString() 62 | const { id, model } = JSON.parse(infoFile.toString()) 63 | project.completion = { text, id, model } 64 | } 65 | 66 | await project.buildAndPush({ debug }) 67 | 68 | if (!offline) { 69 | let { text } = project.completion 70 | await writeFile("./build/completion.json", text!) 71 | } 72 | } -------------------------------------------------------------------------------- /prompt.ts: -------------------------------------------------------------------------------- 1 | const newProjectPrompt = ` 2 | Help me to create a repository containing the code for: 3 | - {DESCRIPTION} 4 | 5 | The project name is: {REPOSITORY_NAME} 6 | 7 | Give me instructions to generate complete, working code for this project, but use only shell commands. No need to give further explanation. All of your output must be given as a single /bin/sh script. 8 | 9 | For example, when want to edit a file, use the command \`echo "file contents" > filename.ext\`. Create intermediate directories before writing files. 10 | 11 | At the beginning of the script, create a git repo in the current directory before running any other commands. Then, create a .gitignore file. 12 | 13 | Assume all commands will be run on a clean \`{BASE_IMAGE}\` system with no packages installed. 14 | 15 | Follow each command in the instructions with a git commit command including a detailed commit message. 16 | 17 | The repository should also contain a helpful README.md file. The README should include a high-level descrition of the code, a list of software needed to run the code, and basic instructions on how to run the application. The README should not include any text or information about a software license. This project has no license. 18 | 19 | Do not include any commands to run, start or deploy or test the app. 20 | 21 | Do not use an exit command at the end of the script. 22 | `; 23 | 24 | const changeProjectPrompt = ` 25 | {FILE_CONTENTS} 26 | 27 | The above are contents of existing files in my project. Help me to modify the repository with the following changes only: 28 | 29 | {DESCRIPTION} 30 | 31 | {CHANGE_PREVIEW} 32 | - install any dependencies that were added in the above steps 33 | 34 | Give me instructions to modify the repository, but use only shell commands. Use as few commands as possible. Use the shortest commands possible. Provide a single /bin/sh script with no extra explanation. Complete all code and don't leave placeholder code. 35 | 36 | Do not include any commands to run, start or deploy or test the app. 37 | 38 | Follow each command in the instructions with a git commit command including a detailed commit message. 39 | `; 40 | 41 | const planChangesPrompt = ` 42 | {FILE_LIST} 43 | The above is my project structure. Help me to modify the repository with the following changes only: 44 | - {DESCRIPTION} 45 | 46 | By giving an array of the files I need to edit and new files I need to add to accomplish this, using the format below: 47 | [ 48 | ["filename", "add|edit","description of changes to be made"], 49 | ... 50 | ] 51 | The list should have as few items as possible, ideally, one, two or three items, and an absolute maximum of five items. 52 | 53 | Only give the array. No code fences, no other text. 54 | `; 55 | 56 | export { newProjectPrompt, changeProjectPrompt, planChangesPrompt }; 57 | -------------------------------------------------------------------------------- /src/helpers/container.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as tar from 'tar'; 3 | import * as Docker from 'dockerode'; 4 | 5 | const cleanOutput = (textInput: string) => { 6 | return textInput 7 | .replace(/[^\x00-\x7F]+/g, "") // Remove non-ASCII characters 8 | .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, "") // Remove ASCII control characters 9 | } 10 | 11 | async function createContainer(docker: Docker, tag: string, environment?: string[]): Promise { 12 | // create a new container from the image 13 | return await docker.createContainer({ 14 | Image: tag, // specify the image to use 15 | Env: environment ?? [], 16 | Tty: true, 17 | Cmd: ['/bin/sh'], 18 | OpenStdin: true, 19 | }); 20 | } 21 | 22 | async function startContainer(container: Docker.Container) { 23 | process.on('SIGINT', async function () { 24 | console.log("Caught interrupt signal"); 25 | await container.stop({ force: true }); 26 | }); 27 | await container.start(); 28 | const stream = await container.logs({ 29 | follow: true, 30 | stdout: true, 31 | stderr: true 32 | }); 33 | stream.on('data', chunk => console.log(chunk.toString())); 34 | return stream; 35 | } 36 | 37 | async function waitForStreamEnd(stream: NodeJS.ReadableStream): Promise { 38 | return new Promise((resolve, reject) => { 39 | try { 40 | stream.on('end', async () => { 41 | resolve(); 42 | }); 43 | } catch (err) { 44 | reject(err); 45 | } 46 | }); 47 | } 48 | 49 | async function runCommandInContainer(container: Docker.Container, command: string[], silent: boolean = false): Promise { 50 | const exec = await container.exec({ 51 | Cmd: command, 52 | AttachStdout: true, 53 | AttachStderr: true, 54 | }); 55 | const stream = await exec.start({ hijack: true, stdin: true }); 56 | let output = ""; 57 | stream.on('data', (data) => { 58 | output += data; 59 | }); 60 | await waitForStreamEnd(stream); 61 | if (!silent) console.log(output); 62 | return cleanOutput(output); 63 | } 64 | 65 | async function runScriptInContainer(container: Docker.Container, script: string, parameters: { [key: string]: string }, silent: boolean = false) { 66 | // Substitutes values in the template string. 67 | const replaceParameters = (templateString: string, parameters: { [key: string]: string }): string => { 68 | return Object.keys(parameters).reduce( 69 | (acc, key) => acc.replace(new RegExp(`{${key}}`, "g"), parameters[key] ?? ""), 70 | templateString 71 | ); 72 | }; 73 | 74 | // Run the given script as a bash script. 75 | const result = await runCommandInContainer(container, ["bash", "-c", replaceParameters(script, parameters)], silent) 76 | return result; 77 | } 78 | 79 | async function readFileFromContainer(container: Docker.Container, path: string) { 80 | return await runCommandInContainer(container, ["cat", path], true) 81 | } 82 | 83 | async function copyFileToContainer(container: Docker.Container, localFilePath: string, containerFilePath: string) { 84 | const baseDir = path.dirname(localFilePath); 85 | const archive = tar.create({ gzip: false, portable: true, cwd: baseDir }, [path.basename(localFilePath)]); 86 | await container.putArchive(archive, { path: containerFilePath }); 87 | } 88 | 89 | export { createContainer, startContainer, runCommandInContainer, runScriptInContainer, copyFileToContainer, readFileFromContainer } 90 | -------------------------------------------------------------------------------- /src/helpers/github.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch" 2 | 3 | function errorMessage(result: any) { 4 | if (result.message) { 5 | let message = `${result.message}`; 6 | if (result.errors) { 7 | for (const error of result.errors) { 8 | message += ` ${error.resource} ${error.message}.`; 9 | } 10 | } 11 | return message; 12 | } 13 | return undefined; 14 | } 15 | 16 | function incrementName(name: string) { 17 | const regex = /\-(\d+)$/; 18 | const match = name.match(regex); 19 | if (match) { 20 | const number = parseInt(match[1]!); 21 | return name.replace(regex, `-${number + 1}`); 22 | } else { 23 | return `${name}-1`; 24 | } 25 | } 26 | 27 | interface GitHubRepoOptions { 28 | token: string; 29 | name: string; 30 | description: string; 31 | org?: string; 32 | template?: { repository: string, owner: string } 33 | attempts?: number; 34 | } 35 | 36 | async function createGitHubRepo({ token, name, description, org, template, attempts = 10 }: GitHubRepoOptions) { 37 | let failedAttempts = 0; 38 | let currentName = name; 39 | let result: any = {}; 40 | 41 | // Try new names until we find one that doesn't exist, or we run out of attempts. 42 | while (failedAttempts < attempts) { 43 | 44 | // Request to the GitHub API. 45 | const requestOptions = { 46 | method: 'POST', 47 | headers: { 48 | 'Authorization': `token ${token}`, 49 | 'Content-Type': 'application/json' 50 | }, 51 | body: JSON.stringify({ 52 | name: currentName, 53 | description: description.replace(/\n/g, "").trim().slice(0, 350), 54 | private: true, 55 | // If generating from a template and owner is an organization, specify the owner. 56 | ...(template && org && { owner: org }) 57 | }) 58 | }; 59 | 60 | // Create the repo at username/repo or org/repo. 61 | const response = template 62 | // Generate from a template repository: 63 | ? await fetch(`https://api.github.com/repos/${template.owner}/${template.repository}/generate`, requestOptions) 64 | // Create a new repository: 65 | : org 66 | ? await fetch(`https://api.github.com/orgs/${org}/repos`, requestOptions) 67 | : await fetch('https://api.github.com/user/repos', requestOptions); 68 | result = await response.json() ?? {}; 69 | 70 | // If the repo already exists, add a number to the end of the name. 71 | const alreadyExists = (errors: Record[]): boolean => errors 72 | && errors[0].field === "name" // When creating a new repository. 73 | || errors[0].includes("already exists") // When generating from a template. 74 | 75 | if (result.errors && alreadyExists(result.errors)) { 76 | console.log(`Repository name already exists. Trying ${currentName}.`) 77 | failedAttempts++ 78 | currentName = incrementName(currentName); 79 | } else { 80 | break; 81 | } 82 | } 83 | 84 | // Throw an error if repository creation failed. 85 | const message = errorMessage(result); 86 | if (message) { 87 | console.log(result) 88 | throw new Error("Failed to create repository: " + message) 89 | } 90 | 91 | return result; 92 | } 93 | 94 | async function addGitHubCollaborator(token: string, repoName: string, collaborator: string) { 95 | // Add collaborator to the repo. 96 | // Note: Repo name is in the format of "org/repo". 97 | const requestOptions = { 98 | method: 'PUT', 99 | headers: { 100 | 'Authorization': `token ${token}`, 101 | 'Content-Type': 'application/json' 102 | }, 103 | body: JSON.stringify({ 104 | permission: 'push' 105 | }) 106 | }; 107 | 108 | const response = await fetch(`https://api.github.com/repos/${repoName}/collaborators/${collaborator}`, requestOptions); 109 | if (response.status >= 200 && response.status < 300) { 110 | return true; 111 | } else { 112 | const result = await response.json(); 113 | // Print errors if there are any. 114 | const message = errorMessage(result); 115 | if (message) { 116 | console.log(result); 117 | throw new Error("Failed to add collaborator: " + message) 118 | } 119 | return result; 120 | } 121 | } 122 | 123 | async function getGitHubBranches(token: string, repository: string): Promise { 124 | const requestOptions = { 125 | method: 'GET', 126 | headers: { 127 | 'Authorization': `token ${token}`, 128 | 'Content-Type': 'application/json' 129 | } 130 | }; 131 | 132 | const response = await fetch(`https://api.github.com/repos/${repository}/branches`, requestOptions); 133 | if (response.status === 204) { 134 | return []; 135 | } else { 136 | const result = await response.json(); 137 | // Print errors if there are any. 138 | const message = errorMessage(result); 139 | if (message) { 140 | console.log(result) 141 | throw new Error("Failed to get branches: " + message) 142 | } 143 | return result; 144 | } 145 | } 146 | 147 | async function correctBranchName(token: string, sourceRepository: string, branchName: string) { 148 | const branches = await getGitHubBranches(process.env.GITHUB_TOKEN!, sourceRepository) 149 | const branchNames = branches.map((branch) => branch.name) 150 | let correctedName = branchName 151 | while (branchNames.includes(correctedName)) { 152 | correctedName = incrementName(correctedName) 153 | } 154 | return correctedName; 155 | } 156 | 157 | export { createGitHubRepo, addGitHubCollaborator, getGitHubBranches, correctBranchName } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitWit Agent 2 | 3 | 4 | 5 | GitWit is a container-based agent specialized in making useful commits to git repositories. Given a description (i.e. "implement dark mode") it either checks out a repository or creates a new one, makes these changes, and then pushes the changes to master. (Skip to [How it works](#how-it-works).) 6 | 7 | Given there exist [a few agents](https://github.com/jamesmurdza/awesome-ai-devtools#pr-agents) with a similar purpose—**why is GitWit different?** GitWit interacts with the filesystem in a temporary sandbox and thus can run any shell command. It _writes code that writes code_. This makes it very flexible and repurposable for a number of interesting use cases. 8 | 9 | This agent is also live for testing at [app.gitwit.dev](https://app.gitwit.dev) and has generated over 1000 repositories! 10 | 11 | ### Contents 12 | - [How to run it](#how-to-run-it) 13 | - [Commands](#commands) 14 | - [Examples](#examples) 15 | - [Demos](#demos) 16 | - [Additional configuration](#additional-configuration) 17 | - [LLM configuration](#llm-configuration) 18 | - [How it works](#how-it-works) 19 | 20 | ## How to run it 21 | 22 | Before you start: 23 | 1. You need NodeJS (v18). 24 | 2. You need Docker. 25 | 3. The agent will access to your GitHub account via [personal access token](https://github.com/settings/tokens). 26 | 4. You need an [OpenAI API key](https://platform.openai.com/account/api-keys). 27 | 28 | Setup: 29 | 1. `git clone https://github.com/jamesmurdza/gitwit && cd gitwit` to clone this repository. 30 | 2. `cp .env.example .env` to create a .env file. Update **GITHUB_USERNAME**, **GITHUB_TOKEN** and **OPENAI_API_KEY** with your values. 31 | 3. Start Docker! (GitWit creates a temporary Docker container for each run.) The easiest way to do this locally is with Docker Desktop. See here to connect to a remote docker server. 32 | 4. `docker pull node:latest` to download the base Docker image. 33 | 5. `run npm install` to install dependencies. 34 | 35 | You are ready to go! 36 | 37 | ## Commands 38 | 39 | Generate a new GitHub repository: 40 | 41 | `npm run start` 42 | 43 | Generate a repository with the same name and description as the last run: 44 | 45 | `npm run start -- --again` 46 | 47 | Generate a repository with the same name, description, and build script as the last run: 48 | 49 | `npm run start -- --offline` 50 | 51 | Debug the build script from the last run: 52 | 53 | `npm run start -- --offline --debug` 54 | 55 | Generate a new branch on an existing repository: 56 | 57 | `npm run start -- --branch` 58 | 59 | Generate a new branch with the same name and description as the last run: 60 | 61 | `npm run start -- --branch --again` 62 | 63 | ## Examples 64 | 65 | Articles and tutorials: 66 | 67 | - [Building a Chrome Extension from Scratch using GitWit](https://codesphere.com/articles/building-a-chrome-extension-using-gitwit) 68 | 69 | Examples of entire repositories generated with GitWit: 70 | 71 | - [gitwitapp/doodle-app](https://github.com/gitwitapp/doodle-app): HTML/JS drawing app. 72 | - [gitwitapp/cached-http-proxy-server](https://github.com/gitwitapp/cached-http-proxy-server): NodeJS proxy server with caching. 73 | - [gitwitapp/reddit-news-viewer](https://github.com/gitwitapp/reddit-news-viewer): Python script for scraping Reddit headlines. 74 | - [gitwitapp/python-discord-chatbot](https://github.com/gitwitapp/python-discord-chatbot): Simple Discord bot written in Python. 75 | - [gitwitapp/live-BTC-ticker](https://github.com/gitwitapp/live-BTC-ticker): ReactJS app using d3.js to chart BTC prices. 76 | - [gitwitapp/web-calculator](https://github.com/gitwitapp/web-calculator): Simple HTML/JS calculator. 77 | - [gitwitapp/customer-oop-demo](https://github.com/gitwitapp/customer-oop-demo): Example of generated unit tests. 78 | 79 | ## Demos 80 | 81 | The agent has two modes: 82 | - Create new **repository**: Given a prompt and a repository name, spawn the repository 83 | 84 | https://github.com/gitwitdev/gitwitdev.github.io/assets/33395784/55537249-c301-4e13-84e5-0cdb06174071 85 | 86 | - Create new **branch**: Given a prompt, an existing repository and a branch name, spawn the new branch 87 | 88 | https://github.com/gitwitdev/gitwitdev.github.io/assets/33395784/9315a17c-fc72-431a-a648-16ba42938faa 89 | 90 | ## Additional configuration 91 | 92 | To add new repositories to a GitHub organization: 93 | ```sh 94 | GITHUB_ORGNAME=mygithuborg 95 | ``` 96 | 97 | To use a remote Docker server: 98 | ```sh 99 | DOCKER_API_HOST=1.2.3.4 100 | DOCKER_API_PORT=2375 101 | DOCKER_API_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY----- 102 | DOCKER_API_CA=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE----- 103 | DOCKER_API_CERT=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE----- 104 | ``` 105 | 106 | To enable logging or caching with Helicone: 107 | ```sh 108 | # Required: 109 | OPENAI_BASE_URL=https://oai.hconeai.com/v1 110 | HELICONE_API_KEY=sk-xxxxxxx-xxxxxxx-xxxxxxx-xxxxxxx 111 | # Optional: 112 | # OPENAI_CACHE_ENABLED=true 113 | ``` 114 | 115 | ## LLM configuration 116 | 117 | By default, GitWit Agent is set to use the OpenAI API with gpt-3.5-turbo and a temperature setting of 0.2. These settings can be configured in [index.js](https://github.com/jamesmurdza/gitwit-agent/blob/main/index.ts). 118 | 119 | GitWit can also be used with LangChain to compose any LangChain supported [chat model](https://js.langchain.com/docs/modules/model_io/models/chat/). An example of this is in [llm.js](https://github.com/jamesmurdza/gitwit-agent/blob/langchain/llm.ts) on the [langchain](https://github.com/jamesmurdza/gitwit-agent/tree/langchain) branch. 120 | 121 | ## How it works 122 | 123 | 124 | 125 | 145 | 149 | 150 | 151 | 162 | 166 | 167 |
126 |

127 | This shows the various APIs and connections in the program. 128 |

129 |

130 | Code generator: (index.ts) This is the central component that contains the logic necessary to create a new repository or branch. 131 |

132 |

133 | OpenAI API: (openai.ts) This is a wrapper functions I wrote around the OpenAI chat completion API. 134 |

135 |

136 | GitHub API: (github.ts) This is a collection of wrapper functions I wrote around the GitHub API. 137 |

138 |

139 | Docker/Container: (container.ts) This is a collection of wrapper functions I wrote around dockerode to simplify interacting with a Docker server 140 |

141 |

142 | Git Repository: (scripts.ts) This is a collection of shell scripts that are injected into the container in order to perform basic git operations. 143 |

144 |
146 | GitWit Architecture 147 |

Overview of the system and its parts

148 |
152 |

153 | This diagram shows a sequential breakdown of the steps in index.ts. A user prompt is used to generate a plan, which is then used to generate a shell script which is run in the container. 154 |

155 |

156 | Note: This diagram is for the "branch creation" mode. The equivalent diagram for "repository generation" mode would have "Create a new repo" for Step 1, and Step 2 would be removed. That's because the main purpose of the plan is to selectively decide which files to inject in the context of the final LLM call. 157 |

158 |

159 | We select entire files that should or should not be included in the context of the final LLM call, a simple implementation of retrieval-augmented generation! 160 |

161 |
163 | GitWit Agent 164 |

Overview of the agentic process

165 |
168 | -------------------------------------------------------------------------------- /src/lib/build.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as path from "path" 3 | import * as os from "os" 4 | import Docker from 'dockerode' 5 | import packageInfo from '../../package.json'; 6 | 7 | import { 8 | createContainer, 9 | startContainer, 10 | runCommandInContainer, 11 | runScriptInContainer, 12 | copyFileToContainer, 13 | readFileFromContainer 14 | } from "../helpers/container" 15 | import { simpleOpenAIRequest, Completion } from "../helpers/openai" 16 | import { applyCorrections } from "../helpers/corrections" 17 | import { newProjectPrompt, changeProjectPrompt, planChangesPrompt } from "../../prompt" 18 | import { createGitHubRepo, addGitHubCollaborator, correctBranchName } from "../helpers/github" 19 | import * as scripts from "../../scripts" 20 | import { BuildPlan } from "./buildPlan" 21 | 22 | const { exec } = require('child_process'); 23 | 24 | // Container constants: 25 | const baseImage = "node:latest" 26 | const containerHome = "/app/" 27 | 28 | // OpenAI constants: 29 | const gptModel = "gpt-3.5-turbo" 30 | const temperature = 0.2 31 | const maxPromptLength = 5500 32 | 33 | // Reading and writing files: 34 | async function writeFile(path: string, contents: string): Promise { 35 | await fs.promises.writeFile(path, contents) 36 | console.log(`Wrote: ${path}`) 37 | } 38 | 39 | function executeCommand(command: string): Promise { 40 | return new Promise((resolve, reject) => { 41 | exec(command, (error: Error | null, stdout: string, stderr: string) => { 42 | if (error) { 43 | reject(error); 44 | return; 45 | } 46 | 47 | if (stderr) { 48 | reject(new Error(stderr)); 49 | return; 50 | } 51 | 52 | resolve(stdout.trim()); 53 | }); 54 | }); 55 | } 56 | 57 | type BuildType = "REPOSITORY" | "BRANCH" | "TEMPLATE" 58 | type BuildConstructorProps = { 59 | userInput: string, // User prompt 60 | buildType: BuildType, // Build type 61 | suggestedName: string, // Name of new repository or branch 62 | creator: string, // Username to create the repository with 63 | sourceGitURL?: string, // Repository to branch from 64 | sourceBranch?: string, // Branch to branch from 65 | organization?: string, // Oganization to create the repository under 66 | collaborator?: string, // User to add as a collaborator 67 | } 68 | 69 | // Project generation 70 | export class Build { 71 | // Input parameters to create a build: 72 | userInput: string 73 | isBranch?: boolean = false 74 | isCopy?: boolean = false 75 | suggestedName: string 76 | sourceGitURL?: string 77 | sourceBranch?: string 78 | creator: string 79 | organization?: string 80 | collaborator?: string 81 | dockerDaemonPath?: string 82 | 83 | // Generated values: 84 | completion?: any 85 | planCompletion?: any 86 | fileList?: string 87 | buildPlan?: BuildPlan 88 | 89 | // Output parameters: 90 | buildScript?: string 91 | buildLog?: string 92 | outputGitURL?: string 93 | outputHTMLURL?: string 94 | 95 | private constructor(props: BuildConstructorProps) { 96 | // To create a new project: 97 | this.suggestedName = props.suggestedName // The suggested name of the branch 98 | this.userInput = props.userInput // The description of the project. 99 | 100 | // The username(s) to create the repository under. 101 | this.creator = props.creator 102 | this.organization = props.organization 103 | this.collaborator = props.collaborator 104 | 105 | // To create a new branch or fork: 106 | this.isBranch = props.buildType === "BRANCH" 107 | this.isCopy = props.buildType === "TEMPLATE" 108 | 109 | if (this.isBranch || this.isCopy) { 110 | if (!props.sourceGitURL) { 111 | throw new Error("Source repository is required to make a branch.") 112 | } 113 | this.sourceGitURL = props.sourceGitURL // The source repository URL. 114 | this.sourceBranch = props.sourceBranch // The source branch name. 115 | } 116 | } 117 | 118 | // Factory class that only returns a working object if Docker is found 119 | static async create(props: BuildConstructorProps): Promise { 120 | const classInstance = new Build(props); 121 | try { 122 | classInstance.dockerDaemonPath = await executeCommand("lsof -U -n -c docker | awk '/docker\\.sock/ {print $8}' | head -n 1"); 123 | } catch (error: any) { 124 | console.log(`Unable to find a running Docker daemon.`) 125 | throw new Error(`Unable to find a running Docker daemon.`) 126 | } 127 | 128 | return classInstance; 129 | } 130 | 131 | // Generate a build script to create a new repository. 132 | private getCompletion = async (): Promise => { 133 | 134 | console.log("Calling on the great machine god...") 135 | 136 | // Generate a new repository from an empty directory. 137 | const prompt = newProjectPrompt 138 | .replace("{REPOSITORY_NAME}", this.suggestedName) 139 | .replace("{DESCRIPTION}", this.userInput) 140 | .replace("{BASE_IMAGE}", baseImage); 141 | 142 | this.completion = await simpleOpenAIRequest(prompt.slice(-maxPromptLength), { 143 | model: gptModel, 144 | user: this.collaborator ?? this.creator, 145 | temperature: temperature 146 | }); 147 | 148 | console.log("Prayers were answered. (1/1)"); 149 | 150 | return this.completion 151 | } 152 | 153 | // Generate a plan to modify an existing repository. 154 | private getPlanCompletion = async (): Promise => { 155 | 156 | console.log("Generating plan...") 157 | 158 | // Prompt to generate the build plan. 159 | const prompt = planChangesPrompt 160 | .replace("{DESCRIPTION}", this.userInput) 161 | .replace("{FILE_LIST}", this.fileList ?? ""); 162 | 163 | this.planCompletion = await simpleOpenAIRequest(prompt.slice(-maxPromptLength), { 164 | model: gptModel, 165 | user: this.collaborator ?? this.creator, 166 | temperature: temperature 167 | }); 168 | console.log("Completion received. (1/2)") 169 | 170 | return this.planCompletion; 171 | } 172 | 173 | // Generate a build script to modify an existing repository. 174 | private getBranchCompletion = async (previewContext: string, fileContentsContext: string): Promise => { 175 | 176 | // Prompt to generate the build script. 177 | const fullPrompt = changeProjectPrompt 178 | .replace("{DESCRIPTION}", this.userInput) 179 | .replace("{FILE_CONTENTS}", fileContentsContext) 180 | .replace("{CHANGE_PREVIEW}", previewContext); 181 | 182 | this.completion = await simpleOpenAIRequest(fullPrompt.slice(-maxPromptLength), { 183 | model: gptModel, 184 | user: this.collaborator ?? this.creator, 185 | temperature: temperature 186 | }); 187 | 188 | console.log("Completion received. (2/2)"); 189 | 190 | return this.completion 191 | } 192 | 193 | buildAndPush = async ({ debug = false, onStatusUpdate = async ({ }) => { }, } = {}) => { 194 | 195 | // This function pushes a status update to the database. 196 | const updateStatus = async ({ finished = false } = {}) => { 197 | await onStatusUpdate({ 198 | outputGitURL: this.outputGitURL, 199 | outputHTMLURL: this.outputHTMLURL, 200 | buildScript: this.buildScript, 201 | buildLog: this.buildLog, 202 | buildPlan: this.planCompletion?.text, 203 | completionId: this.completion?.id, 204 | planCompletionId: this.planCompletion?.id, 205 | gptModel: this.completion?.model, 206 | gitwitVersion: packageInfo.version, 207 | finished: finished 208 | }) 209 | } 210 | 211 | // Build directory 212 | const buildDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "gitwit-")) + "/" 213 | console.log(`Created temporary directory: ${buildDirectory} `) 214 | 215 | // Get the name of the source repository as username/reponame. 216 | const regex = /\/\/github\.com\/([\w-]+)\/([\w-]+)\.git/ 217 | const [, sourceRepositoryUser, sourceRepositoryName] = this.sourceGitURL?.match(regex) ?? [] 218 | 219 | // Intermediate build script. 220 | const buildScriptPath = buildDirectory + "build.sh" 221 | const buildLogPath = buildDirectory + "build.log" 222 | 223 | // If we're creating a new repository, call the OpenAI API already. 224 | if (!this.isBranch && !this.isCopy && !this.completion) { 225 | await this.getCompletion() 226 | } 227 | 228 | let repositoryName: string | undefined; 229 | let branchName = ""; 230 | 231 | if (this.isBranch) { 232 | // Use the provided repository. 233 | repositoryName = sourceRepositoryName; 234 | console.log(`Using repository: ${this.sourceGitURL} `) 235 | 236 | // Find an available branch name. 237 | branchName = await correctBranchName( 238 | process.env.GITHUB_TOKEN!, 239 | `${sourceRepositoryUser}/${sourceRepositoryName}`, 240 | this.suggestedName! 241 | ) 242 | 243 | this.outputGitURL = this.sourceGitURL; 244 | const sourceHTMLRoot = this.sourceGitURL?.replace(".git", ""); // What is this sourceHTML? 245 | this.outputHTMLURL = `${sourceHTMLRoot}/tree/${branchName}`; 246 | console.log(`Creating branch: ${branchName}`) 247 | } else { 248 | 249 | // Create a new GitHub repository. 250 | const template = { owner: sourceRepositoryUser, repository: sourceRepositoryName }; 251 | const templateOptions = this.isCopy ? { template } : undefined 252 | const newRepository: any = await createGitHubRepo({ 253 | token: process.env.GITHUB_TOKEN!, 254 | name: this.suggestedName, 255 | org: this.organization, 256 | description: this.userInput, 257 | ...templateOptions 258 | }); 259 | 260 | // Outputs from the new repository. 261 | this.outputGitURL = newRepository.clone_url 262 | this.outputHTMLURL = newRepository.html_url 263 | repositoryName = newRepository.name 264 | console.log(`Created repository: ${newRepository.html_url}`) 265 | 266 | // Add the user as a collaborator on the GitHub repository. 267 | if (newRepository.full_name && this.collaborator) { 268 | const result = this.collaborator ? await addGitHubCollaborator( 269 | process.env.GITHUB_TOKEN!, 270 | newRepository.full_name, 271 | this.collaborator! 272 | ) : null 273 | console.log(`Added ${this.collaborator} to ${newRepository.full_name}.`) 274 | } 275 | } 276 | 277 | if (this.isCopy) { 278 | await updateStatus({ finished: true }); 279 | } else { 280 | // Define the parameters used by the scripts. 281 | let parameters = { 282 | REPO_NAME: repositoryName!, 283 | FULL_REPO_NAME: `${sourceRepositoryUser}/${sourceRepositoryName}`, 284 | PUSH_URL: this.outputGitURL!, 285 | REPO_DESCRIPTION: this.userInput!, 286 | GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL!, 287 | GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME!, 288 | GITHUB_USERNAME: process.env.GITHUB_USERNAME!, 289 | GITHUB_TOKEN: process.env.GITHUB_TOKEN!, 290 | GITWIT_VERSION: packageInfo.version, 291 | BRANCH_NAME: branchName ?? "", 292 | SOURCE_BRANCH_NAME: this.sourceBranch ?? "", 293 | GITHUB_ACCOUNT: this.creator, 294 | } 295 | 296 | // Connect to Docker... 297 | console.log( 298 | "Connecting to Docker on " 299 | + (process.env.DOCKER_API_HOST ?? "localhost") 300 | + (process.env.DOCKER_API_PORT ? `:${process.env.DOCKER_API_PORT}` : "") 301 | ); 302 | 303 | const docker = new Docker({ 304 | socketPath: this.dockerDaemonPath!, 305 | host: process.env.DOCKER_API_HOST, 306 | port: process.env.DOCKER_API_PORT, 307 | // Flightcontrol doesn't support environment variables with newlines. 308 | ca: process.env.DOCKER_API_CA?.replace(/\\n/g, "\n"), 309 | cert: process.env.DOCKER_API_CERT?.replace(/\\n/g, "\n"), 310 | key: process.env.DOCKER_API_KEY?.replace(/\\n/g, "\n"), 311 | // We use HTTPS when there is an SSL key. 312 | protocol: process.env.DOCKER_API_KEY ? 'https' : undefined, 313 | }) 314 | 315 | // Create a new docker container. 316 | const container = await createContainer(docker, baseImage) 317 | console.log(`Container ${container.id} created.`) 318 | 319 | // Start the container. 320 | await startContainer(container) 321 | console.log(`Container ${container.id} started.`) 322 | 323 | // Copy the metadata file to the container. 324 | await runCommandInContainer(container, ["mkdir", containerHome]) 325 | 326 | // These scripts are appended together to maintain the current directory. 327 | if (this.isBranch) { 328 | await runScriptInContainer(container, 329 | scripts.SETUP_GIT_CONFIG + // Setup the git commit author 330 | scripts.CLONE_PROJECT_REPO, 331 | parameters); 332 | 333 | // Get a list of files in the repository. 334 | this.fileList = await runScriptInContainer(container, 335 | scripts.GET_FILE_LIST, 336 | parameters, true); 337 | 338 | // Use ChatGPT to generate a plan. 339 | await this.getPlanCompletion() 340 | this.buildPlan = new BuildPlan( 341 | this.planCompletion.text, 342 | this.fileList.split('\n')) 343 | console.log(this.buildPlan.items) 344 | await updateStatus() 345 | 346 | // Get contents of the files to modify. 347 | const planContext = this.buildPlan.readableString() 348 | const contentsContext = await this.buildPlan.readableContents( 349 | async (filePath: string) => { 350 | return await readFileFromContainer(container, `/root/${repositoryName}/${filePath}`) 351 | }) 352 | 353 | // Use ChatGPT to generate the build script. 354 | await this.getBranchCompletion(planContext, contentsContext) 355 | } 356 | 357 | // Generate the build script from the OpenAI completion. 358 | this.buildScript = applyCorrections(this.completion.text.trim()) 359 | await updateStatus() 360 | 361 | await writeFile(buildScriptPath, this.buildScript) 362 | await copyFileToContainer(container, buildScriptPath, containerHome) 363 | 364 | if (this.isBranch) { 365 | // Run the build script on a new branch, and push it to GitHub. 366 | await runScriptInContainer(container, 367 | scripts.CREATE_NEW_BRANCH + 368 | scripts.RUN_BUILD_SCRIPT + 369 | scripts.CD_GIT_ROOT + 370 | scripts.SETUP_GIT_CREDENTIALS + 371 | scripts.PUSH_BRANCH, 372 | parameters) 373 | } else { 374 | await runScriptInContainer(container, 375 | // Run the build script in an empty directory, and push the results to GitHub. 376 | scripts.SETUP_GIT_CONFIG + 377 | scripts.MAKE_PROJECT_DIR + 378 | scripts.RUN_BUILD_SCRIPT + 379 | scripts.CD_GIT_ROOT + 380 | scripts.SETUP_GIT_CREDENTIALS + 381 | scripts.PUSH_TO_REPO, 382 | parameters) 383 | } 384 | 385 | this.buildLog = await runScriptInContainer(container, 386 | scripts.GET_BUILD_LOG, 387 | parameters, true); 388 | 389 | await updateStatus({ finished: true }); 390 | 391 | if (debug) { 392 | // This is how we can debug the build script interactively. 393 | console.log("The container is still running!") 394 | console.log("To debug, run:") 395 | console.log("-----") 396 | console.log(`docker exec -it ${container.id} bash`) 397 | console.log("-----") 398 | // If we don't this, the process won't end because the container is running. 399 | process.exit() 400 | } else { 401 | // Stop and remove the container. 402 | await container.stop() 403 | console.log(`Container ${container.id} stopped.`) 404 | await container.remove() 405 | console.log(`Container ${container.id} removed.`) 406 | } 407 | } 408 | } 409 | } 410 | --------------------------------------------------------------------------------