diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index cd67cc9b7bc..270c61d60b3 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -21,7 +21,8 @@ "Programming Languages" ], "extensionDependencies": [ - "hbenl.vscode-test-explorer" + "hbenl.vscode-test-explorer", + "vscode.git" ], "capabilities": { "untrustedWorkspaces": { @@ -38,7 +39,8 @@ "onWebviewPanel:resultsView", "onWebviewPanel:codeQL.variantAnalysis", "onWebviewPanel:codeQL.dataFlowPaths", - "onFileSystem:codeql-zip-archive" + "onFileSystem:codeql-zip-archive", + "workspaceContains:.git" ], "main": "./out/extension", "files": [ diff --git a/extensions/ql-vscode/src/common/vscode/extension/git.ts b/extensions/ql-vscode/src/common/vscode/extension/git.ts new file mode 100644 index 00000000000..6129e6914a3 --- /dev/null +++ b/extensions/ql-vscode/src/common/vscode/extension/git.ts @@ -0,0 +1,436 @@ +// From https://github.com/microsoft/vscode/blob/5e27a2845a87be4b4bede3e51073f94609445e51/extensions/git/src/api/git.d.ts + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Uri, + Event, + Disposable, + ProviderResult, + Command, + CancellationToken, + ThemeIcon, +} from "vscode"; + +interface Git { + readonly path: string; +} + +interface InputBox { + value: string; +} + +const enum ForcePushMode { + Force, + ForceWithLease, + ForceWithLeaseIfIncludes, +} + +const enum RefType { + Head, + RemoteHead, + Tag, +} + +interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +interface UpstreamRef { + readonly remote: string; + readonly name: string; +} + +interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; +} + +interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED, +} + +interface Change { + /** + * Returns either `originalUri` or `renameUri`, depending + * on whether this change is a rename change. When + * in doubt always use `uri` over the other two alternatives. + */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + + readonly onDidChange: Event; +} + +interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +/** + * Log options. + */ +interface LogOptions { + /** Max number of log entries to retrieve. If not specified, the default is 32. */ + readonly maxEntries?: number; + readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; +} + +interface CommitOptions { + all?: boolean | "tracked"; + amend?: boolean; + signoff?: boolean; + signCommit?: boolean; + empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; +} + +interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +interface InitOptions { + defaultBranch?: string; +} + +interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: "alphabetically" | "committerdate"; +} + +interface BranchQuery extends RefQuery { + readonly remote?: boolean; +} + +export interface Repository { + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + + getConfigs(): Promise>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + getGlobalConfig(key: string): Promise; + + getObjectDetails( + treeish: string, + path: string, + ): Promise<{ mode: string; object: string; size: number }>; + detectObjectType( + object: string, + ): Promise<{ mimetype: string; encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + add(paths: string[]): Promise; + revert(paths: string[]): Promise; + clean(paths: string[]): Promise; + + apply(patch: string, reverse?: boolean): Promise; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches( + query: BranchQuery, + cancellationToken?: CancellationToken, + ): Promise; + getBranchBase(name: string): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + getRefs( + query: RefQuery, + cancellationToken?: CancellationToken, + ): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, upstream: string): Promise; + deleteTag(name: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + renameRemote(name: string, newName: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; + pull(unshallow?: boolean): Promise; + push( + remoteName?: string, + branchName?: string, + setUpstream?: boolean, + force?: ForcePushMode, + ): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; +} + +interface RemoteSource { + readonly name: string; + readonly description?: string; + readonly url: string | string[]; +} + +interface RemoteSourceProvider { + readonly name: string; + readonly icon?: string; // codicon name + readonly supportsQuery?: boolean; + getRemoteSources(query?: string): ProviderResult; + getBranches?(url: string): ProviderResult; + publishRepository?(repository: Repository): Promise; +} + +interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + +interface Credentials { + readonly username: string; + readonly password: string; +} + +interface CredentialsProvider { + getCredentials(host: Uri): ProviderResult; +} + +interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + +interface PushErrorHandler { + handlePushError( + repository: Repository, + remote: Remote, + refspec: string, + error: Error & { gitErrorCode: GitErrorCodes }, + ): Promise; +} + +interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +interface CommitMessageProvider { + readonly title: string; + readonly icon?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + provideCommitMessage( + repository: Repository, + changes: string[], + cancellationToken?: CancellationToken, + ): Promise; +} + +type APIState = "uninitialized" | "initialized"; + +interface PublishEvent { + repository: Repository; + branch?: string; +} + +export interface API { + readonly state: APIState; + readonly onDidChangeState: Event; + readonly onDidPublish: Event; + readonly git: Git; + readonly repositories: Repository[]; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; + getRepository(uri: Uri): Repository | null; + init(root: Uri, options?: InitOptions): Promise; + openRepository(root: Uri): Promise; + + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + registerCredentialsProvider(provider: CredentialsProvider): Disposable; + registerPostCommitCommandsProvider( + provider: PostCommitCommandsProvider, + ): Disposable; + registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider( + root: Uri, + provider: BranchProtectionProvider, + ): Disposable; + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable; +} + +export interface GitExtension { + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + + /** + * Returns a specific API version. + * + * Throws error if git extension is disabled. You can listen to the + * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event + * to know when the extension becomes enabled/disabled. + * + * @param version Version number. + * @returns API instance + */ + getAPI(version: 1): API; +} + +const enum GitErrorCodes { + BadConfigFile = "BadConfigFile", + AuthenticationFailed = "AuthenticationFailed", + NoUserNameConfigured = "NoUserNameConfigured", + NoUserEmailConfigured = "NoUserEmailConfigured", + NoRemoteRepositorySpecified = "NoRemoteRepositorySpecified", + NotAGitRepository = "NotAGitRepository", + NotAtRepositoryRoot = "NotAtRepositoryRoot", + Conflict = "Conflict", + StashConflict = "StashConflict", + UnmergedChanges = "UnmergedChanges", + PushRejected = "PushRejected", + ForcePushWithLeaseRejected = "ForcePushWithLeaseRejected", + ForcePushWithLeaseIfIncludesRejected = "ForcePushWithLeaseIfIncludesRejected", + RemoteConnectionError = "RemoteConnectionError", + DirtyWorkTree = "DirtyWorkTree", + CantOpenResource = "CantOpenResource", + GitNotFound = "GitNotFound", + CantCreatePipe = "CantCreatePipe", + PermissionDenied = "PermissionDenied", + CantAccessRemote = "CantAccessRemote", + RepositoryNotFound = "RepositoryNotFound", + RepositoryIsLocked = "RepositoryIsLocked", + BranchNotFullyMerged = "BranchNotFullyMerged", + NoRemoteReference = "NoRemoteReference", + InvalidBranchName = "InvalidBranchName", + BranchAlreadyExists = "BranchAlreadyExists", + NoLocalChanges = "NoLocalChanges", + NoStashFound = "NoStashFound", + LocalChangesOverwritten = "LocalChangesOverwritten", + NoUpstreamBranch = "NoUpstreamBranch", + IsInSubmodule = "IsInSubmodule", + WrongCase = "WrongCase", + CantLockRef = "CantLockRef", + CantRebaseMultipleBranches = "CantRebaseMultipleBranches", + PatchDoesNotApply = "PatchDoesNotApply", + NoPathFound = "NoPathFound", + UnknownPath = "UnknownPath", + EmptyCommitMessage = "EmptyCommitMessage", + BranchFastForwardRejected = "BranchFastForwardRejected", + BranchNotYetBorn = "BranchNotYetBorn", + TagConflict = "TagConflict", +} diff --git a/extensions/ql-vscode/src/databases/github-database-module.ts b/extensions/ql-vscode/src/databases/github-database-module.ts new file mode 100644 index 00000000000..57852cc3586 --- /dev/null +++ b/extensions/ql-vscode/src/databases/github-database-module.ts @@ -0,0 +1,49 @@ +import { DisposableObject } from "../common/disposable-object"; +import { App } from "../common/app"; +import { findGitHubRepositoryForWorkspace } from "./github-repository-finder"; +import { redactableError } from "../common/errors"; +import { asError } from "../common/helpers-pure"; + +export class GithubDatabaseModule extends DisposableObject { + private constructor(private readonly app: App) { + super(); + } + + public static async initialize(app: App): Promise { + const githubDatabaseModule = new GithubDatabaseModule(app); + app.subscriptions.push(githubDatabaseModule); + + await githubDatabaseModule.initialize(); + return githubDatabaseModule; + } + + private async initialize(): Promise { + // Start the check and downloading the database asynchronously. We don't want to block on this + // in extension activation since this makes network requests and waits for user input. + void this.promptGitHubRepositoryDownload().catch((e: unknown) => { + const error = redactableError( + asError(e), + )`Failed to prompt for GitHub repository download`; + + void this.app.logger.log(error.fullMessageWithStack); + this.app.telemetry?.sendError(error); + }); + } + + private async promptGitHubRepositoryDownload(): Promise { + const githubRepositoryResult = await findGitHubRepositoryForWorkspace(); + if (githubRepositoryResult.isFailure) { + void this.app.logger.log( + `Did not find a GitHub repository for workspace: ${githubRepositoryResult.errors.join( + ", ", + )}`, + ); + return; + } + + const githubRepository = githubRepositoryResult.value; + void this.app.logger.log( + `Found GitHub repository for workspace: '${githubRepository.owner}/${githubRepository.name}'`, + ); + } +} diff --git a/extensions/ql-vscode/src/databases/github-repository-finder.ts b/extensions/ql-vscode/src/databases/github-repository-finder.ts new file mode 100644 index 00000000000..45d59ee33b3 --- /dev/null +++ b/extensions/ql-vscode/src/databases/github-repository-finder.ts @@ -0,0 +1,182 @@ +import { + API as GitExtensionAPI, + GitExtension, + Repository, +} from "../common/vscode/extension/git"; +import { extensions, Uri } from "vscode"; +import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; +import { ValueResult } from "../common/value-result"; + +// Based on https://github.com/microsoft/sarif-vscode-extension/blob/a1740e766122c1759d9f39d580c18b82d9e0dea4/src/extension/index.activateGithubAnalyses.ts + +async function getGitExtensionAPI(): Promise< + ValueResult +> { + const gitExtension = extensions.getExtension("vscode.git"); + if (!gitExtension) { + return ValueResult.fail(["Git extension not found"]); + } + + if (!gitExtension.isActive) { + await gitExtension.activate(); + } + + const gitExtensionExports = gitExtension.exports; + + if (!gitExtensionExports.enabled) { + return ValueResult.fail(["Git extension is not enabled"]); + } + + const git = gitExtensionExports.getAPI(1); + if (git.state === "initialized") { + return ValueResult.ok(git); + } + + return new Promise((resolve) => { + git.onDidChangeState((state) => { + if (state === "initialized") { + resolve(ValueResult.ok(git)); + } + }); + }); +} + +async function findRepositoryForWorkspaceFolder( + git: GitExtensionAPI, + workspaceFolderUri: Uri, +): Promise { + return git.repositories.find( + (repo) => repo.rootUri.toString() === workspaceFolderUri.toString(), + ); +} + +/** + * Finds the primary remote fetch URL for a repository. + * + * The priority is: + * 1. The remote associated with the current branch + * 2. The remote named "origin" + * 3. The first remote + * + * If none of these are found, undefined is returned. + * + * This is just a heuristic. We may not find the correct remote in all cases, + * for example when the user has defined an alias in their SSH or Git config. + * + * @param repository The repository to find the remote for. + */ +async function findRemote(repository: Repository): Promise { + // Try to retrieve the remote 5 times with a 5 second delay between each attempt. + // This is to account for the case where the Git extension has not yet retrieved + // the state for all Git repositories. + // This can happen on Codespaces where the Git extension is initialized before the + // filesystem is ready. + for (let count = 0; count < 5; count++) { + const remoteName = repository.state.HEAD?.upstream?.remote ?? "origin"; + const upstreamRemoteUrl = repository.state.remotes.find( + (remote) => remote.name === remoteName, + )?.fetchUrl; + if (upstreamRemoteUrl) { + return upstreamRemoteUrl; + } + + if (remoteName !== "origin") { + const originRemoteUrl = repository.state.remotes.find( + (remote) => remote.name === "origin", + )?.fetchUrl; + + if (originRemoteUrl) { + return originRemoteUrl; + } + } + + // Maybe they have a different remote that is not named origin and is not the + // upstream of the current branch. If so, just select the first one. + const firstRemoteUrl = repository.state.remotes[0]?.fetchUrl; + if (firstRemoteUrl) { + return firstRemoteUrl; + } + + // Wait for 5 seconds before trying again. + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + + return undefined; +} + +// Example: https://github.com/github/vscode-codeql.git +const githubHTTPSRegex = + /https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)/; + +// Example: git@github.com:github/vscode-codeql.git +const githubSSHRegex = /git@github\.com:(?[^/]+)\/(?[^/]+)/; + +function findGitHubRepositoryForRemote(remoteUrl: string): + | { + owner: string; + name: string; + } + | undefined { + const match = + remoteUrl.match(githubHTTPSRegex) ?? remoteUrl.match(githubSSHRegex); + if (!match) { + return undefined; + } + + const owner = match.groups?.owner; + let name = match.groups?.name; + + if (!owner || !name) { + return undefined; + } + + // If a repository ends with ".git", remove it. + if (name.endsWith(".git")) { + name = name.slice(0, -4); + } + + return { + owner, + name, + }; +} + +export async function findGitHubRepositoryForWorkspace(): Promise< + ValueResult<{ owner: string; name: string }, string> +> { + const gitResult = await getGitExtensionAPI(); + if (gitResult.isFailure) { + return ValueResult.fail(gitResult.errors); + } + + const git = gitResult.value; + + const primaryWorkspaceFolder = getOnDiskWorkspaceFoldersObjects()[0]?.uri; + if (!primaryWorkspaceFolder) { + return ValueResult.fail(["No workspace folder found"]); + } + + const primaryRepository = await findRepositoryForWorkspaceFolder( + git, + primaryWorkspaceFolder, + ); + if (!primaryRepository) { + return ValueResult.fail([ + "No Git repository found in primary workspace folder", + ]); + } + + const remoteUrl = await findRemote(primaryRepository); + if (!remoteUrl) { + return ValueResult.fail(["No remote found"]); + } + + const repoNwo = findGitHubRepositoryForRemote(remoteUrl); + if (!repoNwo) { + return ValueResult.fail(["Remote is not a GitHub repository"]); + } + + const { owner, name } = repoNwo; + + return ValueResult.ok({ owner, name }); +} diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index f07a502f5ba..da37ca0b8a6 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -137,6 +137,7 @@ import { QueriesModule } from "./queries-panel/queries-module"; import { OpenReferencedFileCodeLensProvider } from "./local-queries/open-referenced-file-code-lens-provider"; import { LanguageContextStore } from "./language-context-store"; import { LanguageSelectionPanel } from "./language-selection-panel/language-selection-panel"; +import { GithubDatabaseModule } from "./databases/github-database-module"; /** * extension.ts @@ -870,6 +871,8 @@ async function activateWithInstalledDistribution( ), ); + await GithubDatabaseModule.initialize(app); + void extLogger.log("Initializing query history."); const queryHistoryDirs: QueryHistoryDirs = { localQueriesDirPath: queryStorageDir, diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-repository-finder.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-repository-finder.test.ts new file mode 100644 index 00000000000..757dcc5a9ee --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-repository-finder.test.ts @@ -0,0 +1,352 @@ +import { Extension, extensions, Uri } from "vscode"; +import * as workspaceFolders from "../../../../src/common/vscode/workspace-folders"; +import { + GitExtension, + API as GitExtensionAPI, +} from "../../../../src/common/vscode/extension/git"; +import { mockedObject } from "../../utils/mocking.helpers"; +import { findGitHubRepositoryForWorkspace } from "../../../../src/databases/github-repository-finder"; +import { ValueResult } from "../../../../src/common/value-result"; + +describe("findGitHubRepositoryForWorkspace", () => { + let mockGitExtensionAPI: GitExtensionAPI; + + let getOnDiskWorkspaceFolderObjectsSpy: jest.SpiedFunction< + typeof workspaceFolders.getOnDiskWorkspaceFoldersObjects + >; + let getExtensionSpy: jest.SpiedFunction; + const getAPISpy: jest.MockedFunction = jest.fn(); + + const repositories = [ + { + rootUri: Uri.file("a/b/c"), + state: { + HEAD: { + name: "main", + upstream: { + name: "origin", + remote: "fork", + }, + }, + remotes: [ + { + name: "origin", + fetchUrl: "https://github.com/codeql/test-incorrect.git", + }, + { + name: "fork", + fetchUrl: "https://github.com/codeql/test.git", + }, + ], + }, + }, + ]; + + beforeEach(() => { + mockGitExtensionAPI = mockedObject({ + state: "initialized", + repositories, + }); + + getOnDiskWorkspaceFolderObjectsSpy = jest.spyOn( + workspaceFolders, + "getOnDiskWorkspaceFoldersObjects", + ); + getExtensionSpy = jest.spyOn(extensions, "getExtension"); + + getOnDiskWorkspaceFolderObjectsSpy.mockReturnValue([ + { + name: "workspace1", + uri: Uri.file("/a/b/c"), + index: 0, + }, + ]); + + getExtensionSpy.mockReturnValue( + mockedObject>({ + isActive: true, + exports: { + enabled: true, + getAPI: getAPISpy, + }, + }), + ); + + getAPISpy.mockReturnValue(mockGitExtensionAPI); + }); + + it("returns the GitHub repository name with owner", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.ok({ + owner: "codeql", + name: "test", + }), + ); + }); + + describe("when the git extension is not installed or disabled", () => { + beforeEach(() => { + getExtensionSpy.mockReturnValue(undefined); + }); + + it("returns an error", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.fail(["Git extension not found"]), + ); + }); + }); + + describe("when the git extension is not activated", () => { + const activate = jest.fn(); + + beforeEach(() => { + getExtensionSpy.mockReturnValue( + mockedObject>({ + isActive: false, + activate, + exports: { + enabled: true, + getAPI: getAPISpy, + }, + }), + ); + }); + + it("returns the GitHub repository name with owner", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.ok({ + owner: "codeql", + name: "test", + }), + ); + + expect(activate).toHaveBeenCalledTimes(1); + }); + }); + + describe("when the git extension is disabled by the setting", () => { + beforeEach(() => { + getExtensionSpy.mockReturnValue( + mockedObject>({ + isActive: true, + exports: { + enabled: false, + getAPI: getAPISpy, + }, + }), + ); + }); + + it("returns an error", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.fail(["Git extension is not enabled"]), + ); + + expect(getAPISpy).not.toHaveBeenCalled(); + }); + }); + + describe("when the git extension is not yet initialized", () => { + beforeEach(() => { + const onDidChangeState = jest.fn(); + + onDidChangeState.mockImplementation((callback) => { + callback("initialized"); + }); + + mockGitExtensionAPI = mockedObject({ + state: "uninitialized", + onDidChangeState, + repositories, + }); + + getAPISpy.mockReturnValue(mockGitExtensionAPI); + }); + + it("returns the GitHub repository name with owner", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.ok({ + owner: "codeql", + name: "test", + }), + ); + }); + }); + + describe("when there are no workspace folders", () => { + beforeEach(() => { + getOnDiskWorkspaceFolderObjectsSpy.mockReturnValue([]); + }); + + it("returns an error", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.fail(["No workspace folder found"]), + ); + }); + }); + + describe("when the workspace folder does not match a Git repository", () => { + beforeEach(() => { + getOnDiskWorkspaceFolderObjectsSpy.mockReturnValue([ + { + name: "workspace1", + uri: Uri.file("/a/b/d"), + index: 0, + }, + ]); + }); + + it("returns an error", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.fail([ + "No Git repository found in primary workspace folder", + ]), + ); + }); + }); + + describe("when the current branch does not have a remote but origin remote exists", () => { + beforeEach(() => { + mockGitExtensionAPI = mockedObject({ + state: "initialized", + repositories: [ + { + ...repositories[0], + state: { + ...repositories[0].state, + HEAD: { + ...repositories[0].state.HEAD, + upstream: undefined, + }, + remotes: [ + { + name: "upstream", + fetchUrl: "https://github.com/github/codeql-incorrect.git", + }, + { + name: "origin", + fetchUrl: "https://github.com/github/codeql.git", + }, + ], + }, + }, + ], + }); + + getAPISpy.mockReturnValue(mockGitExtensionAPI); + }); + + it("returns the GitHub repository name with owner", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.ok({ + owner: "github", + name: "codeql", + }), + ); + }); + }); + + describe("when the current branch does not have a remote and no origin remote", () => { + beforeEach(() => { + mockGitExtensionAPI = mockedObject({ + state: "initialized", + repositories: [ + { + ...repositories[0], + state: { + ...repositories[0].state, + HEAD: { + ...repositories[0].state.HEAD, + upstream: undefined, + }, + remotes: [ + { + name: "upstream", + fetchUrl: "https://github.com/github/codeql.git", + }, + { + name: "fork", + fetchUrl: "https://github.com/github/codeql-incorrect.git", + }, + ], + }, + }, + ], + }); + + getAPISpy.mockReturnValue(mockGitExtensionAPI); + }); + + it("returns the GitHub repository name with owner", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.ok({ + owner: "github", + name: "codeql", + }), + ); + }); + }); + + describe("when the remote is an SSH GitHub URL", () => { + beforeEach(() => { + mockGitExtensionAPI = mockedObject({ + state: "initialized", + repositories: [ + { + ...repositories[0], + state: { + ...repositories[0].state, + remotes: [ + { + name: "origin", + fetchUrl: "git@github.com:github/codeql.git", + }, + ], + }, + }, + ], + }); + + getAPISpy.mockReturnValue(mockGitExtensionAPI); + }); + + it("returns the GitHub repository name with owner", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.ok({ + owner: "github", + name: "codeql", + }), + ); + }); + }); + + describe("when the remote does not match a GitHub repository", () => { + beforeEach(() => { + mockGitExtensionAPI = mockedObject({ + state: "initialized", + repositories: [ + { + ...repositories[0], + state: { + ...repositories[0].state, + remotes: [ + { + name: "origin", + fetchUrl: "https://example.com/codeql/test.git", + }, + ], + }, + }, + ], + }); + + getAPISpy.mockReturnValue(mockGitExtensionAPI); + }); + + it("returns an error", async () => { + expect(await findGitHubRepositoryForWorkspace()).toEqual( + ValueResult.fail(["Remote is not a GitHub repository"]), + ); + }); + }); +});