This is a first cut at a GitHub Action that lets you define prompt
templates in `.md` files under `.github/codex/labels` that will run
Codex with the associated prompt when the label is added to a GitHub
pull request.
For example, this PR includes these files:
```
.github/codex/labels/codex-attempt.md
.github/codex/labels/codex-code-review.md
.github/codex/labels/codex-investigate-issue.md
```
And the new `.github/workflows/codex.yml` workflow declares the
following triggers:
```yaml
on:
issues:
types: [opened, labeled]
pull_request:
branches: [main]
types: [labeled]
```
as well as the following expression to gate the action:
```
jobs:
codex:
if: |
(github.event_name == 'issues' && (
(github.event.action == 'labeled' && (github.event.label.name == 'codex-attempt' || github.event.label.name == 'codex-investigate-issue'))
)) ||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'codex-code-review')
```
Note the "actor" who added the label must have write access to the repo
for the action to take effect.
After adding a label, the action will "ack" the request by replacing the
original label (e.g., `codex-review`) with an `-in-progress` suffix
(e.g., `codex-review-in-progress`). When it is finished, it will swap
the `-in-progress` label with a `-completed` one (e.g.,
`codex-review-completed`).
Users of the action are responsible for providing an `OPENAI_API_KEY`
and making it available as a secret to the action.
150 lines
4.0 KiB
TypeScript
150 lines
4.0 KiB
TypeScript
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<string | undefined> {
|
|
// 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;
|
|
}
|