From d43571779d372f67ecf79172c0c1637e045d9ff5 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Wed, 14 Oct 2020 02:51:41 -0400 Subject: [PATCH] Adds provider-based avatar support --- src/annotations/gutterBlameAnnotationProvider.ts | 10 +- src/avatars.ts | 214 +++++++++++++++-- src/constants.ts | 1 + src/git/formatters/commitFormatter.ts | 2 +- src/git/gitService.ts | 2 + src/git/models/author.ts | 7 + src/git/models/commit.ts | 12 +- src/git/models/contributor.ts | 12 +- src/git/models/models.ts | 1 + src/git/remotes/github.ts | 30 ++- src/git/remotes/provider.ts | 75 +++++- src/github/github.ts | 285 +++++++++++++++++------ src/system/string.ts | 4 + src/views/contributorsView.ts | 10 +- src/views/nodes/branchNode.ts | 1 - src/views/nodes/commitFileNode.ts | 2 +- src/views/nodes/commitNode.ts | 4 +- src/views/nodes/contributorNode.ts | 6 +- src/views/nodes/contributorsNode.ts | 4 + src/webviews/rebaseEditor.ts | 8 +- 20 files changed, 582 insertions(+), 108 deletions(-) create mode 100644 src/git/models/author.ts diff --git a/src/annotations/gutterBlameAnnotationProvider.ts b/src/annotations/gutterBlameAnnotationProvider.ts index 5836fb6..208302a 100644 --- a/src/annotations/gutterBlameAnnotationProvider.ts +++ b/src/annotations/gutterBlameAnnotationProvider.ts @@ -144,7 +144,7 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { decorationOptions.push(gutter); if (avatars && commit.email != null) { - this.applyAvatarDecoration(commit, gutter, gravatarDefault, avatarDecorationsMap!); + await this.applyAvatarDecoration(commit, gutter, gravatarDefault, avatarDecorationsMap!); } decorationsMap.set(l.sha, gutter); @@ -202,7 +202,7 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { this.editor.setDecorations(Decorations.gutterBlameHighlight, highlightDecorationRanges); } - private applyAvatarDecoration( + private async applyAvatarDecoration( commit: GitBlameCommit, gutter: DecorationOptions, gravatarDefault: GravatarDefaultStyle, @@ -211,10 +211,12 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { let avatarDecoration = map.get(commit.email!); if (avatarDecoration == null) { avatarDecoration = { - contentIconPath: commit.getAvatarUri(gravatarDefault), + contentText: '', height: '16px', width: '16px', - textDecoration: 'none;position:absolute;top:1px;left:5px', + textDecoration: `none;position:absolute;top:1px;left:5px;background:url(${( + await commit.getAvatarUri(true, { fallback: gravatarDefault }) + ).toString()});background-size:16px 16px`, }; map.set(commit.email!, avatarDecoration); } diff --git a/src/avatars.ts b/src/avatars.ts index 90b183e..02aa31b 100644 --- a/src/avatars.ts +++ b/src/avatars.ts @@ -1,20 +1,75 @@ 'use strict'; import * as fs from 'fs'; -import { Uri } from 'vscode'; +import { EventEmitter, Uri } from 'vscode'; import { GravatarDefaultStyle } from './config'; -import { Strings } from './system'; -import { ContactPresenceStatus } from './vsls/vsls'; +import { WorkspaceState } from './constants'; import { Container } from './container'; +import { GitRevisionReference } from './git/git'; +import { Functions, Strings } from './system'; +import { ContactPresenceStatus } from './vsls/vsls'; + +// TODO@eamodio Use timestamp +// TODO@eamodio Clear avatar cache on remote / provider connection change + +interface Avatar { + uri?: T | null; + fallback: T; + timestamp: number; + // TODO@eamodio Add a fail count, to avoid failing on a single failure +} + +type SerializedAvatar = Avatar; + +let avatarCache: Map | undefined; +const avatarQueue = new Map | null>(); -const avatarCache = new Map(); const missingGravatarHash = '00000000000000000000000000000000'; const presenceCache = new Map(); const gitHubNoReplyAddressRegex = /^(?:(?\d+)\+)?(?[a-zA-Z\d-]{1,39})@users\.noreply\.github\.com$/; +const _onDidFetchAvatar = new EventEmitter<{ email: string }>(); +export const onDidFetchAvatar = _onDidFetchAvatar.event; + +onDidFetchAvatar( + Functions.debounce(() => { + void Container.context.workspaceState.update( + WorkspaceState.Avatars, + avatarCache == null + ? undefined + : [...avatarCache.entries()].map<[string, SerializedAvatar]>(([key, value]) => [ + key, + { + uri: value.uri != null ? value.uri.toString() : value.uri, + fallback: value.fallback.toString(), + timestamp: value.timestamp, + }, + ]), + ); + }, 5000), +); + export function clearAvatarCache() { - avatarCache.clear(); + avatarCache?.clear(); + avatarQueue.clear(); + void Container.context.workspaceState.update(WorkspaceState.Avatars, undefined); +} + +function ensureAvatarCache(cache: Map | undefined): asserts cache is Map { + if (cache == null) { + const avatars: [string, Avatar][] | undefined = Container.context.workspaceState + .get<[string, SerializedAvatar][]>(WorkspaceState.Avatars) + ?.map<[string, Avatar]>(([key, value]) => [ + key, + { + uri: value.uri != null ? Uri.parse(value.uri) : value.uri, + fallback: Uri.parse(value.fallback), + timestamp: value.timestamp, + }, + ]); + avatarCache = new Map(avatars); + } } function getAvatarUriFromGitHubNoReplyAddress(email: string | undefined, size: number = 16): Uri | undefined { @@ -27,25 +82,154 @@ function getAvatarUriFromGitHubNoReplyAddress(email: string | undefined, size: n return Uri.parse(`https://avatars.githubusercontent.com/${userId ? `u/${userId}` : userName}?size=${size}`); } -export function getAvatarUri(email: string | undefined, fallback: GravatarDefaultStyle, size: number = 16): Uri { - const hash = - email != null && email.length !== 0 ? Strings.md5(email.trim().toLowerCase(), 'hex') : missingGravatarHash; +export function getAvatarUri( + email: string | undefined, + repoPathOrCommit: string | GitRevisionReference | undefined, + wait: false, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, +): Uri; +export function getAvatarUri( + email: string | undefined, + repoPathOrCommit: string | GitRevisionReference | undefined, + wait: true, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, +): Promise; +export function getAvatarUri( + email: string | undefined, + repoPathOrCommit: string | GitRevisionReference | undefined, + wait: boolean, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, +): Uri | Promise; +export function getAvatarUri( + email: string | undefined, + repoPathOrCommit: string | GitRevisionReference | undefined, + wait: boolean, + { fallback, listener, size = 16 }: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number } = {}, +): Uri | Promise { + ensureAvatarCache(avatarCache); + + if (email == null || email.length === 0) { + const key = `${missingGravatarHash}:${size}`; + let avatar = avatarCache.get(key); + if (avatar == null) { + avatar = { + fallback: Uri.parse( + `https://www.gravatar.com/avatar/${missingGravatarHash}.jpg?s=${size}&d=${fallback}`, + ), + timestamp: Date.now(), + }; + avatarCache.set(key, avatar); + } + + return avatar.uri ?? avatar.fallback; + } + + const hash = Strings.md5(email.trim().toLowerCase(), 'hex'); const key = `${hash}:${size}`; + let avatar = avatarCache.get(key); - if (avatar !== undefined) return avatar; + if (avatar == null) { + avatar = { + fallback: + getAvatarUriFromGitHubNoReplyAddress(email, size) ?? + Uri.parse(`https://www.gravatar.com/avatar/${hash}.jpg?s=${size}&d=${fallback}`), + timestamp: Date.now(), + }; + avatarCache.set(key, avatar); + } - avatar = - getAvatarUriFromGitHubNoReplyAddress(email, size) ?? - Uri.parse(`https://www.gravatar.com/avatar/${hash}.jpg?s=${size}&d=${fallback}`); - avatarCache.set(key, avatar); + let query = avatarQueue.get(key); + if (query == null && avatar.uri === undefined && repoPathOrCommit != null) { + query = getRemoteProviderAvatarUri(key, email, repoPathOrCommit, avatar.fallback, { size: size }); + avatarQueue.set(key, query); - return avatar; + void signalOnAvatarQueryComplete(email, query, listener, true); + } else if (query != null) { + void signalOnAvatarQueryComplete(email, query, listener, false); + } + + if (wait && query != null) { + return query.then(value => value ?? avatar!.uri ?? avatar!.fallback); + } + + return avatar.uri ?? avatar.fallback; +} + +async function getRemoteProviderAvatarUri( + key: string, + email: string, + repoPathOrCommit: string | GitRevisionReference, + fallback: Uri, + { size = 16 }: { size?: number } = {}, +) { + ensureAvatarCache(avatarCache); + + try { + let account; + // if (typeof repoPathOrCommit === 'string') { + // const remote = await Container.git.getRemoteWithApiProvider(repoPathOrCommit); + // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size }); + // } else { + if (typeof repoPathOrCommit !== 'string') { + const remote = await Container.git.getRemoteWithApiProvider(repoPathOrCommit.repoPath); + account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size }); + } + if (account == null) { + avatarCache.set(key, { uri: null, fallback: fallback, timestamp: Date.now() }); + + return undefined; + } + + const uri = Uri.parse(account.avatarUrl); + avatarCache.set(key, { uri: uri, fallback: fallback, timestamp: Date.now() }); + if (account.email != null && Strings.equalsIgnoreCase(email, account.email)) { + avatarCache.set(`${Strings.md5(account.email.trim().toLowerCase(), 'hex')}:${size}`, { + uri: uri, + fallback: fallback, + timestamp: Date.now(), + }); + } + + return uri; + } catch { + avatarCache.set(key, { uri: null, fallback: fallback, timestamp: Date.now() }); + + return undefined; + } finally { + avatarQueue.delete(key); + } +} + +async function signalOnAvatarQueryComplete( + email: string, + query: Promise, + listener: (() => void) | undefined, + fire: boolean, +) { + if (listener == null) { + if (fire) { + _onDidFetchAvatar.fire({ email: email }); + } + + return; + } + + const disposable = onDidFetchAvatar(listener); + try { + await query; + + if (fire) { + _onDidFetchAvatar.fire({ email: email }); + } + } finally { + disposable.dispose(); + } } export function getPresenceDataUri(status: ContactPresenceStatus) { let dataUri = presenceCache.get(status); - if (dataUri === undefined) { + if (dataUri == null) { const contents = fs .readFileSync(Container.context.asAbsolutePath(`images/dark/icon-presence-${status}.svg`)) .toString('base64'); diff --git a/src/constants.ts b/src/constants.ts index b1efc2f..187cbbd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -182,6 +182,7 @@ export interface StarredRepositories { } export enum WorkspaceState { + Avatars = 'gitlens:avatars', BranchComparisons = 'gitlens:branch:comparisons', DefaultRemote = 'gitlens:remote:default', PinnedComparisons = 'gitlens:pinned:comparisons', diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index 2e5ac53..5d1d279 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -214,7 +214,7 @@ export class CommitFormatter extends Formatter { private _getAvatarMarkdown(title: string) { const size = Container.config.hovers.avatarSize; return `![${title}](${this._item - .getAvatarUri(Container.config.defaultGravatarsStyle, size) + .getAvatarUri(false, { fallback: Container.config.defaultGravatarsStyle, size: size }) .toString(true)}|width=${size},height=${size} "${title}")`; } diff --git a/src/git/gitService.ts b/src/git/gitService.ts index a47a7a4..6f9cc3e 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -2799,6 +2799,8 @@ export class GitService implements Disposable { ): Promise | undefined> { if (remotesOrRepoPath == null) return undefined; + // TODO@eamodio Add caching to avoid constant lookups + const remotes = (typeof remotesOrRepoPath === 'string' ? await this.getRemotes(remotesOrRepoPath) : remotesOrRepoPath diff --git a/src/git/models/author.ts b/src/git/models/author.ts new file mode 100644 index 0000000..185f72c --- /dev/null +++ b/src/git/models/author.ts @@ -0,0 +1,7 @@ +'use strict'; +export interface Account { + provider: string; + name: string | undefined; + email: string | undefined; + avatarUrl: string; +} diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index bdb36bc..dbd8700 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -225,8 +225,16 @@ export abstract class GitCommit implements GitRevisionReference { return GitUri.getFormattedPath(this.fileName, options); } - getAvatarUri(fallback: GravatarDefaultStyle, size: number = 16): Uri { - return getAvatarUri(this.email, fallback, size); + getAvatarUri(wait: false, options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }): Uri; + getAvatarUri( + wait: true, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, + ): Promise; + getAvatarUri( + wait: boolean, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, + ): Uri | Promise { + return getAvatarUri(this.email, this, wait, options); } @memoize() diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts index 477a189..bbee8e6 100644 --- a/src/git/models/contributor.ts +++ b/src/git/models/contributor.ts @@ -20,8 +20,16 @@ export class GitContributor { public readonly current: boolean = false, ) {} - getAvatarUri(fallback: GravatarDefaultStyle, size: number = 16): Uri { - return getAvatarUri(this.email, fallback, size); + getAvatarUri(wait: false, options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }): Uri; + getAvatarUri( + wait: true, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, + ): Promise; + getAvatarUri( + wait: boolean, + options?: { fallback?: GravatarDefaultStyle; listener?: () => void; size?: number }, + ): Uri | Promise { + return getAvatarUri(this.email, undefined /*this.repoPath*/, wait, options); } toCoauthor(): string { diff --git a/src/git/models/models.ts b/src/git/models/models.ts index 36e3682..c037b11 100644 --- a/src/git/models/models.ts +++ b/src/git/models/models.ts @@ -309,6 +309,7 @@ export namespace GitReference { } } +export * from './author'; export * from './blame'; export * from './blameCommit'; export * from './branch'; diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 7ff026e..0e3c913 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -5,7 +5,7 @@ import { AutolinkReference } from '../../config'; import { Container } from '../../container'; import { GitHubPullRequest } from '../../github/github'; import { IssueOrPullRequest } from '../models/issue'; -import { GitRevision } from '../models/models'; +import { Account, GitRevision } from '../models/models'; import { PullRequest, PullRequestState } from '../models/pullRequest'; import { Repository } from '../models/repository'; import { RemoteProviderWithApi } from './provider'; @@ -155,6 +155,34 @@ export class GitHubRemote extends RemoteProviderWithApi { return `${this.baseUrl}?path=${fileName}${line}`; } + protected async onGetAccountForCommit( + { accessToken }: AuthenticationSession, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const [owner, repo] = this.splitPath(); + return (await Container.github)?.getAccountForCommit(this.name, accessToken, owner, repo, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected async onGetAccountForEmail( + { accessToken }: AuthenticationSession, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const [owner, repo] = this.splitPath(); + return (await Container.github)?.getAccountForEmail(this.name, accessToken, owner, repo, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + protected async onGetIssueOrPullRequest( { accessToken }: AuthenticationSession, id: string, diff --git a/src/git/remotes/provider.ts b/src/git/remotes/provider.ts index a8f9900..46cc2a9 100644 --- a/src/git/remotes/provider.ts +++ b/src/git/remotes/provider.ts @@ -15,10 +15,7 @@ import { AutolinkReference } from '../../config'; import { Container } from '../../container'; import { Logger } from '../../logger'; import { Messages } from '../../messages'; -import { IssueOrPullRequest } from '../models/issue'; -import { GitLogCommit } from '../models/logCommit'; -import { PullRequest, PullRequestState } from '../models/pullRequest'; -import { Repository } from '../models/repository'; +import { Account, GitLogCommit, IssueOrPullRequest, PullRequest, PullRequestState, Repository } from '../models/models'; import { debug, gate, log, Promises } from '../../system'; const _onDidChangeAuthentication = new EventEmitter<{ reason: 'connected' | 'disconnected'; key: string }>(); @@ -305,6 +302,60 @@ export abstract class RemoteProviderWithApi extends RemoteProvider { @gate() @debug() + async getAccountForCommit( + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.onGetAccountForCommit(this._session!, ref, options); + this.invalidAuthenticationCount = 0; + return author; + } catch (ex) { + Logger.error(ex, cc); + + if (ex instanceof AuthenticationError) { + this.handleAuthenticationException(); + } + return undefined; + } + } + + @gate() + @debug() + async getAccountForEmail( + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.onGetAccountForEmail(this._session!, email, options); + this.invalidAuthenticationCount = 0; + return author; + } catch (ex) { + Logger.error(ex, cc); + + if (ex instanceof AuthenticationError) { + this.handleAuthenticationException(); + } + return undefined; + } + } + + @gate() + @debug() async getIssueOrPullRequest(id: string): Promise { const cc = Logger.getCorrelationContext(); @@ -371,6 +422,22 @@ export abstract class RemoteProviderWithApi extends RemoteProvider { protected abstract get authProvider(): { id: string; scopes: string[] }; + protected abstract onGetAccountForCommit( + session: AuthenticationSession, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + protected abstract onGetAccountForEmail( + session: AuthenticationSession, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise; + protected abstract onGetIssueOrPullRequest( session: AuthenticationSession, id: string, diff --git a/src/github/github.ts b/src/github/github.ts index ceb4c71..000ef4b 100644 --- a/src/github/github.ts +++ b/src/github/github.ts @@ -3,6 +3,7 @@ import { graphql } from '@octokit/graphql'; import { Logger } from '../logger'; import { debug } from '../system'; import { AuthenticationError, IssueOrPullRequest, PullRequest, PullRequestState } from '../git/git'; +import { Account } from '../git/models/author'; export class GitHubApi { @debug({ @@ -10,6 +11,222 @@ export class GitHubApi { 1: _ => '', }, }) + async getAccountForCommit( + provider: string, + token: string, + owner: string, + repo: string, + ref: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + try { + const query = `query ($owner: String!, $repo: String!, $ref: GitObjectID!, $avatarSize: Int) { + repository(name: $repo, owner: $owner) { + object(oid: $ref) { + ... on Commit { + author { + name + email + avatarUrl(size: $avatarSize) + } + } + } + } +}`; + + const rsp = await graphql<{ + repository: + | { + object: + | { + author?: { + name: string | null; + email: string | null; + avatarUrl: string; + }; + } + | null + | undefined; + } + | null + | undefined; + }>(query, { + owner: owner, + repo: repo, + ref: ref, + headers: { authorization: `Bearer ${token}` }, + ...options, + }); + + const author = rsp?.repository?.object?.author; + if (author == null) return undefined; + + return { + provider: provider, + name: author.name ?? undefined, + email: author.email ?? undefined, + avatarUrl: author.avatarUrl, + }; + } catch (ex) { + Logger.error(ex, cc); + + if (ex.code === 401) { + throw new AuthenticationError(ex); + } + throw ex; + } + } + + @debug({ + args: { + 1: _ => '', + }, + }) + async getAccountForEmail( + provider: string, + token: string, + owner: string, + repo: string, + email: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + try { + const query = `query ($emailQuery: String!, $avatarSize: Int) { + search(type: USER, query: $emailQuery, first: 1) { + nodes { + ... on User { + name + email + avatarUrl(size: $avatarSize) + } + } + } +}`; + + const rsp = await graphql<{ + search: + | { + nodes: + | { + name: string | null; + email: string | null; + avatarUrl: string; + }[] + | null + | undefined; + } + | null + | undefined; + }>(query, { + owner: owner, + repo: repo, + emailQuery: `in:email ${email}`, + headers: { authorization: `Bearer ${token}` }, + ...options, + }); + + const author = rsp?.search?.nodes?.[0]; + if (author == null) return undefined; + + return { + provider: provider, + name: author.name ?? undefined, + email: author.email ?? undefined, + avatarUrl: author.avatarUrl, + }; + } catch (ex) { + Logger.error(ex, cc); + + if (ex.code === 401) { + throw new AuthenticationError(ex); + } + throw ex; + } + } + + @debug({ + args: { + 1: _ => '', + }, + }) + async getIssueOrPullRequest( + provider: string, + token: string, + owner: string, + repo: string, + number: number, + options?: { + baseUrl?: string; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + try { + const query = `query pr($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 + } + } + } +}`; + + const rsp = await graphql<{ repository?: { issueOrPullRequest?: GitHubIssueOrPullRequest } }>(query, { + owner: owner, + repo: repo, + number: number, + headers: { authorization: `Bearer ${token}` }, + ...options, + }); + + const issue = rsp?.repository?.issueOrPullRequest; + if (issue == null) return undefined; + + return { + provider: provider, + type: issue.type, + id: number, + date: new Date(issue.createdAt), + title: issue.title, + closed: issue.closed, + closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), + }; + } catch (ex) { + Logger.error(ex, cc); + + if (ex.code === 401) { + throw new AuthenticationError(ex); + } + throw ex; + } + } + + @debug({ + args: { + 1: _ => '', + }, + }) async getPullRequestForBranch( provider: string, token: string, @@ -177,74 +394,6 @@ export class GitHubApi { throw ex; } } - - @debug({ - args: { - 1: _ => '', - }, - }) - async getIssueOrPullRequest( - provider: string, - token: string, - owner: string, - repo: string, - number: number, - options?: { - baseUrl?: string; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - try { - const query = `query pr($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 - } - } - } -}`; - - const rsp = await graphql<{ repository?: { issueOrPullRequest?: GitHubIssueOrPullRequest } }>(query, { - owner: owner, - repo: repo, - number: number, - headers: { authorization: `Bearer ${token}` }, - ...options, - }); - - const issue = rsp?.repository?.issueOrPullRequest; - if (issue == null) return undefined; - - return { - provider: provider, - type: issue.type, - id: number, - date: new Date(issue.createdAt), - title: issue.title, - closed: issue.closed, - closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), - }; - } catch (ex) { - Logger.error(ex, cc); - - if (ex.code === 401) { - throw new AuthenticationError(ex); - } - throw ex; - } - } } interface GitHubIssueOrPullRequest { diff --git a/src/system/string.ts b/src/system/string.ts index b0cd0f2..4b678ad 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -38,6 +38,10 @@ export function escapeMarkdown(s: string, options: { quoted?: boolean } = {}): s return s.replace(markdownQuotedRegex, '\t\n> '); } +export function equalsIgnoreCase(a: string, b: string): boolean { + return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0; +} + export function escapeRegex(s: string) { return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); } diff --git a/src/views/contributorsView.ts b/src/views/contributorsView.ts index 76bb7f8..176838b 100644 --- a/src/views/contributorsView.ts +++ b/src/views/contributorsView.ts @@ -1,5 +1,5 @@ 'use strict'; -import { commands, ConfigurationChangeEvent, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { commands, ConfigurationChangeEvent, Disposable, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { configuration, ContributorsViewConfig, ViewFilesLayout } from '../configuration'; import { Container } from '../container'; import { Repository, RepositoryChange, RepositoryChangeEvent } from '../git/git'; @@ -13,8 +13,9 @@ import { unknownGitUri, ViewNode, } from './nodes'; -import { debug, gate } from '../system'; +import { debug, Functions, gate } from '../system'; import { ViewBase } from './viewBase'; +import { onDidFetchAvatar } from '../avatars'; export class ContributorsRepositoryNode extends SubscribeableViewNode { protected splatted = true; @@ -72,7 +73,10 @@ export class ContributorsRepositoryNode extends SubscribeableViewNode this.refresh(), 500)), + ); } @debug({ diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index 49f1c5a..20797b8 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -15,7 +15,6 @@ import { GitBranchReference, GitLog, GitRemoteType, - GitRevision, PullRequestState, } from '../../git/git'; import { GitUri } from '../../git/gitUri'; diff --git a/src/views/nodes/commitFileNode.ts b/src/views/nodes/commitFileNode.ts index dbef702..0c42316 100644 --- a/src/views/nodes/commitFileNode.ts +++ b/src/views/nodes/commitFileNode.ts @@ -90,7 +90,7 @@ export class CommitFileNode extends ViewRefFileNode { if (!this.commit.isUncommitted && !(this.view instanceof StashesView) && this.view.config.avatars) { item.iconPath = this._options.unpublished ? new ThemeIcon('arrow-up') - : this.commit.getAvatarUri(Container.config.defaultGravatarsStyle); + : await this.commit.getAvatarUri(true, { fallback: Container.config.defaultGravatarsStyle }); } } diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 54dc321..616eec5 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -98,7 +98,7 @@ export class CommitNode extends ViewRefNode { const label = CommitFormatter.fromTemplate(this.view.config.commitFormat, this.commit, { dateFormat: Container.config.defaultDateFormat, getBranchAndTagTips: (sha: string) => this.getBranchAndTagTips?.(sha, true), @@ -121,7 +121,7 @@ export class CommitNode extends ViewRefNode { const presence = this._presenceMap?.get(this.contributor.email); const item = new TreeItem( @@ -80,7 +80,9 @@ export class ContributorNode extends ViewNode