Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions .github/scripts/backport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
/**
* Copyright 2025 NVIDIA CORPORATION
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

module.exports = async ({ github, context, core }) => {
const branches = JSON.parse(process.env.BRANCHES_JSON || '[]');

// Get PR number from event
const prNumber = context.payload.pull_request?.number || context.payload.issue.number;

// Fetch full PR data (needed when triggered via issue_comment)
const { data: pullRequest } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});

const prTitle = pullRequest.title;
const prAuthor = pullRequest.user.login;
const isMerged = pullRequest.merged;

// Get all commits from the PR
const { data: commits } = await github.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});

if (commits.length === 0) {
core.warning('No commits found in PR - skipping backport');
return [];
}

core.info(`Backporting PR #${prNumber}: "${prTitle}"`);
core.info(`Commits to cherry-pick: ${commits.length}`);
commits.forEach((commit, index) => {
core.info(` ${index + 1}. ${commit.sha.substring(0, 7)} - ${commit.commit.message.split('\n')[0]}`);
});

const { execSync } = require('child_process');

const results = [];

for (const targetBranch of branches) {
core.info(`\n========================================`);
core.info(`Backporting to ${targetBranch}`);
core.info(`========================================`);
const backportBranch = `backport-${prNumber}-to-${targetBranch}`;
try {
// Create/reset backport branch from target release branch
core.info(`Creating/resetting branch ${backportBranch} from ${targetBranch}`);
execSync(`git fetch origin ${targetBranch}:${targetBranch}`, { stdio: 'inherit' });
execSync(`git checkout -B ${backportBranch} ${targetBranch}`, { stdio: 'inherit' });
// Cherry-pick each commit from the PR
let hasConflicts = false;
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
const commitSha = commit.sha;
const commitMessage = commit.commit.message.split('\n')[0];
core.info(`Cherry-picking commit ${i + 1}/${commits.length}: ${commitSha.substring(0, 7)} - ${commitMessage}`);
try {
execSync(`git cherry-pick -x ${commitSha}`, {
encoding: 'utf-8',
stdio: 'pipe'
});
} catch (error) {
// Check if it's a conflict
const status = execSync('git status', { encoding: 'utf-8' });
if (status.includes('Unmerged paths') || status.includes('both modified')) {
hasConflicts = true;
core.warning(`Cherry-pick has conflicts for commit ${commitSha.substring(0, 7)}.`);
// Add all files (including conflicted ones) and commit
execSync('git add .', { stdio: 'inherit' });
try {
execSync(`git -c core.editor=true cherry-pick --continue`, { stdio: 'inherit' });
} catch (e) {
// If continue fails, make a simple commit
execSync(`git commit --no-edit --allow-empty-message || git commit -m "Cherry-pick ${commitSha} (with conflicts)"`, { stdio: 'inherit' });
}
} else if (error.message && error.message.includes('previous cherry-pick is now empty')) {
// Handle empty commits (changes already exist in target branch)
core.info(`Commit ${commitSha.substring(0, 7)} is empty (changes already in target branch), skipping`);
execSync('git cherry-pick --skip', { stdio: 'inherit' });
} else {
throw error;
}
}
}
// Push the backport branch (force to handle updates)
core.info(`Pushing ${backportBranch} to origin`);
execSync(`git push --force-with-lease origin ${backportBranch}`, { stdio: 'inherit' });

// Check if a PR already exists for this backport branch
const { data: existingPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${backportBranch}`,
base: targetBranch,
state: 'open'
});
const existingPR = existingPRs.length > 0 ? existingPRs[0] : null;

// Create pull request
const commitList = commits.map(c => `- \`${c.sha.substring(0, 7)}\` ${c.commit.message.split('\n')[0]}`).join('\n');

// Build PR body based on conflict status and merge status
let prBody = `🤖 **Automated backport of #${prNumber} to \`${targetBranch}\`**\n\n`;

// Add merge status indicator
if (!isMerged) {
prBody += `⚠️ **Note:** The source PR #${prNumber} is not yet merged. This backport was created from the current state of the PR and may need updates if more commits are added before merge.\n\n`;
}

if (hasConflicts) {
prBody += `⚠️ **This PR has merge conflicts that need manual resolution.**

Original PR: #${prNumber} ${isMerged ? '(merged)' : '(not yet merged)'}
Original Author: @${prAuthor}

**Cherry-picked commits (${commits.length}):**
${commitList}

**Next Steps:**
1. Review the conflicts in the "Files changed" tab
2. Check out this branch locally: \`git fetch origin ${backportBranch} && git checkout ${backportBranch}\`
3. Resolve conflicts manually
4. Push the resolution: \`git push --force-with-lease origin ${backportBranch}\`

---
<details>
<summary>Instructions for resolving conflicts</summary>

\`\`\`bash
git fetch origin ${backportBranch}
git checkout ${backportBranch}
# Resolve conflicts in your editor
git add .
git commit
git push --force-with-lease origin ${backportBranch}
\`\`\`
</details>`;
} else {
prBody += `✅ Cherry-pick completed successfully with no conflicts.

Original PR: #${prNumber} ${isMerged ? '(merged)' : '(not yet merged)'}
Original Author: @${prAuthor}

**Cherry-picked commits (${commits.length}):**
${commitList}

This backport was automatically created by the backport bot.`;
}

if (existingPR) {
// Update existing PR
core.info(`Found existing PR #${existingPR.number}, updating it`);
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: existingPR.number,
body: prBody,
draft: hasConflicts
});

// Update labels
const currentLabels = existingPR.labels.map(l => l.name);
const desiredLabels = ['backport', hasConflicts ? 'needs-manual-resolution' : 'auto-backport'];

// Remove old labels if conflict status changed
if (hasConflicts && currentLabels.includes('auto-backport')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingPR.number,
name: 'auto-backport'
}).catch(() => {}); // Ignore if label doesn't exist
} else if (!hasConflicts && currentLabels.includes('needs-manual-resolution')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingPR.number,
name: 'needs-manual-resolution'
}).catch(() => {}); // Ignore if label doesn't exist
}

// Add current labels
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingPR.number,
labels: desiredLabels
});

// Comment about the update
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🤖 Updated existing backport PR for \`${targetBranch}\`: #${existingPR.number} ${hasConflicts ? '⚠️ (has conflicts)' : '✅'}`
});

results.push({
branch: targetBranch,
success: true,
prNumber: existingPR.number,
prUrl: existingPR.html_url,
hasConflicts,
updated: true
});
core.info(`✅ Successfully updated backport PR #${existingPR.number}`);
} else {
// Create new PR
const newPR = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[${targetBranch}] ${prTitle}`,
head: backportBranch,
base: targetBranch,
body: prBody,
draft: hasConflicts
});
// Add labels
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: newPR.data.number,
labels: ['backport', hasConflicts ? 'needs-manual-resolution' : 'auto-backport']
});
// Link to original PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🤖 Backport PR created for \`${targetBranch}\`: #${newPR.data.number} ${hasConflicts ? '⚠️ (has conflicts)' : '✅'}`
});
results.push({
branch: targetBranch,
success: true,
prNumber: newPR.data.number,
prUrl: newPR.data.html_url,
hasConflicts,
updated: false
});
core.info(`✅ Successfully created backport PR #${newPR.data.number}`);
}
} catch (error) {
core.error(`❌ Failed to backport to ${targetBranch}: ${error.message}`);
// Comment on original PR about the failure
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `❌ Failed to create backport PR for \`${targetBranch}\`\n\nError: ${error.message}\n\nPlease backport manually.`
});
results.push({
branch: targetBranch,
success: false,
error: error.message
});
} finally {
// Clean up: go back to main branch
try {
execSync('git checkout main', { stdio: 'inherit' });
execSync(`git branch -D ${backportBranch} 2>/dev/null || true`, { stdio: 'inherit' });
} catch (e) {
// Ignore cleanup errors
}
}
}

// Summary (console only)
core.info('\n========================================');
core.info('Backport Summary');
core.info('========================================');
for (const result of results) {
if (result.success) {
const action = result.updated ? 'Updated' : 'Created';
core.info(`✅ ${result.branch}: ${action} PR #${result.prNumber} ${result.hasConflicts ? '(has conflicts)' : ''}`);
} else {
core.error(`❌ ${result.branch}: ${result.error}`);
}
}
return results;
};
65 changes: 65 additions & 0 deletions .github/scripts/extract-branches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Copyright 2025 NVIDIA CORPORATION
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

module.exports = async ({ github, context, core }) => {
let branches = [];

// Get PR number
const prNumber = context.payload.pull_request?.number || context.payload.issue?.number;

if (!prNumber) {
core.warning('Could not determine PR number from event - skipping backport');
return [];
}

// Check PR body
if (context.payload.pull_request?.body) {
const prBody = context.payload.pull_request.body;
// Strict ASCII, anchored; allow X.Y or X.Y.Z
// Support multiple space-separated branches on one line
const lineMatches = prBody.matchAll(/^\/cherry-pick\s+(.+)$/gmi);
for (const match of lineMatches) {
const branchMatches = match[1].matchAll(/release-\d+\.\d+(?:\.\d+)?/g);
branches.push(...Array.from(branchMatches, m => m[0]));
}
}

// Check all comments
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});

for (const comment of comments.data) {
const lineMatches = comment.body.matchAll(/^\/cherry-pick\s+(.+)$/gmi);
for (const match of lineMatches) {
const branchMatches = match[1].matchAll(/release-\d+\.\d+(?:\.\d+)?/g);
branches.push(...Array.from(branchMatches, m => m[0]));
}
}

// Deduplicate
branches = [...new Set(branches)];

if (branches.length === 0) {
core.info('No cherry-pick requests found - skipping backport');
return [];
}

core.info(`Target branches: ${branches.join(', ')}`);
return branches;
};
Loading
Loading