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>
This commit is contained in:
Keith Williams
2026-01-14 18:02:25 +00:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 1487901f8e
commit ea3bab57b0
4 changed files with 232 additions and 182 deletions
+124
View File
@@ -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', '');
+16 -92
View File
@@ -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: |
+24 -71
View File
@@ -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}`);
+68 -19
View File
@@ -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
});