Browse Source

Adds built-in create pr on GitHub

main
Eric Amodio 3 years ago
parent
commit
2829d4b5b3
13 changed files with 304 additions and 51 deletions
  1. +1
    -0
      src/commands.ts
  2. +1
    -0
      src/commands/common.ts
  3. +53
    -0
      src/commands/createPullRequestOnRemote.ts
  4. +52
    -36
      src/commands/openOnRemote.ts
  5. +20
    -2
      src/extension.ts
  6. +7
    -0
      src/git/models/defaultBranch.ts
  7. +1
    -0
      src/git/models/models.ts
  8. +5
    -5
      src/git/models/remote.ts
  9. +2
    -2
      src/git/models/repository.ts
  10. +29
    -1
      src/git/remotes/github.ts
  11. +49
    -0
      src/git/remotes/provider.ts
  12. +58
    -0
      src/github/github.ts
  13. +26
    -5
      src/quickpicks/remoteProviderPicker.ts

+ 1
- 0
src/commands.ts View File

@ -9,6 +9,7 @@ export * from './commands/compareWith';
export * from './commands/copyCurrentBranch';
export * from './commands/copyMessageToClipboard';
export * from './commands/copyShaToClipboard';
export * from './commands/createPullRequestOnRemote';
export * from './commands/openDirectoryCompare';
export * from './commands/diffLineWithPrevious';
export * from './commands/diffLineWithWorking';

+ 1
- 0
src/commands/common.ts View File

