Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
7 changes: 7 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions src/common/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
35 changes: 33 additions & 2 deletions src/github/pullRequestGitHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> {
const branchName = `pr/${pullRequest.author.login}/${pullRequest.number}`;
const template = vscode.workspace
.getConfiguration(PR_SETTINGS_NAMESPACE)
.get<string>(PULL_REQUEST_CHECKOUT_BRANCH_TITLE)!;

const branchName = PullRequestGitHelper.prBranchNameVariableSubstitution(template, pullRequest);
let result = branchName;
let number = 1;

Expand Down
98 changes: 98 additions & 0 deletions src/test/github/pullRequestGitHelper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
});