diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 58299ae6826..c36879fd6fe 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -327,6 +327,7 @@ export interface BranchProtectionProvider { } export interface SourceControlHistoryItemDetailsProvider { + provideAvatar(repository: Repository, commit: string, authorName?: string, authorEmail?: string): ProviderResult; provideHoverCommands(repository: Repository): ProviderResult; provideMessageLinks(repository: Repository, message: string): ProviderResult; } diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index f472116e0e4..ac7bd402081 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -12,7 +12,7 @@ import { BlameInformation, Commit } from './git'; import { fromGitUri, isGitUri } from './uri'; import { emojify, ensureEmojis } from './emoji'; import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation } from './staging'; -import { provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; +import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; function lineRangesContainLine(changes: readonly TextEditorChange[], lineNumber: number): boolean { return changes.some(c => c.modified.startLineNumber <= lineNumber && lineNumber < c.modified.endLineNumberExclusive); @@ -205,6 +205,7 @@ export class GitBlameController { async getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation, includeCommitDetails = false): Promise { const remoteHoverCommands: Command[] = []; + let commitAvatar: string | undefined; let commitInformation: Commit | undefined; let commitMessageWithLinks: string | undefined; @@ -214,6 +215,10 @@ export class GitBlameController { if (includeCommitDetails) { try { commitInformation = await repository.getCommit(blameInformation.hash); + + // Avatar + commitAvatar = await provideSourceControlHistoryItemAvatar( + this._model, repository, blameInformation.hash, blameInformation.authorName, blameInformation.authorEmail); } catch { } } @@ -234,16 +239,18 @@ export class GitBlameController { markdownString.supportThemeIcons = true; // Author, date + const hash = commitInformation?.hash ?? blameInformation.hash; const authorName = commitInformation?.authorName ?? blameInformation.authorName; const authorEmail = commitInformation?.authorEmail ?? blameInformation.authorEmail; const authorDate = commitInformation?.authorDate ?? blameInformation.authorDate; + const avatar = commitAvatar ? `![${authorName}](${commitAvatar})` : '$(account)'; if (authorName) { if (authorEmail) { const emailTitle = l10n.t('Email'); - markdownString.appendMarkdown(`$(account) [**${authorName}**](mailto:${authorEmail} "${emailTitle} ${authorName}")`); + markdownString.appendMarkdown(`${avatar} [**${authorName}**](mailto:${authorEmail} "${emailTitle} ${authorName}")`); } else { - markdownString.appendMarkdown(`$(account) **${authorName}**`); + markdownString.appendMarkdown(`${avatar} **${authorName}**`); } if (authorDate) { @@ -282,8 +289,6 @@ export class GitBlameController { } // Commands - const hash = commitInformation?.hash ?? blameInformation.hash; - markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, hash]))} "${l10n.t('Open Commit')}")`); markdownString.appendMarkdown(' '); markdownString.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`); diff --git a/extensions/git/src/historyItemDetailsProvider.ts b/extensions/git/src/historyItemDetailsProvider.ts index 26d6bac30dd..33b887430f0 100644 --- a/extensions/git/src/historyItemDetailsProvider.ts +++ b/extensions/git/src/historyItemDetailsProvider.ts @@ -13,6 +13,24 @@ export interface ISourceControlHistoryItemDetailsProviderRegistry { getSourceControlHistoryItemDetailsProviders(): SourceControlHistoryItemDetailsProvider[]; } +export async function provideSourceControlHistoryItemAvatar( + registry: ISourceControlHistoryItemDetailsProviderRegistry, + repository: Repository, + commit: string, + authorName?: string, + authorEmail?: string +): Promise { + for (const provider of registry.getSourceControlHistoryItemDetailsProviders()) { + const result = await provider.provideAvatar(new ApiRepository(repository), commit, authorName, authorEmail); + + if (result) { + return result; + } + } + + return undefined; +} + export async function provideSourceControlHistoryItemHoverCommands( registry: ISourceControlHistoryItemDetailsProviderRegistry, repository: Repository diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index 5ca0d1cb6d5..72a9111326e 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -101,7 +101,7 @@ function initializeGitExtension(context: ExtensionContext, telemetryReporter: Te disposables.add(new GithubBranchProtectionProviderManager(gitAPI, context.globalState, logger, telemetryReporter)); disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler(telemetryReporter))); disposables.add(gitAPI.registerRemoteSourcePublisher(new GithubRemoteSourcePublisher(gitAPI))); - disposables.add(gitAPI.registerSourceControlHistoryItemDetailsProvider(new GitHubSourceControlHistoryItemDetailsProvider())); + disposables.add(gitAPI.registerSourceControlHistoryItemDetailsProvider(new GitHubSourceControlHistoryItemDetailsProvider(gitAPI, logger))); disposables.add(new GitHubCanonicalUriProvider(gitAPI)); disposables.add(new VscodeDevShareProvider(gitAPI)); setGitHubContext(gitAPI, disposables); diff --git a/extensions/github/src/historyItemDetailsProvider.ts b/extensions/github/src/historyItemDetailsProvider.ts index 4f7d0120ced..c3e7899015a 100644 --- a/extensions/github/src/historyItemDetailsProvider.ts +++ b/extensions/github/src/historyItemDetailsProvider.ts @@ -3,13 +3,142 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Command, l10n } from 'vscode'; -import { Repository, SourceControlHistoryItemDetailsProvider } from './typings/git'; -import { getRepositoryDefaultRemote, getRepositoryDefaultRemoteUrl } from './util'; +import { authentication, Command, l10n, LogOutputChannel } from 'vscode'; +import { Commit, Repository as GitHubRepository, Maybe } from '@octokit/graphql-schema'; +import { API, Repository, SourceControlHistoryItemDetailsProvider } from './typings/git'; +import { DisposableStore, getRepositoryDefaultRemote, getRepositoryDefaultRemoteUrl, getRepositoryFromUrl, sequentialize } from './util'; +import { AuthenticationError, getOctokitGraphql } from './auth'; + +const AVATAR_SIZE = 20; const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/g; +const ASSIGNABLE_USERS_QUERY = ` + query assignableUsers($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + assignableUsers(first: 100) { + nodes { + id + login + name + email + avatarUrl(size: ${AVATAR_SIZE}) + } + } + } + } +`; + +const COMMIT_AUTHOR_QUERY = ` + query commitAuthor($owner: String!, $repo: String!, $commit: String!) { + repository(owner: $owner, name: $repo) { + object(expression: $commit) { + ... on Commit { + author { + name + email + avatarUrl(size: ${AVATAR_SIZE}) + user { + id + login + } + } + } + } + } + } +`; + +interface GitHubRepositoryStore { + readonly users: GitHubUser[]; + readonly commits: Set; +} + +interface GitHubUser { + readonly id: string; + readonly login: string; + readonly name?: Maybe; + readonly email: string; + readonly avatarUrl: string; +} + export class GitHubSourceControlHistoryItemDetailsProvider implements SourceControlHistoryItemDetailsProvider { + private _enabled = true; + private readonly _store = new Map(); + private readonly _disposables = new DisposableStore(); + + constructor(private readonly _gitAPI: API, private readonly _logger: LogOutputChannel) { + this._disposables.add(this._gitAPI.onDidCloseRepository(this._onDidCloseRepository)); + + this._disposables.add(authentication.onDidChangeSessions(e => { + if (e.provider.id === 'github') { + this._enabled = true; + } + })); + } + + async provideAvatar(repository: Repository, commit: string, authorName?: string, authorEmail?: string): Promise { + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Avatar resolution for ${commit} in ${repository.rootUri.fsPath}.`); + + if (!this._enabled) { + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Avatar resolution is disabled.`); + return undefined; + } + + const descriptor = getRepositoryDefaultRemote(repository); + if (!descriptor) { + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Repository does not have a GitHub remote.`); + return undefined; + } + + try { + // Get the first page of the assignable users + await this._loadAssignableUsers(descriptor); + + const repositoryStore = this._store.get(this._getRepositoryKey(descriptor)); + if (!repositoryStore) { + return undefined; + } + + // Lookup the user in the cache + const avatarUrl = repositoryStore.users.find( + user => user.email === authorEmail || user.name === authorName)?.avatarUrl; + if (avatarUrl) { + return this._getAvatarUrl(avatarUrl, AVATAR_SIZE); + } + + // Check the commit against the list of known commits + // that are known to have incomplte author information + if (repositoryStore.commits.has(commit)) { + return undefined; + } + + // Get the commit details + const commitAuthor = await this._getCommitAuthor(descriptor, commit); + if (!commitAuthor) { + // The commit has incomplete author information, + // so we should not try to query the authors details + // again + repositoryStore.commits.add(commit); + return undefined; + } + + // Save the user to the cache + repositoryStore.users.push(commitAuthor); + + return this._getAvatarUrl(commitAuthor.avatarUrl, AVATAR_SIZE); + } catch (err) { + // A GitHub authentication session could be missing if the user has not yet + // signed in with their GitHub account or they have signed out. Disable the + // avatar resolution until the user signes in with their GitHub account. + if (err instanceof AuthenticationError) { + this._enabled = false; + } + } + + return undefined; + } + async provideHoverCommands(repository: Repository): Promise { const url = getRepositoryDefaultRemoteUrl(repository); if (!url) { @@ -47,4 +176,97 @@ export class GitHubSourceControlHistoryItemDetailsProvider implements SourceCont return `[${label}](https://github.com/${owner}/${repo}/issues/${number})`; }); } + + private _onDidCloseRepository(repository: Repository) { + for (const remote of repository.state.remotes) { + if (!remote.fetchUrl) { + continue; + } + + const repository = getRepositoryFromUrl(remote.fetchUrl); + if (!repository) { + continue; + } + + this._store.delete(this._getRepositoryKey(repository)); + } + } + + @sequentialize + private async _loadAssignableUsers(descriptor: { owner: string; repo: string }): Promise { + if (this._store.has(this._getRepositoryKey(descriptor))) { + return; + } + + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][_loadAssignableUsers] Querying assignable user(s) for ${descriptor.owner}/${descriptor.repo}.`); + + try { + const graphql = await getOctokitGraphql(); + const { repository } = await graphql<{ repository: GitHubRepository }>(ASSIGNABLE_USERS_QUERY, descriptor); + + const users: GitHubUser[] = []; + for (const node of repository.assignableUsers.nodes ?? []) { + if (!node) { + continue; + } + + users.push({ + id: node.id, + login: node.login, + name: node.name, + email: node.email, + avatarUrl: node.avatarUrl, + } satisfies GitHubUser); + } + + this._store.set(this._getRepositoryKey(descriptor), { users, commits: new Set() }); + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][_loadAssignableUsers] Successfully queried assignable user(s) for ${descriptor.owner}/${descriptor.repo}: ${users.length} user(s).`); + } catch (err) { + this._logger.warn(`[GitHubSourceControlHistoryItemDetailsProvider][_loadAssignableUsers] Failed to load assignable user(s) for ${descriptor.owner}/${descriptor.repo}: ${err}`); + throw err; + } + } + + private async _getCommitAuthor(descriptor: { owner: string; repo: string }, commit: string): Promise { + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][_getCommitAuthor] Querying commit author for ${descriptor.owner}/${descriptor.repo}/${commit}.`); + + try { + const graphql = await getOctokitGraphql(); + const { repository } = await graphql<{ repository: GitHubRepository }>(COMMIT_AUTHOR_QUERY, { ...descriptor, commit }); + + const commitAuthor = (repository.object as Commit).author; + if (!commitAuthor?.user?.id || !commitAuthor.user?.login || + !commitAuthor?.name || !commitAuthor?.email || !commitAuthor?.avatarUrl) { + this._logger.info(`[GitHubSourceControlHistoryItemDetailsProvider][_getCommitAuthor] Incomplete commit author for ${descriptor.owner}/${descriptor.repo}/${commit}: ${JSON.stringify(repository.object)}`); + + return undefined; + } + + const user = { + id: commitAuthor.user.id, + login: commitAuthor.user.login, + name: commitAuthor.name, + email: commitAuthor.email, + avatarUrl: commitAuthor.avatarUrl, + } satisfies GitHubUser; + + this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][_getCommitAuthor] Successfully queried commit author for ${descriptor.owner}/${descriptor.repo}/${commit}: ${user.login}.`); + return user; + } catch (err) { + this._logger.warn(`[GitHubSourceControlHistoryItemDetailsProvider][_getCommitAuthor] Failed to get commit author for ${descriptor.owner}/${descriptor.repo}/${commit}: ${err}`); + throw err; + } + } + + private _getAvatarUrl(url: string, size: number): string { + return `${url}|height=${size},width=${size}`; + } + + private _getRepositoryKey(descriptor: { owner: string; repo: string }): string { + return `${descriptor.owner}/${descriptor.repo}`; + } + + dispose(): void { + this._disposables.dispose(); + } } diff --git a/extensions/github/src/typings/git.d.ts b/extensions/github/src/typings/git.d.ts index 714474533e0..59dfa09aa83 100644 --- a/extensions/github/src/typings/git.d.ts +++ b/extensions/github/src/typings/git.d.ts @@ -290,6 +290,7 @@ export interface BranchProtectionProvider { } export interface SourceControlHistoryItemDetailsProvider { + provideAvatar(repository: Repository, commit: string, authorName?: string, authorEmail?: string): Promise; provideHoverCommands(repository: Repository): Promise; provideMessageLinks(repository: Repository, message: string): Promise; } diff --git a/extensions/github/src/util.ts b/extensions/github/src/util.ts index eba3bced698..979b912d7b4 100644 --- a/extensions/github/src/util.ts +++ b/extensions/github/src/util.ts @@ -23,6 +23,40 @@ export class DisposableStore { } } +function decorate(decorator: (fn: Function, key: string) => Function): Function { + return (_target: any, key: string, descriptor: any) => { + let fnKey: string | null = null; + let fn: Function | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn || !fnKey) { + throw new Error('not supported'); + } + + descriptor[fnKey] = decorator(fn, key); + }; +} + +function _sequentialize(fn: Function, key: string): Function { + const currentKey = `__$sequence$${key}`; + + return function (this: any, ...args: any[]) { + const currentPromise = this[currentKey] as Promise || Promise.resolve(null); + const run = async () => await fn.apply(this, args); + this[currentKey] = currentPromise.then(run, run); + return this[currentKey]; + }; +} + +export const sequentialize = decorate(_sequentialize); + export function getRepositoryFromUrl(url: string): { owner: string; repo: string } | undefined { const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/i.exec(url) || /^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/i.exec(url);