Skip to content

Commit

Permalink
Added Microsoft Authentication provider as default method (#78)
Browse files Browse the repository at this point in the history
* Pulled changes from PR 2850 and 2863

* fixed the browserify issue

* changes from upstream for git.d.ts and api.d.ts

* Implemented Auth using microsoft provider

* removed redundant info

* fixed issues with signout and signin

* Updated version to 0.2.0

---------

Co-authored-by: Ankit Sinha <[email protected]>
ankitbko and Ankit Sinha authored Nov 20, 2023
1 parent f4ed0d6 commit b556562
Showing 28 changed files with 1,640 additions and 218 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -353,4 +353,5 @@ preview-src

.eslintcache
.eslintcache.browser
*.tsbuildinfo
*.tsbuildinfo
.vscode-test-web
50 changes: 47 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-extension=GitHub.vscode-pull-request-github-insiders"
"--disable-extension=GitHub.vscode-pull-request-github"
],
"skipFiles": ["<node_internals>/**/*.js", "**/node_modules/**/*.js"],
"smartStep": true,
@@ -23,6 +23,7 @@
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-extension=GitHub.vscode-pull-request-github",
"--disable-extension=GitHub.vscode-pull-request-github-insiders"
],
"skipFiles": ["<node_internals>/**/*.js", "**/node_modules/**/*.js"],
@@ -37,7 +38,7 @@
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-extension=GitHub.vscode-pull-request-github-insiders",
"--disable-extension=GitHub.vscode-pull-request-github",
],
"skipFiles": ["<node_internals>/**/*.js", "**/node_modules/**/*.js"],
"preLaunchTask": "npm: watch",
@@ -53,7 +54,7 @@
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-extension=GitHub.vscode-pull-request-github-insiders",
"--disable-extension=GitHub.vscode-pull-request-github",
],
"skipFiles": ["<node_internals>/**/*.js", "**/node_modules/**/*.js"],
"preLaunchTask": "npm: watch",
@@ -77,6 +78,49 @@
"smartStep": true,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/out/src/test/**/*.js"]
},
{
"type": "pwa-node",
"request": "launch",
"name": "Attach Web Test",
"program": "${workspaceFolder}/node_modules/@vscode/test-web/out/index.js",
"args": [
"--extensionTestsPath=dist/browser/test/index.js",
"--extensionDevelopmentPath=.",
"--browserType=chromium",
"--attach=9229"
],
"cascadeTerminateToConfigurations": [
"Launch Web Test"
],
"presentation": {
"hidden": true,
}
},
{
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Web Test",
"skipFiles": [
"<node_internals>/**"
],
"port": 9229,
"resolveSourceMapLocations": [
"!**/vs/**", // exclude core vscode sources
"!**/static/build/extensions/**", // exclude built-in extensions
],
"presentation": {
"hidden": true,
}
}
],
"compounds": [
{
"name": "Debug Web Test",
"configurations": [
"Attach Web Test",
"Launch Web Test"
]
}
]
}
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.2.0
- Fixed [#68](https://github.com/ankitbko/vscode-pull-request-azdo/issues/68) - Changed the authentication mechanism from PAT to OAuth using vscode provided authentication session. This will require users to re-authenticate.

## 0.0.25

- Removed explicit check of azdo url to resolve [#55](https://github.com/ankitbko/vscode-pull-request-azdo/issues/55)
12 changes: 1 addition & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -28,23 +28,13 @@ It's easy to get started with Azure Devops Pull Requests for Visual Studio Code.
1. Reload VS Code after the installation (click the reload button next to the extension).
1. Open your desired Azure Devops repository.
1. You will need to configure the `azdoPullRequests.projectName` and `azdoPullRequests.orgUrl` setting. You can configure it in workspace settings and commit it so others in your team wouldn't need to do this configuration again. (Look at the next section to understand the format of these settings).
1. You will need to configure [PAT token in Azure Devops](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) to login. Click on _show all scopes_ and select the following scopes for the token - `Code: Read & Write`, `Pull Request Threads: Read & Write`, `Work Items: Read & Write`, `Member Entitlement Management: Read`. Read more about these scope in next section.
1. A new tab would have appeared on the activity bar on the left. Open it and click on `Sign in` button. Enter the PAT token and press enter.
1. Signin to VS Code using same Microsoft account that you use to signin to Azure Devops. Authentication will work automatically. **PAT token is no longer required**.
1. You should be good to go!

## Features

Learn all about different features of the extension in the [wiki](https://github.com/ankitbko/vscode-pull-request-azdo/wiki).

### Scopes required by PAT Token

- **Minimum**: These scopes are required for extension to access Pull Requests and Threads. Without these scopes the extension will not even start.
- `Code: Read & Write`: Required to access repository metadata and pull requests.
- `Pull Request Threads: Read & Write`: Access Pull Request comment threads.
- **Additional**: These scopes are required to experience the extension in its completeness. Without these some functionality of extension may not work or the extension may cause errors.
- `Work Items: Read & Write`: Allow to read and associate work items to a PR.
- `Member Entitlement Management: Read`: Used to search for users when adding reviewers to PR.

## Configuring the extension

#### azdoPullRequests.orgUrl
23 changes: 17 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -12,10 +12,10 @@
},
"enableProposedApi": false,
"preview": true,
"version": "0.0.25",
"version": "0.2.0",
"publisher": "ankitbko",
"engines": {
"vscode": "^1.53.0"
"vscode": "^1.79.0"
},
"categories": [
"Other"
@@ -29,7 +29,8 @@
"capabilities": {
"untrustedWorkspaces": {
"supported": true
}
},
"virtualWorkspaces": true
},
"contributes": {
"configuration": {
@@ -732,6 +733,8 @@
"scripts": {
"postinstall": "yarn update-dts",
"vscode:prepublish": "yarn run bundle",
"browsertest:preprocess": "yarn run compile && tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs",
"browsertest": "yarn run browsertest:preprocess && node ./dist/browser/test/runTests.js",
"bundle": "webpack --mode production --env esbuild",
"bundle:node": "webpack --mode production --config-name extension:node --config-name webviews",
"bundle:web": "webpack --mode production --config-name extension:webworker --config-name webviews",
@@ -763,12 +766,17 @@
"@types/sinon": "7.0.11",
"@types/temp": "0.8.34",
"@types/uuid": "^8.3.0",
"@types/vscode": "1.79.0",
"@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "4.18.0",
"@typescript-eslint/parser": "4.18.0",
"@vscode/test-electron": "^1.6.1",
"@vscode/test-web": "^0.0.8",
"assert": "2.0.0",
"browserify-zlib": "0.2.0",
"buffer": "6.0.3",
"buffer": "^6.0.3",
"chai": "^4.2.0",
"constants-browserify": "^1.0.0",
"crypto-browserify": "3.12.0",
"css-loader": "5.1.3",
"dotenv": "^8.2.0",
@@ -779,7 +787,7 @@
"eslint-plugin-import": "2.22.1",
"fork-ts-checker-webpack-plugin": "6.1.1",
"glob": "7.1.6",
"https-browserify": "1.0.0",
"https-browserify": "^1.0.0",
"jsdom": "16.4.0",
"jsdom-global": "3.0.2",
"json5": "2.2.0",
@@ -791,13 +799,14 @@
"mocha-multi-reporters": "1.1.7",
"path-browserify": "1.0.1",
"prettier": "2.2.1",
"process": "^0.11.10",
"raw-loader": "4.0.2",
"react-testing-library": "7.0.1",
"remark-gfm": "^1.0.0",
"sinon": "9.0.0",
"source-map-support": "0.5.19",
"stream-browserify": "^3.0.0",
"stream-http": "3.1.1",
"stream-http": "^3.2.0",
"style-loader": "2.0.0",
"svg-inline-loader": "^0.8.2",
"temp": "0.9.4",
@@ -807,6 +816,7 @@
"tty": "1.0.1",
"ttypescript": "^1.5.12",
"typescript": "4.2.3",
"url": "^0.11.0",
"util": "0.12.3",
"vsce": "1.87.0",
"vscode-test": "^1.5.1",
@@ -831,6 +841,7 @@
"moment": "^2.22.1",
"node-emoji": "^1.8.1",
"node-fetch": "3.0.0-beta.9",
"os-browserify": "^0.3.0",
"query-string": "^6.2.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
47 changes: 45 additions & 2 deletions src/@types/git.d.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/


import { Uri, Event, Disposable, ProviderResult } from 'vscode';
export { ProviderResult } from 'vscode';

@@ -14,6 +15,11 @@ export interface InputBox {
value: string;
}

export const enum ForcePushMode {
Force,
ForceWithLease
}

export const enum RefType {
Head,
RemoteHead,
@@ -130,6 +136,16 @@ export interface CommitOptions {
signoff?: boolean;
signCommit?: boolean;
empty?: boolean;
noVerify?: boolean;
requireUserConfig?: boolean;
}

export interface FetchOptions {
remote?: string;
ref?: string;
all?: boolean;
prune?: boolean;
depth?: number;
}

export interface BranchQuery {
@@ -190,9 +206,10 @@ export interface Repository {
removeRemote(name: string): Promise<void>;
renameRemote(name: string, newName: string): Promise<void>;

fetch(options?: FetchOptions): Promise<void>;
fetch(remote?: string, ref?: string, depth?: number): Promise<void>;
pull(unshallow?: boolean): Promise<void>;
push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise<void>;
push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise<void>;

blame(path: string): Promise<string>;
log(options?: LogOptions): Promise<Commit[]>;
@@ -211,13 +228,34 @@ export interface RemoteSourceProvider {
readonly icon?: string; // codicon name
readonly supportsQuery?: boolean;
getRemoteSources(query?: string): ProviderResult<RemoteSource[]>;
getBranches?(url: string): ProviderResult<string[]>;
publishRepository?(repository: Repository): Promise<void>;
}

export interface Credentials {
readonly username: string;
readonly password: string;
}

export interface CredentialsProvider {
getCredentials(host: Uri): ProviderResult<Credentials>;
}

export interface PushErrorHandler {
handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise<boolean>;
}

export type APIState = 'uninitialized' | 'initialized';

export interface PublishEvent {
repository: Repository;
branch?: string;
}

export interface GitAPI {
readonly state: APIState;
readonly onDidChangeState: Event<APIState>;
readonly onDidPublish: Event<PublishEvent>;
readonly git: Git;
readonly repositories: Repository[];
readonly onDidOpenRepository: Event<Repository>;
@@ -226,7 +264,11 @@ export interface GitAPI {
toGitUri(uri: Uri, ref: string): Uri;
getRepository(uri: Uri): Repository | null;
init(root: Uri): Promise<Repository | null>;
openRepository(root: Uri): Promise<Repository | null>

registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
registerCredentialsProvider(provider: CredentialsProvider): Disposable;
registerPushErrorHandler(handler: PushErrorHandler): Disposable;
}

export interface GitExtension {
@@ -264,6 +306,7 @@ export const enum GitErrorCodes {
CantOpenResource = 'CantOpenResource',
GitNotFound = 'GitNotFound',
CantCreatePipe = 'CantCreatePipe',
PermissionDenied = 'PermissionDenied',
CantAccessRemote = 'CantAccessRemote',
RepositoryNotFound = 'RepositoryNotFound',
RepositoryIsLocked = 'RepositoryIsLocked',
@@ -282,4 +325,4 @@ export const enum GitErrorCodes {
PatchDoesNotApply = 'PatchDoesNotApply',
NoPathFound = 'NoPathFound',
UnknownPath = 'UnknownPath',
}
}
61 changes: 61 additions & 0 deletions src/@types/vscode-test-web.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env node
export declare type BrowserType = 'chromium' | 'firefox' | 'webkit';
export declare type VSCodeVersion = 'insiders' | 'stable' | 'sources';
export interface Options {
/**
* Browser to run the test against: 'chromium' | 'firefox' | 'webkit'
*/
browserType: BrowserType;
/**
* Absolute path to folder that contains one or more extensions (in subfolders).
* Extension folders include a `package.json` extension manifest.
*/
extensionDevelopmentPath?: string;
/**
* Absolute path to the extension tests runner module.
* Can be either a file path or a directory path that contains an `index.js`.
* The module is expected to have a `run` function of the following signature:
*
* ```ts
* function run(): Promise<void>;
* ```
*
* When running the extension test, the Extension Development Host will call this function
* that runs the test suite. This function should throws an error if any test fails.
*/
extensionTestsPath?: string;
/**
* The VS Code version to use. Valid versions are:
* - `'stable'` : The latest stable build
* - `'insiders'` : The latest insiders build
* - `'sources'`: From sources, served at localhost:8080 by running `yarn web` in the vscode repo
*
* Currently defaults to `insiders`, which is latest stable insiders.
*/
version?: VSCodeVersion;
/**
* Open the dev tools.
*/
devTools?: boolean;
/**
* Do not show the browser. Defaults to `true` if a extensionTestsPath is provided, `false` otherwise.
*/
headless?: boolean;
/**
* Expose browser debugging on this port number, and wait for the debugger to attach before running tests.
*/
waitForDebugger?: number;
/**
* The folder URI to open VSCode on
*/
folderUri?: string;
}
/**
* Runs the tests in a browser.
*
* @param options The options defining browser type, extension and test location.
*/
export declare function runTests(options: Options & {
extensionTestsPath: string;
}): Promise<void>;
export declare function open(options: Options): Promise<void>;
3 changes: 2 additions & 1 deletion src/api/api.d.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { Disposable, Event, Uri } from 'vscode';
import { APIState } from '../@types/git';
import { APIState, PublishEvent } from '../@types/git';

export interface InputBox {
value: string;
@@ -223,6 +223,7 @@ export interface IGit {
readonly repositories: Repository[];
readonly onDidOpenRepository: Event<Repository>;
readonly onDidCloseRepository: Event<Repository>;
readonly onDidPublish?: Event<PublishEvent>;

// Used by the actual git extension to indicate it has finished initializing state information
readonly state?: APIState;
25 changes: 23 additions & 2 deletions src/api/api1.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,8 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { APIState } from '../@types/git';
import { APIState, PublishEvent } from '../@types/git';
import Logger from '../common/logger';
import { TernarySearchTree } from '../common/utils';
import { API, IGit, Repository } from './api';

@@ -83,21 +84,41 @@ export class GitApiImpl implements API, IGit, vscode.Disposable {
readonly onDidCloseRepository: vscode.Event<Repository> = this._onDidCloseRepository.event;
private _onDidChangeState = new vscode.EventEmitter<APIState>();
readonly onDidChangeState: vscode.Event<APIState> = this._onDidChangeState.event;
private _onDidPublish = new vscode.EventEmitter<PublishEvent>();
readonly onDidPublish: vscode.Event<PublishEvent> = this._onDidPublish.event;

private _disposables: vscode.Disposable[];
constructor() {
this._disposables = [];
}

private _updateReposContext() {
const reposCount = Array.from(this._providers.values()).reduce((prev, current) => {
return prev + current.repositories.length;
}, 0);
vscode.commands.executeCommand('setContext', 'azdoOpenRepositoryCount', reposCount);
}

registerGitProvider(provider: IGit): vscode.Disposable {
Logger.appendLine(`Registering git provider`);
const handler = this._nextHandle();
this._providers.set(handler, provider);

this._disposables.push(provider.onDidCloseRepository(e => this._onDidCloseRepository.fire(e)));
this._disposables.push(provider.onDidOpenRepository(e => this._onDidOpenRepository.fire(e)));
this._disposables.push(
provider.onDidOpenRepository(e => {
Logger.appendLine(`Repository ${e.rootUri} has been opened`);
this._updateReposContext();
this._onDidOpenRepository.fire(e);
}),
);
if (provider.onDidChangeState) {
this._disposables.push(provider.onDidChangeState(e => this._onDidChangeState.fire(e)));
}
if (provider.onDidPublish) {
this._disposables.push(provider.onDidPublish(e => this._onDidPublish.fire(e)));
}
this._updateReposContext();

provider.repositories.forEach(repository => {
this._onDidOpenRepository.fire(repository);
6 changes: 0 additions & 6 deletions src/authentication/configuration.ts
Original file line number Diff line number Diff line change
@@ -5,12 +5,6 @@ export interface IHostConfiguration {
token: string | undefined;
}

export const AuthenticationScopes = ['499b84ac-1321-427f-aa17-267ca6975798/.default', 'offline_access'];

export const AuthenticationOptions: vscode.AuthenticationGetSessionOptions = {
createIfNone: true,
};

let USE_TEST_SERVER = false;

export const HostHelper = class {
20 changes: 19 additions & 1 deletion src/azdo/azdoRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { GitPullRequestSearchCriteria, GitRepository, PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces';
import {
GitPullRequest,
GitPullRequestSearchCriteria,
GitRepository,
PullRequestStatus,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import { Identity } from 'azure-devops-node-api/interfaces/IdentitiesInterfaces';
import * as vscode from 'vscode';
import Logger from '../common/logger';
@@ -148,6 +153,19 @@ export class AzdoRepository implements vscode.Disposable {
return await this.getPullRequests({ sourceRefName: branch });
}

async createPullRequest(pullRequest: GitPullRequest): Promise<PullRequestModel> {
Logger.debug(`Creating pull request`, AzdoRepository.ID);
try {
const metadata = await this.getMetadata();
const gitApi = await this._hub?.connection?.getGitApi();
const pullRequestModel = await gitApi?.createPullRequest(pullRequest, metadata?.id);
Logger.debug(`Created pull request`, AzdoRepository.ID);
return new PullRequestModel(this._telemetry, this, this.remote, pullRequestModel);
} catch (e) {
Logger.appendLine(`AzdoRepository> Creating pull request failed: ${e}`);
}
}

async getPullRequests(search: GitPullRequestSearchCriteria): Promise<PullRequestModel[]> {
try {
Logger.debug(`Fetch pull requests for branch - enter`, AzdoRepository.ID);
140 changes: 72 additions & 68 deletions src/azdo/credentials.ts
Original file line number Diff line number Diff line change
@@ -2,22 +2,25 @@ import * as azdev from 'azure-devops-node-api';
import { IRequestHandler } from 'azure-devops-node-api/interfaces/common/VsoBaseInterfaces';
import { Identity } from 'azure-devops-node-api/interfaces/IdentitiesInterfaces';
import * as vscode from 'vscode';
import { AuthenticationOptions, AuthenticationScopes } from '../authentication/configuration';
import Logger from '../common/logger';
import { ITelemetry } from '../common/telemetry';
import { SETTINGS_NAMESPACE } from '../constants';

const CREDENTIALS_COMPONENT_ID = 'azdo_component';
const PROJECT_SETTINGS = 'projectName';
const ORGURL_SETTINGS = 'orgUrl';
const TRY_AGAIN = vscode.l10n.t('Try again?');
const CANCEL = vscode.l10n.t('Cancel');
const ERROR = vscode.l10n.t('Error signing in to Azure DevOps');

export class Azdo {
private _authHandler: IRequestHandler;
public connection: azdev.WebApi;
public authenticatedUser: Identity | undefined;

constructor(public orgUrl: string, public projectName: string, token: string) {
this._authHandler = azdev.getPersonalAccessTokenHandler(token, true);
this._authHandler = azdev.getBearerHandler(token, true);
// this._authHandler = azdev.getPersonalAccessTokenHandler(token, true);
this.connection = this.getNewWebApiClient(this.orgUrl);
}

@@ -33,22 +36,15 @@ export class CredentialStore implements vscode.Disposable {
private _disposables: vscode.Disposable[];
private _onDidInitialize: vscode.EventEmitter<void> = new vscode.EventEmitter();
public readonly onDidInitialize: vscode.Event<void> = this._onDidInitialize.event;

private static PAT_TOKEN_KEY = 'azdoRepo.pat.';
private _sessionId: string | undefined;
private _sessionOptions: vscode.AuthenticationGetSessionOptions = { createIfNone: true };

constructor(private readonly _telemetry: ITelemetry, private readonly _secretStore: vscode.SecretStorage) {
this._disposables = [];
// this._disposables.push(vscode.authentication.onDidChangeSessions(() => {
// if (!this.isAuthenticated()) {
// return this.initialize();
// }
// }));

this._disposables.push(
_secretStore.onDidChange(e => {
const tokenKey = this.getTokenKey();
if (e.key === tokenKey && !this.isAuthenticated()) {
return this.initialize();
vscode.authentication.onDidChangeSessions(async () => {
if (!this.isAuthenticated()) {
return await this.initialize();
}
}),
);
@@ -59,7 +55,9 @@ export class CredentialStore implements vscode.Disposable {
}

public async reset() {
this._azdoAPI = undefined;
this._sessionOptions.forceNewSession = true;
this._sessionOptions.createIfNone = false;
this._sessionOptions.clearSessionPreference = true;
await this.initialize();
}

@@ -71,35 +69,11 @@ export class CredentialStore implements vscode.Disposable {
return this._azdoAPI;
}

private async requestPersonalAccessToken(): Promise<string | undefined> {
// Based on https://github.com/microsoft/azure-repos-vscode/blob/6bc90f0853086623486d0e527e9fe5a577370e9b/src/team-extension.ts#L74

const session = await vscode.authentication.getSession('microsoft', AuthenticationScopes, AuthenticationOptions);
const token = session.accessToken;

vscode.window.showInformationMessage('Successfully authorized extension in DevOps');

if (token) {
this._telemetry.sendTelemetryEvent('auth.manual');
}
return token;
}

public async logout(): Promise<void> {
// if (this._sessionId) {
// vscode.authentication.logout('github', this._sessionId);
// }

await this._secretStore.delete(this.getTokenKey(this._orgUrl ?? ''));
this._azdoAPI = undefined;
}

public getTokenKey(orgUrl?: string): string {
let url = this._orgUrl ?? '';
if (!!orgUrl) {
url = orgUrl;
}
return CredentialStore.PAT_TOKEN_KEY.concat(url);
this._sessionOptions.forceNewSession = true;
this._sessionOptions.createIfNone = false;
this._sessionOptions.clearSessionPreference = true;
}

public async login(): Promise<Azdo | undefined> {
@@ -124,39 +98,69 @@ export class CredentialStore implements vscode.Disposable {
}
Logger.appendLine(`orgUrl is ${this._orgUrl}`, CredentialStore.ID);

const tokenKey = this.getTokenKey(this._orgUrl);
const token = await this.getToken(tokenKey);
let retry: boolean = true;

if (!token) {
Logger.appendLine('PAT token is not provided');
this._telemetry.sendTelemetryEvent('auth.failed');
return undefined;
}
while (retry) {
try
{
const session = await this.getSession(this._sessionOptions);
if (!session) {
Logger.appendLine('Auth> Unable to get session', CredentialStore.ID);
this._telemetry.sendTelemetryEvent('auth.failed');
return undefined;
}
this._sessionId = session.id;
const token = await this.getToken(session);

try {
const azdo = new Azdo(this._orgUrl, projectName, token);
azdo.authenticatedUser = (await azdo.connection.connect()).authenticatedUser;
if (!token) {
Logger.appendLine('Auth> Unable to get token', CredentialStore.ID);
this._telemetry.sendTelemetryEvent('auth.failed');
return undefined;
}

Logger.debug(`Auth> Successful: Logged userid: ${azdo?.authenticatedUser?.id}`, CredentialStore.ID);
this._telemetry.sendTelemetryEvent('auth.success');
const azdo = new Azdo(this._orgUrl, projectName, token);
azdo.authenticatedUser = (await azdo.connection.connect()).authenticatedUser;

Logger.debug(`Auth> Successful: Logged userid: ${azdo?.authenticatedUser?.id}`, CredentialStore.ID);
this._telemetry.sendTelemetryEvent('auth.success');
this._sessionOptions.forceNewSession = false;
this._sessionOptions.createIfNone = true;
this._sessionOptions.clearSessionPreference = false;

return azdo;
} catch (e) {
Logger.appendLine(`Auth> Failed: ${e.message}`, CredentialStore.ID);
this._telemetry.sendTelemetryEvent('auth.failed');
if (e instanceof Error && e.stack) {
Logger.appendLine(e.stack);
}
if (e.message === 'User canceled authentication') {
return undefined;
}
}

return azdo;
} catch (e) {
await this._secretStore.delete(tokenKey);
vscode.window.showErrorMessage('Unable to authenticate. Signout and try again.');
return undefined;
retry = (await vscode.window.showErrorMessage(ERROR, TRY_AGAIN, CANCEL)) === TRY_AGAIN;
if (retry) {
this._sessionOptions.forceNewSession = true;
this._sessionOptions.createIfNone = false;
this._sessionOptions.clearSessionPreference = true;
}
}
}

private async getToken(tokenKey: string): Promise<string | undefined> {
let token = await this._secretStore.get(tokenKey);
if (!token) {
token = await this.requestPersonalAccessToken();
if (!!token) {
this._secretStore.store(tokenKey, token);
}
}
return token;
private async getSession(sessionOptions: vscode.AuthenticationGetSessionOptions): Promise<vscode.AuthenticationSession> {
return await vscode.authentication.getSession(
// Specifies the Microsoft Auth Provider
'microsoft',
// This GUID is the Azure DevOps GUID and you basically ask for a token that can be used to interact with AzDO. This is publicly documented all over
['499b84ac-1321-427f-aa17-267ca6975798/.default', 'offline_access'],
sessionOptions
);
}


private async getToken(session: vscode.AuthenticationSession): Promise<string | undefined> {
return session?.accessToken;
}

public getAuthenticatedUser(): Identity | undefined {
2 changes: 1 addition & 1 deletion src/azdo/repositoriesManager.ts
Original file line number Diff line number Diff line change
@@ -177,7 +177,7 @@ export class RepositoriesManager implements vscode.Disposable {

async authenticate(): Promise<boolean> {
// return !!(await this._credentialStore.login());
await this._credentialStore.initialize();
await this._credentialStore.reset();
if (this._credentialStore.getHub() !== undefined) {
return true;
}
2 changes: 1 addition & 1 deletion src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import { sep } from 'path';
import moment from 'moment';
import { Disposable, Event } from 'vscode';

export function uniqBy<T>(arr: T[], fn: (el: T) => string): T[] {
export function uniqBy<T>(arr: (T[] | readonly T[]), fn: (el: T) => string): T[] {
const seen = Object.create(null);

return arr.filter(el => {
3 changes: 3 additions & 0 deletions src/env/browser/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
exports.existsSync = function (path) {
return false;
};
104 changes: 103 additions & 1 deletion src/env/browser/ssh.ts
Original file line number Diff line number Diff line change
@@ -2,5 +2,107 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { parse as parseConfig } from 'ssh-config';

export const resolve = (url: string) => undefined;
const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/;
const URL_SCHEME_RE = /^([a-z-]+):\/\//;

export const sshParse = (url: string): Config | undefined => {
const urlMatch = URL_SCHEME_RE.exec(url);
if (urlMatch) {
const [fullSchemePrefix, scheme] = urlMatch;
if (scheme === 'ssh') {
url = url.slice(fullSchemePrefix.length);
} else {
return;
}
}
const match = SSH_URL_RE.exec(url);
if (!match) {
return;
}
const [, User, Host, path] = match;
return { User, Host, path };
};

/**
* Parse and resolve an SSH url. Resolves host aliases using the configuration
* specified by ~/.ssh/config, if present.
*
* Examples:
*
* resolve("git@github.com:Microsoft/vscode")
* {
* Host: 'github.com',
* HostName: 'github.com',
* User: 'git',
* path: 'Microsoft/vscode',
* }
*
* resolve("hub:queerviolet/vscode", resolverFromConfig("Host hub\n HostName github.com\n User git\n"))
* {
* Host: 'hub',
* HostName: 'github.com',
* User: 'git',
* path: 'queerviolet/vscode',
* }
*
* @param {string} url the url to parse
* @param {ConfigResolver?} resolveConfig ssh config resolver (default: from ~/.ssh/config)
* @returns {Config}
*/
export const resolve = (url: string, resolveConfig = Resolvers.current) => {
const config = sshParse(url);
return config && resolveConfig(config);
};

export function baseResolver(config: Config) {
return {
...config,
Hostname: config.Host,
};
}

/**
* SSH Config interface
*
* Note that this interface atypically capitalizes field names. This is for consistency
* with SSH config files.
*/
export interface Config {
Host: string;
[param: string]: string;
}

/**
* ConfigResolvers take a config, resolve some additional data (perhaps using
* a config file), and return a new Config.
*/
export type ConfigResolver = (config: Config) => Config;

export function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver {
const resolvers = chain.filter(x => !!x) as ConfigResolver[];
return (config: Config) =>
resolvers.reduce(
(resolved, next) => ({
...resolved,
...next(resolved),
}),
config,
);
}

export function resolverFromConfig(text: string): ConfigResolver {
const config = parseConfig(text);
return h => config.compute(h.Host);
}

export class Resolvers {
static default = baseResolver;

static fromConfig(conf: string) {
return chainResolvers(baseResolver, resolverFromConfig(conf));
}

static current = Resolvers.default;
}
82 changes: 10 additions & 72 deletions src/env/node/ssh.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,18 @@
import { parse as parseConfig } from 'ssh-config';
import { readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { join } from 'path';
import Logger from '../../common/logger';
import { baseResolver, chainResolvers, ConfigResolver, resolverFromConfig, sshParse } from '../browser/ssh';

const SSH_URL_RE = /^(?:([^@:]+)@)?([^:/]+):?(.+)$/;
const URL_SCHEME_RE = /^([a-z-]+):\/\//;
export class Resolvers {
static default = chainResolvers(baseResolver, resolverFromConfigFile());

/**
* SSH Config interface
*
* Note that this interface atypically capitalizes field names. This is for consistency
* with SSH config files.
*/
export interface Config {
Host: string;
[param: string]: string;
}
static fromConfig(conf: string) {
return chainResolvers(baseResolver, resolverFromConfig(conf));
}

/**
* ConfigResolvers take a config, resolve some additional data (perhaps using
* a config file), and return a new Config.
*/
export type ConfigResolver = (config: Config) => Config;
static current = Resolvers.default;
}

/**
* Parse and resolve an SSH url. Resolves host aliases using the configuration
@@ -51,45 +41,10 @@ export type ConfigResolver = (config: Config) => Config;
* @returns {Config}
*/
export const resolve = (url: string, resolveConfig = Resolvers.current) => {
const config = parse(url);
const config = sshParse(url);
return config && resolveConfig(config);
};

export class Resolvers {
static default = chainResolvers(baseResolver, resolverFromConfigFile());

static fromConfig(conf: string) {
return chainResolvers(baseResolver, resolverFromConfig(conf));
}

static current = Resolvers.default;
}

const parse = (url: string): Config | undefined => {
const urlMatch = URL_SCHEME_RE.exec(url);
if (urlMatch) {
const [fullSchemePrefix, scheme] = urlMatch;
if (scheme === 'ssh') {
url = url.slice(fullSchemePrefix.length);
} else {
return;
}
}
const match = SSH_URL_RE.exec(url);
if (!match) {
return;
}
const [, User, Host, path] = match;
return { User, Host, path };
};

function baseResolver(config: Config) {
return {
...config,
Hostname: config.Host,
};
}

function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')): ConfigResolver | undefined {
try {
const config = readFileSync(configPath).toString();
@@ -98,20 +53,3 @@ function resolverFromConfigFile(configPath = join(homedir(), '.ssh', 'config')):
Logger.appendLine(`${configPath}: ${error.message}`);
}
}

export function resolverFromConfig(text: string): ConfigResolver {
const config = parseConfig(text);
return h => config.compute(h.Host);
}

function chainResolvers(...chain: (ConfigResolver | undefined)[]): ConfigResolver {
const resolvers = chain.filter(x => !!x) as ConfigResolver[];
return (config: Config) =>
resolvers.reduce(
(resolved, next) => ({
...resolved,
...next(resolved),
}),
config,
);
}
17 changes: 15 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ import { handler as uriHandler } from './common/uri';
import { onceEvent } from './common/utils';
import { EXTENSION_ID, SETTINGS_NAMESPACE } from './constants';
import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitProviders/api';
import { MockGitProvider } from './gitProviders/mockGitProvider';
import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider';
import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider';
import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider';
@@ -64,8 +65,8 @@ async function init(
const localStorageService = new LocalStorageService(context.workspaceState);
const fileReviewedStatusService = new FileReviewedStatusService(localStorageService);

context.secrets.onDidChange(async e => {
if (e.key === credentialStore.getTokenKey()) {
vscode.authentication.onDidChangeSessions(async (e) => {
if (e.provider.id === 'microsoft') {
await reposManager.clearCredentialCache();
if (reviewManagers) {
reviewManagers.forEach(reviewManager => reviewManager.updateState());
@@ -191,6 +192,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<GitApi
telemetry = new TelemetryReporter(EXTENSION_ID, version, aiKey);
context.subscriptions.push(telemetry);

// const session = await registerGithubExtension();

PersistentState.init(context);
const credentialStore = new CredentialStore(telemetry, context.secrets);
context.subscriptions.push(credentialStore);
@@ -199,7 +202,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<GitApi
const builtInGitProvider = await registerBuiltinGitProvider(credentialStore, apiImpl);
if (builtInGitProvider) {
context.subscriptions.push(builtInGitProvider);
} else {
const mockGitProvider = new MockGitProvider();
context.subscriptions.push(apiImpl.registerGitProvider(mockGitProvider));
}

const liveshareGitProvider = registerLiveShareGitProvider(apiImpl);
context.subscriptions.push(liveshareGitProvider);
const liveshareApiPromise = liveshareGitProvider.initialize();
@@ -220,6 +227,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<GitApi
return apiImpl;
}

const SCOPES = ['vso.identity', 'vso.code'];
async function registerGithubExtension() {
const session = await vscode.authentication.getSession('azdo', SCOPES, { createIfNone: false });
return session;
}

export async function deactivate() {
if (telemetry) {
telemetry.dispose();
15 changes: 13 additions & 2 deletions src/gitProviders/builtinGit.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { APIState, GitAPI, GitExtension } from '../@types/git';
import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git';
import { IGit, Repository } from '../api/api';

export class BuiltinGitProvider implements IGit, vscode.Disposable {
@@ -22,17 +22,28 @@ export class BuiltinGitProvider implements IGit, vscode.Disposable {
readonly onDidCloseRepository: vscode.Event<Repository> = this._onDidCloseRepository.event;
private _onDidChangeState = new vscode.EventEmitter<APIState>();
readonly onDidChangeState: vscode.Event<APIState> = this._onDidChangeState.event;
private _onDidPublish = new vscode.EventEmitter<PublishEvent>();
readonly onDidPublish: vscode.Event<PublishEvent> = this._onDidPublish.event;

private _gitAPI: GitAPI;
private _disposables: vscode.Disposable[];

private constructor(extension: vscode.Extension<GitExtension>) {
const gitExtension = extension.exports;
this._gitAPI = gitExtension.getAPI(1);
try {
this._gitAPI = gitExtension.getAPI(1);
} catch (e) {
// The git extension will throw if a git model cannot be found, i.e. if git is not installed.
vscode.window.showErrorMessage(
'Activating the AzDO Pull Requests extension failed. Please make sure you have git installed.',
);
throw e;
}
this._disposables = [];
this._disposables.push(this._gitAPI.onDidCloseRepository(e => this._onDidCloseRepository.fire(e as any)));
this._disposables.push(this._gitAPI.onDidOpenRepository(e => this._onDidOpenRepository.fire(e as any)));
this._disposables.push(this._gitAPI.onDidChangeState(e => this._onDidChangeState.fire(e)));
this._disposables.push(this._gitAPI.onDidPublish(e => this._onDidPublish.fire(e)));
}

static async createProvider(): Promise<BuiltinGitProvider | undefined> {
43 changes: 43 additions & 0 deletions src/gitProviders/mockGitProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { APIState, GitAPI, GitExtension, PublishEvent } from '../@types/git';
import { IGit, Repository } from '../api/api';
import { MockRepository } from './mockRepository';

export class MockGitProvider implements IGit, vscode.Disposable {
private _mockRepository: MockRepository;
get repositories(): Repository[] {
return [this._mockRepository];
}

get state(): APIState {
return 'initialized';
}

private _onDidOpenRepository = new vscode.EventEmitter<Repository>();
readonly onDidOpenRepository: vscode.Event<Repository> = this._onDidOpenRepository.event;
private _onDidCloseRepository = new vscode.EventEmitter<Repository>();
readonly onDidCloseRepository: vscode.Event<Repository> = this._onDidCloseRepository.event;
private _onDidChangeState = new vscode.EventEmitter<APIState>();
readonly onDidChangeState: vscode.Event<APIState> = this._onDidChangeState.event;
private _onDidPublish = new vscode.EventEmitter<PublishEvent>();
readonly onDidPublish: vscode.Event<PublishEvent> = this._onDidPublish.event;

private _disposables: vscode.Disposable[];

public constructor() {
this._disposables = [];
this._mockRepository = new MockRepository();
this._mockRepository.addRemote('origin', 'https://anksinha@dev.azure.com/anksinha/test/_git/test');
this._onDidCloseRepository.fire(this._mockRepository);
this._onDidOpenRepository.fire(this._mockRepository);
}

dispose() {
this._disposables.forEach(disposable => disposable.dispose());
}
}
305 changes: 305 additions & 0 deletions src/gitProviders/mockRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
import { Uri } from 'vscode';
import type {
Branch,
BranchQuery,
Change,
Commit,
CommitOptions,
InputBox,
Ref,
Repository,
RepositoryState,
RepositoryUIState,
} from '../api/api';
import { RefType } from '../api/api1';

type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

export class MockRepository implements Repository {
commit(message: string, opts?: CommitOptions): Promise<void> {
return Promise.reject(new Error(`Unexpected commit(${message}, ${opts})`));
}
renameRemote(name: string, newName: string): Promise<void> {
return Promise.reject(new Error(`Unexpected renameRemote (${name}, ${newName})`));
}
getGlobalConfig(key: string): Promise<string> {
return Promise.reject(new Error(`Unexpected getGlobalConfig(${key})`));
}
detectObjectType(object: string): Promise<{ mimetype: string; encoding?: string | undefined }> {
return Promise.reject(new Error(`Unexpected detectObjectType(${object})`));
}
buffer(ref: string, path: string): Promise<Buffer> {
return Promise.reject(new Error(`Unexpected buffer(${ref}, ${path})`));
}
clean(paths: string[]): Promise<void> {
return Promise.reject(new Error(`Unexpected clean(${paths})`));
}
diffWithHEAD(path?: any): any {
return Promise.reject(new Error(`Unexpected diffWithHEAD(${path})`));
}
diffIndexWithHEAD(path?: any): any {
return Promise.reject(new Error(`Unexpected diffIndexWithHEAD(${path})`));
}
diffIndexWith(ref: any, path?: any): any {
return Promise.reject(new Error(`Unexpected diffIndexWith(${ref}, ${path})`));
}
getMergeBase(ref1: string, ref2: string): Promise<string> {
return Promise.reject(new Error(`Unexpected getMergeBase(${ref1}, ${ref2})`));
}
log(options?: any): Promise<Commit[]> {
return Promise.reject(new Error(`Unexpected log(${options})`));
}

private _state: Mutable<RepositoryState> = {
HEAD: undefined,
refs: [],
remotes: [],
submodules: [],
rebaseCommit: undefined,
mergeChanges: [],
indexChanges: [],
workingTreeChanges: [],
onDidChange: () => ({ dispose() {} }),
};
private _config: Map<string, string> = new Map();
private _branches: Branch[] = [];
private _expectedFetches: { remoteName?: string; ref?: string; depth?: number }[] = [];
private _expectedPulls: { unshallow?: boolean }[] = [];
private _expectedPushes: { remoteName?: string; branchName?: string; setUpstream?: boolean }[] = [];

inputBox: InputBox = { value: '' };

rootUri = Uri.file('/root');

state: RepositoryState = this._state;

ui: RepositoryUIState = {
selected: true,
onDidChange: () => ({ dispose() {} }),
};

async getConfigs(): Promise<{ key: string; value: string }[]> {
return Array.from(this._config, ([k, v]) => ({ key: k, value: v }));
}

async getConfig(key: string): Promise<string> {
return this._config.get(key) || '';
}

async setConfig(key: string, value: string): Promise<string> {
const oldValue = this._config.get(key) || '';
this._config.set(key, value);
return oldValue;
}

getObjectDetails(treeish: string, treePath: string): Promise<{ mode: string; object: string; size: number }> {
return Promise.reject(new Error(`Unexpected getObjectDetails(${treeish}, ${treePath})`));
}

show(ref: string, treePath: string): Promise<string> {
return Promise.reject(new Error(`Unexpected show(${ref}, ${treePath})`));
}

getCommit(ref: string): Promise<Commit> {
return Promise.reject(new Error(`Unexpected getCommit(${ref})`));
}

apply(patch: string, reverse?: boolean | undefined): Promise<void> {
return Promise.reject(new Error(`Unexpected apply(..., ${reverse})`));
}

diff(cached?: boolean | undefined): Promise<string> {
return Promise.reject(new Error(`Unexpected diff(${cached})`));
}

diffWith(ref: string): Promise<Change[]>;
diffWith(ref: string, treePath: string): Promise<string>;
diffWith(ref: string, treePath?: string) {
return Promise.reject(new Error(`Unexpected diffWith(${ref}, ${treePath})`));
}

diffBlobs(object1: string, object2: string): Promise<string> {
return Promise.reject(new Error(`Unexpected diffBlobs(${object1}, ${object2})`));
}

diffBetween(ref1: string, ref2: string): Promise<Change[]>;
diffBetween(ref1: string, ref2: string, treePath: string): Promise<string>;
diffBetween(ref1: string, ref2: string, treePath?: string) {
return Promise.reject(new Error(`Unexpected diffBlobs(${ref1}, ${ref2}, ${treePath})`));
}

hashObject(data: string): Promise<string> {
return Promise.reject(new Error('Unexpected hashObject(...)'));
}

async createBranch(name: string, checkout: boolean, ref?: string | undefined): Promise<void> {
if (this._branches.some(b => b.name === name)) {
throw new Error(`A branch named ${name} already exists`);
}

const branch = {
type: RefType.Head,
name,
commit: ref,
};

if (checkout) {
this._state.HEAD = branch;
}

this._state.refs.push(branch);
this._branches.push(branch);
}

async deleteBranch(name: string, force?: boolean | undefined): Promise<void> {
const index = this._branches.findIndex(b => b.name === name);
if (index === -1) {
throw new Error(`Attempt to delete nonexistent branch ${name}`);
}
this._branches.splice(index, 1);
}

async getBranch(name: string): Promise<Branch> {
const branch = this._branches.find(b => b.name === name);
if (!branch) {
throw new Error(`getBranch called with unrecognized name "${name}"`);
}
return branch;
}

async getBranches(_query: BranchQuery): Promise<Ref[]> {
return [];
}

async setBranchUpstream(name: string, upstream: string): Promise<void> {
const index = this._branches.findIndex(b => b.name === name);
if (index === -1) {
throw new Error(`setBranchUpstream called with unrecognized branch name ${name})`);
}

const match = /^refs\/remotes\/([^\/]+)\/(.+)$/.exec(upstream);
if (!match) {
throw new Error(
`upstream ${upstream} provided to setBranchUpstream did match pattern refs/remotes/<name>/<remote-branch>`,
);
}
const [, remoteName, remoteRef] = match;

const existing = this._branches[index];
const replacement = {
...existing,
upstream: {
remote: remoteName,
name: remoteRef,
},
};
this._branches.splice(index, 1, replacement);

if (this._state.HEAD === existing) {
this._state.HEAD = replacement;
}
}

status(): Promise<void> {
return Promise.reject(new Error('Unexpected status()'));
}

async checkout(treeish: string): Promise<void> {
const branch = this._branches.find(b => b.name === treeish);

// Also: tags

if (!branch) {
throw new Error(`checked called with unrecognized ref ${treeish}`);
}

this._state.HEAD = branch;
}

async addRemote(name: string, url: string): Promise<void> {
if (this._state.remotes.some(r => r.name === name)) {
throw new Error(`A remote named ${name} already exists.`);
}

this._state.remotes.push({
name,
fetchUrl: url,
pushUrl: url,
isReadOnly: false,
});
}

async removeRemote(name: string): Promise<void> {
const index = this._state.remotes.findIndex(r => r.name === name);
if (index === -1) {
throw new Error(`No remote named ${name} exists.`);
}
this._state.remotes.splice(index, 1);
}

async fetch(arg0?: string | undefined | any, ref?: string | undefined, depth?: number | undefined): Promise<void> {
let remoteName: string | undefined;
if (typeof arg0 === 'object') {
remoteName = arg0.remote;
ref = arg0.ref;
depth = arg0.depth;
} else {
remoteName = arg0;
}

const index = this._expectedFetches.findIndex(f => f.remoteName === remoteName && f.ref === ref && f.depth === depth);
if (index === -1) {
throw new Error(`Unexpected fetch(${remoteName}, ${ref}, ${depth})`);
}

if (ref) {
const match = /^(?:\+?[^:]+\:)?(.*)$/.exec(ref);
if (match) {
const [, localRef] = match;
await this.createBranch(localRef, false);
}
}

this._expectedFetches.splice(index, 1);
}

async pull(unshallow?: boolean | undefined): Promise<void> {
const index = this._expectedPulls.findIndex(f => f.unshallow === unshallow);
if (index === -1) {
throw new Error(`Unexpected pull(${unshallow})`);
}
this._expectedPulls.splice(index, 1);
}

async push(
remoteName?: string | undefined,
branchName?: string | undefined,
setUpstream?: boolean | undefined,
): Promise<void> {
const index = this._expectedPushes.findIndex(
f => f.remoteName === remoteName && f.branchName === branchName && f.setUpstream === setUpstream,
);
if (index === -1) {
throw new Error(`Unexpected push(${remoteName}, ${branchName}, ${setUpstream})`);
}
this._expectedPushes.splice(index, 1);
}

blame(treePath: string): Promise<string> {
return Promise.reject(new Error(`Unexpected blame(${treePath})`));
}

expectFetch(remoteName?: string, ref?: string, depth?: number) {
this._expectedFetches.push({ remoteName, ref, depth });
}

expectPull(unshallow?: boolean) {
this._expectedPulls.push({ unshallow });
}

expectPush(remoteName?: string, branchName?: string, setUpstream?: boolean) {
this._expectedPushes.push({ remoteName, branchName, setUpstream });
}
}
41 changes: 41 additions & 0 deletions src/test/browser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// This file is providing the test runner to use when running extension tests.
import * as vscode from 'vscode';
require('mocha/mocha');
import { mockWebviewEnvironment } from '../mocks/mockWebviewEnvironment';
import { EXTENSION_ID } from '../../constants';

async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null, failures?: number) => void): Promise<any> {
// Ensure the dev-mode extension is activated
await vscode.extensions.getExtension(EXTENSION_ID)!.activate();

mockWebviewEnvironment.install(global);

mocha.setup({
ui: 'bdd',
reporter: undefined
});

try {
const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r);
importAll(require.context('../', true, /\.test$/));
} catch (e) {
console.log(e);
}

if (process.env.TEST_JUNIT_XML_PATH) {
mocha.reporter('mocha-multi-reporters', {
reporterEnabled: 'mocha-junit-reporter, spec',
mochaJunitReporterReporterOptions: {
mochaFile: process.env.TEST_JUNIT_XML_PATH,
suiteTitleSeparatedBy: ' / ',
outputs: true,
},
});
}

return mocha.run(failures => clb(null, failures));
}

export function run(testsRoot: string, clb: (error: Error | null, failures?: number) => void): void {
runAllExtensionTests(testsRoot, clb);
}
28 changes: 28 additions & 0 deletions src/test/browser/runTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as path from 'path';
import { BrowserType, runTests } from '@vscode/test-web';

async function go() {
try {
const extensionDevelopmentPath = path.resolve(__dirname, '../../../');
const extensionTestsPath = path.resolve(__dirname, './index');
console.log(extensionDevelopmentPath, extensionTestsPath);
const attachArgName = '--waitForDebugger=';
const waitForDebugger = process.argv.find(arg => arg.startsWith(attachArgName));
const browserTypeName = '--browserType=';
const browserType = process.argv.find(arg => arg.startsWith(browserTypeName));

/**
* Basic usage
*/
await runTests({
browserType: browserType ? <BrowserType>browserType.slice(browserTypeName.length) : 'chromium',
extensionDevelopmentPath,
extensionTestsPath,
waitForDebugger: waitForDebugger ? Number(waitForDebugger.slice(attachArgName.length)) : undefined,
});
} catch (e) {
console.log(e);
}
}

go();
4 changes: 2 additions & 2 deletions src/view/pullRequestCommentController.ts
Original file line number Diff line number Diff line change
@@ -91,7 +91,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac
this.setContextKey(this.pullRequestModel.hasPendingReview);
}

private getPREditors(editors: vscode.TextEditor[]): vscode.TextEditor[] {
private getPREditors(editors: readonly vscode.TextEditor[]): vscode.TextEditor[] {
return editors.filter(editor => {
if (editor.document.uri.scheme !== URI_SCHEME_PR) {
return false;
@@ -159,7 +159,7 @@ export class PullRequestCommentController implements CommentHandler, CommentReac
this.addThreadsForEditors(prEditors);
}

private onDidChangeOpenEditors(editors: vscode.TextEditor[]): void {
private onDidChangeOpenEditors(editors: readonly vscode.TextEditor[]): void {
const prEditors = this.getPREditors(editors);
const removed = this._openPREditors.filter(x => !prEditors.includes(x));
const added = prEditors.filter(x => !this._openPREditors.includes(x));
2 changes: 1 addition & 1 deletion src/view/reviewCommentController.ts
Original file line number Diff line number Diff line change
@@ -335,7 +335,7 @@ export class ReviewCommentController
);
}

private visibleEditorsEqual(a: vscode.TextEditor[], b: vscode.TextEditor[]): boolean {
private visibleEditorsEqual(a: vscode.TextEditor[], b: readonly vscode.TextEditor[]): boolean {
a = a.filter(ed => ed.document.uri.scheme !== 'comment');
b = b.filter(ed => ed.document.uri.scheme !== 'comment');

8 changes: 8 additions & 0 deletions test_workspace/pr.http
Original file line number Diff line number Diff line change
@@ -41,4 +41,12 @@ Authorization: Basic {{user}} {{$dotenv pat}}

###
OPTIONS https://vsaex.dev.azure.com/anksinha/_apis/MemberEntitlementManagement
Authorization: Basic {{user}} {{$dotenv pat}}

###
GET https://dev.azure.com/anksinha/_apis/projects/{{projectName}}/teams?$mine=True&api-version=6.1-preview.3
Authorization: Basic {{user}} {{$dotenv pat}}

###
GET https://dev.azure.com/anksinha/_apis/git/repositories/{{reposiory}}/pullrequests?searchCriteria.reviewerId=63f938a9-93f3-4991-aef2-11b73f5ceda4&api-version=6.1-preview.1
Authorization: Basic {{user}} {{$dotenv pat}}
54 changes: 40 additions & 14 deletions webpack.config.js
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ const { ESBuildMinifyPlugin } = require('esbuild-loader');
const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin');
const JSON5 = require('json5');
const TerserPlugin = require('terser-webpack-plugin');
const webpack = require('webpack');

async function resolveTSConfig(configFile) {
const data = await new Promise((resolve, reject) => {
@@ -172,11 +173,27 @@ async function getExtensionConfig(target, mode, env) {
}),
];

if (target === 'webworker') {
plugins.push(new webpack.ProvidePlugin({
process: path.join(
__dirname,
'node_modules',
'process',
'browser.js'),
Buffer: ['buffer', 'Buffer'],
}));
}

const entry = {
extension: './src/extension.ts',
};
if (target === 'webworker') {
entry['test/index'] = './src/test/browser/index.ts';
}

return {
name: `extension:${target}`,
entry: {
extension: './src/extension.ts',
},
entry,
mode: mode,
target: target,
devtool: mode !== 'production' ? 'source-map' : undefined,
@@ -286,6 +303,7 @@ async function getExtensionConfig(target, mode, env) {
),
'../env/node/net': path.resolve(__dirname, 'src', 'env', 'browser', 'net'),
'../env/node/ssh': path.resolve(__dirname, 'src', 'env', 'browser', 'ssh'),
'../../env/node/ssh': path.resolve(__dirname, 'src', 'env', 'browser', 'ssh'),
'./env/node/gitProviders/api': path.resolve(
__dirname,
'src',
@@ -303,24 +321,32 @@ async function getExtensionConfig(target, mode, env) {
target === 'webworker'
? {
path: require.resolve('path-browserify'),
url: false,
// stream: require.resolve("stream-browserify"),
url: require.resolve('url'),
stream: require.resolve("stream-browserify"),
// zlib: require.resolve("browserify-zlib"),
// crypto: require.resolve("crypto-browserify"),
// http: require.resolve("stream-http"),
// https: require.resolve("https-browserify"),
http: require.resolve("stream-http"),
https: require.resolve("https-browserify"),
// util: require.resolve("util/"),
// buffer: require.resolve("buffer/"),
buffer: require.resolve("buffer/"),
// assert: require.resolve("assert/"),
stream:false,
// stream:false,
zlib: false,
crypto:false,
http: false,
https:false,
// http: false,
//https:false,
util: false,
buffer:false,
assert:false,
fs: false,
// buffer:false,
'assert': require.resolve('assert'),
'os': require.resolve('os-browserify/browser'),
"constants": require.resolve("constants-browserify"),
fs: path.resolve(
__dirname,
'src',
'env',
'browser',
'fs',
),
net: false,
tls: false

755 changes: 734 additions & 21 deletions yarn.lock

Large diffs are not rendered by default.

0 comments on commit b556562

Please sign in to comment.