Files
gemini-cli/scripts/releasing/create-patch-pr.js

251 lines
7.8 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
async function main() {
const argv = await yargs(hideBin(process.argv))
.option('commit', {
alias: 'c',
description: 'The commit SHA to cherry-pick for the patch.',
type: 'string',
demandOption: true,
})
.option('channel', {
alias: 'ch',
description: 'The release channel to patch.',
choices: ['stable', 'preview'],
demandOption: true,
})
.option('dry-run', {
description: 'Whether to run in dry-run mode.',
type: 'boolean',
default: false,
})
.help()
.alias('help', 'h').argv;
const { commit, channel, dryRun } = argv;
console.log(`Starting patch process for commit: ${commit}`);
console.log(`Targeting channel: ${channel}`);
if (dryRun) {
console.log('Running in dry-run mode.');
}
run('git fetch --all --tags --prune', dryRun);
const latestTag = getLatestTag(channel);
console.log(`Found latest tag for ${channel}: ${latestTag}`);
const releaseBranch = `release/${latestTag}`;
2025-09-18 16:31:39 -07:00
const hotfixBranch = `hotfix/${latestTag}/${channel}/cherry-pick-${commit.substring(0, 7)}`;
// Create the release branch from the tag if it doesn't exist.
if (!branchExists(releaseBranch)) {
console.log(
`Release branch ${releaseBranch} does not exist. Creating it from tag ${latestTag}...`,
);
run(`git checkout -b ${releaseBranch} ${latestTag}`, dryRun);
run(`git push origin ${releaseBranch}`, dryRun);
} else {
console.log(`Release branch ${releaseBranch} already exists.`);
}
2025-09-18 09:56:45 -07:00
// Check if hotfix branch already exists
if (branchExists(hotfixBranch)) {
console.log(`Hotfix branch ${hotfixBranch} already exists.`);
2025-09-18 10:45:42 -07:00
// Check if there's already a PR for this branch
try {
2025-09-18 11:19:43 -07:00
const prInfo = execSync(
`gh pr list --head ${hotfixBranch} --json number,url --jq '.[0] // empty'`,
)
.toString()
.trim();
2025-09-18 10:45:42 -07:00
if (prInfo && prInfo !== 'null' && prInfo !== '') {
const pr = JSON.parse(prInfo);
console.log(`Found existing PR #${pr.number}: ${pr.url}`);
console.log(`Hotfix branch ${hotfixBranch} already has an open PR.`);
return { existingBranch: hotfixBranch, existingPR: pr };
} else {
console.log(`Hotfix branch ${hotfixBranch} exists but has no open PR.`);
2025-09-18 11:19:43 -07:00
console.log(
`You may need to delete the branch and run this command again.`,
);
2025-09-18 10:45:42 -07:00
return { existingBranch: hotfixBranch };
}
} catch (err) {
console.error(`Error checking for existing PR: ${err.message}`);
console.log(`Hotfix branch ${hotfixBranch} already exists.`);
return { existingBranch: hotfixBranch };
}
2025-09-18 09:56:45 -07:00
}
// Create the hotfix branch from the release branch.
console.log(
`Creating hotfix branch ${hotfixBranch} from ${releaseBranch}...`,
);
run(`git checkout -b ${hotfixBranch} origin/${releaseBranch}`, dryRun);
// Cherry-pick the commit.
console.log(`Cherry-picking commit ${commit} into ${hotfixBranch}...`);
2025-09-18 17:33:08 -07:00
let hasConflicts = false;
if (!dryRun) {
try {
execSync(`git cherry-pick ${commit}`, { stdio: 'pipe' });
console.log(`✅ Cherry-pick successful - no conflicts detected`);
} catch (error) {
// Check if this is a cherry-pick conflict
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
const conflictFiles = status
.split('\n')
.filter(
(line) =>
line.startsWith('UU ') ||
line.startsWith('AA ') ||
line.startsWith('DU ') ||
line.startsWith('UD '),
);
if (conflictFiles.length > 0) {
hasConflicts = true;
console.log(
`⚠️ Cherry-pick has conflicts in ${conflictFiles.length} file(s):`,
);
conflictFiles.forEach((file) =>
console.log(` - ${file.substring(3)}`),
);
// Add all files (including conflict markers) and commit
console.log(
`📝 Creating commit with conflict markers for manual resolution...`,
);
execSync('git add .');
execSync(`git commit --no-edit`);
console.log(`✅ Committed cherry-pick with conflict markers`);
} else {
// Re-throw if it's not a conflict error
throw error;
}
} catch (_statusError) {
// Re-throw original error if we can't determine the status
throw error;
}
}
} else {
console.log(`[DRY RUN] Would cherry-pick ${commit}`);
}
// Push the hotfix branch.
console.log(`Pushing hotfix branch ${hotfixBranch} to origin...`);
run(`git push --set-upstream origin ${hotfixBranch}`, dryRun);
// Create the pull request.
console.log(
`Creating pull request from ${hotfixBranch} to ${releaseBranch}...`,
);
2025-09-18 17:33:08 -07:00
let prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch}`;
let prBody = `This PR automatically cherry-picks commit ${commit} to patch the ${channel} release.`;
2025-09-18 17:33:08 -07:00
if (hasConflicts) {
prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch} [CONFLICTS]`;
prBody += `
## Merge Conflicts Detected
This cherry-pick resulted in merge conflicts that need manual resolution.
### 🔧 Next Steps:
1. **Review the conflicts**: Check out this branch and review the conflict markers
2. **Resolve conflicts**: Edit the affected files to resolve the conflicts
3. **Test the changes**: Ensure the patch works correctly after resolution
4. **Update this PR**: Push your conflict resolution
### 📋 Files with conflicts:
The commit has been created with conflict markers for easier manual resolution.
### 🚨 Important:
- Do not merge this PR until conflicts are resolved
- The automated patch release will trigger once this PR is merged`;
}
if (dryRun) {
prBody += '\n\n**[DRY RUN]**';
}
2025-09-18 17:33:08 -07:00
const prCommand = `gh pr create --base ${releaseBranch} --head ${hotfixBranch} --title "${prTitle}" --body "${prBody}"`;
run(prCommand, dryRun);
2025-09-18 17:33:08 -07:00
if (hasConflicts) {
console.log(
'⚠️ Patch process completed with conflicts - manual resolution required!',
);
} else {
console.log('✅ Patch process completed successfully!');
}
if (dryRun) {
console.log('\n--- Dry Run Summary ---');
console.log(`Release Branch: ${releaseBranch}`);
console.log(`Hotfix Branch: ${hotfixBranch}`);
console.log(`Pull Request Command: ${prCommand}`);
console.log('---------------------');
}
2025-09-18 09:56:45 -07:00
2025-09-18 17:33:08 -07:00
return { newBranch: hotfixBranch, created: true, hasConflicts };
}
2025-09-18 09:56:45 -07:00
function run(command, dryRun = false, throwOnError = true) {
console.log(`> ${command}`);
if (dryRun) {
return;
}
try {
return execSync(command).toString().trim();
} catch (err) {
console.error(`Command failed: ${command}`);
2025-09-18 09:56:45 -07:00
if (throwOnError) {
throw err;
}
return null;
}
}
function branchExists(branchName) {
try {
execSync(`git ls-remote --exit-code --heads origin ${branchName}`);
return true;
} catch (_e) {
return false;
}
}
function getLatestTag(channel) {
console.log(`Fetching latest tag for channel: ${channel}...`);
const pattern =
channel === 'stable'
? '(contains("nightly") or contains("preview")) | not'
: '(contains("preview"))';
const command = `gh release list --limit 30 --json tagName | jq -r '[.[] | select(.tagName | ${pattern})] | .[0].tagName'`;
try {
return execSync(command).toString().trim();
} catch (err) {
console.error(`Failed to get latest tag for channel: ${channel}`);
throw err;
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});