Files
vscode/extensions/github/src/historyItemDetailsProvider.ts
2026-03-09 08:10:36 -07:00

338 lines
12 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Command, l10n, LogOutputChannel, workspace } from 'vscode';
import { Commit, Repository as GitHubRepository, Maybe } from '@octokit/graphql-schema';
import type { API, AvatarQuery, AvatarQueryCommit, Repository, SourceControlHistoryItemDetailsProvider } from './typings/git.d.ts';
import { DisposableStore, getRepositoryDefaultRemote, getRepositoryDefaultRemoteUrl, getRepositoryFromUrl, groupBy, sequentialize } from './util.js';
import { AuthenticationError, OctokitService } from './auth.js';
import { getAvatarLink } from './links.js';
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
}
}
}
}
`;
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
user {
id
login
}
}
}
}
}
}
`;
interface GitHubRepositoryStore {
readonly users: GitHubUser[];
readonly commits: Set<string>;
}
interface GitHubUser {
readonly id: string;
readonly login: string;
readonly name?: Maybe<string>;
readonly email: string;
readonly avatarUrl: string;
}
function getUserIdFromNoReplyEmail(email: string | undefined): string | undefined {
const match = email?.match(/^([0-9]+)\+[^@]+@users\.noreply\.github\.com$/);
return match?.[1];
}
function compareAvatarQuery(a: AvatarQueryCommit, b: AvatarQueryCommit): number {
// Email
const emailComparison = (a.authorEmail ?? '').localeCompare(b.authorEmail ?? '');
if (emailComparison !== 0) {
return emailComparison;
}
// Name
return (a.authorName ?? '').localeCompare(b.authorName ?? '');
}
export class GitHubSourceControlHistoryItemDetailsProvider implements SourceControlHistoryItemDetailsProvider {
private _isUserAuthenticated = true;
private readonly _store = new Map<string, GitHubRepositoryStore>();
private readonly _disposables = new DisposableStore();
constructor(
private readonly _gitAPI: API,
private readonly _octokitService: OctokitService,
private readonly _logger: LogOutputChannel
) {
this._disposables.add(this._gitAPI.onDidCloseRepository(repository => this._onDidCloseRepository(repository)));
this._disposables.add(this._octokitService.onDidChangeSessions(() => {
this._isUserAuthenticated = true;
this._store.clear();
}));
this._disposables.add(workspace.onDidChangeConfiguration(e => {
if (!e.affectsConfiguration('github.showAvatar')) {
return;
}
this._store.clear();
}));
}
async provideAvatar(repository: Repository, query: AvatarQuery): Promise<Map<string, string | undefined> | undefined> {
this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Avatar resolution for ${query.commits.length} commit(s) in ${repository.rootUri.fsPath}.`);
const config = workspace.getConfiguration('github', repository.rootUri);
const showAvatar = config.get<boolean>('showAvatar', true) === true;
if (!this._isUserAuthenticated || !showAvatar) {
this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Avatar resolution is disabled. (${showAvatar === false ? 'setting' : 'auth'})`);
return undefined;
}
// upstream -> origin -> first
const descriptor = getRepositoryDefaultRemote(repository, ['upstream', 'origin']);
if (!descriptor) {
this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Repository does not have a GitHub remote.`);
return undefined;
}
try {
const logs = { cached: 0, email: 0, github: 0, incomplete: 0 };
// Warm up the in-memory cache with the first page
// (100 users) from this list of assignable users
await this._loadAssignableUsers(descriptor);
const repositoryStore = this._store.get(this._getRepositoryKey(descriptor));
if (!repositoryStore) {
return undefined;
}
// Group the query by author
const authorQuery = groupBy<AvatarQueryCommit>(query.commits, compareAvatarQuery);
const results = new Map<string, string | undefined>();
await Promise.all(authorQuery.map(async commits => {
if (commits.length === 0) {
return;
}
// Query the in-memory cache for the user
const avatarUrl = repositoryStore.users.find(
user => user.email === commits[0].authorEmail || user.name === commits[0].authorName)?.avatarUrl;
// Cache hit
if (avatarUrl) {
// Add avatar for each commit
logs.cached += commits.length;
commits.forEach(({ hash }) => results.set(hash, `${avatarUrl}&s=${query.size}`));
return;
}
// Check if any of the commit are being tracked in the list
// of known commits that have incomplte author information
if (commits.some(({ hash }) => repositoryStore.commits.has(hash))) {
commits.forEach(({ hash }) => results.set(hash, undefined));
return;
}
// Try to extract the user identifier from GitHub no-reply emails
const userIdFromEmail = getUserIdFromNoReplyEmail(commits[0].authorEmail);
if (userIdFromEmail) {
logs.email += commits.length;
const avatarUrl = getAvatarLink(userIdFromEmail, query.size);
commits.forEach(({ hash }) => results.set(hash, avatarUrl));
return;
}
// Get the commit details
const commitAuthor = await this._getCommitAuthor(descriptor, commits[0].hash);
if (!commitAuthor) {
// The commit has incomplete author information, so
// we should not try to query the authors details again
logs.incomplete += commits.length;
for (const { hash } of commits) {
repositoryStore.commits.add(hash);
results.set(hash, undefined);
}
return;
}
// Save the user to the cache
repositoryStore.users.push(commitAuthor);
// Add avatar for each commit
logs.github += commits.length;
commits.forEach(({ hash }) => results.set(hash, `${commitAuthor.avatarUrl}&s=${query.size}`));
}));
this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][provideAvatar] Avatar resolution for ${query.commits.length} commit(s) in ${repository.rootUri.fsPath} complete: ${JSON.stringify(logs)}.`);
return results;
} 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._isUserAuthenticated = false;
}
return undefined;
}
}
async provideHoverCommands(repository: Repository): Promise<Command[] | undefined> {
// origin -> upstream -> first
const url = getRepositoryDefaultRemoteUrl(repository, ['origin', 'upstream']);
if (!url) {
return undefined;
}
return [{
title: l10n.t('{0} Open on GitHub', '$(github)'),
tooltip: l10n.t('Open on GitHub'),
command: 'github.openOnGitHub',
arguments: [url]
}];
}
async provideMessageLinks(repository: Repository, message: string): Promise<string | undefined> {
// upstream -> origin -> first
const descriptor = getRepositoryDefaultRemote(repository, ['upstream', 'origin']);
if (!descriptor) {
return undefined;
}
return message.replace(
ISSUE_EXPRESSION,
(match, _group1, owner: string | undefined, repo: string | undefined, _group2, number: string | undefined) => {
if (!number || Number.isNaN(parseInt(number))) {
return match;
}
const label = owner && repo
? `${owner}/${repo}#${number}`
: `#${number}`;
owner = owner ?? descriptor.owner;
repo = repo ?? descriptor.repo;
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<void> {
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 this._octokitService.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<GitHubUser | undefined> {
this._logger.trace(`[GitHubSourceControlHistoryItemDetailsProvider][_getCommitAuthor] Querying commit author for ${descriptor.owner}/${descriptor.repo}/${commit}.`);
try {
const graphql = await this._octokitService.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 _getRepositoryKey(descriptor: { owner: string; repo: string }): string {
return `${descriptor.owner}/${descriptor.repo}`;
}
dispose(): void {
this._disposables.dispose();
}
}