From ea3bab57b09de3abf4d8ff1475e0167e2b90b8d2 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Wed, 14 Jan 2026 15:02:25 -0300 Subject: [PATCH] refactor: consolidate Devin session logic into reusable action (#26843) * fix: reuse existing Devin sessions in stale PR completion workflow Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor: consolidate Devin session logic into reusable action - Create .github/actions/devin-session composite action for session checking - Update stale-pr-devin-completion.yml to use the new action - Update cubic-devin-review.yml to use the new action - Update devin-conflict-resolver.yml to use the new action - Rename workflows to 'PR Labeled' with descriptive job names The new action checks for existing Devin sessions by: 1. Looking for session URLs in PR body (for PRs created by Devin) 2. Searching PR comments for known Devin session patterns 3. Verifying session is active (working, blocked, or resumed status) This eliminates duplicated session checking logic across all three workflows. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert: restore original workflow names Reverted workflow names back to descriptive names: - 'Stale Community PR Devin Completion' (was 'PR Labeled') - 'Devin PR Conflict Resolver' (was 'PR Labeled') Descriptive names are the recommended convention as they appear in GitHub Actions tab, status checks, and notifications. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: add active status check for PR body sessions Addresses Cubic AI feedback (confidence 9/10): PR body sessions now verify the session is active (working, blocked, or resumed) before reusing, matching the behavior of comment-based session checks. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/actions/devin-session/action.yml | 124 ++++++++++++++++++ .github/workflows/cubic-devin-review.yml | 108 +++------------ .github/workflows/devin-conflict-resolver.yml | 95 ++++---------- .../workflows/stale-pr-devin-completion.yml | 87 +++++++++--- 4 files changed, 232 insertions(+), 182 deletions(-) create mode 100644 .github/actions/devin-session/action.yml diff --git a/.github/actions/devin-session/action.yml b/.github/actions/devin-session/action.yml new file mode 100644 index 0000000000..5d8066be77 --- /dev/null +++ b/.github/actions/devin-session/action.yml @@ -0,0 +1,124 @@ +name: 'Devin Session Checker' +description: 'Check for existing Devin sessions on a PR' + +inputs: + devin-api-key: + description: 'Devin API key' + required: true + github-token: + description: 'GitHub token for API calls' + required: true + pr-number: + description: 'PR number to check for existing sessions' + required: true + +outputs: + has-existing-session: + description: 'Whether an existing active session was found (true/false)' + value: ${{ steps.check-session.outputs.has-existing-session }} + session-id: + description: 'ID of the existing session (if found)' + value: ${{ steps.check-session.outputs.session-id }} + session-url: + description: 'URL of the existing session (if found)' + value: ${{ steps.check-session.outputs.session-url }} + +runs: + using: 'composite' + steps: + - name: Check for existing Devin session + id: check-session + uses: actions/github-script@v7 + env: + DEVIN_API_KEY: ${{ inputs.devin-api-key }} + PR_NUMBER: ${{ inputs.pr-number }} + with: + github-token: ${{ inputs.github-token }} + script: | + const { owner, repo } = context.repo; + const prNumber = parseInt(process.env.PR_NUMBER, 10); + const SESSION_URL_REGEX = /app\.devin\.ai\/sessions\/([a-f0-9-]+)/; + const ACTIVE_STATUSES = ['working', 'blocked', 'resumed']; + + async function checkSessionStatus(sessionId) { + const response = await fetch(`https://api.devin.ai/v1/sessions/${sessionId}`, { + headers: { + 'Authorization': `Bearer ${process.env.DEVIN_API_KEY}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.log(`Failed to fetch session ${sessionId}: ${response.status}`); + return null; + } + + return await response.json(); + } + + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + }); + + const prBody = pr.body || ''; + const prSessionMatch = prBody.match(SESSION_URL_REGEX); + + if (prSessionMatch) { + const sessionId = prSessionMatch[1]; + console.log(`PR was created by Devin session: ${sessionId}`); + const session = await checkSessionStatus(sessionId); + if (session) { + console.log(`PR creator session status: ${session.status_enum}`); + if (ACTIVE_STATUSES.includes(session.status_enum)) { + core.setOutput('has-existing-session', 'true'); + core.setOutput('session-id', sessionId); + core.setOutput('session-url', `https://app.devin.ai/sessions/${sessionId}`); + return; + } else { + console.log(`PR creator session ${sessionId} is not active, will check comments`); + } + } + } + + const devinSessionPatterns = [ + 'Devin AI is completing this stale PR', + 'Devin AI is addressing Cubic AI', + 'Devin AI is resolving merge conflicts' + ]; + + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber + }); + + for (const comment of comments.data.reverse()) { + const hasDevinSession = devinSessionPatterns.some(pattern => comment.body?.includes(pattern)); + if (hasDevinSession) { + const match = comment.body?.match(SESSION_URL_REGEX); + if (match) { + const sessionId = match[1]; + console.log(`Found existing Devin session from comment: ${sessionId}`); + + const session = await checkSessionStatus(sessionId); + if (session) { + if (ACTIVE_STATUSES.includes(session.status_enum)) { + console.log(`Session ${sessionId} is active (status: ${session.status_enum})`); + core.setOutput('has-existing-session', 'true'); + core.setOutput('session-id', sessionId); + core.setOutput('session-url', `https://app.devin.ai/sessions/${sessionId}`); + return; + } else { + console.log(`Session ${sessionId} is not active (status: ${session.status_enum})`); + } + } + } + } + } + + console.log('No existing active Devin session found'); + core.setOutput('has-existing-session', 'false'); + core.setOutput('session-id', ''); + core.setOutput('session-url', ''); diff --git a/.github/workflows/cubic-devin-review.yml b/.github/workflows/cubic-devin-review.yml index 568434656c..a7bf05fd0b 100644 --- a/.github/workflows/cubic-devin-review.yml +++ b/.github/workflows/cubic-devin-review.yml @@ -46,103 +46,27 @@ jobs: core.setOutput('has-prompt', 'true'); + - name: Checkout repository + if: steps.extract-prompt.outputs.has-prompt == 'true' + uses: actions/checkout@v4 + - name: Check for existing Devin session if: steps.extract-prompt.outputs.has-prompt == 'true' id: check-session - uses: actions/github-script@v7 - env: - DEVIN_API_KEY: ${{ secrets.DEVIN_API_KEY }} + uses: ./.github/actions/devin-session with: - script: | - async function checkSessionStatus(sessionId) { - const response = await fetch(`https://api.devin.ai/v1/sessions/${sessionId}`, { - headers: { - 'Authorization': `Bearer ${process.env.DEVIN_API_KEY}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - console.log(`Failed to fetch session ${sessionId}: ${response.status}`); - return null; - } - - return await response.json(); - } - - // If PR was created through Devin, always use that session - const prBody = context.payload.pull_request.body || ''; - const prSessionMatch = prBody.match(/app\.devin\.ai\/sessions\/([a-f0-9-]+)/); - - if (prSessionMatch) { - const sessionId = prSessionMatch[1]; - console.log(`PR was created by Devin session: ${sessionId}`); - - const session = await checkSessionStatus(sessionId); - if (session) { - console.log(`PR creator session status: ${session.status_enum}`); - core.exportVariable('EXISTING_SESSION_ID', sessionId); - core.exportVariable('SESSION_URL', `https://app.devin.ai/sessions/${sessionId}`); - core.setOutput('has-active-session', 'true'); - return; - } - } - - // PR was not created through Devin - check for existing Devin session from PR comments - // Look for any Devin session comment (cubic review, conflict resolution, stale PR completion, etc.) - const devinSessionPatterns = [ - 'Devin AI is addressing Cubic AI', - 'Devin AI is resolving merge conflicts', - 'Devin AI is completing this stale PR' - ]; - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number - }); - - let sessionId = null; - for (const comment of comments.data.reverse()) { - const hasDevinSession = devinSessionPatterns.some(pattern => comment.body.includes(pattern)); - if (hasDevinSession) { - const match = comment.body.match(/app\.devin\.ai\/sessions\/([a-f0-9-]+)/); - if (match) { - sessionId = match[1]; - console.log(`Found existing Devin session from comment: ${sessionId}`); - break; - } - } - } - - if (!sessionId) { - console.log('No existing Devin session found in PR comments'); - core.setOutput('has-active-session', 'false'); - return; - } - - console.log(`Found session ID from comment: ${sessionId}`); - - const session = await checkSessionStatus(sessionId); - if (!session) { - core.setOutput('has-active-session', 'false'); - return; - } - - const activeStatuses = ['working', 'blocked', 'resumed']; - - if (activeStatuses.includes(session.status_enum)) { - console.log(`Session ${sessionId} is active (status: ${session.status_enum})`); - core.exportVariable('EXISTING_SESSION_ID', sessionId); - core.exportVariable('SESSION_URL', `https://app.devin.ai/sessions/${sessionId}`); - core.setOutput('has-active-session', 'true'); - } else { - console.log(`Session ${sessionId} is not active (status: ${session.status_enum})`); - core.setOutput('has-active-session', 'false'); - } + devin-api-key: ${{ secrets.DEVIN_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ github.event.pull_request.number }} + + - name: Set session environment variables + if: steps.extract-prompt.outputs.has-prompt == 'true' && steps.check-session.outputs.has-existing-session == 'true' + run: | + echo "EXISTING_SESSION_ID=${{ steps.check-session.outputs.session-id }}" >> $GITHUB_ENV + echo "SESSION_URL=${{ steps.check-session.outputs.session-url }}" >> $GITHUB_ENV - name: Send message to existing Devin session - if: steps.extract-prompt.outputs.has-prompt == 'true' && steps.check-session.outputs.has-active-session == 'true' + if: steps.extract-prompt.outputs.has-prompt == 'true' && steps.check-session.outputs.has-existing-session == 'true' env: DEVIN_API_KEY: ${{ secrets.DEVIN_API_KEY }} run: | @@ -172,7 +96,7 @@ jobs: echo "Message sent to existing session successfully" - name: Create new Devin session - if: steps.extract-prompt.outputs.has-prompt == 'true' && steps.check-session.outputs.has-active-session == 'false' + if: steps.extract-prompt.outputs.has-prompt == 'true' && steps.check-session.outputs.has-existing-session == 'false' env: DEVIN_API_KEY: ${{ secrets.DEVIN_API_KEY }} run: | diff --git a/.github/workflows/devin-conflict-resolver.yml b/.github/workflows/devin-conflict-resolver.yml index e939bf293e..f1ad2d57d7 100644 --- a/.github/workflows/devin-conflict-resolver.yml +++ b/.github/workflows/devin-conflict-resolver.yml @@ -216,11 +216,27 @@ jobs: console.log(`Posted comment and removed label for PR #${prNumber}`); + - name: Checkout repository + if: steps.get-pr.outputs.has-pr == 'true' && steps.get-pr.outputs.needs-maintainer-access != 'true' && steps.get-pr.outputs.has-conflicts == 'true' + uses: actions/checkout@v4 + + - name: Check for existing Devin session + if: steps.get-pr.outputs.has-pr == 'true' && steps.get-pr.outputs.needs-maintainer-access != 'true' && steps.get-pr.outputs.has-conflicts == 'true' + id: check-session + uses: ./.github/actions/devin-session + with: + devin-api-key: ${{ secrets.DEVIN_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.get-pr.outputs.pr-number }} + - name: Create Devin session for conflict resolution if: steps.get-pr.outputs.has-pr == 'true' && steps.get-pr.outputs.needs-maintainer-access != 'true' && steps.get-pr.outputs.has-conflicts == 'true' env: DEVIN_API_KEY: ${{ secrets.DEVIN_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HAS_EXISTING_SESSION: ${{ steps.check-session.outputs.has-existing-session }} + EXISTING_SESSION_ID: ${{ steps.check-session.outputs.session-id }} + EXISTING_SESSION_URL: ${{ steps.check-session.outputs.session-url }} uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -229,75 +245,12 @@ jobs: const pr = JSON.parse(fs.readFileSync('/tmp/pr-data.json', 'utf8')); const { owner, repo } = context.repo; - async function checkSessionStatus(sessionId) { - const response = await fetch(`https://api.devin.ai/v1/sessions/${sessionId}`, { - headers: { - 'Authorization': `Bearer ${process.env.DEVIN_API_KEY}`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - console.log(`Failed to fetch session ${sessionId}: ${response.status}`); - return null; - } - - return await response.json(); - } - - async function findExistingSession(prNumber, prBody) { - // First check if PR was created through Devin - const prSessionMatch = prBody?.match(/app\.devin\.ai\/sessions\/([a-f0-9-]+)/); - if (prSessionMatch) { - const sessionId = prSessionMatch[1]; - console.log(`PR #${prNumber} was created by Devin session: ${sessionId}`); - const session = await checkSessionStatus(sessionId); - if (session) { - return { sessionId, sessionUrl: `https://app.devin.ai/sessions/${sessionId}`, isFromPrBody: true }; - } - } - - // Check PR comments for existing Devin session (conflict resolution or Cubic AI review) - const comments = await github.rest.issues.listComments({ - owner, - repo, - issue_number: prNumber - }); - - const sessionPatterns = [ - 'Devin AI is resolving merge conflicts', - 'Devin AI is addressing Cubic AI' - ]; - - for (const comment of comments.data.reverse()) { - const hasDevinSession = sessionPatterns.some(pattern => comment.body?.includes(pattern)); - if (hasDevinSession) { - const match = comment.body?.match(/app\.devin\.ai\/sessions\/([a-f0-9-]+)/); - if (match) { - const sessionId = match[1]; - console.log(`Found existing session from comment: ${sessionId}`); - - const session = await checkSessionStatus(sessionId); - if (session) { - const activeStatuses = ['working', 'blocked', 'resumed']; - if (activeStatuses.includes(session.status_enum)) { - console.log(`Session ${sessionId} is active (status: ${session.status_enum})`); - return { sessionId, sessionUrl: `https://app.devin.ai/sessions/${sessionId}`, isFromPrBody: false }; - } else { - console.log(`Session ${sessionId} is not active (status: ${session.status_enum})`); - } - } - } - } - } - - return null; - } + const hasExistingSession = process.env.HAS_EXISTING_SESSION === 'true'; + const existingSessionId = process.env.EXISTING_SESSION_ID; + const existingSessionUrl = process.env.EXISTING_SESSION_URL; console.log(`Processing PR #${pr.number}: ${pr.title}${pr.is_fork ? ' (fork)' : ''}`); - const existingSession = await findExistingSession(pr.number, pr.body); - const forkInstructions = pr.is_fork ? ` IMPORTANT: This PR is from a fork. - Clone the FORK repository: ${pr.head_repo_owner}/${pr.head_repo_name} @@ -386,8 +339,8 @@ jobs: let sessionUrl; let isNewSession = false; - if (existingSession) { - console.log(`Sending message to existing session ${existingSession.sessionId} for PR #${pr.number}`); + if (hasExistingSession) { + console.log(`Sending message to existing session ${existingSessionId} for PR #${pr.number}`); const message = `PR #${pr.number} has merge conflicts that need to be resolved. @@ -395,7 +348,7 @@ jobs: Continue working on the same PR branch and push your fixes.`; - const response = await fetch(`https://api.devin.ai/v1/sessions/${existingSession.sessionId}/message`, { + const response = await fetch(`https://api.devin.ai/v1/sessions/${existingSessionId}/message`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.DEVIN_API_KEY}`, @@ -405,11 +358,11 @@ jobs: }); if (!response.ok) { - console.error(`Failed to send message to session ${existingSession.sessionId}: ${response.status}`); + console.error(`Failed to send message to session ${existingSessionId}: ${response.status}`); throw new Error(`Failed to send message to existing session: ${response.status}`); } - sessionUrl = existingSession.sessionUrl; + sessionUrl = existingSessionUrl; console.log(`Message sent to existing session for PR #${pr.number}`); } else { console.log(`Creating new Devin session for PR #${pr.number}`); diff --git a/.github/workflows/stale-pr-devin-completion.yml b/.github/workflows/stale-pr-devin-completion.yml index 951ee84c7a..5911f93bc6 100644 --- a/.github/workflows/stale-pr-devin-completion.yml +++ b/.github/workflows/stale-pr-devin-completion.yml @@ -140,7 +140,18 @@ jobs: core.setFailed(`Cannot complete this fork PR: the fork owner has not enabled "Allow edits from maintainers". A comment has been posted requesting access.`); - - name: Create Devin session + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for existing Devin session + id: check-session + uses: ./.github/actions/devin-session + with: + devin-api-key: ${{ secrets.DEVIN_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-number: ${{ steps.pr.outputs.pr_number }} + + - name: Create or reuse Devin session env: DEVIN_API_KEY: ${{ secrets.DEVIN_API_KEY }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} @@ -150,6 +161,9 @@ jobs: IS_FORK: ${{ steps.pr.outputs.is_fork }} HEAD_REPO_FULL_NAME: ${{ steps.pr.outputs.head_repo_full_name }} REPO_NAME: ${{ github.repository }} + HAS_EXISTING_SESSION: ${{ steps.check-session.outputs.has-existing-session }} + EXISTING_SESSION_ID: ${{ steps.check-session.outputs.session-id }} + EXISTING_SESSION_URL: ${{ steps.check-session.outputs.session-url }} run: | if [ "$IS_FORK" = "true" ]; then CLONE_INSTRUCTIONS="This is a fork PR. Clone from the fork repository: ${HEAD_REPO_FULL_NAME} @@ -200,25 +214,54 @@ jobs: 6. Credit the original author in your commit messages where appropriate. 7. CRITICAL: If this is a fork PR and you encounter ANY error when pushing (permission denied, authentication failure, etc.) even after using the DEVIN_ACTIONS_PAT, you MUST fail the task immediately. Do NOT attempt to push to a new branch in the main repository as a workaround. Simply report the error and stop." - RESPONSE=$(curl -s -X POST "https://api.devin.ai/v1/sessions" \ - -H "Authorization: Bearer ${DEVIN_API_KEY}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg prompt "$FULL_PROMPT" \ - --arg title "Complete Stale PR #${PR_NUMBER}: ${PR_TITLE}" \ - '{ - prompt: $prompt, - title: $title, - tags: ["stale-pr-completion", "pr-'${PR_NUMBER}'"] - }')") + if [ "$HAS_EXISTING_SESSION" = "true" ]; then + echo "Sending message to existing session ${EXISTING_SESSION_ID}" - SESSION_URL=$(echo "$RESPONSE" | jq -r '.url // .session_url // empty') - if [ -n "$SESSION_URL" ]; then - echo "Devin session created: $SESSION_URL" - echo "SESSION_URL=$SESSION_URL" >> $GITHUB_ENV + MESSAGE="This PR has been marked as stale and needs to be completed. + + ${FULL_PROMPT} + + Continue working on the same PR branch and push your fixes." + + HTTP_CODE=$(curl -s -o /tmp/devin-response.json -w "%{http_code}" -X POST "https://api.devin.ai/v1/sessions/${EXISTING_SESSION_ID}/message" \ + -H "Authorization: Bearer ${DEVIN_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg message "$MESSAGE" '{message: $message}')") + + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo "Failed to send message to Devin session: HTTP $HTTP_CODE" + cat /tmp/devin-response.json + exit 1 + fi + + echo "Message sent to existing session successfully" + echo "SESSION_URL=$EXISTING_SESSION_URL" >> $GITHUB_ENV + echo "IS_NEW_SESSION=false" >> $GITHUB_ENV else - echo "Failed to create Devin session" - exit 1 + echo "Creating new Devin session" + + RESPONSE=$(curl -s -X POST "https://api.devin.ai/v1/sessions" \ + -H "Authorization: Bearer ${DEVIN_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg prompt "$FULL_PROMPT" \ + --arg title "Complete Stale PR #${PR_NUMBER}: ${PR_TITLE}" \ + '{ + prompt: $prompt, + title: $title, + tags: ["stale-pr-completion", "pr-'${PR_NUMBER}'"] + }')") + + SESSION_URL=$(echo "$RESPONSE" | jq -r '.url // .session_url // empty') + if [ -n "$SESSION_URL" ]; then + echo "Devin session created: $SESSION_URL" + echo "SESSION_URL=$SESSION_URL" >> $GITHUB_ENV + echo "IS_NEW_SESSION=true" >> $GITHUB_ENV + else + echo "Failed to create Devin session" + echo "$RESPONSE" + exit 1 + fi fi - name: Post comment with Devin session link @@ -233,9 +276,15 @@ jobs: const sessionUrl = process.env.SESSION_URL; const prAuthor = process.env.PR_AUTHOR; const prNumber = process.env.PR_NUMBER; + const isNewSession = process.env.IS_NEW_SESSION === 'true'; + + const message = isNewSession + ? `### Devin AI is completing this stale PR\n\nThis PR by @${prAuthor} has been marked as stale. A Devin session has been created to complete the remaining work.\n\n[View Devin Session](${sessionUrl})\n\n---\n*Devin will review the PR, address any feedback, and push updates to complete this PR.*` + : `### Devin AI is completing this stale PR\n\nThis PR by @${prAuthor} has been marked as stale. The existing Devin session has been notified to complete the remaining work.\n\n[View Devin Session](${sessionUrl})\n\n---\n*Devin will review the PR, address any feedback, and push updates to complete this PR.*`; + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: parseInt(prNumber), - body: `### Devin AI is completing this stale PR\n\nThis PR by @${prAuthor} has been marked as stale. A Devin session has been created to complete the remaining work.\n\n[View Devin Session](${sessionUrl})\n\n---\n*Devin will review the PR, address any feedback, and push updates to complete this PR.*` + body: message });