diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index 907082a..bdf2465 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -257,6 +257,11 @@ export class Autolinks implements Disposable { } if (remotes != null && remotes.length !== 0) { + remotes = [...remotes].sort((a, b) => { + const aConnected = a.provider?.maybeConnected; + const bConnected = b.provider?.maybeConnected; + return aConnected !== bConnected ? (aConnected ? -1 : bConnected ? 1 : 0) : 0; + }); for (const r of remotes) { if (r.provider == null) continue; diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index af96a90..3325d99 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -2166,7 +2166,16 @@ export class GitProviderService implements Disposable { } // Don't choose a remote unless its weighted above - const matchedWeight = weightedRemotes.get(r.name) ?? -1; + let matchedWeight = weightedRemotes.get(r.name) ?? -1; + + const p = r.provider; + if (p.hasRichIntegration() && p.maybeConnected) { + const m = await p.getRepositoryMetadata(); + if (m?.isFork === false) { + matchedWeight += 101; + } + } + if (matchedWeight > weight) { bestRemote = r; weight = matchedWeight; diff --git a/src/git/models/repositoryMetadata.ts b/src/git/models/repositoryMetadata.ts new file mode 100644 index 0000000..393040e --- /dev/null +++ b/src/git/models/repositoryMetadata.ts @@ -0,0 +1,12 @@ +import type { RemoteProviderReference } from './remoteProvider'; + +export interface RepositoryMetadata { + provider: RemoteProviderReference; + owner: string; + name: string; + isFork: boolean; + parent?: { + owner: string; + name: string; + }; +} diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 86c52f0..e70badc 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -18,6 +18,7 @@ import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RepositoryMetadata } from '../models/repositoryMetadata'; import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; const autolinkFullIssuesRegex = /\b(?<repo>[^/\s]+\/[^/\s]+)#(?<num>[0-9]+)\b(?!]\()/g; @@ -259,7 +260,7 @@ export class GitHubRemote extends RichRemoteProvider { return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - protected async getProviderAccountForCommit( + protected override async getProviderAccountForCommit( { accessToken }: AuthenticationSession, ref: string, options?: { @@ -273,7 +274,7 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async getProviderAccountForEmail( + protected override async getProviderAccountForEmail( { accessToken }: AuthenticationSession, email: string, options?: { @@ -287,7 +288,7 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async getProviderDefaultBranch({ + protected override async getProviderDefaultBranch({ accessToken, }: AuthenticationSession): Promise<DefaultBranch | undefined> { const [owner, repo] = this.splitPath(); @@ -296,7 +297,7 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async getProviderIssueOrPullRequest( + protected override async getProviderIssueOrPullRequest( { accessToken }: AuthenticationSession, id: string, ): Promise<IssueOrPullRequest | undefined> { @@ -306,7 +307,7 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async getProviderPullRequestForBranch( + protected override async getProviderPullRequestForBranch( { accessToken }: AuthenticationSession, branch: string, options?: { @@ -326,7 +327,7 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async getProviderPullRequestForCommit( + protected override async getProviderPullRequestForCommit( { accessToken }: AuthenticationSession, ref: string, ): Promise<PullRequest | undefined> { @@ -336,7 +337,16 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async searchProviderMyPullRequests({ + protected override async getProviderRepositoryMetadata({ + accessToken, + }: AuthenticationSession): Promise<RepositoryMetadata | undefined> { + const [owner, repo] = this.splitPath(); + return (await this.container.github)?.getRepositoryMetadata(this, accessToken, owner, repo, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async searchProviderMyPullRequests({ accessToken, }: AuthenticationSession): Promise<SearchedPullRequest[] | undefined> { return (await this.container.github)?.searchMyPullRequests(this, accessToken, { @@ -345,7 +355,7 @@ export class GitHubRemote extends RichRemoteProvider { }); } - protected async searchProviderMyIssues({ + protected override async searchProviderMyIssues({ accessToken, }: AuthenticationSession): Promise<SearchedIssue[] | undefined> { return (await this.container.github)?.searchMyIssues(this, accessToken, { diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index e7e3fe2..087073f 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -18,6 +18,7 @@ import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RepositoryMetadata } from '../models/repositoryMetadata'; import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; const autolinkFullIssuesRegex = /\b(?<repo>[^/\s]+\/[^/\s]+)#(?<num>[0-9]+)\b(?!]\()/g; @@ -290,7 +291,7 @@ export class GitLabRemote extends RichRemoteProvider { return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - protected async getProviderAccountForCommit( + protected override async getProviderAccountForCommit( { accessToken }: AuthenticationSession, ref: string, options?: { @@ -304,7 +305,7 @@ export class GitLabRemote extends RichRemoteProvider { }); } - protected async getProviderAccountForEmail( + protected override async getProviderAccountForEmail( { accessToken }: AuthenticationSession, email: string, options?: { @@ -318,7 +319,7 @@ export class GitLabRemote extends RichRemoteProvider { }); } - protected async getProviderDefaultBranch({ + protected override async getProviderDefaultBranch({ accessToken, }: AuthenticationSession): Promise<DefaultBranch | undefined> { const [owner, repo] = this.splitPath(); @@ -327,7 +328,7 @@ export class GitLabRemote extends RichRemoteProvider { }); } - protected async getProviderIssueOrPullRequest( + protected override async getProviderIssueOrPullRequest( { accessToken }: AuthenticationSession, id: string, ): Promise<IssueOrPullRequest | undefined> { @@ -337,7 +338,7 @@ export class GitLabRemote extends RichRemoteProvider { }); } - protected async getProviderPullRequestForBranch( + protected override async getProviderPullRequestForBranch( { accessToken }: AuthenticationSession, branch: string, options?: { @@ -357,7 +358,7 @@ export class GitLabRemote extends RichRemoteProvider { }); } - protected async getProviderPullRequestForCommit( + protected override async getProviderPullRequestForCommit( { accessToken }: AuthenticationSession, ref: string, ): Promise<PullRequest | undefined> { @@ -367,13 +368,24 @@ export class GitLabRemote extends RichRemoteProvider { }); } - protected async searchProviderMyPullRequests( + protected override async getProviderRepositoryMetadata({ + accessToken, + }: AuthenticationSession): Promise<RepositoryMetadata | undefined> { + const [owner, repo] = this.splitPath(); + return (await this.container.gitlab)?.getRepositoryMetadata(this, accessToken, owner, repo, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async searchProviderMyPullRequests( _session: AuthenticationSession, ): Promise<SearchedPullRequest[] | undefined> { return Promise.resolve(undefined); } - protected async searchProviderMyIssues(_session: AuthenticationSession): Promise<SearchedIssue[] | undefined> { + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + ): Promise<SearchedIssue[] | undefined> { return Promise.resolve(undefined); } } diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 3c1dc86..d9f4e2a 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -59,6 +59,10 @@ export abstract class RemoteProvider implements RemoteProviderReference { return this.type === 'rich'; } + get maybeConnected(): boolean | undefined { + return false; + } + abstract getLocalInfoFromRemoteUri( repository: Repository, uri: Uri, diff --git a/src/git/remotes/richRemoteProvider.ts b/src/git/remotes/richRemoteProvider.ts index e0c5b9a..488e05c 100644 --- a/src/git/remotes/richRemoteProvider.ts +++ b/src/git/remotes/richRemoteProvider.ts @@ -17,6 +17,7 @@ import type { Account } from '../models/author'; import type { DefaultBranch } from '../models/defaultBranch'; import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; +import type { RepositoryMetadata } from '../models/repositoryMetadata'; import { RemoteProvider } from './remoteProvider'; // TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart @@ -73,7 +74,7 @@ export abstract class RichRemoteProvider extends RemoteProvider { return `connected:${this.key}`; } - get maybeConnected(): boolean | undefined { + override get maybeConnected(): boolean | undefined { return this._session === undefined ? undefined : this._session !== null; } @@ -145,6 +146,7 @@ export abstract class RichRemoteProvider extends RemoteProvider { } this.resetRequestExceptionCount(); + this._repoMetadata = undefined; this._prsByCommit.clear(); this._session = null; @@ -286,6 +288,37 @@ export abstract class RichRemoteProvider extends RemoteProvider { accessToken, }: AuthenticationSession): Promise<DefaultBranch | undefined>; + private _repoMetadata: RepositoryMetadata | undefined; + + @gate() + @debug() + async getRepositoryMetadata(): Promise<RepositoryMetadata | undefined> { + if (this._repoMetadata != null) return this._repoMetadata; + + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const metadata = await this.getProviderRepositoryMetadata(this._session!); + this._repoMetadata = metadata; + this.resetRequestExceptionCount(); + return metadata; + } catch (ex) { + Logger.error(ex, scope); + + if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { + this.trackRequestException(); + } + return undefined; + } + } + + protected abstract getProviderRepositoryMetadata({ + accessToken, + }: AuthenticationSession): Promise<RepositoryMetadata | undefined>; + @gate() @debug() async searchMyPullRequests(): Promise<SearchedPullRequest[] | undefined> { diff --git a/src/plus/github/github.ts b/src/plus/github/github.ts index 8b9b04c..6571833 100644 --- a/src/plus/github/github.ts +++ b/src/plus/github/github.ts @@ -23,6 +23,7 @@ import type { DefaultBranch } from '../../git/models/defaultBranch'; import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequest'; import { isSha } from '../../git/models/reference'; +import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; import type { GitUser } from '../../git/models/user'; import { getGitHubNoReplyAddressParts } from '../../git/remotes/github'; import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; @@ -736,6 +737,93 @@ export class GitHubApi implements Disposable { } } + @debug<GitHubApi['getRepositoryMetadata']>({ args: { 0: p => p.name, 1: '<token>' } }) + async getRepositoryMetadata( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + options?: { + baseUrl?: string; + }, + ): Promise<RepositoryMetadata | undefined> { + const scope = getLogScope(); + + interface QueryResult { + repository: + | { + owner: { + login: string; + }; + name: string; + parent: + | { + owner: { + login: string; + }; + name: string; + } + | null + | undefined; + } + | null + | undefined; + } + + try { + const query = `query getRepositoryMetadata( + $owner: String! + $repo: String! +) { + repository(name: $repo, owner: $owner) { + owner { + login + } + name + parent { + owner { + login + } + name + } + } +}`; + + const rsp = await this.graphql<QueryResult>( + provider, + token, + query, + { + ...options, + owner: owner, + repo: repo, + }, + scope, + ); + + const r = rsp?.repository ?? undefined; + if (r == null) return undefined; + + return { + provider: provider, + owner: r.owner.login, + name: r.name, + isFork: r.parent != null, + parent: + r.parent != null + ? { + owner: r.parent.owner.login, + name: r.parent.name, + } + : undefined, + }; + } catch (ex) { + if (ex instanceof ProviderRequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + @debug<GitHubApi['getBlame']>({ args: { 0: '<token>' } }) async getBlame(token: string, owner: string, repo: string, ref: string, path: string): Promise<GitHubBlame> { const scope = getLogScope(); diff --git a/src/plus/gitlab/gitlab.ts b/src/plus/gitlab/gitlab.ts index d550a70..8af2e1b 100644 --- a/src/plus/gitlab/gitlab.ts +++ b/src/plus/gitlab/gitlab.ts @@ -19,6 +19,7 @@ import type { DefaultBranch } from '../../git/models/defaultBranch'; import type { IssueOrPullRequest } from '../../git/models/issue'; import { IssueOrPullRequestType } from '../../git/models/issue'; import { PullRequest } from '../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; import { showIntegrationRequestFailed500WarningMessage, @@ -32,7 +33,14 @@ import type { LogScope } from '../../system/logger.scope'; import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; import { Stopwatch } from '../../system/stopwatch'; import { equalsIgnoreCase } from '../../system/string'; -import type { GitLabCommit, GitLabIssue, GitLabMergeRequest, GitLabMergeRequestREST, GitLabUser } from './models'; +import type { + GitLabCommit, + GitLabIssue, + GitLabMergeRequest, + GitLabMergeRequestREST, + GitLabProjectREST, + GitLabUser, +} from './models'; import { fromGitLabMergeRequestREST, fromGitLabMergeRequestState, GitLabMergeRequestState } from './models'; export class GitLabApi implements Disposable { @@ -533,6 +541,56 @@ export class GitLabApi implements Disposable { } } + @debug<GitLabApi['getRepositoryMetadata']>({ args: { 0: p => p.name, 1: '<token>' } }) + async getRepositoryMetadata( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + options?: { + baseUrl?: string; + }, + ): Promise<RepositoryMetadata | undefined> { + const scope = getLogScope(); + + const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl); + if (!projectId) return undefined; + + try { + const proj = await this.request<GitLabProjectREST>( + provider, + + token, + options?.baseUrl, + `v4/projects/${projectId}`, + { + method: 'GET', + // ...options, + }, + scope, + ); + if (proj == null) return undefined; + + return { + provider: provider, + owner: proj.namespace.full_path, + name: proj.path, + isFork: proj.forked_from_project != null, + parent: + proj.forked_from_project != null + ? { + owner: proj.forked_from_project.namespace.full_path, + name: proj.forked_from_project.path, + } + : undefined, + } satisfies RepositoryMetadata; + } catch (ex) { + if (ex instanceof ProviderRequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + private async findUser( provider: RichRemoteProvider, token: string, diff --git a/src/plus/gitlab/models.ts b/src/plus/gitlab/models.ts index 1443ca1..1ddd7cf 100644 --- a/src/plus/gitlab/models.ts +++ b/src/plus/gitlab/models.ts @@ -118,3 +118,19 @@ export function fromGitLabMergeRequestREST(pr: GitLabMergeRequestREST, provider: pr.merged_at == null ? undefined : new Date(pr.merged_at), ); } + +export interface GitLabProjectREST { + namespace: { + path: string; + full_path: string; + }; + path: string; + + forked_from_project?: { + namespace: { + path: string; + full_path: string; + }; + path: string; + }; +} diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 71ba451..041dd2f 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -243,6 +243,11 @@ export class CommitNode extends ViewRefNode<ViewsWithCommits | FileHistoryView, const remotes = await this.view.container.git.getRemotesWithProviders(this.commit.repoPath, { sort: true }); const remote = await this.view.container.git.getBestRemoteWithRichProvider(remotes); + // If we have a "best" remote, move it to the front of the list + if (remote != null) { + remotes.sort((a, b) => (a === remote ? -1 : b === remote ? 1 : 0)); + } + if (this.commit.message == null) { await this.commit.ensureFullDetails(); }