@ -52,6 +52,7 @@ export enum Commands {
CopyRemotePullRequestUrl = 'gitlens.copyRemotePullRequestUrl',
CopyRemoteRepositoryUrl = 'gitlens.copyRemoteRepositoryUrl',
CopyShaToClipboard = 'gitlens.copyShaToClipboard',
CreatePullRequestOnRemote = 'gitlens.createPullRequestOnRemote',
DiffDirectory = 'gitlens.diffDirectory',
DiffDirectoryWithHead = 'gitlens.diffDirectoryWithHead',
DiffWith = 'gitlens.diffWith',

+ 53
- 0
src/commands/createPullRequestOnRemote.ts View File

@ -0,0 +1,53 @@
'use strict';
import { Command, command, Commands, executeCommand } from './common';
import { Container } from '../container';
import { GitRemote, RemoteProvider, RemoteResource, RemoteResourceType } from '../git/git';
import { OpenOnRemoteCommandArgs } from './openOnRemote';
export interface CreatePullRequestOnRemoteCommandArgs {
base?: string;
compare: string;
remote: string;
repoPath: string;
clipboard?: boolean;
}
@command()
export class CreatePullRequestOnRemoteCommand extends Command {
constructor() {
super(Commands.CreatePullRequestOnRemote);
}
async execute(args?: CreatePullRequestOnRemoteCommandArgs) {
if (args?.repoPath == null) return;
const repo = await Container.git.getRepository(args.repoPath);
if (repo == null) return;
const compareRemote = await repo.getRemote(args.remote);
if (compareRemote?.provider == null) return;
const providerId = compareRemote.provider.id;
const remotes = (await repo.getRemotes({
filter: r => r.provider?.id === providerId,
})) as GitRemote<RemoteProvider>[];
const resource: RemoteResource = {
type: RemoteResourceType.CreatePullRequest,
base: {
branch: args.base,
remote: undefined!,
},
compare: {
branch: args.compare,
remote: { path: compareRemote.path, url: compareRemote.url },
},
};
void (await executeCommand<OpenOnRemoteCommandArgs>(Commands.OpenOnRemote, {
resource: resource,
remotes: remotes,
}));
}
}

+ 52
- 36
src/commands/openOnRemote.ts View File

@ -75,72 +75,88 @@ export class OpenOnRemoteCommand extends Command {
}
}
// If there is only one or a default just execute it directly
const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default);
if (remote != null) {
void (await new CopyOrOpenRemoteCommandQuickPickItem(remote, args.resource, args.clipboard).execute());
return;
}
const providers = GitRemote.getHighlanderProviders(remotes);
const provider = providers?.length ? providers[0].name : 'Remote';
const options: Parameters<typeof RemoteProviderPicker.show>[4] = {
autoPick: 'default',
clipboard: args.clipboard,
setDefault: true,
};
let title;
let placeHolder = `Choose which remote to ${args.clipboard ? 'copy the url for' : 'open on'}`;
switch (args.resource.type) {
case RemoteResourceType.Branch:
title = `${args.clipboard ? 'Copy Branch Url' : 'Open Branch'}${Strings.pad(GlyphChars.Dot, 2, 2)}${
args.resource.branch
}`;
title = `${
args.clipboard ? `Copy ${provider} Branch Url` : `Open Branch on ${provider}`
}${Strings.pad(GlyphChars.Dot, 2, 2)}${args.resource.branch}`;
break;
case RemoteResourceType.Branches:
title = `${args.clipboard ? 'Copy Branches Url' : 'Open Branches'}`;
title = `${args.clipboard ? `Copy ${provider} Branches Url` : `Open Branches on ${provider}`}`;
break;
case RemoteResourceType.Commit:
title = `${args.clipboard ? 'Copy Commit Url' : 'Open Commit'}${Strings.pad(
GlyphChars.Dot,
2,
2,
)}${GitRevision.shorten(args.resource.sha)}`;
title = `${
args.clipboard ? `Copy ${provider} Commit Url` : `Open Commit on ${provider}`
}${Strings.pad(GlyphChars.Dot, 2, 2)}${GitRevision.shorten(args.resource.sha)}`;
break;
case RemoteResourceType.Comparison:
title = `${args.clipboard ? 'Copy Comparison Url' : 'Open Comparison'}${Strings.pad(
GlyphChars.Dot,
2,
2,
)}${GitRevision.createRange(args.resource.base, args.resource.compare, '...')}`;
title = `${
args.clipboard ? `Copy ${provider} Comparison Url` : `Open Comparison on ${provider}`
}${Strings.pad(GlyphChars.Dot, 2, 2)}${GitRevision.createRange(
args.resource.base,
args.resource.compare,
args.resource.notation ?? '...',
)}`;
break;
case RemoteResourceType.File:
title = `${args.clipboard ? 'Copy File Url' : 'Open File'}${Strings.pad(GlyphChars.Dot, 2, 2)}${
args.resource.fileName
case RemoteResourceType.CreatePullRequest:
options.autoPick = true;
options.setDefault = false;
title = `${
args.clipboard
? `Copy ${provider} Create Pull Request Url`
: `Create Pull Request on ${provider}`
}${Strings.pad(GlyphChars.Dot, 2, 2)}${
args.resource.base?.branch
? GitRevision.createRange(args.resource.base.branch, args.resource.compare.branch, '...')
: args.resource.compare.branch
}`;
placeHolder = `Choose which remote to ${
args.clipboard ? 'copy the create pull request url for' : 'create the pull request on'
}`;
break;
case RemoteResourceType.File:
title = `${args.clipboard ? `Copy ${provider} File Url` : `Open File on ${provider}`}${Strings.pad(
GlyphChars.Dot,
2,
2,
)}${args.resource.fileName}`;
break;
case RemoteResourceType.Repo:
title = `${args.clipboard ? 'Copy Repository Url' : 'Open Repository'}`;
title = `${args.clipboard ? `Copy ${provider} Repository Url` : `Open Repository on ${provider}`}`;
break;
case RemoteResourceType.Revision: {
title = `${args.clipboard ? 'Copy File Url' : 'Open File'}${Strings.pad(
title = `${args.clipboard ? `Copy ${provider} File Url` : `Open File on ${provider}`}${Strings.pad(
GlyphChars.Dot,
2,
2,
)}${GitRevision.shorten(args.resource.sha)}${Strings.pad(GlyphChars.Dot, 2, 2)}${
)}${GitRevision.shorten(args.resource.sha)}${Strings.pad(GlyphChars.Dot, 1, 1)}${
args.resource.fileName
}`;
break;
}
}
const pick = await RemoteProviderPicker.show(
title,
`Choose which remote to ${args.clipboard ? 'copy the url from' : 'open on'}`,
args.resource,
remotes,
{
clipboard: args.clipboard,
},
);
const pick = await RemoteProviderPicker.show(title, placeHolder, args.resource, remotes, options);
if (pick instanceof SetADefaultRemoteCommandQuickPickItem) {
const remote = await pick.execute();

+ 20
- 2
src/extension.ts View File

@ -1,13 +1,14 @@
'use strict';
import * as paths from 'path';
import { commands, ExtensionContext, extensions, window, workspace } from 'vscode';
import { GitLensApi, OpenPullRequestActionContext } from '../src/api/gitlens';
import { CreatePullRequestActionContext, GitLensApi, OpenPullRequestActionContext } from '../src/api/gitlens';
import { Api } from './api/api';
import { Commands, executeCommand, OpenPullRequestOnRemoteCommandArgs, registerCommands } from './commands';
import { CreatePullRequestOnRemoteCommandArgs } from './commands/createPullRequestOnRemote';
import { configuration, Configuration } from './configuration';
import { ContextKeys, GlobalState, GlyphChars, setContext, SyncedState } from './constants';
import { Container } from './container';
import { Git, GitCommit } from './git/git';
import { Git, GitBranch, GitCommit } from './git/git';
import { GitService } from './git/gitService';
import { GitUri } from './git/gitUri';
import { Logger } from './logger';
@ -206,6 +207,23 @@ export function notifyOnUnsupportedGitVersion(version: string) {
function registerBuiltInActionRunners(context: ExtensionContext): void {
context.subscriptions.push(
Container.actionRunners.registerBuiltIn<CreatePullRequestActionContext>('createPullRequest', {
label: ctx => `Create Pull Request on ${ctx.remote?.provider?.name ?? 'Remote'}`,
run: async ctx => {
if (ctx.type !== 'createPullRequest') return;
void (await executeCommand<CreatePullRequestOnRemoteCommandArgs>(Commands.CreatePullRequestOnRemote, {
base: undefined,
compare: ctx.branch.isRemote
? GitBranch.getNameWithoutRemote(ctx.branch.name)
: ctx.branch.upstream
? GitBranch.getNameWithoutRemote(ctx.branch.upstream)
: ctx.branch.name,
remote: ctx.remote?.name ?? '',
repoPath: ctx.repoPath,
}));
},
}),
Container.actionRunners.registerBuiltIn<OpenPullRequestActionContext>('openPullRequest', {
label: ctx => `Open Pull Request on ${ctx.provider?.name ?? 'Remote'}`,
run: async ctx => {

+ 7
- 0
src/git/models/defaultBranch.ts View File

@ -0,0 +1,7 @@
'use strict';
import { RemoteProviderReference } from './remoteProvider';
export interface DefaultBranch {
provider: RemoteProviderReference;
name: string;
}

+ 1
- 0
src/git/models/models.ts View File

@ -344,6 +344,7 @@ export * from './blameCommit';
export * from './branch';
export * from './commit';
export * from './contributor';
export * from './defaultBranch';
export * from './diff';
export * from './file';
export * from './issue';

+ 5
- 5
src/git/models/remote.ts View File

@ -63,19 +63,19 @@ export class GitRemote
return this.id === defaultRemote;
}
get url(): string | undefined {
let url;
get url(): string {
let bestUrl: string | undefined;
for (const remoteUrl of this.urls) {
if (remoteUrl.type === GitRemoteType.Push) {
return remoteUrl.url;
}
if (url == null) {
url = remoteUrl.url;
if (bestUrl == null) {
bestUrl = remoteUrl.url;
}
}
return url;
return bestUrl!;
}
async setAsDefault(state: boolean = true, updateViews: boolean = true) {

+ 2
- 2
src/git/models/repository.ts View File

@ -606,7 +606,7 @@ export class Repository implements Disposable {
return (await this.getRemotes()).find(r => r.name === remote);
}
getRemotes(_options: { sort?: boolean } = {}): Promise<GitRemote[]> {
async getRemotes(options: { filter?: (remote: GitRemote) => boolean; sort?: boolean } = {}): Promise<GitRemote[]> {
if (this._remotes == null || !this.supportsChangeEvents) {
if (this._providers == null) {
const remotesCfg = configuration.get('remotes', this.folder.uri);
@ -618,7 +618,7 @@ export class Repository implements Disposable {
void this.subscribeToRemotes(this._remotes);
}
return this._remotes;
return options.filter != null ? (await this._remotes).filter(options.filter) : this._remotes;
}
async getRichRemote(connectedOnly: boolean = false): Promise<GitRemote<RichRemoteProvider> | undefined> {

+ 29
- 1
src/git/remotes/github.ts View File

@ -4,7 +4,15 @@ import { DynamicAutolinkReference } from '../../annotations/autolinks';
import { AutolinkReference } from '../../config';
import { Container } from '../../container';
import { GitHubPullRequest } from '../../github/github';
import { Account, GitRevision, IssueOrPullRequest, PullRequest, PullRequestState, Repository } from '../models/models';
import {
Account,
DefaultBranch,
GitRevision,
IssueOrPullRequest,
PullRequest,
PullRequestState,
Repository,
} from '../models/models';
import { RichRemoteProvider } from './provider';
const issueEnricher3rdParyRegex = /\b(\w+\\?-?\w+(?!\\?-)\/\w+\\?-?\w+(?!\\?-))\\?#([0-9]+)\b/g;
@ -143,6 +151,18 @@ export class GitHubRemote extends RichRemoteProvider {
return this.encodeUrl(`${this.baseUrl}/compare/${base}${notation}${compare}`);
}
protected getUrlForCreatePullRequest(
base: { branch?: string; remote: { path: string; url: string } },
compare: { branch: string; remote: { path: string; url: string } },
): string | undefined {
if (base.remote.url === compare.remote.url) {
return this.encodeUrl(`${this.baseUrl}/pull/new/${base.branch ?? 'HEAD'}...${compare.branch}`);
}
const [owner] = compare.remote.path.split('/', 1);
return this.encodeUrl(`${this.baseUrl}/pull/new/${base.branch ?? 'HEAD'}...${owner}:${compare.branch}`);
}
protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string {
let line;
if (range != null) {
@ -188,6 +208,14 @@ export class GitHubRemote extends RichRemoteProvider {
});
}
protected async getProviderDefaultBranch({
accessToken,
}: AuthenticationSession): Promise<DefaultBranch | undefined> {
const [owner, repo] = this.splitPath();
return (await Container.github)?.getDefaultBranch(this, accessToken, owner, repo, {
baseUrl: this.apiBaseUrl,
});
}
protected async getProviderIssueOrPullRequest(
{ accessToken }: AuthenticationSession,
id: string,

+ 49
- 0
src/git/remotes/provider.ts View File

@ -16,6 +16,7 @@ import { Container } from '../../container';
import { Logger } from '../../logger';
import {
Account,
DefaultBranch,
GitLogCommit,
IssueOrPullRequest,
PullRequest,
@ -30,6 +31,7 @@ export enum RemoteResourceType {
Branches = 'branches',
Commit = 'commit',
Comparison = 'comparison',
CreatePullRequest = 'createPullRequest',
File = 'file',
Repo = 'repo',
Revision = 'revision',
@ -54,6 +56,17 @@ export type RemoteResource =
notation?: '..' | '...';
}
| {
type: RemoteResourceType.CreatePullRequest;
base: {
branch?: string;
remote: { path: string; url: string };
};
compare: {
branch: string;
remote: { path: string; url: string };
};
}
| {
type: RemoteResourceType.File;
branchOrTag?: string;
fileName: string;
@ -81,6 +94,8 @@ export function getNameFromRemoteResource(resource: RemoteResource) {
return 'Commit';
case RemoteResourceType.Comparison:
return 'Comparison';
case RemoteResourceType.CreatePullRequest:
return 'Create Pull Request';
case RemoteResourceType.File:
return 'File';
case RemoteResourceType.Repo:
@ -153,6 +168,9 @@ export abstract class RemoteProvider implements RemoteProviderReference {
case RemoteResourceType.Comparison: {
return this.getUrlForComparison?.(resource.base, resource.compare, resource.notation ?? '...');
}
case RemoteResourceType.CreatePullRequest: {
return this.getUrlForCreatePullRequest?.(resource.base, resource.compare);
}
case RemoteResourceType.File:
return this.getUrlForFile(
resource.fileName,
@ -196,6 +214,11 @@ export abstract class RemoteProvider implements RemoteProviderReference {
protected getUrlForComparison?(base: string, compare: string, notation: '..' | '...'): string | undefined;
protected getUrlForCreatePullRequest?(
base: { branch?: string; remote: { path: string; url: string } },
compare: { branch: string; remote: { path: string; url: string } },
): string | undefined;
protected abstract getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string;
protected getUrlForRepository(): string {
@ -412,6 +435,32 @@ export abstract class RichRemoteProvider extends RemoteProvider {
@gate()
@debug()
async getDefaultBranch(): Promise<DefaultBranch | undefined> {
const cc = Logger.getCorrelationContext();
const connected = this.maybeConnected ?? (await this.isConnected());
if (!connected) return undefined;
try {
const defaultBranch = await this.getProviderDefaultBranch(this._session!);
this.invalidClientExceptionCount = 0;
return defaultBranch;
} catch (ex) {
Logger.error(ex, cc);
if (ex instanceof ClientError || ex instanceof AuthenticationError) {
this.handleClientException();
}
return undefined;
}
}
protected abstract getProviderDefaultBranch({
accessToken,
}: AuthenticationSession): Promise<DefaultBranch | undefined>;
@gate()
@debug()
async getIssueOrPullRequest(id: string): Promise<IssueOrPullRequest | undefined> {
const cc = Logger.getCorrelationContext();

+ 58
- 0
src/github/github.ts View File

@ -5,6 +5,7 @@ import { debug } from '../system';
import {
AuthenticationError,
ClientError,
DefaultBranch,
IssueOrPullRequest,
PullRequest,
PullRequestState,
@ -171,6 +172,63 @@ export class GitHubApi {
1: _ => '<token>',
},
})
async getDefaultBranch(
provider: RichRemoteProvider,
token: string,
owner: string,
repo: string,
options?: {
baseUrl?: string;
},
): Promise<DefaultBranch | undefined> {
const cc = Logger.getCorrelationContext();
try {
const query = `query defaultBranch($owner: String!, $repo: String!) {
repository(name: $repo, owner: $owner) {
defaultBranchRef {
name
}
}
}`;
const rsp = await graphql<{
repository: {
defaultBranchRef: {
name: string;
} | null;
} | null;
}>(query, {
owner: owner,
repo: repo,
headers: { authorization: `Bearer ${token}` },
...options,
});
const defaultBranch = rsp?.repository?.defaultBranchRef?.name ?? undefined;
if (defaultBranch == null) return undefined;
return {
provider: provider,
name: defaultBranch,
};
} catch (ex) {
Logger.error(ex, cc);
if (ex.code >= 400 && ex.code <= 500) {
if (ex.code === 401) throw new AuthenticationError(ex);
throw new ClientError(ex);
}
throw ex;
}
}
@debug({
args: {
0: (p: RichRemoteProvider) => p.name,
1: _ => '<token>',
},
})
async getIssueOrPullRequest(
provider: RichRemoteProvider,
token: string,

+ 26
- 5
src/quickpicks/remoteProviderPicker.ts View File

@ -33,8 +33,8 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem {
private readonly clipboard?: boolean,
) {
super({
label: clipboard ? `Copy ${remote.provider.name} Url` : `Open on ${remote.provider.name}`,
detail: `$(repo) ${remote.provider.path}`,
label: `$(repo) ${remote.provider.path}`,
description: remote.name,
});
}
@ -48,6 +48,17 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem {
if (GitBranch.getRemote(resource.compare) === this.remote.name) {
resource = { ...resource, compare: GitBranch.getNameWithoutRemote(resource.compare) };
}
} else if (resource.type === RemoteResourceType.CreatePullRequest) {
let branch = resource.base.branch;
if (branch == null && this.remote.provider.hasApi()) {
const defaultBranch = await this.remote.provider.getDefaultBranch?.();
branch = defaultBranch?.name;
}
resource = {
...resource,
base: { branch: branch, remote: { path: this.remote.path, url: this.remote.url } },
};
} else if (
resource.type === RemoteResourceType.File &&
resource.branchOrTag != null &&
@ -147,14 +158,14 @@ export namespace RemoteProviderPicker {
placeHolder: string,
resource: RemoteResource,
remotes: GitRemote<RemoteProvider>[],
options?: { clipboard?: boolean; setDefault?: boolean },
options?: { autoPick?: 'default' | boolean; clipboard?: boolean; setDefault?: boolean },
): Promise<
| ConfigureCustomRemoteProviderCommandQuickPickItem
| CopyOrOpenRemoteCommandQuickPickItem
| SetADefaultRemoteCommandQuickPickItem
| undefined
> {
const { clipboard, setDefault } = { clipboard: false, setDefault: true, ...options };
const { autoPick, clipboard, setDefault } = { autoPick: false, clipboard: false, setDefault: true, ...options };
let items: (
| ConfigureCustomRemoteProviderCommandQuickPickItem
@ -163,15 +174,25 @@ export namespace RemoteProviderPicker {
)[];
if (remotes.length === 0) {
items = [new ConfigureCustomRemoteProviderCommandQuickPickItem()];
//
placeHolder = 'No auto-detected or configured remote providers found';
} else {
if (autoPick === 'default') {
// If there is a default just execute it directly
const remote = remotes.find(r => r.default);
if (remote != null) {
remotes = [remote];
}
}
items = remotes.map(r => new CopyOrOpenRemoteCommandQuickPickItem(r, resource, clipboard));
if (setDefault) {
items.push(new SetADefaultRemoteCommandQuickPickItem(remotes));
}
}
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (autoPick && remotes.length === 1) return items[0];
const quickpick = window.createQuickPick<
| ConfigureCustomRemoteProviderCommandQuickPickItem
| CopyOrOpenRemoteCommandQuickPickItem

Loading…
Cancel
Save