fix(github): action branch detection and 422 handling (#14322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
@@ -553,8 +553,12 @@ export const GithubRunCommand = cmd({
|
|||||||
const branch = await checkoutNewBranch(branchPrefix)
|
const branch = await checkoutNewBranch(branchPrefix)
|
||||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||||
const response = await chat(userPrompt, promptFiles)
|
const response = await chat(userPrompt, promptFiles)
|
||||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
|
||||||
if (dirty) {
|
if (switched) {
|
||||||
|
// Agent switched branches (likely created its own branch/PR)
|
||||||
|
console.log("Agent managed its own branch, skipping infrastructure push/PR")
|
||||||
|
console.log("Response:", response)
|
||||||
|
} else if (dirty) {
|
||||||
const summary = await summarize(response)
|
const summary = await summarize(response)
|
||||||
// workflow_dispatch has an actor for co-author attribution, schedule does not
|
// workflow_dispatch has an actor for co-author attribution, schedule does not
|
||||||
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
|
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
|
||||||
@@ -565,7 +569,11 @@ export const GithubRunCommand = cmd({
|
|||||||
summary,
|
summary,
|
||||||
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
|
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
|
||||||
)
|
)
|
||||||
console.log(`Created PR #${pr}`)
|
if (pr) {
|
||||||
|
console.log(`Created PR #${pr}`)
|
||||||
|
} else {
|
||||||
|
console.log("Skipped PR creation (no new commits)")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("Response:", response)
|
console.log("Response:", response)
|
||||||
}
|
}
|
||||||
@@ -580,8 +588,11 @@ export const GithubRunCommand = cmd({
|
|||||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||||
const dataPrompt = buildPromptDataForPR(prData)
|
const dataPrompt = buildPromptDataForPR(prData)
|
||||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
|
||||||
if (dirty) {
|
if (switched) {
|
||||||
|
console.log("Agent managed its own branch, skipping infrastructure push")
|
||||||
|
}
|
||||||
|
if (dirty && !switched) {
|
||||||
const summary = await summarize(response)
|
const summary = await summarize(response)
|
||||||
await pushToLocalBranch(summary, uncommittedChanges)
|
await pushToLocalBranch(summary, uncommittedChanges)
|
||||||
}
|
}
|
||||||
@@ -591,12 +602,15 @@ export const GithubRunCommand = cmd({
|
|||||||
}
|
}
|
||||||
// Fork PR
|
// Fork PR
|
||||||
else {
|
else {
|
||||||
await checkoutForkBranch(prData)
|
const forkBranch = await checkoutForkBranch(prData)
|
||||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||||
const dataPrompt = buildPromptDataForPR(prData)
|
const dataPrompt = buildPromptDataForPR(prData)
|
||||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
|
||||||
if (dirty) {
|
if (switched) {
|
||||||
|
console.log("Agent managed its own branch, skipping infrastructure push")
|
||||||
|
}
|
||||||
|
if (dirty && !switched) {
|
||||||
const summary = await summarize(response)
|
const summary = await summarize(response)
|
||||||
await pushToForkBranch(summary, prData, uncommittedChanges)
|
await pushToForkBranch(summary, prData, uncommittedChanges)
|
||||||
}
|
}
|
||||||
@@ -612,8 +626,13 @@ export const GithubRunCommand = cmd({
|
|||||||
const issueData = await fetchIssue()
|
const issueData = await fetchIssue()
|
||||||
const dataPrompt = buildPromptDataForIssue(issueData)
|
const dataPrompt = buildPromptDataForIssue(issueData)
|
||||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||||
const { dirty, uncommittedChanges } = await branchIsDirty(head)
|
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
|
||||||
if (dirty) {
|
if (switched) {
|
||||||
|
// Agent switched branches (likely created its own branch/PR).
|
||||||
|
// Don't push the stale infrastructure branch — just comment.
|
||||||
|
await createComment(`${response}${footer({ image: true })}`)
|
||||||
|
await removeReaction(commentType)
|
||||||
|
} else if (dirty) {
|
||||||
const summary = await summarize(response)
|
const summary = await summarize(response)
|
||||||
await pushToNewBranch(summary, branch, uncommittedChanges, false)
|
await pushToNewBranch(summary, branch, uncommittedChanges, false)
|
||||||
const pr = await createPR(
|
const pr = await createPR(
|
||||||
@@ -622,7 +641,11 @@ export const GithubRunCommand = cmd({
|
|||||||
summary,
|
summary,
|
||||||
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
|
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
|
||||||
)
|
)
|
||||||
await createComment(`Created PR #${pr}${footer({ image: true })}`)
|
if (pr) {
|
||||||
|
await createComment(`Created PR #${pr}${footer({ image: true })}`)
|
||||||
|
} else {
|
||||||
|
await createComment(`${response}${footer({ image: true })}`)
|
||||||
|
}
|
||||||
await removeReaction(commentType)
|
await removeReaction(commentType)
|
||||||
} else {
|
} else {
|
||||||
await createComment(`${response}${footer({ image: true })}`)
|
await createComment(`${response}${footer({ image: true })}`)
|
||||||
@@ -1068,6 +1091,7 @@ export const GithubRunCommand = cmd({
|
|||||||
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
|
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
|
||||||
await $`git fetch fork --depth=${depth} ${remoteBranch}`
|
await $`git fetch fork --depth=${depth} ${remoteBranch}`
|
||||||
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
|
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
|
||||||
|
return localBranch
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
|
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
|
||||||
@@ -1125,23 +1149,44 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
|||||||
await $`git push fork HEAD:${remoteBranch}`
|
await $`git push fork HEAD:${remoteBranch}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function branchIsDirty(originalHead: string) {
|
async function branchIsDirty(originalHead: string, expectedBranch: string) {
|
||||||
console.log("Checking if branch is dirty...")
|
console.log("Checking if branch is dirty...")
|
||||||
|
// Detect if the agent switched branches during chat (e.g. created
|
||||||
|
// its own branch, committed, and possibly pushed/created a PR).
|
||||||
|
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
|
||||||
|
if (current !== expectedBranch) {
|
||||||
|
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
|
||||||
|
return { dirty: true, uncommittedChanges: false, switched: true }
|
||||||
|
}
|
||||||
|
|
||||||
const ret = await $`git status --porcelain`
|
const ret = await $`git status --porcelain`
|
||||||
const status = ret.stdout.toString().trim()
|
const status = ret.stdout.toString().trim()
|
||||||
if (status.length > 0) {
|
if (status.length > 0) {
|
||||||
return {
|
return { dirty: true, uncommittedChanges: true, switched: false }
|
||||||
dirty: true,
|
|
||||||
uncommittedChanges: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const head = await $`git rev-parse HEAD`
|
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||||
return {
|
return {
|
||||||
dirty: head.stdout.toString().trim() !== originalHead,
|
dirty: head !== originalHead,
|
||||||
uncommittedChanges: false,
|
uncommittedChanges: false,
|
||||||
|
switched: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify commits exist between base ref and a branch using rev-list.
|
||||||
|
// Falls back to fetching from origin when local refs are missing
|
||||||
|
// (common in shallow clones from actions/checkout).
|
||||||
|
async function hasNewCommits(base: string, head: string) {
|
||||||
|
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
console.log(`rev-list failed, fetching origin/${base}...`)
|
||||||
|
await $`git fetch origin ${base} --depth=1`.nothrow()
|
||||||
|
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
|
||||||
|
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
|
||||||
|
return parseInt(retry.stdout.toString().trim()) > 0
|
||||||
|
}
|
||||||
|
return parseInt(result.stdout.toString().trim()) > 0
|
||||||
|
}
|
||||||
|
|
||||||
async function assertPermissions() {
|
async function assertPermissions() {
|
||||||
// Only called for non-schedule events, so actor is defined
|
// Only called for non-schedule events, so actor is defined
|
||||||
console.log(`Asserting permissions for user ${actor}...`)
|
console.log(`Asserting permissions for user ${actor}...`)
|
||||||
@@ -1261,7 +1306,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPR(base: string, branch: string, title: string, body: string) {
|
async function createPR(base: string, branch: string, title: string, body: string): Promise<number | null> {
|
||||||
console.log("Creating pull request...")
|
console.log("Creating pull request...")
|
||||||
|
|
||||||
// Check if an open PR already exists for this head→base combination
|
// 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>"`
|
|||||||
console.log(`Failed to check for existing PR: ${e}`)
|
console.log(`Failed to check for existing PR: ${e}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pr = await withRetry(() =>
|
// Verify there are commits between base and head before creating the PR.
|
||||||
octoRest.rest.pulls.create({
|
// In shallow clones, the branch can appear dirty but share the same
|
||||||
owner,
|
// commit as the base, causing a 422 from GitHub.
|
||||||
repo,
|
if (!(await hasNewCommits(base, branch))) {
|
||||||
head: branch,
|
console.log(`No commits between ${base} and ${branch}, skipping PR creation`)
|
||||||
base,
|
return null
|
||||||
title,
|
}
|
||||||
body,
|
|
||||||
}),
|
try {
|
||||||
)
|
const pr = await withRetry(() =>
|
||||||
return pr.data.number
|
octoRest.rest.pulls.create({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
head: branch,
|
||||||
|
base,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return pr.data.number
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Handle "No commits between X and Y" validation error from GitHub.
|
||||||
|
// This can happen when the branch was pushed but has no new commits
|
||||||
|
// relative to the base (e.g. shallow clone edge cases).
|
||||||
|
if (e instanceof Error && e.message.includes("No commits between")) {
|
||||||
|
console.log(`GitHub rejected PR: ${e.message}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {
|
async function withRetry<T>(fn: () => Promise<T>, retries = 1, delayMs = 5000): Promise<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user