diff --git a/.github/actions/codex/.gitignore b/.github/actions/codex/.gitignore new file mode 100644 index 00000000..2ccbe465 --- /dev/null +++ b/.github/actions/codex/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/.github/actions/codex/.prettierrc.toml b/.github/actions/codex/.prettierrc.toml new file mode 100644 index 00000000..4c58c583 --- /dev/null +++ b/.github/actions/codex/.prettierrc.toml @@ -0,0 +1,8 @@ +printWidth = 80 +quoteProps = "consistent" +semi = true +tabWidth = 2 +trailingComma = "all" + +# Preserve existing behavior for markdown/text wrapping. +proseWrap = "preserve" diff --git a/.github/actions/codex/README.md b/.github/actions/codex/README.md new file mode 100644 index 00000000..a0be8ecb --- /dev/null +++ b/.github/actions/codex/README.md @@ -0,0 +1,140 @@ +# openai/codex-action + +`openai/codex-action` is a GitHub Action that facilitates the use of [Codex](https://github.com/openai/codex) on GitHub issues and pull requests. Using the action, associate **labels** to run Codex with the appropriate prompt for the given context. Codex will respond by posting comments or creating PRs, whichever you specify! + +Here is a sample workflow that uses `openai/codex-action`: + +```yaml +name: Codex + +on: + issues: + types: [opened, labeled] + pull_request: + branches: [main] + types: [labeled] + +jobs: + codex: + if: ... # optional, but can be effective in conserving CI resources + runs-on: ubuntu-latest + # TODO(mbolin): Need to verify if/when `write` is necessary. + permissions: + contents: write + issues: write + pull-requests: write + steps: + # By default, Codex runs network disabled using --full-auto, so perform + # any setup that requires network (such as installing dependencies) + # before openai/codex-action. + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Codex + uses: openai/codex-action@latest + with: + openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +See sample usage in [`codex.yml`](../../workflows/codex.yml). + +## Triggering the Action + +Using the sample workflow above, we have: + +```yaml +on: + issues: + types: [opened, labeled] + pull_request: + branches: [main] + types: [labeled] +``` + +which means our workflow will be triggered when any of the following events occur: + +- a label is added to an issue +- a label is added to a pull request against the `main` branch + +### Label-Based Triggers + +To define a GitHub label that should trigger Codex, create a file named `.github/codex/labels/LABEL-NAME.md` in your repository where `LABEL-NAME` is the name of the label. The content of the file is the prompt template to use when the label is added (see more on [Prompt Template Variables](#prompt-template-variables) below). + +For example, if the file `.github/codex/labels/codex-review.md` exists, then: + +- Adding the `codex-review` label will trigger the workflow containing the `openai/codex-action` GitHub Action. +- When `openai/codex-action` starts, it will replace the `codex-review` label with `codex-review-in-progress`. +- When `openai/codex-action` is finished, it will replace the `codex-review-in-progress` label with `codex-review-completed`. + +If Codex sees that either `codex-review-in-progress` or `codex-review-completed` is already present, it will not perform the action. + +As determined by the [default config](./src/default-label-config.ts), Codex will act on the following labels by default: + +- Adding the `codex-review` label to a pull request will have Codex review the PR and add it to the PR as a comment. +- Adding the `codex-triage` label to an issue will have Codex investigate the issue and report its findings as a comment. +- Adding the `codex-issue-fix` label to an issue will have Codex attempt to fix the issue and create a PR wit the fix, if any. + +## Action Inputs + +The `openai/codex-action` GitHub Action takes the following inputs + +### `openai_api_key` (required) + +Set your `OPENAI_API_KEY` as a [repository secret](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). See **Secrets and varaibles** then **Actions** in the settings for your GitHub repo. + +Note that the secret name does not have to be `OPENAI_API_KEY`. For example, you might want to name it `CODEX_OPENAI_API_KEY` and then configure it on `openai/codex-action` as follows: + +```yaml +openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }} +``` + +### `github_token` (required) + +This is required so that Codex can post a comment or create a PR. Set this value on the action as follows: + +```yaml +github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +### `codex_args` + +A whitespace-delimited list of arguments to pass to Codex. Defaults to `--full-auto`, but if you want to override the default model to use `o3`: + +```yaml +codex_args: "--full-auto --model o3" +``` + +For more complex configurations, use the `codex_home` input. + +### `codex_home` + +If set, the value to use for the `$CODEX_HOME` environment variable when running Codex. As explained [in the docs](https://github.com/openai/codex/tree/main/codex-rs#readme), this folder can contain the `config.toml` to configure Codex, custom instructions, and log files. + +This should be a relative path within your repo. + +## Prompt Template Variables + +As shown above, `"prompt"` and `"promptPath"` are used to define prompt templates that will be populated and passed to Codex in response to certain events. All template variables are of the form `{CODEX_ACTION_...}` and the supported values are defined below. + +### `CODEX_ACTION_ISSUE_TITLE` + +If the action was triggered on a GitHub issue, this is the issue title. + +Specifically it is read as the `.issue.title` from the `$GITHUB_EVENT_PATH`. + +### `CODEX_ACTION_ISSUE_BODY` + +If the action was triggered on a GitHub issue, this is the issue body. + +Specifically it is read as the `.issue.body` from the `$GITHUB_EVENT_PATH`. + +### `CODEX_ACTION_GITHUB_EVENT_PATH` + +The value of the `$GITHUB_EVENT_PATH` environment variable, which is the path to the file that contains the JSON payload for the event that triggered the workflow. Codex can use `jq` to read only the fields of interest from this file. + +### `CODEX_ACTION_PR_DIFF` + +If the action was triggered on a pull request, this is the diff between the base and head commits of the PR. It is the output from `git diff`. + +Note that the content of the diff could be quite large, so is generally safer to point Codex at `CODEX_ACTION_GITHUB_EVENT_PATH` and let it decide how it wants to explore the change. diff --git a/.github/actions/codex/action.yml b/.github/actions/codex/action.yml new file mode 100644 index 00000000..715423d0 --- /dev/null +++ b/.github/actions/codex/action.yml @@ -0,0 +1,124 @@ +name: "Codex [reusable action]" +description: "A reusable action that runs a Codex model." + +inputs: + openai_api_key: + description: "The value to use as the OPENAI_API_KEY environment variable when running Codex." + required: true + trigger_phrase: + description: "Text to trigger Codex from a PR/issue body or comment." + required: false + default: "" + github_token: + description: "Token so Codex can comment on the PR or issue." + required: true + codex_args: + description: "A whitespace-delimited list of arguments to pass to Codex. Due to limitations in YAML, arguments with spaces are not supported. For more complex configurations, use the `codex_home` input." + required: false + default: "--full-auto" + codex_home: + description: "Value to use as the CODEX_HOME environment variable when running Codex." + required: false + codex_release_tag: + description: "The release tag of the Codex model to run." + required: false + default: "codex-rs-d519bd8bbd1e1fd9efdc5d68cf7bebdec0dd0f28-1-rust-v0.0.2505270918" + +runs: + using: "composite" + steps: + # Do this in Bash so we do not even bother to install Bun if the sender does + # not have write access to the repo. + - name: Verify user has write access to the repo. + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + PERMISSION=$(gh api \ + "/repos/${GITHUB_REPOSITORY}/collaborators/${{ github.event.sender.login }}/permission" \ + | jq -r '.permission') + + if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "write" ]]; then + exit 1 + fi + + - name: Download Codex + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + # Determine OS/arch and corresponding Codex artifact name. + uname_s=$(uname -s) + uname_m=$(uname -m) + + case "$uname_s" in + Linux*) os="linux" ;; + Darwin*) os="apple-darwin" ;; + *) echo "Unsupported operating system: $uname_s"; exit 1 ;; + esac + + case "$uname_m" in + x86_64*) arch="x86_64" ;; + arm64*|aarch64*) arch="aarch64" ;; + *) echo "Unsupported architecture: $uname_m"; exit 1 ;; + esac + + # linux builds differentiate between musl and gnu. + if [[ "$os" == "linux" ]]; then + if [[ "$arch" == "x86_64" ]]; then + triple="${arch}-unknown-linux-musl" + else + # Only other supported linux build is aarch64 gnu. + triple="${arch}-unknown-linux-gnu" + fi + else + # macOS + triple="${arch}-apple-darwin" + fi + + # Note that if we start baking version numbers into the artifact name, + # we will need to update this action.yml file to match. + artifact="codex-exec-${triple}.tar.gz" + + gh release download ${{ inputs.codex_release_tag }} --repo openai/codex \ + --pattern "$artifact" --output - \ + | tar xzO > /usr/local/bin/codex-exec + chmod +x /usr/local/bin/codex-exec + + # Display Codex version to confirm binary integrity; ensure we point it + # at the checked-out repository via --cd so that any subsequent commands + # use the correct working directory. + codex-exec --cd "$GITHUB_WORKSPACE" --version + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.11 + + - name: Install dependencies + shell: bash + run: | + cd ${{ github.action_path }} + bun install --production + + - name: Run Codex + shell: bash + run: bun run ${{ github.action_path }}/src/main.ts + # Process args plus environment variables often have a max of 128 KiB, + # so we should fit within that limit? + env: + INPUT_CODEX_ARGS: ${{ inputs.codex_args || '' }} + INPUT_CODEX_HOME: ${{ inputs.codex_home || ''}} + INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase || '' }} + OPENAI_API_KEY: ${{ inputs.openai_api_key }} + GITHUB_TOKEN: ${{ inputs.github_token }} + GITHUB_EVENT_ACTION: ${{ github.event.action || '' }} + GITHUB_EVENT_LABEL_NAME: ${{ github.event.label.name || '' }} + GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number || '' }} + GITHUB_EVENT_ISSUE_BODY: ${{ github.event.issue.body || '' }} + GITHUB_EVENT_REVIEW_BODY: ${{ github.event.review.body || '' }} + GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body || '' }} diff --git a/.github/actions/codex/bun.lock b/.github/actions/codex/bun.lock new file mode 100644 index 00000000..11b79165 --- /dev/null +++ b/.github/actions/codex/bun.lock @@ -0,0 +1,85 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "codex-action", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.1", + }, + "devDependencies": { + "@types/bun": "^1.2.11", + "@types/node": "^22.15.21", + "prettier": "^3.5.3", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + + "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "@actions/github": ["@actions/github@6.0.1", "", { "dependencies": { "@actions/http-client": "^2.2.0", "@octokit/core": "^5.0.1", "@octokit/plugin-paginate-rest": "^9.2.2", "@octokit/plugin-rest-endpoint-methods": "^10.4.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "undici": "^5.28.5" } }, "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw=="], + + "@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="], + + "@octokit/core": ["@octokit/core@5.2.1", "", { "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.0.0", "before-after-hook": "^2.2.0", "universal-user-agent": "^6.0.0" } }, "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ=="], + + "@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="], + + "@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@9.2.2", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@10.4.1", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg=="], + + "@octokit/request": ["@octokit/request@8.4.1", "", { "dependencies": { "@octokit/endpoint": "^9.0.6", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw=="], + + "@octokit/request-error": ["@octokit/request-error@5.1.1", "", { "dependencies": { "@octokit/types": "^13.1.0", "deprecation": "^2.0.0", "once": "^1.4.0" } }, "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g=="], + + "@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], + + "@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="], + + "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + + "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], + + "deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], + + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + } +} diff --git a/.github/actions/codex/package.json b/.github/actions/codex/package.json new file mode 100644 index 00000000..bb35ee3a --- /dev/null +++ b/.github/actions/codex/package.json @@ -0,0 +1,21 @@ +{ + "name": "codex-action", + "version": "0.0.0", + "private": true, + "scripts": { + "format": "prettier --check src", + "format:fix": "prettier --write src", + "test": "bun test", + "typecheck": "tsc" + }, + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.1" + }, + "devDependencies": { + "@types/bun": "^1.2.11", + "@types/node": "^22.15.21", + "prettier": "^3.5.3", + "typescript": "^5.8.3" + } +} diff --git a/.github/actions/codex/src/add-reaction.ts b/.github/actions/codex/src/add-reaction.ts new file mode 100644 index 00000000..85026dd9 --- /dev/null +++ b/.github/actions/codex/src/add-reaction.ts @@ -0,0 +1,85 @@ +import * as github from "@actions/github"; +import type { EnvContext } from "./env-context"; + +/** + * Add an "eyes" reaction to the entity (issue, issue comment, or pull request + * review comment) that triggered the current Codex invocation. + * + * The purpose is to provide immediate feedback to the user – similar to the + * *-in-progress label flow – indicating that the bot has acknowledged the + * request and is working on it. + * + * We attempt to add the reaction best suited for the current GitHub event: + * + * • issues → POST /repos/{owner}/{repo}/issues/{issue_number}/reactions + * • issue_comment → POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions + * • pull_request_review_comment → POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions + * + * If the specific target is unavailable (e.g. unexpected payload shape) we + * silently skip instead of failing the whole action because the reaction is + * merely cosmetic. + */ +export async function addEyesReaction(ctx: EnvContext): Promise { + const octokit = ctx.getOctokit(); + const { owner, repo } = github.context.repo; + const eventName = github.context.eventName; + + try { + switch (eventName) { + case "issue_comment": { + const commentId = (github.context.payload as any)?.comment?.id; + if (commentId) { + await octokit.rest.reactions.createForIssueComment({ + owner, + repo, + comment_id: commentId, + content: "eyes", + }); + return; + } + break; + } + case "pull_request_review_comment": { + const commentId = (github.context.payload as any)?.comment?.id; + if (commentId) { + await octokit.rest.reactions.createForPullRequestReviewComment({ + owner, + repo, + comment_id: commentId, + content: "eyes", + }); + return; + } + break; + } + case "issues": { + const issueNumber = github.context.issue.number; + if (issueNumber) { + await octokit.rest.reactions.createForIssue({ + owner, + repo, + issue_number: issueNumber, + content: "eyes", + }); + return; + } + break; + } + default: { + // Fallback: try to react to the issue/PR if we have a number. + const issueNumber = github.context.issue.number; + if (issueNumber) { + await octokit.rest.reactions.createForIssue({ + owner, + repo, + issue_number: issueNumber, + content: "eyes", + }); + } + } + } + } catch (error) { + // Do not fail the action if reaction creation fails – log and continue. + console.warn(`Failed to add \"eyes\" reaction: ${error}`); + } +} diff --git a/.github/actions/codex/src/comment.ts b/.github/actions/codex/src/comment.ts new file mode 100644 index 00000000..6e2833af --- /dev/null +++ b/.github/actions/codex/src/comment.ts @@ -0,0 +1,53 @@ +import type { EnvContext } from "./env-context"; +import { runCodex } from "./run-codex"; +import { postComment } from "./post-comment"; +import { addEyesReaction } from "./add-reaction"; + +/** + * Handle `issue_comment` and `pull_request_review_comment` events once we know + * the action is supported. + */ +export async function onComment(ctx: EnvContext): Promise { + const triggerPhrase = ctx.tryGet("INPUT_TRIGGER_PHRASE"); + if (!triggerPhrase) { + console.warn("Empty trigger phrase: skipping."); + return; + } + + // Attempt to get the body of the comment from the environment. Depending on + // the event type either `GITHUB_EVENT_COMMENT_BODY` (issue & PR comments) or + // `GITHUB_EVENT_REVIEW_BODY` (PR reviews) is set. + const commentBody = + ctx.tryGetNonEmpty("GITHUB_EVENT_COMMENT_BODY") ?? + ctx.tryGetNonEmpty("GITHUB_EVENT_REVIEW_BODY") ?? + ctx.tryGetNonEmpty("GITHUB_EVENT_ISSUE_BODY"); + + if (!commentBody) { + console.warn("Comment body not found in environment: skipping."); + return; + } + + // Check if the trigger phrase is present. + if (!commentBody.includes(triggerPhrase)) { + console.log( + `Trigger phrase '${triggerPhrase}' not found: nothing to do for this comment.`, + ); + return; + } + + // Derive the prompt by removing the trigger phrase. Remove only the first + // occurrence to keep any additional occurrences that might be meaningful. + const prompt = commentBody.replace(triggerPhrase, "").trim(); + + if (prompt.length === 0) { + console.warn("Prompt is empty after removing trigger phrase: skipping"); + return; + } + + // Provide immediate feedback that we are working on the request. + await addEyesReaction(ctx); + + // Run Codex and post the response as a new comment. + const lastMessage = await runCodex(prompt, ctx); + await postComment(lastMessage, ctx); +} diff --git a/.github/actions/codex/src/config.ts b/.github/actions/codex/src/config.ts new file mode 100644 index 00000000..1f98f946 --- /dev/null +++ b/.github/actions/codex/src/config.ts @@ -0,0 +1,11 @@ +import { readdirSync, statSync } from "fs"; +import * as path from "path"; + +export interface Config { + labels: Record; +} + +export interface LabelConfig { + /** Returns the prompt template. */ + getPromptTemplate(): string; +} diff --git a/.github/actions/codex/src/default-label-config.ts b/.github/actions/codex/src/default-label-config.ts new file mode 100644 index 00000000..270f1f9c --- /dev/null +++ b/.github/actions/codex/src/default-label-config.ts @@ -0,0 +1,44 @@ +import type { Config } from "./config"; + +export function getDefaultConfig(): Config { + return { + labels: { + "codex-investigate-issue": { + getPromptTemplate: () => + ` +Troubleshoot whether the reported issue is valid. + +Provide a concise and respectful comment summarizing the findings. + +### {CODEX_ACTION_ISSUE_TITLE} + +{CODEX_ACTION_ISSUE_BODY} +`.trim(), + }, + "codex-code-review": { + getPromptTemplate: () => + ` +Review this PR and respond with a very concise final message, formatted in Markdown. + +There should be a summary of the changes (1-2 sentences) and a few bullet points if necessary. + +Then provide the **review** (1-2 sentences plus bullet points, friendly tone). + +{CODEX_ACTION_GITHUB_EVENT_PATH} contains the JSON that triggered this GitHub workflow. It contains the \`base\` and \`head\` refs that define this PR. Both refs are available locally. +`.trim(), + }, + "codex-attempt-fix": { + getPromptTemplate: () => + ` +Attempt to solve the reported issue. + +If a code change is required, create a new branch, commit the fix, and open a pull-request that resolves the problem. + +### {CODEX_ACTION_ISSUE_TITLE} + +{CODEX_ACTION_ISSUE_BODY} +`.trim(), + }, + }, + }; +} diff --git a/.github/actions/codex/src/env-context.ts b/.github/actions/codex/src/env-context.ts new file mode 100644 index 00000000..9c18e0e6 --- /dev/null +++ b/.github/actions/codex/src/env-context.ts @@ -0,0 +1,116 @@ +/* + * Centralised access to environment variables used by the Codex GitHub + * Action. + * + * To enable proper unit-testing we avoid reading from `process.env` at module + * initialisation time. Instead a `EnvContext` object is created (usually from + * the real `process.env`) and passed around explicitly or – where that is not + * yet practical – imported as the shared `defaultContext` singleton. Tests can + * create their own context backed by a stubbed map of variables without having + * to mutate global state. + */ + +import { fail } from "./fail"; +import * as github from "@actions/github"; + +export interface EnvContext { + /** + * Return the value for a given environment variable or terminate the action + * via `fail` if it is missing / empty. + */ + get(name: string): string; + + /** + * Attempt to read an environment variable. Returns the value when present; + * otherwise returns undefined (does not call `fail`). + */ + tryGet(name: string): string | undefined; + + /** + * Attempt to read an environment variable. Returns non-empty string value or + * null if unset or empty string. + */ + tryGetNonEmpty(name: string): string | null; + + /** + * Return a memoised Octokit instance authenticated via the token resolved + * from the provided argument (when defined) or the environment variables + * `GITHUB_TOKEN`/`GH_TOKEN`. + * + * Subsequent calls return the same cached instance to avoid spawning + * multiple REST clients within a single action run. + */ + getOctokit(token?: string): ReturnType; +} + +/** Internal helper – *not* exported. */ +function _getRequiredEnv( + name: string, + env: Record, +): string | undefined { + const value = env[name]; + + // Avoid leaking secrets into logs while still logging non-secret variables. + if (name.endsWith("KEY") || name.endsWith("TOKEN")) { + if (value) { + console.log(`value for ${name} was found`); + } + } else { + console.log(`${name}=${value}`); + } + + return value; +} + +/** Create a context backed by the supplied environment map (defaults to `process.env`). */ +export function createEnvContext( + env: Record = process.env, +): EnvContext { + // Lazily instantiated Octokit client – shared across this context. + let cachedOctokit: ReturnType | null = null; + + return { + get(name: string): string { + const value = _getRequiredEnv(name, env); + if (value == null) { + fail(`Missing required environment variable: ${name}`); + } + return value; + }, + + tryGet(name: string): string | undefined { + return _getRequiredEnv(name, env); + }, + + tryGetNonEmpty(name: string): string | null { + const value = _getRequiredEnv(name, env); + return value == null || value === "" ? null : value; + }, + + getOctokit(token?: string) { + if (cachedOctokit) { + return cachedOctokit; + } + + // Determine the token to authenticate with. + const githubToken = token ?? env["GITHUB_TOKEN"] ?? env["GH_TOKEN"]; + + if (!githubToken) { + fail( + "Unable to locate a GitHub token. `github_token` should have been set on the action.", + ); + } + + cachedOctokit = github.getOctokit(githubToken!); + return cachedOctokit; + }, + }; +} + +/** + * Shared context built from the actual `process.env`. Production code that is + * not yet refactored to receive a context explicitly may import and use this + * singleton. Tests should avoid the singleton and instead pass their own + * context to the functions they exercise. + */ +export const defaultContext: EnvContext = createEnvContext(); diff --git a/.github/actions/codex/src/fail.ts b/.github/actions/codex/src/fail.ts new file mode 100644 index 00000000..924d7009 --- /dev/null +++ b/.github/actions/codex/src/fail.ts @@ -0,0 +1,4 @@ +export function fail(message: string): never { + console.error(message); + process.exit(1); +} diff --git a/.github/actions/codex/src/git-helpers.ts b/.github/actions/codex/src/git-helpers.ts new file mode 100644 index 00000000..001ccde3 --- /dev/null +++ b/.github/actions/codex/src/git-helpers.ts @@ -0,0 +1,149 @@ +import { spawnSync } from "child_process"; +import * as github from "@actions/github"; +import { EnvContext } from "./env-context"; + +function runGit(args: string[], silent = true): string { + console.info(`Running git ${args.join(" ")}`); + const res = spawnSync("git", args, { + encoding: "utf8", + stdio: silent ? ["ignore", "pipe", "pipe"] : "inherit", + }); + if (res.error) { + throw res.error; + } + if (res.status !== 0) { + // Return stderr so caller may handle; else throw. + throw new Error( + `git ${args.join(" ")} failed with code ${res.status}: ${res.stderr}`, + ); + } + return res.stdout.trim(); +} + +function stageAllChanges() { + runGit(["add", "-A"]); +} + +function hasStagedChanges(): boolean { + const res = spawnSync("git", ["diff", "--cached", "--quiet", "--exit-code"]); + return res.status !== 0; +} + +function ensureOnBranch( + issueNumber: number, + protectedBranches: string[], + suggestedSlug?: string, +): string { + let branch = ""; + try { + branch = runGit(["symbolic-ref", "--short", "-q", "HEAD"]); + } catch { + branch = ""; + } + + // If detached HEAD or on a protected branch, create a new branch. + if (!branch || protectedBranches.includes(branch)) { + if (suggestedSlug) { + const safeSlug = suggestedSlug + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-"); + branch = `codex-fix-${issueNumber}-${safeSlug}`; + } else { + branch = `codex-fix-${issueNumber}-${Date.now()}`; + } + runGit(["switch", "-c", branch]); + } + return branch; +} + +function commitIfNeeded(issueNumber: number) { + if (hasStagedChanges()) { + runGit([ + "commit", + "-m", + `fix: automated fix for #${issueNumber} via Codex`, + ]); + } +} + +function pushBranch(branch: string, githubToken: string, ctx: EnvContext) { + const repoSlug = ctx.get("GITHUB_REPOSITORY"); // owner/repo + const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoSlug}.git`; + + runGit(["push", "--force-with-lease", "-u", remoteUrl, `HEAD:${branch}`]); +} + +/** + * If this returns a string, it is the URL of the created PR. + */ +export async function maybePublishPRForIssue( + issueNumber: number, + lastMessage: string, + ctx: EnvContext, +): Promise { + // Only proceed if GITHUB_TOKEN available. + const githubToken = + ctx.tryGetNonEmpty("GITHUB_TOKEN") ?? ctx.tryGetNonEmpty("GH_TOKEN"); + if (!githubToken) { + console.warn("No GitHub token - skipping PR creation."); + return undefined; + } + + // Print `git status` for debugging. + runGit(["status"]); + + // Stage any remaining changes so they can be committed and pushed. + stageAllChanges(); + + const octokit = ctx.getOctokit(githubToken); + + const { owner, repo } = github.context.repo; + + // Determine default branch to treat as protected. + let defaultBranch = "main"; + try { + const repoInfo = await octokit.rest.repos.get({ owner, repo }); + defaultBranch = repoInfo.data.default_branch ?? "main"; + } catch (e) { + console.warn(`Failed to get default branch, assuming 'main': ${e}`); + } + + const sanitizedMessage = lastMessage.replace(/\u2022/g, "-"); + const [summaryLine] = sanitizedMessage.split(/\r?\n/); + const branch = ensureOnBranch(issueNumber, [defaultBranch, "master"], summaryLine); + commitIfNeeded(issueNumber); + pushBranch(branch, githubToken, ctx); + + // Try to find existing PR for this branch + const headParam = `${owner}:${branch}`; + const existing = await octokit.rest.pulls.list({ + owner, + repo, + head: headParam, + state: "open", + }); + if (existing.data.length > 0) { + return existing.data[0].html_url; + } + + // Determine base branch (default to main) + let baseBranch = "main"; + try { + const repoInfo = await octokit.rest.repos.get({ owner, repo }); + baseBranch = repoInfo.data.default_branch ?? "main"; + } catch (e) { + console.warn(`Failed to get default branch, assuming 'main': ${e}`); + } + + const pr = await octokit.rest.pulls.create({ + owner, + repo, + title: summaryLine, + head: branch, + base: baseBranch, + body: sanitizedMessage, + }); + return pr.data.html_url; +} diff --git a/.github/actions/codex/src/git-user.ts b/.github/actions/codex/src/git-user.ts new file mode 100644 index 00000000..bd84a61a --- /dev/null +++ b/.github/actions/codex/src/git-user.ts @@ -0,0 +1,16 @@ +export function setGitHubActionsUser(): void { + const commands = [ + ["git", "config", "--global", "user.name", "github-actions[bot]"], + [ + "git", + "config", + "--global", + "user.email", + "41898282+github-actions[bot]@users.noreply.github.com", + ], + ]; + + for (const command of commands) { + Bun.spawnSync(command); + } +} diff --git a/.github/actions/codex/src/github-workspace.ts b/.github/actions/codex/src/github-workspace.ts new file mode 100644 index 00000000..8a1f7cae --- /dev/null +++ b/.github/actions/codex/src/github-workspace.ts @@ -0,0 +1,11 @@ +import * as pathMod from "path"; +import { EnvContext } from "./env-context"; + +export function resolveWorkspacePath(path: string, ctx: EnvContext): string { + if (pathMod.isAbsolute(path)) { + return path; + } else { + const workspace = ctx.get("GITHUB_WORKSPACE"); + return pathMod.join(workspace, path); + } +} diff --git a/.github/actions/codex/src/load-config.ts b/.github/actions/codex/src/load-config.ts new file mode 100644 index 00000000..f225e81a --- /dev/null +++ b/.github/actions/codex/src/load-config.ts @@ -0,0 +1,56 @@ +import type { Config, LabelConfig } from "./config"; + +import { getDefaultConfig } from "./default-label-config"; +import { readFileSync, readdirSync, statSync } from "fs"; +import * as path from "path"; + +/** + * Build an in-memory configuration object by scanning the repository for + * Markdown templates located in `.github/codex/labels`. + * + * Each `*.md` file in that directory represents a label that can trigger the + * Codex GitHub Action. The filename **without** the extension is interpreted + * as the label name, e.g. `codex-review.md` ➜ `codex-review`. + * + * For every such label we derive the corresponding `doneLabel` by appending + * the suffix `-completed`. + */ +export function loadConfig(workspace: string): Config { + const labelsDir = path.join(workspace, ".github", "codex", "labels"); + + let entries: string[]; + try { + entries = readdirSync(labelsDir); + } catch { + // If the directory is missing, return the default configuration. + return getDefaultConfig(); + } + + const labels: Record = {}; + + for (const entry of entries) { + if (!entry.endsWith(".md")) { + continue; + } + + const fullPath = path.join(labelsDir, entry); + + if (!statSync(fullPath).isFile()) { + continue; + } + + const labelName = entry.slice(0, -3); // trim ".md" + + labels[labelName] = new FileLabelConfig(fullPath); + } + + return { labels }; +} + +class FileLabelConfig implements LabelConfig { + constructor(private readonly promptPath: string) {} + + getPromptTemplate(): string { + return readFileSync(this.promptPath, "utf8"); + } +} diff --git a/.github/actions/codex/src/main.ts b/.github/actions/codex/src/main.ts new file mode 100755 index 00000000..a334c689 --- /dev/null +++ b/.github/actions/codex/src/main.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun + +import type { Config } from "./config"; + +import { defaultContext, EnvContext } from "./env-context"; +import { loadConfig } from "./load-config"; +import { setGitHubActionsUser } from "./git-user"; +import { onLabeled } from "./process-label"; +import { ensureBaseAndHeadCommitsForPRAreAvailable } from "./prompt-template"; +import { performAdditionalValidation } from "./verify-inputs"; +import { onComment } from "./comment"; +import { onReview } from "./review"; + +async function main(): Promise { + const ctx: EnvContext = defaultContext; + + // Build the configuration dynamically by scanning `.github/codex/labels`. + const GITHUB_WORKSPACE = ctx.get("GITHUB_WORKSPACE"); + const config: Config = loadConfig(GITHUB_WORKSPACE); + + // Optionally perform additional validation of prompt template files. + performAdditionalValidation(config, GITHUB_WORKSPACE); + + const GITHUB_EVENT_NAME = ctx.get("GITHUB_EVENT_NAME"); + const GITHUB_EVENT_ACTION = ctx.get("GITHUB_EVENT_ACTION"); + + // Set user.name and user.email to a bot before Codex runs, just in case it + // creates a commit. + setGitHubActionsUser(); + + switch (GITHUB_EVENT_NAME) { + case "issues": { + if (GITHUB_EVENT_ACTION === "labeled") { + await onLabeled(config, ctx); + return; + } else if (GITHUB_EVENT_ACTION === "opened") { + await onComment(ctx); + return; + } + break; + } + case "issue_comment": { + if (GITHUB_EVENT_ACTION === "created") { + await onComment(ctx); + return; + } + break; + } + case "pull_request": { + if (GITHUB_EVENT_ACTION === "labeled") { + await ensureBaseAndHeadCommitsForPRAreAvailable(ctx); + await onLabeled(config, ctx); + return; + } + break; + } + case "pull_request_review": { + await ensureBaseAndHeadCommitsForPRAreAvailable(ctx); + if (GITHUB_EVENT_ACTION === "submitted") { + await onReview(ctx); + return; + } + break; + } + case "pull_request_review_comment": { + await ensureBaseAndHeadCommitsForPRAreAvailable(ctx); + if (GITHUB_EVENT_ACTION === "created") { + await onComment(ctx); + return; + } + break; + } + } + + console.warn( + `Unsupported action '${GITHUB_EVENT_ACTION}' for event '${GITHUB_EVENT_NAME}'.`, + ); +} + +main(); diff --git a/.github/actions/codex/src/post-comment.ts b/.github/actions/codex/src/post-comment.ts new file mode 100644 index 00000000..9a3d7528 --- /dev/null +++ b/.github/actions/codex/src/post-comment.ts @@ -0,0 +1,60 @@ +import { fail } from "./fail"; +import * as github from "@actions/github"; +import { EnvContext } from "./env-context"; + +/** + * Post a comment to the issue / pull request currently in scope. + * + * Provide the environment context so that token lookup (inside getOctokit) does + * not rely on global state. + */ +export async function postComment( + commentBody: string, + ctx: EnvContext, +): Promise { + // Append a footer with a link back to the workflow run, if available. + const footer = buildWorkflowRunFooter(ctx); + const bodyWithFooter = footer ? `${commentBody}${footer}` : commentBody; + + const octokit = ctx.getOctokit(); + const { owner, repo } = github.context.repo; + const issueNumber = github.context.issue.number; + + if (!issueNumber) { + console.warn( + "No issue or pull_request number found in GitHub context; skipping comment creation.", + ); + return; + } + + try { + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: bodyWithFooter, + }); + } catch (error) { + fail(`Failed to create comment via GitHub API: ${error}`); + } +} + +/** + * Helper to build a Markdown fragment linking back to the workflow run that + * generated the current comment. Returns `undefined` if required environment + * variables are missing – e.g. when running outside of GitHub Actions – so we + * can gracefully skip the footer in those cases. + */ +function buildWorkflowRunFooter(ctx: EnvContext): string | undefined { + const serverUrl = + ctx.tryGetNonEmpty("GITHUB_SERVER_URL") ?? "https://github.com"; + const repository = ctx.tryGetNonEmpty("GITHUB_REPOSITORY"); + const runId = ctx.tryGetNonEmpty("GITHUB_RUN_ID"); + + if (!repository || !runId) { + return undefined; + } + + const url = `${serverUrl}/${repository}/actions/runs/${runId}`; + return `\n\n---\n*[_View workflow run_](${url})*`; +} diff --git a/.github/actions/codex/src/process-label.ts b/.github/actions/codex/src/process-label.ts new file mode 100644 index 00000000..4b4361e1 --- /dev/null +++ b/.github/actions/codex/src/process-label.ts @@ -0,0 +1,195 @@ +import { fail } from "./fail"; +import { EnvContext } from "./env-context"; +import { renderPromptTemplate } from "./prompt-template"; + +import { postComment } from "./post-comment"; +import { runCodex } from "./run-codex"; + +import * as github from "@actions/github"; +import { Config, LabelConfig } from "./config"; +import { maybePublishPRForIssue } from "./git-helpers"; + +export async function onLabeled( + config: Config, + ctx: EnvContext, +): Promise { + const GITHUB_EVENT_LABEL_NAME = ctx.get("GITHUB_EVENT_LABEL_NAME"); + const labelConfig = config.labels[GITHUB_EVENT_LABEL_NAME] as + | LabelConfig + | undefined; + if (!labelConfig) { + fail( + `Label \`${GITHUB_EVENT_LABEL_NAME}\` not found in config: ${JSON.stringify(config)}`, + ); + } + + await processLabelConfig(ctx, GITHUB_EVENT_LABEL_NAME, labelConfig); +} + +/** + * Wrapper that handles `-in-progress` and `-completed` semantics around the core lint/fix/review + * processing. It will: + * + * - Skip execution if the `-in-progress` or `-completed` label is already present. + * - Mark the PR/issue as `-in-progress`. + * - After successful execution, mark the PR/issue as `-completed`. + */ +async function processLabelConfig( + ctx: EnvContext, + label: string, + labelConfig: LabelConfig, +): Promise { + const octokit = ctx.getOctokit(); + const { owner, repo, issueNumber, labelNames } = + await getCurrentLabels(octokit); + + const inProgressLabel = `${label}-in-progress`; + const completedLabel = `${label}-completed`; + for (const markerLabel of [inProgressLabel, completedLabel]) { + if (labelNames.includes(markerLabel)) { + console.log( + `Label '${markerLabel}' already present on issue/PR #${issueNumber}. Skipping Codex action.`, + ); + + // Clean up: remove the triggering label to avoid confusion and re-runs. + await addAndRemoveLabels(octokit, { + owner, + repo, + issueNumber, + remove: markerLabel, + }); + + return; + } + } + + // Mark the PR/issue as in progress. + await addAndRemoveLabels(octokit, { + owner, + repo, + issueNumber, + add: inProgressLabel, + remove: label, + }); + + // Run the core Codex processing. + await processLabel(ctx, label, labelConfig); + + // Mark the PR/issue as completed. + await addAndRemoveLabels(octokit, { + owner, + repo, + issueNumber, + add: completedLabel, + remove: inProgressLabel, + }); +} + +async function processLabel( + ctx: EnvContext, + label: string, + labelConfig: LabelConfig, +): Promise { + const template = labelConfig.getPromptTemplate(); + const populatedTemplate = await renderPromptTemplate(template, ctx); + + // Always run Codex and post the resulting message as a comment. + let commentBody = await runCodex(populatedTemplate, ctx); + + // Current heuristic: only try to create a PR if "attempt" or "fix" is in the + // label name. (Yes, we plan to evolve this.) + if (label.indexOf("fix") !== -1 || label.indexOf("attempt") !== -1) { + console.info(`label ${label} indicates we should attempt to create a PR`); + const prUrl = await maybeFixIssue(ctx, commentBody); + if (prUrl) { + commentBody += `\n\n---\nOpened pull request: ${prUrl}`; + } + } else { + console.info( + `label ${label} does not indicate we should attempt to create a PR`, + ); + } + + await postComment(commentBody, ctx); +} + +async function maybeFixIssue( + ctx: EnvContext, + lastMessage: string, +): Promise { + // Attempt to create a PR out of any changes Codex produced. + const issueNumber = github.context.issue.number!; // exists for issues triggering this path + try { + return await maybePublishPRForIssue(issueNumber, lastMessage, ctx); + } catch (e) { + console.warn(`Failed to publish PR: ${e}`); + } +} + +async function getCurrentLabels( + octokit: ReturnType, +): Promise<{ + owner: string; + repo: string; + issueNumber: number; + labelNames: Array; +}> { + const { owner, repo } = github.context.repo; + const issueNumber = github.context.issue.number; + + if (!issueNumber) { + fail("No issue or pull_request number found in GitHub context."); + } + + const { data: issueData } = await octokit.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const labelNames = + issueData.labels?.map((label: any) => + typeof label === "string" ? label : label.name, + ) ?? []; + + return { owner, repo, issueNumber, labelNames }; +} + +async function addAndRemoveLabels( + octokit: ReturnType, + opts: { + owner: string; + repo: string; + issueNumber: number; + add?: string; + remove?: string; + }, +): Promise { + const { owner, repo, issueNumber, add, remove } = opts; + + if (add) { + try { + await octokit.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [add], + }); + } catch (error) { + console.warn(`Failed to add label '${add}': ${error}`); + } + } + + if (remove) { + try { + await octokit.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: remove, + }); + } catch (error) { + console.warn(`Failed to remove label '${remove}': ${error}`); + } + } +} diff --git a/.github/actions/codex/src/prompt-template.ts b/.github/actions/codex/src/prompt-template.ts new file mode 100644 index 00000000..aa52dd2a --- /dev/null +++ b/.github/actions/codex/src/prompt-template.ts @@ -0,0 +1,284 @@ +/* + * Utilities to render Codex prompt templates. + * + * A template is a Markdown (or plain-text) file that may contain one or more + * placeholders of the form `{CODEX_ACTION_}`. At runtime these + * placeholders are substituted with dynamically generated content. Each + * placeholder is resolved **exactly once** even if it appears multiple times + * in the same template. + */ + +import { readFile } from "fs/promises"; + +import { EnvContext } from "./env-context"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Lazily caches parsed `$GITHUB_EVENT_PATH` contents keyed by the file path so + * we only hit the filesystem once per unique event payload. + */ +const githubEventDataCache: Map> = new Map(); + +function getGitHubEventData(ctx: EnvContext): Promise { + const eventPath = ctx.get("GITHUB_EVENT_PATH"); + let cached = githubEventDataCache.get(eventPath); + if (!cached) { + cached = readFile(eventPath, "utf8").then((raw) => JSON.parse(raw)); + githubEventDataCache.set(eventPath, cached); + } + return cached; +} + +async function runCommand(args: Array): Promise { + const result = Bun.spawnSync(args, { + stdout: "pipe", + stderr: "pipe", + }); + + if (result.success) { + return result.stdout.toString(); + } + + console.error(`Error running ${JSON.stringify(args)}: ${result.stderr}`); + return ""; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Regex that captures the variable name without the surrounding { } braces. +const VAR_REGEX = /\{(CODEX_ACTION_[A-Z0-9_]+)\}/g; + +// Cache individual placeholder values so each one is resolved at most once per +// process even if many templates reference it. +const placeholderCache: Map> = new Map(); + +/** + * Parse a template string, resolve all placeholders and return the rendered + * result. + */ +export async function renderPromptTemplate( + template: string, + ctx: EnvContext, +): Promise { + // --------------------------------------------------------------------- + // 1) Gather all *unique* placeholders present in the template. + // --------------------------------------------------------------------- + const variables = new Set(); + for (const match of template.matchAll(VAR_REGEX)) { + variables.add(match[1]); + } + + // --------------------------------------------------------------------- + // 2) Kick off (or reuse) async resolution for each variable. + // --------------------------------------------------------------------- + for (const variable of variables) { + if (!placeholderCache.has(variable)) { + placeholderCache.set(variable, resolveVariable(variable, ctx)); + } + } + + // --------------------------------------------------------------------- + // 3) Await completion so we can perform a simple synchronous replace below. + // --------------------------------------------------------------------- + const resolvedEntries: [string, string][] = []; + for (const [key, promise] of placeholderCache.entries()) { + resolvedEntries.push([key, await promise]); + } + const resolvedMap = new Map(resolvedEntries); + + // --------------------------------------------------------------------- + // 4) Replace each occurrence. We use replace with a callback to ensure + // correct substitution even if variable names overlap (they shouldn't, + // but better safe than sorry). + // --------------------------------------------------------------------- + return template.replace(VAR_REGEX, (_, varName: string) => { + return resolvedMap.get(varName) ?? ""; + }); +} + +export async function ensureBaseAndHeadCommitsForPRAreAvailable( + ctx: EnvContext, +): Promise<{ baseSha: string; headSha: string } | null> { + const prShas = await getPrShas(ctx); + if (prShas == null) { + console.warn("Unable to resolve PR branches"); + return null; + } + + const event = await getGitHubEventData(ctx); + const pr = event.pull_request; + if (!pr) { + console.warn("event.pull_request is not defined - unexpected"); + return null; + } + + const workspace = ctx.get("GITHUB_WORKSPACE"); + + // Refs (branch names) + const baseRef: string | undefined = pr.base?.ref; + const headRef: string | undefined = pr.head?.ref; + + // Clone URLs + const baseRemoteUrl: string | undefined = pr.base?.repo?.clone_url; + const headRemoteUrl: string | undefined = pr.head?.repo?.clone_url; + + if (!baseRef || !headRef || !baseRemoteUrl || !headRemoteUrl) { + console.warn( + "Missing PR ref or remote URL information - cannot fetch commits", + ); + return null; + } + + // Ensure we have the base branch. + await runCommand([ + "git", + "-C", + workspace, + "fetch", + "--no-tags", + "origin", + baseRef, + ]); + + // Ensure we have the head branch. + if (headRemoteUrl === baseRemoteUrl) { + // Same repository – the commit is available from `origin`. + await runCommand([ + "git", + "-C", + workspace, + "fetch", + "--no-tags", + "origin", + headRef, + ]); + } else { + // Fork – make sure a `pr` remote exists that points at the fork. Attempting + // to add a remote that already exists causes git to error, so we swallow + // any non-zero exit codes from that specific command. + await runCommand([ + "git", + "-C", + workspace, + "remote", + "add", + "pr", + headRemoteUrl, + ]); + + // Whether adding succeeded or the remote already existed, attempt to fetch + // the head ref from the `pr` remote. + await runCommand([ + "git", + "-C", + workspace, + "fetch", + "--no-tags", + "pr", + headRef, + ]); + } + + return prShas; +} + +// --------------------------------------------------------------------------- +// Internal helpers – still exported for use by other modules. +// --------------------------------------------------------------------------- + +export async function resolvePrDiff(ctx: EnvContext): Promise { + const prShas = await ensureBaseAndHeadCommitsForPRAreAvailable(ctx); + if (prShas == null) { + console.warn("Unable to resolve PR branches"); + return ""; + } + + const workspace = ctx.get("GITHUB_WORKSPACE"); + const { baseSha, headSha } = prShas; + return runCommand([ + "git", + "-C", + workspace, + "diff", + "--color=never", + `${baseSha}..${headSha}`, + ]); +} + +// --------------------------------------------------------------------------- +// Placeholder resolution +// --------------------------------------------------------------------------- + +async function resolveVariable(name: string, ctx: EnvContext): Promise { + switch (name) { + case "CODEX_ACTION_ISSUE_TITLE": { + const event = await getGitHubEventData(ctx); + const issue = event.issue ?? event.pull_request; + return issue?.title ?? ""; + } + + case "CODEX_ACTION_ISSUE_BODY": { + const event = await getGitHubEventData(ctx); + const issue = event.issue ?? event.pull_request; + return issue?.body ?? ""; + } + + case "CODEX_ACTION_GITHUB_EVENT_PATH": { + return ctx.get("GITHUB_EVENT_PATH"); + } + + case "CODEX_ACTION_BASE_REF": { + const event = await getGitHubEventData(ctx); + return event?.pull_request?.base?.ref ?? ""; + } + + case "CODEX_ACTION_HEAD_REF": { + const event = await getGitHubEventData(ctx); + return event?.pull_request?.head?.ref ?? ""; + } + + case "CODEX_ACTION_PR_DIFF": { + return resolvePrDiff(ctx); + } + + // ------------------------------------------------------------------- + // Add new template variables here. + // ------------------------------------------------------------------- + + default: { + // Unknown variable – leave it blank to avoid leaking placeholders to the + // final prompt. The alternative would be to `fail()` here, but silently + // ignoring unknown placeholders is more forgiving and better matches the + // behaviour of typical template engines. + console.warn(`Unknown template variable: ${name}`); + return ""; + } + } +} + +async function getPrShas( + ctx: EnvContext, +): Promise<{ baseSha: string; headSha: string } | null> { + const event = await getGitHubEventData(ctx); + const pr = event.pull_request; + if (!pr) { + console.warn("event.pull_request is not defined"); + return null; + } + + // Prefer explicit SHAs if available to avoid relying on local branch names. + const baseSha: string | undefined = pr.base?.sha; + const headSha: string | undefined = pr.head?.sha; + + if (!baseSha || !headSha) { + console.warn("one of base or head is not defined on event.pull_request"); + return null; + } + + return { baseSha, headSha }; +} diff --git a/.github/actions/codex/src/review.ts b/.github/actions/codex/src/review.ts new file mode 100644 index 00000000..64f826dc --- /dev/null +++ b/.github/actions/codex/src/review.ts @@ -0,0 +1,42 @@ +import type { EnvContext } from "./env-context"; +import { runCodex } from "./run-codex"; +import { postComment } from "./post-comment"; +import { addEyesReaction } from "./add-reaction"; + +/** + * Handle `pull_request_review` events. We treat the review body the same way + * as a normal comment. + */ +export async function onReview(ctx: EnvContext): Promise { + const triggerPhrase = ctx.tryGet("INPUT_TRIGGER_PHRASE"); + if (!triggerPhrase) { + console.warn("Empty trigger phrase: skipping."); + return; + } + + const reviewBody = ctx.tryGet("GITHUB_EVENT_REVIEW_BODY"); + + if (!reviewBody) { + console.warn("Review body not found in environment: skipping."); + return; + } + + if (!reviewBody.includes(triggerPhrase)) { + console.log( + `Trigger phrase '${triggerPhrase}' not found: nothing to do for this review.`, + ); + return; + } + + const prompt = reviewBody.replace(triggerPhrase, "").trim(); + + if (prompt.length === 0) { + console.warn("Prompt is empty after removing trigger phrase: skipping."); + return; + } + + await addEyesReaction(ctx); + + const lastMessage = await runCodex(prompt, ctx); + await postComment(lastMessage, ctx); +} diff --git a/.github/actions/codex/src/run-codex.ts b/.github/actions/codex/src/run-codex.ts new file mode 100644 index 00000000..2c851823 --- /dev/null +++ b/.github/actions/codex/src/run-codex.ts @@ -0,0 +1,56 @@ +import { fail } from "./fail"; +import { EnvContext } from "./env-context"; +import { tmpdir } from "os"; +import { join } from "node:path"; +import { readFile, mkdtemp } from "fs/promises"; +import { resolveWorkspacePath } from "./github-workspace"; + +/** + * Runs the Codex CLI with the provided prompt and returns the output written + * to the "last message" file. + */ +export async function runCodex( + prompt: string, + ctx: EnvContext, +): Promise { + const OPENAI_API_KEY = ctx.get("OPENAI_API_KEY"); + + const tempDirPath = await mkdtemp(join(tmpdir(), "codex-")); + const lastMessageOutput = join(tempDirPath, "codex-prompt.md"); + + const args = ["/usr/local/bin/codex-exec"]; + + const inputCodexArgs = ctx.tryGet("INPUT_CODEX_ARGS")?.trim(); + if (inputCodexArgs) { + args.push(...inputCodexArgs.split(/\s+/)); + } + + args.push("--output-last-message", lastMessageOutput, prompt); + + const env: Record = { ...process.env, OPENAI_API_KEY }; + const INPUT_CODEX_HOME = ctx.tryGet("INPUT_CODEX_HOME"); + if (INPUT_CODEX_HOME) { + env.CODEX_HOME = resolveWorkspacePath(INPUT_CODEX_HOME, ctx); + } + + console.log(`Running Codex: ${JSON.stringify(args)}`); + const result = Bun.spawnSync(args, { + stdout: "inherit", + stderr: "inherit", + env, + }); + + if (!result.success) { + fail(`Codex failed: see above for details.`); + } + + // Read the output generated by Codex. + let lastMessage: string; + try { + lastMessage = await readFile(lastMessageOutput, "utf8"); + } catch (err) { + fail(`Failed to read Codex output at '${lastMessageOutput}': ${err}`); + } + + return lastMessage; +} diff --git a/.github/actions/codex/src/verify-inputs.ts b/.github/actions/codex/src/verify-inputs.ts new file mode 100644 index 00000000..bfc5dcda --- /dev/null +++ b/.github/actions/codex/src/verify-inputs.ts @@ -0,0 +1,33 @@ +// Validate the inputs passed to the composite action. +// The script currently ensures that the provided configuration file exists and +// matches the expected schema. + +import type { Config } from "./config"; + +import { existsSync } from "fs"; +import * as path from "path"; +import { fail } from "./fail"; + +export function performAdditionalValidation(config: Config, workspace: string) { + // Additional validation: ensure referenced prompt files exist and are Markdown. + for (const [label, details] of Object.entries(config.labels)) { + // Determine which prompt key is present (the schema guarantees exactly one). + const promptPathStr = + (details as any).prompt ?? (details as any).promptPath; + + if (promptPathStr) { + const promptPath = path.isAbsolute(promptPathStr) + ? promptPathStr + : path.join(workspace, promptPathStr); + + if (!existsSync(promptPath)) { + fail(`Prompt file for label '${label}' not found: ${promptPath}`); + } + if (!promptPath.endsWith(".md")) { + fail( + `Prompt file for label '${label}' must be a .md file (got ${promptPathStr}).`, + ); + } + } + } +} diff --git a/.github/actions/codex/tsconfig.json b/.github/actions/codex/tsconfig.json new file mode 100644 index 00000000..c05c2955 --- /dev/null +++ b/.github/actions/codex/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + + "include": ["src"] +} diff --git a/.github/codex/home/config.toml b/.github/codex/home/config.toml new file mode 100644 index 00000000..bb1b362b --- /dev/null +++ b/.github/codex/home/config.toml @@ -0,0 +1,3 @@ +model = "o3" + +# Consider setting [mcp_servers] here! diff --git a/.github/codex/labels/codex-attempt.md b/.github/codex/labels/codex-attempt.md new file mode 100644 index 00000000..b2a3e93a --- /dev/null +++ b/.github/codex/labels/codex-attempt.md @@ -0,0 +1,9 @@ +Attempt to solve the reported issue. + +If a code change is required, create a new branch, commit the fix, and open a pull request that resolves the problem. + +Here is the original GitHub issue that triggered this run: + +### {CODEX_ACTION_ISSUE_TITLE} + +{CODEX_ACTION_ISSUE_BODY} diff --git a/.github/codex/labels/codex-review.md b/.github/codex/labels/codex-review.md new file mode 100644 index 00000000..7c6c14ad --- /dev/null +++ b/.github/codex/labels/codex-review.md @@ -0,0 +1,7 @@ +Review this PR and respond with a very concise final message, formatted in Markdown. + +There should be a summary of the changes (1-2 sentences) and a few bullet points if necessary. + +Then provide the **review** (1-2 sentences plus bullet points, friendly tone). + +{CODEX_ACTION_GITHUB_EVENT_PATH} contains the JSON that triggered this GitHub workflow. It contains the `base` and `head` refs that define this PR. Both refs are available locally. diff --git a/.github/codex/labels/codex-triage.md b/.github/codex/labels/codex-triage.md new file mode 100644 index 00000000..46ed3624 --- /dev/null +++ b/.github/codex/labels/codex-triage.md @@ -0,0 +1,7 @@ +Troubleshoot whether the reported issue is valid. + +Provide a concise and respectful comment summarizing the findings. + +### {CODEX_ACTION_ISSUE_TITLE} + +{CODEX_ACTION_ISSUE_BODY} diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml new file mode 100644 index 00000000..0df24c8a --- /dev/null +++ b/.github/workflows/codex.yml @@ -0,0 +1,76 @@ +name: Codex + +on: + issues: + types: [opened, labeled] + pull_request: + branches: [main] + types: [labeled] + +jobs: + codex: + # This `if` check provides complex filtering logic to avoid running Codex + # on every PR. Admittedly, one thing this does not verify is whether the + # sender has write access to the repo: that must be done as part of a + # runtime step. + # + # Note the label values should match the ones in the .github/codex/labels + # folder. + if: | + (github.event_name == 'issues' && ( + (github.event.action == 'labeled' && (github.event.label.name == 'codex-attempt' || github.event.label.name == 'codex-triage')) + )) || + (github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'codex-review') + runs-on: ubuntu-latest + permissions: + contents: write # can push or create branches + issues: write # for comments + labels on issues/PRs + pull-requests: write # for PR comments/labels + steps: + # TODO: Consider adding an optional mode (--dry-run?) to actions/codex + # that verifies whether Codex should actually be run for this event. + # (For example, it may be rejected because the sender does not have + # write access to the repo.) The benefit would be two-fold: + # 1. As the first step of this job, it gives us a chance to add a reaction + # or comment to the PR/issue ASAP to "ack" the request. + # 2. It saves resources by skipping the clone and setup steps below if + # Codex is not going to run. + + - name: Checkout repository + uses: actions/checkout@v4 + + # We install the dependencies like we would for an ordinary CI job, + # particularly because Codex will not have network access to install + # these dependencies. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies (codex-cli) + working-directory: codex-cli + run: npm ci + + - uses: dtolnay/rust-toolchain@1.87 + with: + targets: x86_64-unknown-linux-gnu + components: clippy + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/codex-rs/target/ + key: cargo-ubuntu-24.04-x86_64-unknown-linux-gnu-${{ hashFiles('**/Cargo.lock') }} + + # Note it is possible that the `verify` step internal to Run Codex will + # fail, in which case the work to setup the repo was worthless :( + - name: Run Codex + uses: ./.github/actions/codex + with: + openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + codex_home: ./.github/codex/home