|
| 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