From 7f622925f420e2260c0629fd5a5d39afd9533190 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Wed, 1 Dec 2021 14:56:12 -0500 Subject: [PATCH] Adds autolinked issues to comparisons --- package.json | 54 +++++++++++++- src/annotations/autolinks.ts | 6 +- src/commands.ts | 1 + src/commands/common.ts | 2 + src/commands/openIssueOnRemote.ts | 36 ++++++++++ src/git/models/issue.ts | 50 ++++++++++++- src/github/github.ts | 49 +++++++------ src/views/nodes/autolinkedItemNode.ts | 69 ++++++++++++++++++ src/views/nodes/autolinkedItemsNode.ts | 98 ++++++++++++++++++++++++++ src/views/nodes/resultsCommitsNode.ts | 6 ++ src/views/nodes/viewNode.ts | 2 + 11 files changed, 345 insertions(+), 28 deletions(-) create mode 100644 src/commands/openIssueOnRemote.ts create mode 100644 src/views/nodes/autolinkedItemNode.ts create mode 100644 src/views/nodes/autolinkedItemsNode.ts diff --git a/package.json b/package.json index a1ea742ace826..ca397df49d039 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,8 @@ "onCommand:gitlens.openBlamePriorToChange", "onCommand:gitlens.openFileRevision", "onCommand:gitlens.openFileRevisionFrom", + "onCommand:gitlens.openIssueOnRemote", + "onCommand:gitlens.copyRemoteIssueUrl", "onCommand:gitlens.openPullRequestOnRemote", "onCommand:gitlens.copyRemotePullRequestUrl", "onCommand:gitlens.openAssociatedPullRequestOnRemote", @@ -3397,6 +3399,24 @@ "dark": "#c74e39", "highContrast": "#c74e39" } + }, + { + "id": "gitlens.autolinkedIssueOpenIconColor", + "defaults": { + "dark": "#22863a", + "light": "#22863a", + "highContrast": "editor.foreground" + }, + "description": "Specifies the icon color indicating that an issue is open" + }, + { + "id": "gitlens.autolinkedIssueClosedIconColor", + "defaults": { + "dark": "#cb2431", + "light": "#cb2431", + "highContrast": "editor.foreground" + }, + "description": "Specifies the icon color indicating that an issue is closed" } ], "commands": [ @@ -4116,6 +4136,18 @@ }, "category": "GitLens" }, + { + "command": "gitlens.openIssueOnRemote", + "title": "Open Issue on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.copyRemoteIssueUrl", + "title": "Copy Issue Url", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.openPullRequestOnRemote", "title": "Open Pull Request on Remote", @@ -5949,6 +5981,14 @@ "command": "gitlens.copyRemoteComparisonUrl", "when": "false" }, + { + "command": "gitlens.openIssueOnRemote", + "when": "false" + }, + { + "command": "gitlens.copyRemoteIssueUrl", + "when": "false" + }, { "command": "gitlens.openPullRequestOnRemote", "when": "false" @@ -8469,6 +8509,18 @@ "group": "5_gitlens_open@2", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, + { + "command": "gitlens.openIssueOnRemote", + "when": "viewItem =~ /gitlens:autolinked:issue\\b/", + "group": "inline@99", + "alt": "gitlens.copyRemoteIssueUrl" + }, + { + "command": "gitlens.openIssueOnRemote", + "when": "viewItem =~ /gitlens:autolinked:issue\\b/", + "group": "1_gitlens_actions@99", + "alt": "gitlens.copyRemoteIssueUrl" + }, { "command": "gitlens.views.openPullRequest", "when": "gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", @@ -9070,7 +9122,7 @@ }, { "command": "gitlens.views.copy", - "when": "viewItem =~ /gitlens:(?=(branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag)\\b)/", + "when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag)\\b)/", "group": "7_gitlens_cutcopypaste@1" }, { diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index dd2ac7f26be80..182c9fedb5912 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -191,9 +191,9 @@ export class Autolinks implements Disposable { index = footnotes.size + 1; footnotes.set( index, - `[**${ - issue.type === 'PullRequest' ? '$(git-pull-request)' : '$(info)' - } ${issueTitle}**](${issueUrl}${title}")\\\n${GlyphChars.Space.repeat( + `${IssueOrPullRequest.getMarkdownIcon( + issue, + )} [**${issueTitle}**](${issueUrl}${title}")\\\n${GlyphChars.Space.repeat( 5, )}${linkText} ${issue.closed ? 'closed' : 'opened'} ${Dates.getFormatter( issue.closedDate ?? issue.date, diff --git a/src/commands.ts b/src/commands.ts index c6924d5e51370..6eae144f698c5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -39,6 +39,7 @@ export * from './commands/openFileOnRemote'; export * from './commands/openFileAtRevision'; export * from './commands/openFileAtRevisionFrom'; export * from './commands/openOnRemote'; +export * from './commands/openIssueOnRemote'; export * from './commands/openPullRequestOnRemote'; export * from './commands/openRepoOnRemote'; export * from './commands/openRevisionFile'; diff --git a/src/commands/common.ts b/src/commands/common.ts index 41b1c0d9afa29..f02d910114568 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -49,6 +49,7 @@ export const enum Commands { CopyRemoteFileUrl = 'gitlens.copyRemoteFileUrlToClipboard', CopyRemoteFileUrlWithoutRange = 'gitlens.copyRemoteFileUrlWithoutRange', CopyRemoteFileUrlFrom = 'gitlens.copyRemoteFileUrlFrom', + CopyRemoteIssueUrl = 'gitlens.copyRemoteIssueUrl', CopyRemotePullRequestUrl = 'gitlens.copyRemotePullRequestUrl', CopyRemoteRepositoryUrl = 'gitlens.copyRemoteRepositoryUrl', CopyShaToClipboard = 'gitlens.copyShaToClipboard', @@ -92,6 +93,7 @@ export const enum Commands { OpenFileAtRevisionFrom = 'gitlens.openFileRevisionFrom', OpenFolderHistory = 'gitlens.openFolderHistory', OpenOnRemote = 'gitlens.openOnRemote', + OpenIssueOnRemote = 'gitlens.openIssueOnRemote', OpenPullRequestOnRemote = 'gitlens.openPullRequestOnRemote', OpenAssociatedPullRequestOnRemote = 'gitlens.openAssociatedPullRequestOnRemote', OpenRepoOnRemote = 'gitlens.openRepoOnRemote', diff --git a/src/commands/openIssueOnRemote.ts b/src/commands/openIssueOnRemote.ts new file mode 100644 index 0000000000000..f9f0eb3a7b150 --- /dev/null +++ b/src/commands/openIssueOnRemote.ts @@ -0,0 +1,36 @@ +'use strict'; +import { env, Uri } from 'vscode'; +import { AutolinkedItemNode } from '../views/nodes/autolinkedItemNode'; +import { Command, command, CommandContext, Commands } from './common'; + +export interface OpenIssueOnRemoteCommandArgs { + clipboard?: boolean; + issue: { url: string }; +} + +@command() +export class OpenIssueOnRemoteCommand extends Command { + constructor() { + super([Commands.OpenIssueOnRemote, Commands.CopyRemoteIssueUrl]); + } + + protected override preExecute(context: CommandContext, args: OpenIssueOnRemoteCommandArgs) { + if (context.type === 'viewItem' && context.node instanceof AutolinkedItemNode) { + args = { + ...args, + issue: { url: context.node.issue.url }, + clipboard: context.command === Commands.CopyRemotePullRequestUrl, + }; + } + + return this.execute(args); + } + + async execute(args: OpenIssueOnRemoteCommandArgs) { + if (args.clipboard) { + void (await env.clipboard.writeText(args.issue.url)); + } else { + void env.openExternal(Uri.parse(args.issue.url)); + } + } +} diff --git a/src/git/models/issue.ts b/src/git/models/issue.ts index 88954aec5fffb..ae5f7c349cb89 100644 --- a/src/git/models/issue.ts +++ b/src/git/models/issue.ts @@ -1,12 +1,58 @@ 'use strict'; +import { ColorThemeKind, ThemeColor, ThemeIcon, window } from 'vscode'; +import { Colors } from '../../constants'; import { RemoteProviderReference } from './remoteProvider'; +export const enum IssueOrPullRequestType { + Issue = 'Issue', + PullRequest = 'PullRequest', +} + export interface IssueOrPullRequest { - type: 'Issue' | 'PullRequest'; + type: IssueOrPullRequestType; provider: RemoteProviderReference; - id: number; + id: string; date: Date; title: string; closed: boolean; closedDate?: Date; + url: string; +} + +export namespace IssueOrPullRequest { + export function getMarkdownIcon(issue: IssueOrPullRequest): string { + if (issue.type === IssueOrPullRequestType.PullRequest) { + if (issue.closed) { + return `$(git-pull-request)`; + } + return `$(git-pull-request)`; + } + + if (issue.closed) { + return `$(pass)`; + } + return `$(issue)`; + } + + export function getThemeIcon(issue: IssueOrPullRequest): ThemeIcon { + if (issue.type === IssueOrPullRequestType.PullRequest) { + if (issue.closed) { + return new ThemeIcon('git-pull-request', new ThemeColor(Colors.MergedPullRequestIconColor)); + } + return new ThemeIcon('git-pull-request', new ThemeColor(Colors.OpenPullRequestIconColor)); + } + + if (issue.closed) { + return new ThemeIcon('pass', new ThemeColor(Colors.MergedPullRequestIconColor)); + } + return new ThemeIcon('issues', new ThemeColor(Colors.OpenPullRequestIconColor)); + } } diff --git a/src/github/github.ts b/src/github/github.ts index f5dfe9f04de17..0902133fbb43f 100644 --- a/src/github/github.ts +++ b/src/github/github.ts @@ -5,6 +5,7 @@ import { ClientError, DefaultBranch, IssueOrPullRequest, + IssueOrPullRequestType, PullRequest, PullRequestState, RichRemoteProvider, @@ -244,28 +245,30 @@ export class GitHubApi { try { const query = `query getIssueOrPullRequest( - $owner: String! - $repo: String! - $number: Int! -) { - repository(name: $repo, owner: $owner) { - issueOrPullRequest(number: $number) { - __typename - ... on Issue { - createdAt - closed - closedAt - title - } - ... on PullRequest { - createdAt - closed - closedAt - title + $owner: String! + $repo: String! + $number: Int! + ) { + repository(name: $repo, owner: $owner) { + issueOrPullRequest(number: $number) { + __typename + ... on Issue { + createdAt + closed + closedAt + title + url + } + ... on PullRequest { + createdAt + closed + closedAt + title + url + } } } - } -}`; + }`; const rsp = await graphql(query, { ...options, @@ -281,11 +284,12 @@ export class GitHubApi { return { provider: provider, type: issue.type, - id: number, + id: String(number), date: new Date(issue.createdAt), title: issue.title, closed: issue.closed, closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), + url: issue.url, }; } catch (ex) { Logger.error(ex, cc); @@ -505,12 +509,13 @@ export class GitHubApi { } interface GitHubIssueOrPullRequest { - type: 'Issue' | 'PullRequest'; + type: IssueOrPullRequestType; number: number; createdAt: string; closed: boolean; closedAt: string | null; title: string; + url: string; } type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; diff --git a/src/views/nodes/autolinkedItemNode.ts b/src/views/nodes/autolinkedItemNode.ts new file mode 100644 index 0000000000000..7243cee5cd3f7 --- /dev/null +++ b/src/views/nodes/autolinkedItemNode.ts @@ -0,0 +1,69 @@ +'use strict'; +import { MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitFile, IssueOrPullRequest, IssueOrPullRequestType } from '../../git/git'; +import { GitUri } from '../../git/gitUri'; +import { Dates } from '../../system'; +import { ViewsWithCommits } from '../viewBase'; +import { ContextValues, ViewNode } from './viewNode'; + +export interface FilesQueryResults { + label: string; + files: GitFile[] | undefined; + filtered?: { + filter: 'left' | 'right'; + files: GitFile[]; + }; +} + +export class AutolinkedItemNode extends ViewNode { + constructor( + view: ViewsWithCommits, + parent: ViewNode, + public readonly repoPath: string, + public readonly issue: IssueOrPullRequest, + ) { + super(GitUri.fromRepoPath(repoPath), view, parent); + } + + override toClipboard(): string { + return this.issue.url; + } + + override get id(): string { + return `${this.parent!.id!}:item(${this.issue.id})`; + } + + getChildren(): ViewNode[] { + return []; + } + + getTreeItem(): TreeItem { + const formatter = Dates.getFormatter(this.issue.closedDate ?? this.issue.date); + + const item = new TreeItem(`${this.issue.id}: ${this.issue.title}`, TreeItemCollapsibleState.None); + item.description = formatter.fromNow(); + item.iconPath = IssueOrPullRequest.getThemeIcon(this.issue); + item.contextValue = + this.issue.type === IssueOrPullRequestType.PullRequest + ? ContextValues.PullRequest + : ContextValues.AutolinkedIssue; + + const linkTitle = ` "Open ${ + this.issue.type === IssueOrPullRequestType.PullRequest ? 'Pull Request' : 'Issue' + } \\#${this.issue.id} on ${this.issue.provider.name}"`; + const tooltip = new MarkdownString( + `${IssueOrPullRequest.getMarkdownIcon(this.issue)} [**${this.issue.title}**](${ + this.issue.url + }${linkTitle}) \\\n[#${this.issue.id}](${this.issue.url}${linkTitle}) was ${ + this.issue.closed ? 'closed' : 'opened' + } ${formatter.fromNow()}`, + true, + ); + tooltip.supportHtml = true; + tooltip.isTrusted = true; + + item.tooltip = tooltip; + + return item; + } +} diff --git a/src/views/nodes/autolinkedItemsNode.ts b/src/views/nodes/autolinkedItemsNode.ts new file mode 100644 index 0000000000000..4d278d46a1bf1 --- /dev/null +++ b/src/views/nodes/autolinkedItemsNode.ts @@ -0,0 +1,98 @@ +'use strict'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitFile, GitLog, GitRemote, IssueOrPullRequest, PullRequest, RichRemoteProvider } from '../../git/git'; +import { GitUri } from '../../git/gitUri'; +import { debug, gate, Promises } from '../../system'; +import { ViewsWithCommits } from '../viewBase'; +import { AutolinkedItemNode } from './autolinkedItemNode'; +import { MessageNode } from './common'; +import { PullRequestNode } from './pullRequestNode'; +import { ContextValues, ViewNode } from './viewNode'; + +export interface FilesQueryResults { + label: string; + files: GitFile[] | undefined; + filtered?: { + filter: 'left' | 'right'; + files: GitFile[]; + }; +} + +export class AutolinkedItemsNode extends ViewNode { + private _children: ViewNode[] | undefined; + + constructor( + view: ViewsWithCommits, + parent: ViewNode, + public readonly repoPath: string, + public readonly remote: GitRemote, + public readonly log: GitLog, + ) { + super(GitUri.fromRepoPath(repoPath), view, parent); + } + + override get id(): string { + return `${this.parent!.id}:results:autolinked`; + } + + async getChildren(): Promise { + if (this._children == null) { + const commits = [...this.log.commits.values()]; + + let children: ViewNode[] | undefined; + if (commits.length) { + const combineMessages = commits.map(c => c.message).join('\n'); + + const [autolinkedMapResult, ...prsResults] = await Promise.allSettled([ + this.view.container.autolinks.getIssueOrPullRequestLinks(combineMessages, this.remote), + ...commits.map(c => this.remote.provider.getPullRequestForCommit(c.sha)), + ]); + + const items = new Map(); + + if (autolinkedMapResult.status === 'fulfilled' && autolinkedMapResult.value != null) { + for (const [id, issue] of autolinkedMapResult.value) { + if (issue == null || issue instanceof Promises.CancellationErrorWithId) continue; + + items.set(id, issue); + } + } + + for (const result of prsResults) { + if (result.status !== 'fulfilled' || result.value == null) continue; + + items.set(result.value.id, result.value); + } + + children = [...items.values()].map(item => + PullRequest.is(item) + ? new PullRequestNode(this.view, this, item, this.log.repoPath) + : new AutolinkedItemNode(this.view, this, this.repoPath, item), + ); + } + + if (children == null || children.length === 0) { + children = [new MessageNode(this.view, this, 'No autolinked issues or pull requests could be found.')]; + } + + this._children = children; + } + return this._children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Autolinked Issues and Pull Requests', TreeItemCollapsibleState.Collapsed); + item.id = this.id; + item.contextValue = ContextValues.AutolinkedItems; + + return item; + } + + @gate() + @debug() + override refresh(reset: boolean = false) { + if (!reset) return; + + this._children = undefined; + } +} diff --git a/src/views/nodes/resultsCommitsNode.ts b/src/views/nodes/resultsCommitsNode.ts index c930727ec31c0..289c45f016f1f 100644 --- a/src/views/nodes/resultsCommitsNode.ts +++ b/src/views/nodes/resultsCommitsNode.ts @@ -4,6 +4,7 @@ import { GitLog } from '../../git/git'; import { GitUri } from '../../git/gitUri'; import { debug, gate, Iterables, Promises } from '../../system'; import { ViewsWithCommits } from '../viewBase'; +import { AutolinkedItemsNode } from './autolinkedItemsNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode } from './common'; import { insertDateMarkers } from './helpers'; @@ -71,6 +72,11 @@ export class ResultsCommitsNode