diff --git a/extensions/git-extended/src/common/models/account.ts b/extensions/git-extended/src/common/models/account.ts new file mode 100644 index 00000000000..faaa1cf88bc --- /dev/null +++ b/extensions/git-extended/src/common/models/account.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export interface IAccount { + login: string; + isUser: boolean; + isEnterprise: boolean; + avatarUrl: string; + ownedPrivateRepositoryCount?: number; + privateRepositoryInPlanCount?: number; +} + +export class Account implements IAccount { + constructor( + public login: string, + public isUser: boolean, + public isEnterprise: boolean, + public avatarUrl: string, + public ownedPrivateRepositoryCount: number, + public privateRepositoryInPlanCount: number + ) { + + } +} \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/gitReferenceModel.ts b/extensions/git-extended/src/common/models/gitReferenceModel.ts new file mode 100644 index 00000000000..f942df9367c --- /dev/null +++ b/extensions/git-extended/src/common/models/gitReferenceModel.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { UriString } from './uriString'; + +export class GitReferenceModel { + public repositoryCloneUrl: UriString; + constructor( + public ref: string, + public label: string, + public sha: string, + repositoryCloneUrl: string + ) { + this.repositoryCloneUrl = new UriString(repositoryCloneUrl); + } +} \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/pullrequest.ts b/extensions/git-extended/src/common/models/pullRequestModel.ts similarity index 52% rename from extensions/git-extended/src/common/models/pullrequest.ts rename to extensions/git-extended/src/common/models/pullRequestModel.ts index b4f710a1b7b..f539c01a489 100644 --- a/extensions/git-extended/src/common/models/pullrequest.ts +++ b/extensions/git-extended/src/common/models/pullRequestModel.ts @@ -6,6 +6,8 @@ import { Remote } from './remote'; import { parseComments } from '../comment'; import { Comment } from './comment'; +import { IAccount } from './account'; +import { GitReferenceModel } from './gitReferenceModel'; export enum PRType { RequestReview = 0, @@ -15,8 +17,70 @@ export enum PRType { All = 4 } -export class PullRequest { - constructor(public readonly otcokit: any, public readonly remote: Remote, public prItem: any) { } +export enum PullRequestStateEnum { + Open, + Merged, + Closed, +} + +export class PullRequestModel { + public prNumber: number; + public title: string; + public state: PullRequestStateEnum = PullRequestStateEnum.Open; + public commentCount: number; + public commitCount: number; + public author: IAccount; + public assignee: IAccount; + public createdAt: string; + public updatedAt: string; + + public get isOpen(): boolean { + return this.state === PullRequestStateEnum.Open; + } + public get isMerged(): boolean { + return this.state === PullRequestStateEnum.Merged; + } + + public head: GitReferenceModel; + public base: GitReferenceModel; + + constructor(public readonly otcokit: any, public readonly remote: Remote, public prItem: any) { + this.prNumber = prItem.number; + this.title = prItem.title; + this.author = { + login: prItem.user.login, + isUser: prItem.user.type === 'User', + isEnterprise: prItem.user.type === 'Enterprise', + avatarUrl: prItem.user.avatar_url, + }; + switch (prItem.state) { + case 'open': + this.state = PullRequestStateEnum.Open; + break; + case 'merged': + this.state = PullRequestStateEnum.Merged; + break; + case 'closed': + this.state = PullRequestStateEnum.Closed; + break; + } + if (prItem.assignee) { + this.assignee = { + login: prItem.assignee.login, + isUser: prItem.assignee.type === 'User', + isEnterprise: prItem.assignee.type === 'Enterprise', + avatarUrl: prItem.assignee.avatar_url, + }; + } + + this.createdAt = prItem.created_at; + this.updatedAt = prItem.updated_at ? prItem.updated_at : this.createdAt; + this.commentCount = prItem.comments; + this.commitCount = prItem.commits; + + this.head = new GitReferenceModel(prItem.head.ref, prItem.head.label, prItem.head.sha, prItem.head.repo.clone_url); + this.base = new GitReferenceModel(prItem.base.ref, prItem.base.label, prItem.base.sha, prItem.base.repo.clone_url); + } async getFiles() { const { data } = await this.otcokit.pullRequests.getFiles({ diff --git a/extensions/git-extended/src/common/models/repository.ts b/extensions/git-extended/src/common/models/repository.ts index cfc53cb1d38..13d8ae85162 100644 --- a/extensions/git-extended/src/common/models/repository.ts +++ b/extensions/git-extended/src/common/models/repository.ts @@ -9,7 +9,8 @@ import { GitProcess } from 'dugite'; import { uniqBy, anyEvent, filterEvent, isDescendant } from '../util'; import { parseRemote } from '../remote'; import { CredentialStore } from '../../credentials'; -import { PullRequest, PRType } from './pullrequest'; +import { PullRequestModel, PRType } from './pullRequestModel'; +import { UriString } from './uriString'; export enum RefType { Head, @@ -58,6 +59,12 @@ export class Repository { return this._remotes; } + // todo + private _cloneUrl: UriString; + get cloneUrl(): UriString { + return this._cloneUrl; + } + private statusTimeout: any; private disposables: vscode.Disposable[] = []; @@ -105,6 +112,13 @@ export class Repository { this._refs = refs; this._remotes = remotes; + if (this._HEAD.upstream.remote) { + let currentRemote = this._remotes.filter(remote => remote.remoteName === this._HEAD.upstream.remote); + if (currentRemote && currentRemote.length) { + this._cloneUrl = new UriString(currentRemote[0].url); + } + } + this._onDidRunGitStatus.fire(); } @@ -169,6 +183,14 @@ export class Repository { } } + async createBranch(branchName: string, tip?: string) { + const result = await GitProcess.exec(['branch', branchName, tip ? tip : ''], this.path); + + if (result.exitCode !== 0) { + throw new Error(result.stderr); + } + } + async getBranch(name: string): Promise { if (name === 'HEAD') { return this.getHEAD(); @@ -176,7 +198,7 @@ export class Repository { const result = await GitProcess.exec(['rev-parse', name], this.path); - if (!result.stdout) { + if (result.exitCode !== 0 || !result.stdout) { return Promise.reject(new Error('No such branch')); } @@ -271,7 +293,7 @@ export class Repository { } } - async checkoutPR(pr: PullRequest) { + async checkoutPR(pr: PullRequestModel) { let cloneUrl = pr.prItem.head.repo.clone_url; let result = await GitProcess.exec(['remote', 'add', `pull/${pr.prItem.number}`, cloneUrl], this.path); @@ -290,6 +312,54 @@ export class Repository { throw (result.exitCode); } } + + async setConfig(key: string, value: string) { + await GitProcess.exec(['config', '--local', key, value], this.path); + } + + async getConfig(key: string) { + let result = await GitProcess.exec(['config', '--local', '--get', key], this.path); + + if (result.exitCode !== 0) { + throw (result.exitCode); + } + + return result.stdout; + } + + async getConfigs() { + let result = await GitProcess.exec(['config', '--local', '-l'], this.path); + + if (result.exitCode !== 0) { + throw (result.exitCode); + } + + let entries = result.stdout.split(/\r|\r\n|\n/); + + return entries.map(entry => { + let ret = entry.split('='); + return { + key: ret[0], + value: ret[1] + }; + }); + } + + async setTrackingBranch(localBranchName: string, trackedBranchName: string) { + let result = await GitProcess.exec(['branch', `--set-upstream-to=${trackedBranchName}`, localBranchName], this.path); + + if (result.exitCode !== 0) { + throw (result.exitCode); + } + } + + async setRemote(name: string, remoteUrl: string) { + let result = await GitProcess.exec(['remote', 'add', name, remoteUrl], this.path); + + if (result.exitCode !== 0) { + throw (result.exitCode); + } + } } export class GitHubRepository { @@ -302,7 +372,7 @@ export class GitHubRepository { owner: this.remote.owner, repo: this.remote.name, }); - let ret = data.map(item => new PullRequest(this.octokit, this.remote, item)); + let ret = data.map(item => new PullRequestModel(this.octokit, this.remote, item)); return ret; } else { @@ -310,7 +380,22 @@ export class GitHubRepository { const { data } = await this.octokit.search.issues({ q: this.getPRFetchQuery(this.remote.owner, this.remote.name, user.data.login, prType) }); - return data.items.map(item => new PullRequest(this.octokit, this.remote, item)); + let promises = []; + + data.items.forEach(item => { + promises.push(new Promise(async (resolve, reject) => { + let prData = await this.octokit.pullRequests.get({ + owner: this.remote.owner, + repo: this.remote.name, + number: item.number + }); + resolve(prData); + })); + }); + + return Promise.all(promises).then(values => { + return values.map(item => new PullRequestModel(this.octokit, this.remote, item.data)); + }); } } @@ -320,7 +405,7 @@ export class GitHubRepository { repo: this.remote.name, number: id }); - let ret = new PullRequest(this.octokit, this.remote, data); + let ret = new PullRequestModel(this.octokit, this.remote, data); return ret; } diff --git a/extensions/git-extended/src/common/models/uriString.ts b/extensions/git-extended/src/common/models/uriString.ts new file mode 100644 index 00000000000..3eea2b8fcb2 --- /dev/null +++ b/extensions/git-extended/src/common/models/uriString.ts @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +const sshRegex = /^.+@(([.*?]|[a-z0-9-.]+?))(:(.*?))?(\/(.*)(.git)?)?$/i; +export class UriString { + public host: string; + + public owner: string; + + public repositoryName: string; + + public nameWithOwner: string; + + public isFileUri: boolean; + + public isScpUri: boolean; + + public isValidUri: boolean; + + public readonly url: vscode.Uri; + constructor( + uriString: string + ) { + let parseUriSuccess = false; + try { + this.url = vscode.Uri.parse(uriString); + + parseUriSuccess = true; + if (this.url.scheme === 'file') { + this.setFilePath(this.url); + } else { + this.setUri(this.url); + } + } catch (e) { } + + if (!parseUriSuccess) { + try { + let matches = sshRegex.exec(uriString); + + if (matches) { + this.host = matches[1]; + this.owner = matches[4]; + this.repositoryName = this.getRepositoryName(matches[6]); + this.isScpUri = true; + } else { + this.setFilePath2(uriString); + } + } catch(e) {} + } + + if (this.repositoryName) { + this.nameWithOwner = this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; + } + } + + setUri(uri: vscode.Uri) { + this.host = uri.authority; + this.repositoryName = this.getRepositoryName(uri.path); + this.owner = this.getOwnerName(uri.path); + } + + setFilePath(uri: vscode.Uri) { + this.host = ''; + this.owner = ''; + this.repositoryName = this.getRepositoryName(uri.path); + this.isFileUri = true; + } + + setFilePath2(path: string) { + this.host = ''; + this.owner = ''; + this.repositoryName = this.getRepositoryName(path); + this.isFileUri = true; + } + + getRepositoryName(path: string) { + let normalized = path.replace('\\', '/'); + let lastIndex = normalized.lastIndexOf('/'); + let lastSegment = normalized.substr(lastIndex + 1); + if (lastSegment === '' || lastSegment === '/') { + return null; + } + + return lastSegment.replace(/\/$/, '').replace(/\.git$/, ''); + } + + getOwnerName(path: string) { + let normalized = path.replace('\\', '/'); + let fragments = normalized.split('/'); + if (fragments.length > 1) { + return fragments[fragments.length - 2]; + } + + return null; + } + + toRepositoryUrl(owner: string = null): vscode.Uri { + if (!this.isScpUri && (this.url === null || this.isFileUri)) { + return this.url; + } + + let scheme = 'https'; + if (this.url !== null && (this.url.scheme === 'http' || this.url.scheme === 'https')) { + scheme = this.url.scheme; + } + + let nameWithOwner = this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; + + try { + return vscode.Uri.parse(`${scheme}://${this.host}/${nameWithOwner}`); + } catch (e) { + return null; + } + } + + equals(other: UriString) { + return this.toRepositoryUrl().toString().toLocaleLowerCase() === other.toRepositoryUrl().toString().toLocaleLowerCase(); + } +} \ No newline at end of file diff --git a/extensions/git-extended/src/common/resources.ts b/extensions/git-extended/src/common/resources.ts index 94f45b5c63d..a9f989e0ff8 100644 --- a/extensions/git-extended/src/common/resources.ts +++ b/extensions/git-extended/src/common/resources.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { GitChangeType } from './models/file'; import { FileChangeTreeItem } from './treeItems'; -import { PullRequest } from './models/pullrequest'; +import { PullRequestModel } from './models/pullRequestModel'; export class Resource { static icons: any; @@ -72,7 +72,7 @@ export class Resource { }; } - static getGravatarUri(pr: PullRequest, size: number = 64): vscode.Uri { + static getGravatarUri(pr: PullRequestModel, size: number = 64): vscode.Uri { let key = pr.getUserGravatar(); let gravatar = vscode.Uri.parse(`${key}&s=${size}`); diff --git a/extensions/git-extended/src/common/treeItems.ts b/extensions/git-extended/src/common/treeItems.ts index b004a1c2d6d..aac7b81b09f 100644 --- a/extensions/git-extended/src/common/treeItems.ts +++ b/extensions/git-extended/src/common/treeItems.ts @@ -5,12 +5,12 @@ import * as vscode from 'vscode'; import { GitChangeType } from './models/file'; -import { PullRequest, PRType } from './models/pullrequest'; +import { PullRequestModel, PRType } from './models/pullRequestModel'; export class PRGroupTreeItem implements vscode.TreeItem { public readonly label: string; public collapsibleState: vscode.TreeItemCollapsibleState; - public prs: PullRequest[]; + public prs: PullRequestModel[]; public type: PRType; constructor(type: PRType) { this.prs = []; diff --git a/extensions/git-extended/src/extension.ts b/extensions/git-extended/src/extension.ts index ff1ba08038b..c7b45fb4874 100644 --- a/extensions/git-extended/src/extension.ts +++ b/extensions/git-extended/src/extension.ts @@ -11,6 +11,7 @@ import { Configuration } from './configuration'; import { Resource } from './common/resources'; import { ReviewMode } from './review/reviewMode'; import { CredentialStore } from './credentials'; +import { PullRequestService } from './services/pullRequestService'; export async function activate(context: vscode.ExtensionContext) { // initialize resources @@ -61,7 +62,8 @@ export async function activate(context: vscode.ExtensionContext) { repositoryInitialized = true; let credentialStore = new CredentialStore(configuration); await repository.connectGitHub(credentialStore); - let reviewMode = new ReviewMode(repository, context.workspaceState, repo); + let pullRequestService = new PullRequestService(); + let reviewMode = new ReviewMode(repository, pullRequestService, context.workspaceState, repo); await (new PRProvider(context, configuration, reviewMode)).activate(repository); }); } diff --git a/extensions/git-extended/src/prView/prProvider.ts b/extensions/git-extended/src/prView/prProvider.ts index f42478f7422..05f8229f9f1 100644 --- a/extensions/git-extended/src/prView/prProvider.ts +++ b/extensions/git-extended/src/prView/prProvider.ts @@ -16,14 +16,14 @@ import { Resource } from '../common/resources'; import { ReviewMode } from '../review/reviewMode'; import { toPRUri } from '../common/uri'; import * as fs from 'fs'; -import { PullRequest, PRType } from '../common/models/pullrequest'; +import { PullRequestModel, PRType } from '../common/models/pullRequestModel'; -export class PRProvider implements vscode.TreeDataProvider, vscode.TextDocumentContentProvider, vscode.DecorationProvider { +export class PRProvider implements vscode.TreeDataProvider, vscode.TextDocumentContentProvider, vscode.DecorationProvider { private context: vscode.ExtensionContext; private repository: Repository; private configuration: Configuration; private reviewMode: ReviewMode; - private _onDidChangeTreeData = new vscode.EventEmitter(); + private _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private _onDidChange = new vscode.EventEmitter(); get onDidChange(): vscode.Event { return this._onDidChange.event; } @@ -38,8 +38,8 @@ export class PRProvider implements vscode.TreeDataProvider('pr', this)); - this.context.subscriptions.push(vscode.commands.registerCommand('pr.pick', async (pr: PullRequest) => { + this.context.subscriptions.push(vscode.window.registerTreeDataProvider('pr', this)); + this.context.subscriptions.push(vscode.commands.registerCommand('pr.pick', async (pr: PullRequestModel) => { await this.reviewMode.switch(pr); })); this.context.subscriptions.push(this.configuration.onDidChange(e => { @@ -47,12 +47,12 @@ export class PRProvider implements vscode.TreeDataProvider { + async getChildren(element?: PRGroupTreeItem | PullRequestModel | FileChangeTreeItem): Promise<(PRGroupTreeItem | PullRequestModel | FileChangeTreeItem)[]> { if (!element) { return Promise.resolve([ new PRGroupTreeItem(PRType.RequestReview), @@ -88,7 +88,7 @@ export class PRProvider implements vscode.TreeDataProvider { + async getPRs(element: PRGroupTreeItem): Promise { let promises = this.repository.githubRepositories.map(async githubRepository => { return await githubRepository.getPullRequests(element.type); }); @@ -243,7 +243,7 @@ export class PRProvider implements vscode.TreeDataProvider { + async getComments(element: PullRequestModel): Promise { const reviewData = await element.otcokit.pullRequests.getComments({ owner: element.remote.owner, repo: element.remote.name, diff --git a/extensions/git-extended/src/review/reviewMode.ts b/extensions/git-extended/src/review/reviewMode.ts index b002c4fa52b..a82623967ed 100644 --- a/extensions/git-extended/src/review/reviewMode.ts +++ b/extensions/git-extended/src/review/reviewMode.ts @@ -11,9 +11,10 @@ import { mapCommentsToHead, parseDiff, mapHeadLineToDiffHunkPosition, getDiffLin import * as _ from 'lodash'; import { GitContentProvider } from './gitContentProvider'; import { Comment } from '../common/models/comment'; -import { PullRequest } from '../common/models/pullrequest'; +import { PullRequestModel } from '../common/models/pullRequestModel'; import { toGitUri } from '../common/uri'; import { GitChangeType } from '../common/models/file'; +import { PullRequestService } from '../services/pullRequestService'; const REVIEW_STATE = 'git-extended.state'; @@ -42,6 +43,7 @@ export class ReviewMode implements vscode.DecorationProvider { private _onDidChangeCommentThreads = new vscode.EventEmitter(); constructor( private _repository: Repository, + private _pullRequestService: PullRequestService, private _workspaceState: vscode.Memento, private _gitRepo: any ) { @@ -72,52 +74,40 @@ export class ReviewMode implements vscode.DecorationProvider { } async validateState() { - let branch = this._repository.HEAD.name; + let localInfo = await this._pullRequestService.getPullRequestForCurrentBranch(this._repository); + if (!localInfo) { + return; + } + + if (this._prNumber === localInfo.prNumber) { + return; + } + + let branch = this._repository.HEAD; if (!branch) { this.clear(); return; } - let state = this._workspaceState.get(`${REVIEW_STATE}:${branch}`) as ReviewState; - if (!state) { // not in review state - this.clear(); - return; - } - - let remoteName = state.remote; - let remote = this._repository.remotes.find(remote => remote.remoteName === remoteName); - + let remote = branch.upstream ? branch.upstream.remote : null; if (!remote) { this.clear(); return; } - if (this._prNumber === state.prNumber) { - return; - } - - this._prNumber = state.prNumber; - if (!state.head || !state.base) { - // load pr - this._lastCommitSha = null; - } else { - this._lastCommitSha = state['head'].sha; - } - // we switch to another PR, let's clean up first. this.clear(); - let githubRepo = this._repository.githubRepositories.find(repo => repo.remote.equals(remote)); + this._prNumber = localInfo.prNumber; + this._lastCommitSha = null; + let githubRepo = this._repository.githubRepositories.find(repo => repo.remote.remoteName === remote); if (!githubRepo) { return; // todo, should show warning } const pr = await githubRepo.getPullRequest(this._prNumber); - state.base = pr.prItem.base; - state.head = pr.prItem.head; - this._workspaceState.update(`${REVIEW_STATE}:${branch}`, state); if (!this._lastCommitSha) { - this._lastCommitSha = state.head.sha; + this._lastCommitSha = pr.head.sha; } await this.getPullRequestData(pr); @@ -274,7 +264,7 @@ export class ReviewMode implements vscode.DecorationProvider { return Promise.resolve(null); } - private async getPullRequestData(pr: PullRequest): Promise { + private async getPullRequestData(pr: PullRequestModel): Promise { this._comments = await pr.getComments(); const data = await pr.getFiles(); const baseSha = await pr.getBaseCommitSha(); @@ -334,7 +324,7 @@ export class ReviewMode implements vscode.DecorationProvider { }; }), collapsibleState: collapsibleState, - reply: this._reply + postReviewComment: this._reply }); } @@ -447,28 +437,23 @@ export class ReviewMode implements vscode.DecorationProvider { }); } - async switch(pr: PullRequest) { + async switch(pr: PullRequestModel) { try { - // if (pr.prItem.maintainer_can_modify) { - // await this._repository.checkoutPR(pr); - // } else { - await this._repository.fetch(pr.remote.remoteName, `pull/${pr.prItem.number}/head:pull-request-${pr.prItem.number}`); - await this._repository.checkout(`pull-request-${pr.prItem.number}`); - // } + let localBranches = await this._pullRequestService.getLocalBranches(this._repository, pr); + + if (localBranches.length > 0) { + await this._pullRequestService.switchToBranch(this._repository, pr); + return; + } else { + let branchName = await this._pullRequestService.getDefaultLocalBranchName(this._repository, pr.prNumber, pr.title); + await this._pullRequestService.checkout(this._repository, pr, branchName); + } } catch (e) { vscode.window.showErrorMessage(e); return; } - this._workspaceState.update(`${REVIEW_STATE}:pull-request-${pr.prItem.number}`, { - remote: pr.remote.remoteName, - prNumber: pr.prItem.number, - branch: `pull-request-${pr.prItem.number}`, - head: pr.prItem.head, - base: pr.prItem.base - }).then(async e => { - await this._repository.status(); - }); + await this._repository.status(); } clear() { diff --git a/extensions/git-extended/src/services/pullRequestService.ts b/extensions/git-extended/src/services/pullRequestService.ts new file mode 100644 index 00000000000..826e4b3f905 --- /dev/null +++ b/extensions/git-extended/src/services/pullRequestService.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Repository } from '../common/models/repository'; +import { PullRequestModel } from '../common/models/pullRequestModel'; +import { UriString } from '../common/models/uriString'; + +const InvalidBranchCharsRegex = /[^0-9A-Za-z\-]/g; +const SettingCreatedByGHfVSC = 'created-by-ghfvsc'; +const SettingGHfVSCPullRequest = 'ghfvs-pr-owner-number'; +const BranchCapture = /branch\.(.+)\.ghfvsc-pr/; + +export class PullRequestService { + + constructor() { + + } + + async checkout(repository: Repository, pullRequest: PullRequestModel, localBranchName: string) { + let existing = await repository.getBranch(localBranchName); + if (existing) { + await repository.checkout(localBranchName); + } else if (repository.cloneUrl.equals(pullRequest.head.repositoryCloneUrl)) { + await repository.fetch('origin', localBranchName); + await repository.checkout(localBranchName); + } else { + // nothing matches + let refSpec = `${pullRequest.head.ref}:${localBranchName}`; + let remoteName = await PullRequestService.createRemote(repository, pullRequest.head.repositoryCloneUrl); + + await repository.fetch(remoteName, refSpec); + await repository.checkout(refSpec); + await repository.setTrackingBranch(localBranchName, `refs/remotes/${remoteName}/${pullRequest.head.ref}`); + } + + // Store the PR number in the branch config with the key "ghfvsc-pr". + var prConfigKey = `branch.${localBranchName}.${SettingGHfVSCPullRequest}`; + await repository.setConfig(prConfigKey, PullRequestService.buildGHfVSConfigKeyValue(pullRequest)); + } + + async getLocalBranches(repository: Repository, pullRequest: PullRequestModel): Promise { + if (PullRequestService.isPullRequestFromRepository(repository, pullRequest)) { + return [pullRequest.head.ref]; + } else { + let key = PullRequestService.buildGHfVSConfigKeyValue(pullRequest); + + let configs = await repository.getConfigs(); + + return configs.map(config => { + let matches = BranchCapture.exec(config.key); + if (matches && matches.length) { + return { + branch: matches[1], + value: config.value + }; + } else { + return { + branch: null, + value: config.value + }; + } + }).filter(c => c.branch && c.value === key).map(c => c.value); + } + } + + async switchToBranch(repository: Repository, pullRequest: PullRequestModel): Promise { + let matchingBranches = await this.getLocalBranches(repository, pullRequest); + if (matchingBranches && matchingBranches.length) { + let branchName = matchingBranches[0]; + let remoteName = repository.HEAD.upstream.remote; + + if (!remoteName) { + return; + } + + await repository.fetch(remoteName, branchName); + let branch = null; + try { + branch = await repository.getBranch(branchName); + } catch (e) {} + + if (!branch) { + const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; + const trackedBranch = await repository.getBranch(trackedBranchName); + + if (trackedBranch) { + // create branch + await repository.createBranch(branchName, trackedBranch.commit); + await repository.setTrackingBranch(branchName, trackedBranchName); + } else { + throw new Error(`Could not find branch '${trackedBranchName}'.`); + } + } + + await repository.checkout(branchName); + await PullRequestService.markBranchAsPullRequest(repository, pullRequest, branchName); + } + + return []; + } + + async getDefaultLocalBranchName(repository: Repository, pullRequestNumber: number, pullRequestTitle: string): Promise { + let initial = 'pr/' + pullRequestNumber + '-' + PullRequestService.getSafeBranchName(pullRequestTitle); + let current = initial; + let index = 2; + + while (true) { + let currentBranch = await repository.getBranch(current); + + if (currentBranch) { + current = initial + '-' + index++; + } + + break; + } + + return current.replace(/-*$/g, ''); + } + + async getPullRequestForCurrentBranch(repository: Repository) { + let configKey = `branch.${repository.HEAD.name}.${SettingGHfVSCPullRequest}`; + let configValue = await repository.getConfig(configKey); + return PullRequestService.parseGHfVSConfigKeyValue(configValue); + } + + static getSafeBranchName(name: string): string { + let before = name.replace(InvalidBranchCharsRegex, '-').replace(/-*$/g, ''); + + for (; ;) { + let after = before.replace('--', '-'); + + if (after === before) { + return before.toLocaleLowerCase(); + } + + before = after; + } + } + + static buildGHfVSConfigKeyValue(pullRequest: PullRequestModel) { + return pullRequest.base.repositoryCloneUrl.owner + '#' + pullRequest.prNumber; + } + static parseGHfVSConfigKeyValue(value: string) { + if (value) { + let separator = value.indexOf('#'); + if (separator !== -1) { + let owner = value.substring(0, separator); + let prNumber = Number(value.substr(separator + 1)); + + if (prNumber) { + return { + owner: owner, + prNumber: prNumber + }; + } + } + } + + return null; + } + + static async createRemote(repository: Repository, cloneUrl: UriString) { + let remotes = repository.remotes; + + remotes.forEach(remote => { + // todo equals + if (remote.url === cloneUrl.toRepositoryUrl().toString()) { + return remote.name; + } + }); + + var remoteName = PullRequestService.createUniqueRemoteName(repository, cloneUrl.owner); + await repository.setRemote(remoteName, cloneUrl.toRepositoryUrl().toString()); + await repository.setConfig(`remote.${remoteName}.${SettingCreatedByGHfVSC}`, 'true'); + return remoteName; + } + + static createUniqueRemoteName(repository: Repository, name: string) { + { + var uniqueName = name; + var number = 1; + + while (repository.remotes[uniqueName] !== null) { + uniqueName = name + number++; + } + + return uniqueName; + } + } + + static isPullRequestFromRepository(repository: Repository, pullRequest: PullRequestModel): boolean { + return repository.cloneUrl && repository.cloneUrl.equals(pullRequest.head.repositoryCloneUrl); + } + + static isBranchMarkedAsPullRequest(repository: Repository) { + + } + + static async markBranchAsPullRequest(repository: Repository, pullRequest: PullRequestModel, branchName: string) { + let prConfigKey = `branch.${branchName}.${SettingGHfVSCPullRequest}`; + await repository.setConfig(prConfigKey, PullRequestService.buildGHfVSConfigKeyValue(pullRequest)); + } +} \ No newline at end of file diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 2f09e079a4f..8410c4319e0 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -4961,7 +4961,7 @@ declare module 'vscode' { * * * *Note 1:* The filesystem provider API works with [uris](#Uri) and assumes hierarchical * paths, e.g. `foo:/my/path` is a child of `foo:/my/` and a parent of `foo:/my/path/deeper`. - * * *Note 2:* There is an activation event `onFileSystem:` that fires when a file + * * *Note 2:* There is an activation event `onFileSystem:<-scheme>` that fires when a file * or folder is being accessed. * */ diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 4d33723d1fd..60835577e74 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -430,7 +430,7 @@ declare module 'vscode' { interface CommentInfo { threads: CommentThread[]; commentingRanges?: Range[]; - reply?: Command; + postReviewComment?: Command; } export enum CommentThreadCollapsibleState { @@ -450,7 +450,7 @@ declare module 'vscode' { range: Range; comments: Comment[]; collapsibleState?: CommentThreadCollapsibleState; - reply?: Command; + postReviewComment?: Command; } interface Comment { diff --git a/src/vs/workbench/api/node/extHostComments.ts b/src/vs/workbench/api/node/extHostComments.ts index ba069be736f..c79205123b4 100644 --- a/src/vs/workbench/api/node/extHostComments.ts +++ b/src/vs/workbench/api/node/extHostComments.ts @@ -107,7 +107,7 @@ function convertCommentInfo(owner: number, vscodeCommentInfo: vscode.CommentInfo owner: owner, threads: vscodeCommentInfo.threads.map(x => convertCommentThread(x, commandsConverter)), commentingRanges: vscodeCommentInfo.commentingRanges ? vscodeCommentInfo.commentingRanges.map(range => extHostTypeConverter.fromRange(range)) : [], - reply: vscodeCommentInfo.reply ? commandsConverter.toInternal(vscodeCommentInfo.reply) : null + reply: vscodeCommentInfo.postReviewComment ? commandsConverter.toInternal(vscodeCommentInfo.postReviewComment) : null }; } @@ -118,7 +118,7 @@ function convertCommentThread(vscodeCommentThread: vscode.CommentThread, command range: extHostTypeConverter.fromRange(vscodeCommentThread.range), comments: vscodeCommentThread.comments.map(convertComment), collapsibleState: vscodeCommentThread.collapsibleState, - reply: vscodeCommentThread.reply ? commandsConverter.toInternal(vscodeCommentThread.reply) : null + reply: vscodeCommentThread.postReviewComment ? commandsConverter.toInternal(vscodeCommentThread.postReviewComment) : null }; }