diff --git a/.github/workflows/devin-conflict-resolver.yml b/.github/workflows/devin-conflict-resolver.yml new file mode 100644 index 0000000000..7c500f3194 --- /dev/null +++ b/.github/workflows/devin-conflict-resolver.yml @@ -0,0 +1,268 @@ +name: Devin PR Conflict Resolver + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to resolve conflicts for (optional, bypasses maintainer access check for fork PRs)' + required: false + type: string + +permissions: + contents: read + pull-requests: write + +jobs: + check-conflicts: + name: Check Open PRs for Conflicts + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Get open PRs and check for conflicts + id: check-prs + uses: actions/github-script@v7 + env: + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const manualPrNumber = process.env.INPUT_PR_NUMBER ? parseInt(process.env.INPUT_PR_NUMBER, 10) : null; + + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(states: OPEN, first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + mergeable + isDraft + headRefName + baseRefName + url + headRepository { + owner { + login + } + name + } + maintainerCanModify + labels(first: 10) { + nodes { + name + } + } + } + } + } + } + `; + + const allPRs = []; + let cursor = null; + + do { + const result = await github.graphql(query, { owner, repo, cursor }); + const { nodes, pageInfo } = result.repository.pullRequests; + allPRs.push(...nodes); + cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null; + } while (cursor); + + console.log(`Found ${allPRs.length} open PRs via GraphQL`); + + if (manualPrNumber) { + console.log(`Manual PR number provided: ${manualPrNumber}`); + } + + const conflictingPRs = []; + + for (const pr of allPRs) { + const isTargetPR = manualPrNumber && pr.number === manualPrNumber; + + if (!isTargetPR && pr.isDraft) { + console.log(`PR #${pr.number} is a draft, skipping`); + continue; + } + + if (!isTargetPR) { + const hasDevinLabel = pr.labels.nodes.some(label => label.name === 'devin-conflict-resolution'); + if (hasDevinLabel) { + console.log(`PR #${pr.number} already has devin-conflict-resolution label, skipping`); + continue; + } + } + + if (pr.mergeable === 'CONFLICTING' || (isTargetPR && pr.mergeable !== 'MERGEABLE')) { + const isFork = pr.headRepository?.owner?.login !== owner; + const headRepoOwner = pr.headRepository?.owner?.login || owner; + const headRepoName = pr.headRepository?.name || repo; + + if (!isTargetPR && isFork && !pr.maintainerCanModify) { + console.log(`PR #${pr.number} is from a fork without maintainer push access, skipping`); + continue; + } + + if (isTargetPR) { + console.log(`PR #${pr.number} manually targeted for conflict resolution${isFork ? ' (from fork)' : ''}`); + } else { + console.log(`PR #${pr.number} has conflicts${isFork ? ' (from fork)' : ''}`); + } + + conflictingPRs.push({ + number: pr.number, + title: pr.title, + head_ref: pr.headRefName, + base_ref: pr.baseRefName, + html_url: pr.url, + is_fork: isFork, + head_repo_owner: headRepoOwner, + head_repo_name: headRepoName, + is_manual: isTargetPR + }); + + if (isTargetPR) break; + } else if (pr.mergeable === 'UNKNOWN') { + console.log(`PR #${pr.number} mergeable status is still being computed`); + } else { + console.log(`PR #${pr.number} has no conflicts (mergeable: ${pr.mergeable})`); + } + } + + if (manualPrNumber && conflictingPRs.length === 0) { + console.log(`Warning: PR #${manualPrNumber} not found or has no conflicts`); + } + + const MAX_PRS = 15; + if (conflictingPRs.length > MAX_PRS) { + console.log(`Warning: Found ${conflictingPRs.length} PRs with conflicts, limiting to ${MAX_PRS} for safety`); + conflictingPRs.length = MAX_PRS; + } + + console.log(`Found ${conflictingPRs.length} PRs with conflicts that need Devin sessions`); + + const fs = require('fs'); + fs.writeFileSync('/tmp/conflicting-prs.json', JSON.stringify(conflictingPRs)); + + core.setOutput('has-conflicts', conflictingPRs.length > 0 ? 'true' : 'false'); + core.setOutput('conflict-count', conflictingPRs.length.toString()); + + - name: Create Devin sessions for conflicting PRs + if: steps.check-prs.outputs.has-conflicts == 'true' + env: + DEVIN_API_KEY: ${{ secrets.DEVIN_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const conflictingPRs = JSON.parse(fs.readFileSync('/tmp/conflicting-prs.json', 'utf8')); + const { owner, repo } = context.repo; + + for (const pr of conflictingPRs) { + console.log(`Creating Devin session for PR #${pr.number}: ${pr.title}${pr.is_fork ? ' (fork)' : ''}`); + + const forkInstructions = pr.is_fork ? ` + IMPORTANT: This PR is from a fork. The contributor has enabled "Allow edits from maintainers". + - Clone the FORK repository: ${pr.head_repo_owner}/${pr.head_repo_name} + - The branch to work on is: ${pr.head_ref} + - Add the upstream remote: git remote add upstream https://github.com/${owner}/${repo}.git + - Fetch upstream and merge: git fetch upstream && git merge upstream/${pr.base_ref}` : ` + - Clone the repository: ${owner}/${repo} + - Check out the PR branch: ${pr.head_ref} + - Merge the base branch: git merge origin/${pr.base_ref}`; + + const prompt = `You are resolving merge conflicts on PR #${pr.number} in repository ${owner}/${repo}. + + PR Title: ${pr.title} + PR URL: ${pr.html_url} + Head Branch: ${pr.head_ref} + Base Branch: ${pr.base_ref} + ${pr.is_fork ? `Fork Repository: ${pr.head_repo_owner}/${pr.head_repo_name}` : ''} + + Your tasks: + ${forkInstructions} + + Then: + 1. Resolve all merge conflicts carefully: + - Review the conflicting changes from both branches + - Make intelligent decisions about how to combine the changes + - Preserve the intent of both the PR changes and the base branch updates + - If unsure about a conflict, prefer keeping both changes where possible + 2. Test that the code still works after resolving conflicts (run lint/type checks). + 3. Commit the merge resolution with a clear commit message. + 4. Push the resolved changes to the PR branch. + + Rules and Guidelines: + 1. Be careful when resolving conflicts - understand the context of both changes. + 2. Follow the existing code style and conventions in the repository. + 3. Run lint and type checks before pushing to ensure the code is valid. + 4. If a conflict seems too complex or risky to resolve automatically, explain the situation in a PR comment instead. + 5. Never ask for user confirmation. Never wait for user messages.`; + + try { + const response = await fetch('https://api.devin.ai/v1/sessions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.DEVIN_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + prompt: prompt, + title: `Resolve Conflicts: PR #${pr.number}`, + tags: ['conflict-resolution', `pr-${pr.number}`] + }) + }); + + if (!response.ok) { + console.error(`Devin API error for PR #${pr.number}: ${response.status} ${response.statusText}`); + continue; + } + + const data = await response.json(); + const sessionUrl = data.url || data.session_url; + + if (sessionUrl) { + console.log(`Devin session created for PR #${pr.number}: ${sessionUrl}`); + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: ['devin-conflict-resolution'] + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: `### Devin AI is resolving merge conflicts + + This PR has merge conflicts with the \`${pr.base_ref}\` branch. A Devin session has been created to automatically resolve them. + + [View Devin Session](${sessionUrl}) + + Devin will: + 1. Merge the latest \`${pr.base_ref}\` into this branch + 2. Resolve any conflicts intelligently + 3. Run lint/type checks to ensure validity + 4. Push the resolved changes + + If you prefer to resolve conflicts manually, you can close the Devin session and handle it yourself.` + }); + } else { + console.log(`Failed to get session URL for PR #${pr.number}:`, data); + } + } catch (error) { + console.error(`Error creating Devin session for PR #${pr.number}:`, error); + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + }