diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5be76376..ee644d6699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Changes + +- Customizable PR checkout branch names with the `githubPullRequests.pullRequestCheckoutBranchTitle` setting. This allows you to configure the branch name pattern when checking out pull requests, similar to the existing `githubIssues.issueBranchTitle` setting for issues. Supported variables include `${owner}`, `${number}`, `${title}`, and `${sanitizedLowercaseTitle}`. The default value is `pr/${owner}/${number}`. + ## 0.124.0 ### Changes diff --git a/package.json b/package.json index 121adffe67..5f98b4b8c8 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,11 @@ "default": "template", "description": "%githubPullRequests.pullRequestDescription.description%" }, + "githubPullRequests.pullRequestCheckoutBranchTitle": { + "type": "string", + "default": "pr/${owner}/${number}", + "markdownDescription": "%githubPullRequests.pullRequestCheckoutBranchTitle.markdownDescription%" + }, "githubPullRequests.defaultCreateOption": { "type": "string", "enum": [ diff --git a/package.nls.json b/package.nls.json index 73c02b7684..7629a2e71c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -6,6 +6,13 @@ "githubPullRequests.pullRequestDescription.commit": "Use the latest commit message only", "githubPullRequests.pullRequestDescription.none": "Do not have a default description", "githubPullRequests.pullRequestDescription.copilot": "Generate a pull request title and description from GitHub Copilot. Requires that the GitHub Copilot extension is installed and authenticated. Will fall back to `commit` if Copilot is not set up.", + "githubPullRequests.pullRequestCheckoutBranchTitle.markdownDescription": { + "message": "Advanced settings for the name of the branch that is created when you check out a pull request. \n- `${owner}` will be replaced with the pull request author's username \n- `${number}` will be replaced with the pull request number \n- `${title}` will be replaced with the pull request title, with all spaces and unsupported characters (https://git-scm.com/docs/git-check-ref-format) removed. For lowercase, use `${sanitizedLowercaseTitle}` ", + "comment": [ + "{Locked='${...}'}", + "Do not translate what's inside of the '${..}'. It is an internal syntax for the extension" + ] + }, "githubPullRequests.defaultCreateOption.description": "The create option that the \"Create\" button will default to when creating a pull request.", "githubPullRequests.defaultCreateOption.lastUsed": "The most recently used create option.", "githubPullRequests.defaultCreateOption.create": "The pull request will be created.", diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index 1376c4c7bc..584e39e881 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -18,6 +18,7 @@ export const NEVER_IGNORE_DEFAULT_BRANCH = 'neverIgnoreDefaultBranch'; export const OVERRIDE_DEFAULT_BRANCH = 'overrideDefaultBranch'; export const PULL_BRANCH = 'pullBranch'; export const PULL_REQUEST_DESCRIPTION = 'pullRequestDescription'; +export const PULL_REQUEST_CHECKOUT_BRANCH_TITLE = 'pullRequestCheckoutBranchTitle'; export const NOTIFICATION_SETTING = 'notifications'; export type NotificationVariants = 'off' | 'pullRequests'; export const POST_CREATE = 'postCreate'; diff --git a/src/github/pullRequestGitHelper.ts b/src/github/pullRequestGitHelper.ts index 599a3eda6d..2d884874f7 100644 --- a/src/github/pullRequestGitHelper.ts +++ b/src/github/pullRequestGitHelper.ts @@ -8,11 +8,12 @@ */ import * as vscode from 'vscode'; import { IResolvedPullRequestModel, PullRequestModel } from './pullRequestModel'; +import { sanitizeIssueTitle } from './utils'; import { Branch, Repository } from '../api/api'; import Logger from '../common/logger'; import { Protocol } from '../common/protocol'; import { parseRepositoryRemotes, Remote } from '../common/remote'; -import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT, PullPRBranchVariants } from '../common/settingKeys'; +import { PR_SETTINGS_NAMESPACE, PULL_PR_BRANCH_BEFORE_CHECKOUT, PULL_REQUEST_CHECKOUT_BRANCH_TITLE, PullPRBranchVariants } from '../common/settingKeys'; const PullRequestRemoteMetadataKey = 'github-pr-remote'; export const PullRequestMetadataKey = 'github-pr-owner-number'; @@ -367,11 +368,41 @@ export class PullRequestGitHelper { } } + /** + * Performs variable substitution for PR checkout branch names. + * Note: This is separate from the variableSubstitution in utils.ts because + * the semantics differ - ${owner} refers to the PR author, not the repo owner. + */ + private static prBranchNameVariableSubstitution( + template: string, + pullRequest: PullRequestModel, + ): string { + const VARIABLE_PATTERN = /\$\{([^-]*?)(-.*?)?\}/g; + return template.replace(VARIABLE_PATTERN, (match: string, variable: string) => { + switch (variable) { + case 'owner': + return pullRequest.author.login; + case 'number': + return `${pullRequest.number}`; + case 'title': + return sanitizeIssueTitle(pullRequest.title); + case 'sanitizedLowercaseTitle': + return sanitizeIssueTitle(pullRequest.title).toLowerCase(); + default: + return match; + } + }); + } + static async calculateUniqueBranchNameForPR( repository: Repository, pullRequest: PullRequestModel, ): Promise { - const branchName = `pr/${pullRequest.author.login}/${pullRequest.number}`; + const template = vscode.workspace + .getConfiguration(PR_SETTINGS_NAMESPACE) + .get(PULL_REQUEST_CHECKOUT_BRANCH_TITLE)!; + + const branchName = PullRequestGitHelper.prBranchNameVariableSubstitution(template, pullRequest); let result = branchName; let number = 1; diff --git a/src/test/github/pullRequestGitHelper.test.ts b/src/test/github/pullRequestGitHelper.test.ts index 590b35f0aa..174d4658a5 100644 --- a/src/test/github/pullRequestGitHelper.test.ts +++ b/src/test/github/pullRequestGitHelper.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { default as assert } from 'assert'; +import * as vscode from 'vscode'; import { MockRepository } from '../mocks/mockRepository'; import { PullRequestGitHelper } from '../../github/pullRequestGitHelper'; @@ -188,4 +189,101 @@ describe('PullRequestGitHelper', function () { assert.strictEqual(await repository.getConfig('branch.pr/me/100.github-pr-owner-number'), 'owner#name#100'); }); }); + + describe('calculateUniqueBranchNameForPR', function () { + it('uses default template to create branch name', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(42) + .user(u => u.login('testuser')) + .title('Add new feature') + .build(), + gitHubRepository, + ); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + const branchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); + assert.strictEqual(branchName, 'pr/testuser/42', 'Should use default template'); + }); + + it('uses custom template with ${owner} and ${number}', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(42) + .user(u => u.login('testuser')) + .title('Add new feature') + .build(), + gitHubRepository, + ); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + // Set custom template + const configStub = sinon.stub().returns('feature/${owner}-${number}'); + sinon.stub(vscode.workspace, 'getConfiguration').returns({ + get: configStub, + } as any); + + const branchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); + assert.strictEqual(branchName, 'feature/testuser-42', 'Should use custom template with ${owner} and ${number}'); + }); + + it('uses custom template with ${title}', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(42) + .user(u => u.login('testuser')) + .title('Add new feature') + .build(), + gitHubRepository, + ); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + // Set custom template + const configStub = sinon.stub().returns('pr-${number}-${title}'); + sinon.stub(vscode.workspace, 'getConfiguration').returns({ + get: configStub, + } as any); + + const branchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); + assert.strictEqual(branchName, 'pr-42-Add-new-feature', 'Should use custom template with sanitized title'); + }); + + it('creates unique branch name when branch already exists', async function () { + const url = 'git@github.com:owner/name.git'; + const remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); + const gitHubRepository = new MockGitHubRepository(remote, credentialStore, telemetry, sinon); + + const prItem = convertRESTPullRequestToRawPullRequest( + new PullRequestBuilder() + .number(42) + .user(u => u.login('testuser')) + .title('Add new feature') + .build(), + gitHubRepository, + ); + + const pullRequest = new PullRequestModel(credentialStore, telemetry, gitHubRepository, remote, prItem); + + // Create existing branch with the same name + await repository.createBranch('pr/testuser/42', false, 'some-commit-hash'); + + const branchName = await PullRequestGitHelper.calculateUniqueBranchNameForPR(repository, pullRequest); + assert.strictEqual(branchName, 'pr/testuser/42-1', 'Should append -1 when branch exists'); + }); + }); });