Skip to content

Commit 45b2b43

Browse files
authored
Have OpenAI's Codex suggest commit messages (#1984)
This new GitHub Actions workflow upserts a pull request comment any time the PR is updated, to suggest an appropriate commit message for the squash-and-merge workflow. The workflow can also be triggered manually to simplify testing.
1 parent cbee505 commit 45b2b43

File tree

1 file changed

+313
-0
lines changed

1 file changed

+313
-0
lines changed
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
name: Suggest PR commit message
2+
on:
3+
pull_request:
4+
types:
5+
- edited
6+
- opened
7+
- reopened
8+
- synchronize
9+
workflow_dispatch:
10+
inputs:
11+
pr_number:
12+
description: Pull request number of interest
13+
required: true
14+
type: number
15+
permissions:
16+
contents: read
17+
concurrency:
18+
group: suggest-commit-message-${{ github.event.pull_request.number || github.event.inputs.pr_number }}
19+
cancel-in-progress: true
20+
jobs:
21+
suggest:
22+
permissions:
23+
contents: read
24+
pull-requests: write
25+
runs-on: ubuntu-24.04
26+
environment: codex
27+
steps:
28+
- name: Install Harden-Runner
29+
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
30+
with:
31+
# We can't disable `sudo`, as `openai/codex-action` unconditionally
32+
# invokes `sudo`. That step does disable `sudo` for itself and
33+
# subsequent steps.
34+
# XXX: Consider splitting this workflow into two jobs, with
35+
# `openai/codex-action` being the first step of the second job.
36+
disable-sudo-and-containers: false
37+
# XXX: Change to `egress-policy: block` once we better understand
38+
# whether Codex attempts to access arbitrary URLs.
39+
egress-policy: audit
40+
- name: Resolve pull request metadata
41+
id: pr-details
42+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
43+
with:
44+
github-token: ${{ secrets.GITHUB_TOKEN }}
45+
script: |
46+
const prNumber = Number(context.payload.pull_request?.number ?? context.payload.inputs?.pr_number);
47+
if (!Number.isFinite(prNumber) || prNumber <= 0) {
48+
throw new Error('Unable to determine pull request number');
49+
}
50+
51+
const { data: pr } = await github.rest.pulls.get({
52+
owner: context.repo.owner,
53+
repo: context.repo.repo,
54+
pull_number: prNumber,
55+
});
56+
57+
core.setOutput('number', String(pr.number));
58+
core.setOutput('title', pr.title ?? '');
59+
core.setOutput('body', pr.body ?? '');
60+
core.setOutput('author', pr.user?.login ?? '');
61+
core.setOutput('baseRef', pr.base.ref ?? '');
62+
core.setOutput('baseSha', pr.base.sha ?? '');
63+
core.setOutput('headRef', pr.head.ref ?? '');
64+
core.setOutput('headSha', pr.head.sha ?? '');
65+
- name: Check out pull request head
66+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
67+
with:
68+
ref: ${{ steps.pr-details.outputs.headSha }}
69+
fetch-depth: 0
70+
- name: Prepare Codex prompt
71+
id: prompt
72+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
73+
env:
74+
REPOSITORY: ${{ github.repository }}
75+
PR_NUMBER: ${{ steps.pr-details.outputs.number }}
76+
PR_TITLE: ${{ steps.pr-details.outputs.title }}
77+
PR_BODY: ${{ steps.pr-details.outputs.body }}
78+
PR_AUTHOR: ${{ steps.pr-details.outputs.author }}
79+
BASE_REF: ${{ steps.pr-details.outputs.baseRef }}
80+
BASE_SHA: ${{ steps.pr-details.outputs.baseSha }}
81+
HEAD_REF: ${{ steps.pr-details.outputs.headRef }}
82+
HEAD_SHA: ${{ steps.pr-details.outputs.headSha }}
83+
with:
84+
script: |
85+
const { execFileSync } = require('child_process');
86+
const fs = require('fs');
87+
88+
const git = (args, limit) => {
89+
const output = execFileSync('git', args, { encoding: 'utf8' }).trim();
90+
if (!limit || !output) {
91+
return output;
92+
}
93+
94+
const lines = output.split(/\r?\n/);
95+
if (lines.length <= limit) {
96+
return output;
97+
}
98+
99+
const truncated = lines.slice(0, limit).join('\n');
100+
return `${truncated}\n... (${limit} of ${lines.length} lines shown)`;
101+
};
102+
103+
const env = process.env;
104+
const repository = env.REPOSITORY;
105+
const prNumber = env.PR_NUMBER;
106+
const title = env.PR_TITLE;
107+
const body = env.PR_BODY;
108+
const author = env.PR_AUTHOR;
109+
const baseRef = env.BASE_REF;
110+
const baseSha = env.BASE_SHA;
111+
const headRef = env.HEAD_REF;
112+
const headSha = env.HEAD_SHA;
113+
114+
const diffStat = git(['diff', '--name-status', `${baseSha}...${headSha}`]) || '<no changed files>';
115+
const diffExcerpt = git(['diff', '--unified=3', `${baseSha}...${headSha}`], 500) || '<no diff>';
116+
const nonUpgradeCommits =
117+
git(['log', '--grep', '^Upgrade', '--invert-grep', '--pretty=format:%h %B%n---', '-n', '50', baseSha]) ||
118+
'<no non-upgrade commits found>';
119+
const upgradeCommits =
120+
git(['log', '--grep', '^Upgrade', '--pretty=format:%h %B%n---', '-n', '150', baseSha]) ||
121+
'<no upgrade commits found>';
122+
123+
const cleanedBody = (body || '').trim() || '<no pull request description>';
124+
125+
const instructions = `
126+
You are an experienced maintainer helping to craft the squash commit message for PR #${prNumber} in the ${repository} repository.
127+
128+
Requirements:
129+
1. Write the summary line in the imperative mood. Try not to exceed 80 characters.
130+
2. End the summary line with the PR number in parentheses, i.e., " (#${prNumber})".
131+
3. Wrap each body paragraph at 72 characters. Focus on the "what" and "why" rather than implementation details.
132+
4. Keep the overall message concise.
133+
5. Match the established format used in similar past commits.
134+
6. Wrap code references in backticks.
135+
7. For dependency upgrades in particular, *very precisely* follow the pattern of past commit messages: reuse the summary wording (only adjust version numbers) and list updated changelog, release note, and diff URLs in the body.
136+
8. Don't hallucinate URLs, version numbers, or other factual information.
137+
9. Never split URLs across multiple lines, even if they exceed 72 characters.
138+
10. If the pull request description already contains a suitable commit message, prefer using that as-is.
139+
140+
Some further guidelines to help you craft good upgrade commit messages:
141+
- Unless highly salient, don't summarize code changes made as part of the upgrade.
142+
- Don't bother linking to anchors within changelogs or release notes; just link to the main page.
143+
- For GitHub-hosted projects, always link to all relevant GitHub release pages, including those for intermediate versions.
144+
- This includes milestones and release candidates; if necessary, use the GitHub API to identify these.
145+
- Libraries that often use milestone and release candidates include, but are not limited to:
146+
- Jackson
147+
- JUnit
148+
- Micrometer
149+
- Project Reactor
150+
- Spring Framework
151+
- Spring Boot
152+
- Spring Security
153+
- For GitHub-hosted projects, always link to the full diff between versions.
154+
- Enumerate links in the following order:
155+
1. First, link to custom release note documents.
156+
2. Then list all GitHub release links in ascending order.
157+
3. Finally, provide the full diff link.
158+
- If the upgrade involves multiple dependencies, group the links by dependency.
159+
- When the Maven \u0060version.error-prone-orig\u0060 property is changed, this upgrades both Error Prone and Picnic's Error Prone fork. In this case:
160+
- Make sure that the commit message includes a diff URL for the latter.
161+
- Don't explicitly mention that \u0060version.error-prone-orig\u0060 got changed; just focus on the fact that Error Prone is being upgraded.
162+
- If the example upgrade commits shown below don't include at least one upgrade of the same dependency being upgraded in this pull request, check the full Git history to find relevant past upgrade commit messages to mimic.
163+
- For major and minor version upgrades, check past dependency upgrade commit messages to infer documentation, blog or wiki URLs to which to link. Do this for at least the following libraries:
164+
- Jackson
165+
- Spring Framework
166+
- Spring Boot
167+
- Spring Security
168+
169+
Return a JSON object with the following shape:
170+
{
171+
"summary": "<summary line>",
172+
"body": "<commit body with paragraphs wrapped at 72 characters, or empty string>"
173+
}
174+
175+
Ensure the JSON is valid. Do not include additional commentary outside the JSON structure.
176+
177+
Pull request metadata:
178+
- Number: ${prNumber}
179+
- Title: ${title}
180+
- Author: ${author}
181+
- Base branch: ${baseRef} (${baseSha})
182+
- Head branch: ${headRef} (${headSha})
183+
184+
Pull request description:
185+
\u0060\u0060\u0060
186+
${cleanedBody}
187+
\u0060\u0060\u0060
188+
189+
Changed files (\u0060git diff --name-status ${baseSha}...${headSha}\u0060):
190+
\u0060\u0060\u0060
191+
${diffStat}
192+
\u0060\u0060\u0060
193+
194+
Diff excerpt (\u0060git diff --unified=3 ${baseSha}...${headSha}\u0060, truncated to 500 lines if necessary):
195+
\u0060\u0060\u0060
196+
${diffExcerpt}
197+
\u0060\u0060\u0060
198+
199+
Recent non-upgrade commits examples (\u0060git log --grep '^Upgrade' --invert-grep --pretty='format:%h %B%n---' -n 50\u0060):
200+
\u0060\u0060\u0060
201+
${nonUpgradeCommits}
202+
\u0060\u0060\u0060
203+
204+
Recent upgrade commit examples (\u0060git log --grep '^Upgrade' --pretty='format:%h %B%n---' -n 150\u0060):
205+
\u0060\u0060\u0060
206+
${upgradeCommits}
207+
\u0060\u0060\u0060
208+
`;
209+
210+
const promptPath = '/tmp/codex-prompt-suggest-commit-message.md';
211+
fs.writeFileSync(promptPath, instructions.trim() + '\n', { encoding: 'utf8' });
212+
- name: Suggest commit message with Codex
213+
id: codex
214+
uses: openai/codex-action@02e7b2943818fbac9f077c3d1249a198ab358352 # v1.2
215+
with:
216+
# XXX: Consider whether to set `safety-strategy: read-only`. In some
217+
# cases the agent may be able to suggest a better commit message by
218+
# following links or otherwise looking up information online. See
219+
# also the `egress-policy` discussion further up.
220+
sandbox: read-only
221+
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
222+
prompt-file: /tmp/codex-prompt-suggest-commit-message.md
223+
output-schema: |
224+
{
225+
"type": "object",
226+
"properties": {
227+
"summary": {
228+
"type": "string",
229+
"description": "Summary line in imperative mood, preferably at most 72 characters"
230+
},
231+
"body": {
232+
"type": "string",
233+
"description": "Commit message body explaining what and why, wrapped at 72 characters"
234+
}
235+
},
236+
"required": ["summary", "body"],
237+
"additionalProperties": false
238+
}
239+
- name: Upsert pull request comment
240+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
241+
env:
242+
PR_NUMBER: ${{ steps.pr-details.outputs.number }}
243+
CODEX_RESULT: ${{ steps.codex.outputs.final-message }}
244+
with:
245+
github-token: ${{ secrets.GITHUB_TOKEN }}
246+
script: |
247+
const prNumber = process.env.PR_NUMBER;
248+
const codexResult = JSON.parse(process.env.CODEX_RESULT);
249+
250+
const summary = codexResult.summary.trim();
251+
const body = codexResult.body.trim();
252+
const commitMessage = body ? `${summary}\n\n${body}` : summary;
253+
254+
// The comment to be upserted includes a hidden marker to identify it.
255+
const marker = '<!-- codex-suggested-commit-message -->';
256+
const commentBody = `Suggested commit message:\n${marker}\n\n\u0060\u0060\u0060\n${commitMessage}\n\u0060\u0060\u0060\n`;
257+
258+
const comments = await github.paginate(github.rest.issues.listComments, {
259+
owner: context.repo.owner,
260+
repo: context.repo.repo,
261+
issue_number: prNumber,
262+
per_page: 100,
263+
});
264+
265+
const existing = comments.find((comment) => comment.body?.includes(marker));
266+
if (!existing) {
267+
await github.rest.issues.createComment({
268+
owner: context.repo.owner,
269+
repo: context.repo.repo,
270+
issue_number: prNumber,
271+
body: commentBody,
272+
});
273+
core.info('Created new commit message suggestion comment.');
274+
return;
275+
}
276+
277+
if (existing.body === commentBody) {
278+
core.info('Existing comment already up to date.');
279+
return;
280+
}
281+
282+
// Determine who, if anybody, last edited the existing comment.
283+
const commentNode = await github.graphql(
284+
`query ($id: ID!) {
285+
node(id: $id) {
286+
... on IssueComment {
287+
editor {
288+
login
289+
}
290+
}
291+
}
292+
}`,
293+
{ id: existing.node_id },
294+
);
295+
296+
// If another user last edited the comment, skip the update. Note that the `[bot]` suffix is stripped
297+
// because it does not seem to be present consistently.
298+
const originalCommenter = existing.user.login.replace(/\[bot\]$/, '');
299+
const lastEditor = commentNode.node.editor?.login?.replace(/\[bot\]$/, '');
300+
if (lastEditor && lastEditor !== originalCommenter) {
301+
core.info(
302+
`Skipping update because comment was last edited by ${lastEditor} rather than ${originalCommenter}.`,
303+
);
304+
return;
305+
}
306+
307+
await github.rest.issues.updateComment({
308+
owner: context.repo.owner,
309+
repo: context.repo.repo,
310+
comment_id: existing.id,
311+
body: commentBody,
312+
});
313+
core.info(`Updated comment ${existing.id} by ${originalCommenter}.`);

0 commit comments

Comments
 (0)