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 });