diff --git a/.github/actions/codex/.gitignore b/.github/actions/codex/.gitignore deleted file mode 100644 index 2ccbe465..00000000 --- a/.github/actions/codex/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/node_modules/ diff --git a/.github/actions/codex/.prettierrc.toml b/.github/actions/codex/.prettierrc.toml deleted file mode 100644 index 4c58c583..00000000 --- a/.github/actions/codex/.prettierrc.toml +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index a0be8ecb..00000000 --- a/.github/actions/codex/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# 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 deleted file mode 100644 index 011cbccf..00000000 --- a/.github/actions/codex/action.yml +++ /dev/null @@ -1,125 +0,0 @@ -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: "--config hide_agent_reasoning=true --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, e.g., 'rust-v0.3.0'. Defaults to the latest release." - required: false - default: "" - -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-${triple}.tar.gz" - - TAG_ARG="${{ inputs.codex_release_tag }}" - # The usage is `gh release download [] [flags]`, so if TAG_ARG - # is empty, we do not pass it so we can default to the latest release. - gh release download ${TAG_ARG:+$TAG_ARG} --repo openai/codex \ - --pattern "$artifact" --output - \ - | tar xzO > /usr/local/bin/codex - chmod +x /usr/local/bin/codex - - # Display Codex version to confirm binary integrity. - codex --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 deleted file mode 100644 index cd2c5214..00000000 --- a/.github/actions/codex/bun.lock +++ /dev/null @@ -1,91 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "codex-action", - "dependencies": { - "@actions/core": "^1.11.1", - "@actions/github": "^6.0.1", - }, - "devDependencies": { - "@types/bun": "^1.2.20", - "@types/node": "^24.3.0", - "prettier": "^3.6.2", - "typescript": "^5.9.2", - }, - }, - }, - "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.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], - - "@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], - - "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "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.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], - - "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], - - "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - - "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - - "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=="], - - "bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], - - "@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 deleted file mode 100644 index 8e0e5403..00000000 --- a/.github/actions/codex/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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.20", - "@types/node": "^24.3.0", - "prettier": "^3.6.2", - "typescript": "^5.9.2" - } -} diff --git a/.github/actions/codex/src/add-reaction.ts b/.github/actions/codex/src/add-reaction.ts deleted file mode 100644 index 85026dd9..00000000 --- a/.github/actions/codex/src/add-reaction.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 6e2833af..00000000 --- a/.github/actions/codex/src/comment.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 1f98f946..00000000 --- a/.github/actions/codex/src/config.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 270f1f9c..00000000 --- a/.github/actions/codex/src/default-label-config.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 9c18e0e6..00000000 --- a/.github/actions/codex/src/env-context.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* - * 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 deleted file mode 100644 index 924d7009..00000000 --- a/.github/actions/codex/src/fail.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 001ccde3..00000000 --- a/.github/actions/codex/src/git-helpers.ts +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index bd84a61a..00000000 --- a/.github/actions/codex/src/git-user.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 8a1f7cae..00000000 --- a/.github/actions/codex/src/github-workspace.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index f225e81a..00000000 --- a/.github/actions/codex/src/load-config.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100755 index a334c689..00000000 --- a/.github/actions/codex/src/main.ts +++ /dev/null @@ -1,80 +0,0 @@ -#!/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 deleted file mode 100644 index 914fd0d3..00000000 --- a/.github/actions/codex/src/post-comment.ts +++ /dev/null @@ -1,62 +0,0 @@ -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(); - console.info("Got Octokit instance for posting comment"); - 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 { - console.info("Calling octokit.rest.issues.createComment()"); - 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 deleted file mode 100644 index 60207efa..00000000 --- a/.github/actions/codex/src/process-label.ts +++ /dev/null @@ -1,226 +0,0 @@ -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(); - - // If this is a review label, prepend explicit PR-diff scoping guidance to - // reduce out-of-scope feedback. Do this before rendering so placeholders in - // the guidance (e.g., {CODEX_ACTION_GITHUB_EVENT_PATH}) are substituted. - const isReview = label.toLowerCase().includes("review"); - const reviewScopeGuidance = ` -PR Diff Scope -- Only review changes between the PR's merge-base and head; do not comment on commits or files outside this range. -- Derive the base/head SHAs from the event JSON at {CODEX_ACTION_GITHUB_EVENT_PATH}, then compute and use the PR diff for all analysis and comments. - -Commands to determine scope -- Resolve SHAs: - - BASE_SHA=$(jq -r '.pull_request.base.sha // .pull_request.base.ref' "{CODEX_ACTION_GITHUB_EVENT_PATH}") - - HEAD_SHA=$(jq -r '.pull_request.head.sha // .pull_request.head.ref' "{CODEX_ACTION_GITHUB_EVENT_PATH}") - - BASE_SHA=$(git rev-parse "$BASE_SHA") - - HEAD_SHA=$(git rev-parse "$HEAD_SHA") -- Prefer triple-dot (merge-base) semantics for PR diffs: - - Changed commits: git log --oneline "$BASE_SHA...$HEAD_SHA" - - Changed files: git diff --name-status "$BASE_SHA...$HEAD_SHA" - - Review hunks: git diff -U0 "$BASE_SHA...$HEAD_SHA" - -Review rules -- Anchor every comment to a file and hunk present in git diff "$BASE_SHA...$HEAD_SHA". -- If you mention context outside the diff, label it as "Follow-up (outside this PR scope)" and keep it brief (<=2 bullets). -- Do not critique commits or files not reachable in the PR range (merge-base(base, head) → head). -`.trim(); - - const effectiveTemplate = isReview - ? `${reviewScopeGuidance}\n\n${template}` - : template; - - const populatedTemplate = await renderPromptTemplate(effectiveTemplate, 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 deleted file mode 100644 index aa52dd2a..00000000 --- a/.github/actions/codex/src/prompt-template.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* - * 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 deleted file mode 100644 index 64f826dc..00000000 --- a/.github/actions/codex/src/review.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 3c0255e2..00000000 --- a/.github/actions/codex/src/run-codex.ts +++ /dev/null @@ -1,58 +0,0 @@ -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"); - - // Use the unified CLI and its `exec` subcommand instead of the old - // standalone `codex-exec` binary. - 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 deleted file mode 100644 index bfc5dcda..00000000 --- a/.github/actions/codex/src/verify-inputs.ts +++ /dev/null @@ -1,33 +0,0 @@ -// 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 deleted file mode 100644 index c05c2955..00000000 --- a/.github/actions/codex/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "lib": ["ESNext"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "moduleResolution": "bundler", - - "noEmit": true, - "strict": true, - "skipLibCheck": true - }, - - "include": ["src"] -} diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml deleted file mode 100644 index 58e85233..00000000 --- a/.github/workflows/codex.yml +++ /dev/null @@ -1,64 +0,0 @@ -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' || github.event.label.name == 'codex-rust-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@v5 - - - uses: dtolnay/rust-toolchain@1.89 - 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-dev-${{ 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