Skip to content

Commit 1a1437e

Browse files
fix(github): action branch detection and 422 handling (#14322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent c76a814 commit 1a1437e

1 file changed

Lines changed: 94 additions & 30 deletions

File tree

packages/opencode/src/cli/cmd/github.ts

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -553,8 +553,12 @@ export const GithubRunCommand = cmd({
553553
const branch = await checkoutNewBranch(branchPrefix)
554554
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
555555
const response = await chat(userPrompt, promptFiles)
556-
const { dirty, uncommittedChanges } = await branchIsDirty(head)
557-
if (dirty) {
556+
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
557+
if (switched) {
558+
// Agent switched branches (likely created its own branch/PR)
559+
console.log("Agent managed its own branch, skipping infrastructure push/PR")
560+
console.log("Response:", response)
561+
} else if (dirty) {
558562
const summary = await summarize(response)
559563
// workflow_dispatch has an actor for co-author attribution, schedule does not
560564
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
@@ -565,7 +569,11 @@ export const GithubRunCommand = cmd({
565569
summary,
566570
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
567571
)
568-
console.log(`Created PR #${pr}`)
572+
if (pr) {
573+
console.log(`Created PR #${pr}`)
574+
} else {
575+
console.log("Skipped PR creation (no new commits)")
576+
}
569577
} else {
570578
console.log("Response:", response)
571579
}
@@ -580,8 +588,11 @@ export const GithubRunCommand = cmd({
580588
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
581589
const dataPrompt = buildPromptDataForPR(prData)
582590
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
583-
const { dirty, uncommittedChanges } = await branchIsDirty(head)
584-
if (dirty) {
591+
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
592+
if (switched) {
593+
console.log("Agent managed its own branch, skipping infrastructure push")
594+
}
595+
if (dirty && !switched) {
585596
const summary = await summarize(response)
586597
await pushToLocalBranch(summary, uncommittedChanges)
587598
}
@@ -591,12 +602,15 @@ export const GithubRunCommand = cmd({
591602
}
592603
// Fork PR
593604
else {
594-
await checkoutForkBranch(prData)
605+
const forkBranch = await checkoutForkBranch(prData)
595606
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
596607
const dataPrompt = buildPromptDataForPR(prData)
597608
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
598-
const { dirty, uncommittedChanges } = await branchIsDirty(head)
599-
if (dirty) {
609+
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
610+
if (switched) {
611+
console.log("Agent managed its own branch, skipping infrastructure push")
612+
}
613+
if (dirty && !switched) {
600614
const summary = await summarize(response)
601615
await pushToForkBranch(summary, prData, uncommittedChanges)
602616
}
@@ -612,8 +626,13 @@ export const GithubRunCommand = cmd({
612626
const issueData = await fetchIssue()
613627
const dataPrompt = buildPromptDataForIssue(issueData)
614628
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
615-
const { dirty, uncommittedChanges } = await branchIsDirty(head)
616-
if (dirty) {
629+
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
630+
if (switched) {
631+
// Agent switched branches (likely created its own branch/PR).
632+
// Don't push the stale infrastructure branch — just comment.
633+
await createComment(`${response}${footer({ image: true })}`)
634+
await removeReaction(commentType)
635+
} else if (dirty) {
617636
const summary = await summarize(response)
618637
await pushToNewBranch(summary, branch, uncommittedChanges, false)
619638
const pr = await createPR(
@@ -622,7 +641,11 @@ export const GithubRunCommand = cmd({
622641
summary,
623642
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
624643
)
625-
await createComment(`Created PR #${pr}${footer({ image: true })}`)
644+
if (pr) {
645+
await createComment(`Created PR #${pr}${footer({ image: true })}`)
646+
} else {
647+
await createComment(`${response}${footer({ image: true })}`)
648+
}
626649
await removeReaction(commentType)
627650
} else {
628651
await createComment(`${response}${footer({ image: true })}`)
@@ -1068,6 +1091,7 @@ export const GithubRunCommand = cmd({
10681091
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
10691092
await $`git fetch fork --depth=${depth} ${remoteBranch}`
10701093
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
1094+
return localBranch
10711095
}
10721096

10731097
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
@@ -1125,21 +1149,42 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
11251149
await $`git push fork HEAD:${remoteBranch}`
11261150
}
11271151

1128-
async function branchIsDirty(originalHead: string) {
1152+
async function branchIsDirty(originalHead: string, expectedBranch: string) {
11291153
console.log("Checking if branch is dirty...")
1154+
// Detect if the agent switched branches during chat (e.g. created
1155+
// its own branch, committed, and possibly pushed/created a PR).
1156+
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
1157+
if (current !== expectedBranch) {
1158+
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
1159+
return { dirty: true, uncommittedChanges: false, switched: true }
1160+
}
1161+
11301162
const ret = await $`git status --porcelain`
11311163
const status = ret.stdout.toString().trim()
11321164
if (status.length > 0) {
1133-
return {
1134-
dirty: true,
1135-
uncommittedChanges: true,
1136-
}
1165+
return { dirty: true, uncommittedChanges: true, switched: false }
11371166
}
1138-
const head = await $`git rev-parse HEAD`
1167+
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
11391168
return {
1140-
dirty: head.stdout.toString().trim() !== originalHead,
1169+
dirty: head !== originalHead,
11411170
uncommittedChanges: false,
1171+
switched: false,
1172+
}
1173+
}
1174+
1175+
// Verify commits exist between base ref and a branch using rev-list.
1176+
// Falls back to fetching from origin when local refs are missing
1177+
// (common in shallow clones from actions/checkout).
1178+
async function hasNewCommits(base: string, head: string) {
1179+
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
1180+
if (result.exitCode !== 0) {
1181+
console.log(`rev-list failed, fetching origin/${base}...`)
1182+
await $`git fetch origin ${base} --depth=1`.nothrow()
1183+
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
1184+
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
1185+
return parseInt(retry.stdout.toString().trim()) > 0
11421186
}
1187+
return parseInt(result.stdout.toString().trim()) > 0
11431188
}
11441189

11451190
async function assertPermissions() {
@@ -1261,7 +1306,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
12611306
})
12621307
}
12631308

1264-
async function createPR(base: string, branch: string, title: string, body: string) {
1309+
async function createPR(base: string, branch: string, title: string, body: string): Promise<number | null> {
12651310
console.log("Creating pull request...")
12661311

12671312
// Check if an open PR already exists for this head→base combination
@@ -1286,17 +1331,36 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
12861331
console.log(`Failed to check for existing PR: ${e}`)
12871332
}
12881333

1289-
const pr = await withRetry(() =>
1290-
octoRest.rest.pulls.create({
1291-
owner,
1292-
repo,
1293-
head: branch,
1294-
base,
1295-
title,
1296-
body,
1297-
}),
1298-
)
1299-
return pr.data.number
1334+
// Verify there are commits between base and head before creating the PR.
1335+
// In shallow clones, the branch can appear dirty but share the same
1336+
// commit as the base, causing a 422 from GitHub.
1337+
if (!(await hasNewCommits(base, branch))) {
1338+
console.log(`No commits between ${base} and ${branch}, skipping PR creation`)
1339+
return null
1340+
}
1341+
1342+
try {
1343+
const pr = await withRetry(() =>
1344+
octoRest.rest.pulls.create({
1345+
owner,
1346+
repo,
1347+
head: branch,
1348+
base,
1349+
title,
1350+
body,
1351+
}),
1352+
)
1353+
return pr.data.number
1354+
} catch (e: unknown) {
1355+
// Handle "No commits between X and Y" validation error from GitHub.
1356+
// This can happen when the branch was pushed but has no new commits
1357+
// relative to the base (e.g. shallow clone edge cases).
1358+
if (e instanceof Error && e.message.includes("No commits between")) {
1359+
console.log(`GitHub rejected PR: ${e.message}`)
1360+
return null
1361+
}
1362+
throw e
1363+
}
13001364
}
13011365

13021366
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {

0 commit comments

Comments
 (0)