diff --git a/CHANGELOG.md b/CHANGELOG.md index f820daa..21264d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ![Pull requests on line annotation and hovers](https://raw.githubusercontent.com/gitkraken/vscode-gitlens/main/images/docs/hovers-current-line-details.png) - Adds associated pull request to status bar blame ![Pull requests on status bar](https://raw.githubusercontent.com/gitkraken/vscode-gitlens/main/images/docs/status-bar.png) + - Adds GitHub avatars - Adds associated pull requests to branches and commits in GitLens views - Adds rich autolinks for GitHub issues and merge requests, including titles, status, and authors - Adds rich support to _Autolinked Issues and Pull Requests_ within comparisons to list autolinked GitHub issues and merge requests in commit messages diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 7f6c7be..097a4bf 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -35,7 +35,7 @@ export class GitHubRemote extends RichRemoteProvider { } get apiBaseUrl() { - return this.custom ? `${this.protocol}://${this.domain}/api` : `https://api.${this.domain}`; + return this.custom ? `${this.protocol}://${this.domain}/api/v3` : `https://${this.domain}/api/v3`; } private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; diff --git a/src/plus/github/github.ts b/src/plus/github/github.ts index db9740b..e65d469 100644 --- a/src/plus/github/github.ts +++ b/src/plus/github/github.ts @@ -3,7 +3,7 @@ import { GraphqlResponseError } from '@octokit/graphql'; import { RequestError } from '@octokit/request-error'; import type { Endpoints, OctokitResponse, RequestParameters } from '@octokit/types'; import type { HttpsProxyAgent } from 'https-proxy-agent'; -import { Disposable, Event, EventEmitter, window } from 'vscode'; +import { Disposable, Event, EventEmitter, Uri, window } from 'vscode'; import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; import { isWeb } from '@env/platform'; import { configuration } from '../../configuration'; @@ -22,6 +22,7 @@ import type { RichRemoteProvider } from '../../git/remotes/provider'; import { LogCorrelationContext, Logger, LogLevel } from '../../logger'; import { debug } from '../../system/decorators/log'; import { Stopwatch } from '../../system/stopwatch'; +import { fromString, satisfies, Version } from '../../system/version'; import { GitHubBlame, GitHubBlameRange, @@ -51,17 +52,13 @@ export class GitHubApi implements Disposable { constructor(_container: Container) { this._disposable = Disposable.from( configuration.onDidChange(e => { - if (configuration.changed(e, 'proxy')) { - this._proxyAgent = null; - this._octokits.clear(); - } else if (configuration.changed(e, 'outputLevel')) { - this._octokits.clear(); + if (configuration.changed(e, 'proxy') || configuration.changed(e, 'outputLevel')) { + this.resetCaches(); } }), configuration.onDidChangeAny(e => { if (e.affectsConfiguration('http.proxy') || e.affectsConfiguration('http.proxyStrictSSL')) { - this._proxyAgent = null; - this._octokits.clear(); + this.resetCaches(); } }), ); @@ -71,6 +68,12 @@ export class GitHubApi implements Disposable { this._disposable?.dispose(); } + private resetCaches(): void { + this._proxyAgent = null; + this._octokits.clear(); + this._enterpriseVersions.clear(); + } + private _proxyAgent: HttpsProxyAgent | null | undefined = null; private get proxyAgent(): HttpsProxyAgent | undefined { if (isWeb) return undefined; @@ -147,7 +150,13 @@ export class GitHubApi implements Disposable { provider: provider, name: author.name ?? undefined, email: author.email ?? undefined, - avatarUrl: author.avatarUrl, + // If we are GitHub Enterprise, we may need to convert the avatar URL since it might require authentication + avatarUrl: + !author.avatarUrl || isGitHubDotCom(options) + ? author.avatarUrl ?? undefined + : author.email && options?.baseUrl != null + ? await this.createEnterpriseAvatarUrl(token, options.baseUrl, author.email, options.avatarSize) + : undefined, }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -216,7 +225,13 @@ export class GitHubApi implements Disposable { provider: provider, name: author.name ?? undefined, email: author.email ?? undefined, - avatarUrl: author.avatarUrl, + // If we are GitHub Enterprise, we may need to convert the avatar URL since it might require authentication + avatarUrl: + !author.avatarUrl || isGitHubDotCom(options) + ? author.avatarUrl ?? undefined + : author.email && options?.baseUrl != null + ? await this.createEnterpriseAvatarUrl(token, options.baseUrl, author.email, options.avatarSize) + : undefined, }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -1715,6 +1730,27 @@ export class GitHubApi implements Disposable { } } + private _enterpriseVersions = new Map(); + + @debug({ args: { 0: '' } }) + private async getEnterpriseVersion(token: string, options?: { baseUrl?: string }): Promise { + let version = this._enterpriseVersions.get(token); + if (version != null) return version; + if (version === null) return undefined; + + try { + const rsp = await this.request(undefined, token, 'GET /meta', options); + const v = (rsp?.data as any)?.installed_version as string | null | undefined; + version = v ? fromString(v) : null; + } catch (ex) { + debugger; + version = null; + } + + this._enterpriseVersions.set(token, version); + return version ?? undefined; + } + private _octokits = new Map(); private octokit(token: string, options?: ConstructorParameters[0]): Octokit { let octokit = this._octokits.get(token); @@ -1915,4 +1951,43 @@ export class GitHubApi implements Disposable { void window.showErrorMessage(ex.message); } } + + private async createEnterpriseAvatarUrl( + token: string, + baseUrl: string, + email: string, + avatarSize: number | undefined, + ): Promise { + avatarSize = avatarSize ?? 16; + + let avatarEndpointUrl = `https://avatars.githubusercontent.com`; + + const version = await this.getEnterpriseVersion(token, { baseUrl: baseUrl }); + if (satisfies(version, '>= 3.0.0')) { + avatarEndpointUrl = `${baseUrl}/enterprise/avatars`; + + const match = /^(?:(\d+)\+)?(.+?)@users\.noreply\.(.*)$/i.exec(email); + if (match != null) { + const uri = Uri.parse(baseUrl); + const [, userId, login, authority] = match; + + if (uri.authority === authority) { + if (userId != null) { + return `${avatarEndpointUrl}/u/${encodeURIComponent(userId)}?s=${avatarSize}`; + } + + if (login != null) { + return `${avatarEndpointUrl}/${encodeURIComponent(login)}?s=${avatarSize}`; + } + } + } + } + + // The /u/e endpoint automatically falls back to gravatar if not found + return `${avatarEndpointUrl}/u/e?email=${encodeURIComponent(email)}&s=${avatarSize}`; + } +} + +function isGitHubDotCom(options?: { baseUrl?: string }) { + return options?.baseUrl == null || options.baseUrl === 'https://api.github.com'; } diff --git a/src/plus/gitlab/gitlab.ts b/src/plus/gitlab/gitlab.ts index aa9d603..eb3855b 100644 --- a/src/plus/gitlab/gitlab.ts +++ b/src/plus/gitlab/gitlab.ts @@ -663,7 +663,7 @@ $search: String! rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () => fetch(`${baseUrl ?? 'https://gitlab.com/api'}/graphql`, { method: 'POST', - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, agent: agent, body: JSON.stringify({ query: query, variables: variables }), }), @@ -715,7 +715,7 @@ $search: String! try { rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () => fetch(url, { - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, agent: agent, ...options, }), diff --git a/src/system/version.ts b/src/system/version.ts index 1ade6c7..d8381f8 100644 --- a/src/system/version.ts +++ b/src/system/version.ts @@ -48,3 +48,23 @@ export function fromString(version: string): Version { const [major, minor, patch] = ver.split('.'); return from(major, minor, patch, pre); } + +export function satisfies(v: string | Version | null | undefined, requirement: string): boolean { + if (v == null) return false; + + const [op, version] = requirement.split(' '); + + if (op === '=') { + return compare(v, version) === 0; + } else if (op === '>') { + return compare(v, version) > 0; + } else if (op === '>=') { + return compare(v, version) >= 0; + } else if (op === '<') { + return compare(v, version) < 0; + } else if (op === '<=') { + return compare(v, version) <= 0; + } + + throw new Error(`Unknown operator: ${op}`); +}