diff --git a/.azure-pipelines/common-steps.yml b/.azure-pipelines/common-steps.yml index b36a888f..335fad05 100644 --- a/.azure-pipelines/common-steps.yml +++ b/.azure-pipelines/common-steps.yml @@ -18,14 +18,11 @@ steps: # echo no-op # displayName: Generate service-schema.json -- script: npm run unittest - displayName: Run unit tests - - script: Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & displayName: Start xvfb - script: npm run test - displayName: Run integration tests + displayName: Run tests env: DISPLAY: ':99.0' diff --git a/package-lock.json b/package-lock.json index 0447a551..03082540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@azure/arm-appservice": "^6.1.0", - "@azure/arm-resources": "^4.0.0", "@azure/arm-subscriptions": "^3.0.0", "@azure/ms-rest-azure-env": "^2.0.0", "@azure/ms-rest-nodeauth": "^3.0.6", @@ -41,7 +40,7 @@ "ts-loader": "^8.0.14", "ts-node": "7.0.1", "tslint": "5.8.0", - "typescript": "^4.1.0", + "typescript": "~5.2.2", "webpack": "^5.76.0", "webpack-cli": "^4.4.0" }, @@ -77,18 +76,6 @@ "tslib": "^1.10.0" } }, - "node_modules/@azure/arm-resources": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-4.2.2.tgz", - "integrity": "sha512-Oic1OcEwgex3X1KkhP9UM/E/taIaS9oID7PL/CZ8knD7qtVNSRvTxP3uvD3ZpH9NYBYXngJsX5xyRu66iFN+rA==", - "deprecated": "Please note, versions of this package with version numbers 4.2.2 and below have been deprecated as of 31-March-2022. We strongly encourage you to upgrade to version 5.0.0 or above to continue receiving updates. Refer to our deprecation policy: https://azure.github.io/azure-sdk/policies_support.html for more details.", - "dependencies": { - "@azure/core-auth": "^1.1.4", - "@azure/ms-rest-azure-js": "^2.1.0", - "@azure/ms-rest-js": "^2.2.0", - "tslib": "^1.10.0" - } - }, "node_modules/@azure/arm-subscriptions": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@azure/arm-subscriptions/-/arm-subscriptions-3.1.2.tgz", @@ -3760,16 +3747,16 @@ } }, "node_modules/typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/underscore": { @@ -4297,17 +4284,6 @@ "tslib": "^1.10.0" } }, - "@azure/arm-resources": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-4.2.2.tgz", - "integrity": "sha512-Oic1OcEwgex3X1KkhP9UM/E/taIaS9oID7PL/CZ8knD7qtVNSRvTxP3uvD3ZpH9NYBYXngJsX5xyRu66iFN+rA==", - "requires": { - "@azure/core-auth": "^1.1.4", - "@azure/ms-rest-azure-js": "^2.1.0", - "@azure/ms-rest-js": "^2.2.0", - "tslib": "^1.10.0" - } - }, "@azure/arm-subscriptions": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@azure/arm-subscriptions/-/arm-subscriptions-3.1.2.tgz", @@ -7109,9 +7085,9 @@ } }, "typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true }, "underscore": { diff --git a/package.json b/package.json index 2438537b..bbd01707 100644 --- a/package.json +++ b/package.json @@ -134,10 +134,9 @@ "vscode:prepublish": "npm run compile", "compile": "webpack --mode production --progress --color", "compile:dev": "webpack --mode development --progress --color", + "compile:test": "tsc --project ./tsconfig.test.json", "watch": "webpack --mode development --progress --color --watch", - "compile:test": "npm run compile:dev && tsc -p ./", - "test": "npm run compile:test && node ./out/test/runTest.js", - "unittest": "npm run compile:test && mocha -u tdd out/unittest/**/*.js" + "test": "npm run compile:test && node ./out/test/runTest.js" }, "devDependencies": { "@types/fs-extra": "4.0.5", @@ -157,13 +156,12 @@ "ts-loader": "^8.0.14", "ts-node": "7.0.1", "tslint": "5.8.0", - "typescript": "^4.1.0", + "typescript": "~5.2.2", "webpack": "^5.76.0", "webpack-cli": "^4.4.0" }, "dependencies": { "@azure/arm-appservice": "^6.1.0", - "@azure/arm-resources": "^4.0.0", "@azure/arm-subscriptions": "^3.0.0", "@azure/ms-rest-azure-env": "^2.0.0", "@azure/ms-rest-nodeauth": "^3.0.6", diff --git a/src/configure/activate.ts b/src/configure/activate.ts index 197d38f3..41550a1a 100644 --- a/src/configure/activate.ts +++ b/src/configure/activate.ts @@ -5,8 +5,7 @@ import { telemetryHelper } from '../helpers/telemetryHelper'; export async function activateConfigurePipeline(): Promise { vscode.commands.registerCommand('azure-pipelines.configure-pipeline', async () => { - telemetryHelper.initialize('configure-pipeline'); - await telemetryHelper.callWithTelemetryAndErrorHandling(async () => { + await telemetryHelper.callWithTelemetryAndErrorHandling('azurePipelines.configure-pipeline', async () => { await configurePipeline(); }); }); diff --git a/src/configure/clients/azure/appServiceClient.ts b/src/configure/clients/azure/appServiceClient.ts index 77242b48..8d1576c0 100644 --- a/src/configure/clients/azure/appServiceClient.ts +++ b/src/configure/clients/azure/appServiceClient.ts @@ -1,101 +1,81 @@ import { v4 as uuid } from 'uuid'; -import { ResourceManagementModels } from '@azure/arm-resources'; import { WebSiteManagementClient, WebSiteManagementModels } from '@azure/arm-appservice'; import { TokenCredentialsBase } from '@azure/ms-rest-nodeauth'; -import { AzureResourceClient } from './azureResourceClient'; -import { WebAppKind, ParsedAzureResourceId } from '../../model/models'; +import { WebAppKind, ValidatedSite } from '../../model/models'; import { Messages } from '../../../messages'; -export class AppServiceClient extends AzureResourceClient { +export class AppServiceClient { - private static resourceType = 'Microsoft.Web/sites'; private webSiteManagementClient: WebSiteManagementClient; private tenantId: string; private portalUrl: string; constructor(credentials: TokenCredentialsBase, tenantId: string, portalUrl: string, subscriptionId: string) { - super(credentials, subscriptionId); this.webSiteManagementClient = new WebSiteManagementClient(credentials, subscriptionId); this.tenantId = tenantId; this.portalUrl = portalUrl; } - public async getAppServiceResource(resourceId: string): Promise { - let parsedResourceId: ParsedAzureResourceId = new ParsedAzureResourceId(resourceId); - return await this.webSiteManagementClient.webApps.get(parsedResourceId.resourceGroup, parsedResourceId.resourceName); - } - - public async GetAppServices(filterForResourceKind: WebAppKind): Promise { - let resourceList = await this.getResourceList(AppServiceClient.resourceType); - if (!!filterForResourceKind) { - resourceList = resourceList.filter(resource => resource.kind === filterForResourceKind); - } - - return resourceList; + public async getAppServices(filterForResourceKind: WebAppKind): Promise { + const sites = await this.webSiteManagementClient.webApps.list(); + return sites.filter(site => site.kind === filterForResourceKind); } public async getDeploymentCenterUrl(resourceId: string): Promise { return `${this.portalUrl}/#@${this.tenantId}/resource/${resourceId}/vstscd`; } - public async getAzurePipelineUrl(resourceId: string): Promise { - let metadata = await this.getAppServiceMetadata(resourceId); - if (metadata.properties['VSTSRM_BuildDefinitionWebAccessUrl']) { + public async getAzurePipelineUrl(site: ValidatedSite): Promise { + const metadata = await this.getAppServiceMetadata(site); + if (metadata.properties?.['VSTSRM_BuildDefinitionWebAccessUrl']) { return metadata.properties['VSTSRM_BuildDefinitionWebAccessUrl']; } throw new Error(Messages.cannotFindPipelineUrlInMetaDataException); } - public async getAppServiceConfig(resourceId: string): Promise { - let parsedResourceId: ParsedAzureResourceId = new ParsedAzureResourceId(resourceId); - return this.webSiteManagementClient.webApps.getConfiguration(parsedResourceId.resourceGroup, parsedResourceId.resourceName); + public async getAppServiceConfig(site: ValidatedSite): Promise { + return this.webSiteManagementClient.webApps.getConfiguration(site.resourceGroup, site.name); } - public async updateScmType(resourceId: string): Promise { - let siteConfig = await this.getAppServiceConfig(resourceId); + public async updateScmType(site: ValidatedSite): Promise { + const siteConfig = await this.getAppServiceConfig(site); siteConfig.scmType = ScmType.VSTSRM; - let parsedResourceId: ParsedAzureResourceId = new ParsedAzureResourceId(resourceId); - return this.webSiteManagementClient.webApps.updateConfiguration(parsedResourceId.resourceGroup, parsedResourceId.resourceName, siteConfig); + return this.webSiteManagementClient.webApps.updateConfiguration(site.resourceGroup, site.name, siteConfig); } - public async getAppServiceMetadata(resourceId: string): Promise { - let parsedResourceId: ParsedAzureResourceId = new ParsedAzureResourceId(resourceId); - return this.webSiteManagementClient.webApps.listMetadata(parsedResourceId.resourceGroup, parsedResourceId.resourceName); + public async getAppServiceMetadata(site: ValidatedSite): Promise { + return this.webSiteManagementClient.webApps.listMetadata(site.resourceGroup, site.name); } - public async updateAppServiceMetadata(resourceId: string, metadata: WebSiteManagementModels.StringDictionary): Promise { - let parsedResourceId: ParsedAzureResourceId = new ParsedAzureResourceId(resourceId); - return this.webSiteManagementClient.webApps.updateMetadata(parsedResourceId.resourceGroup, parsedResourceId.resourceName, metadata); + public async updateAppServiceMetadata(site: ValidatedSite, metadata: WebSiteManagementModels.StringDictionary): Promise { + return this.webSiteManagementClient.webApps.updateMetadata(site.resourceGroup, site.name, metadata); } - public async publishDeploymentToAppService(resourceId: string, buildDefinitionUrl: string, releaseDefinitionUrl: string, triggeredBuildUrl: string): Promise { - let parsedResourceId: ParsedAzureResourceId = new ParsedAzureResourceId(resourceId); - + public async publishDeploymentToAppService(site: ValidatedSite, buildDefinitionUrl: string, releaseDefinitionUrl: string, triggeredBuildUrl: string): Promise { // create deployment object - let deploymentId = uuid(); - let deployment = this.createDeploymentObject(deploymentId, buildDefinitionUrl, releaseDefinitionUrl, triggeredBuildUrl); - return this.webSiteManagementClient.webApps.createDeployment(parsedResourceId.resourceGroup, parsedResourceId.resourceName, deploymentId, deployment); + const deploymentId = uuid(); + const deployment = this.createDeploymentObject(deploymentId, buildDefinitionUrl, releaseDefinitionUrl, triggeredBuildUrl); + return this.webSiteManagementClient.webApps.createDeployment(site.resourceGroup, site.name, deploymentId, deployment); } private createDeploymentObject(deploymentId: string, buildDefinitionUrl: string, releaseDefinitionUrl: string, triggeredBuildUrl: string): WebSiteManagementModels.Deployment { - let deployment: WebSiteManagementModels.Deployment = { - id: deploymentId, - status: 4, - author: 'VSTS', - deployer: 'VSTS' - }; - - let deploymentMessage: DeploymentMessage = { + const message: DeploymentMessage = { type: "CDDeploymentConfiguration", message: "Successfully set up continuous delivery from VS Code and triggered deployment to Azure Web App.", VSTSRM_BuildDefinitionWebAccessUrl: `${buildDefinitionUrl}`, VSTSRM_ConfiguredCDEndPoint: '', VSTSRM_BuildWebAccessUrl: `${triggeredBuildUrl}`, }; - deployment.message = JSON.stringify(deploymentMessage); - return deployment; + + return { + id: deploymentId, + status: 4, + author: 'VSTS', + deployer: 'VSTS', + message: JSON.stringify(message), + }; } } diff --git a/src/configure/clients/azure/azureResourceClient.ts b/src/configure/clients/azure/azureResourceClient.ts deleted file mode 100644 index c58dccb6..00000000 --- a/src/configure/clients/azure/azureResourceClient.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ResourceManagementClient, ResourceManagementModels } from '@azure/arm-resources'; -import { TokenCredentialsBase } from '@azure/ms-rest-nodeauth'; - -export class AzureResourceClient { - - private azureRmClient: ResourceManagementClient; - - constructor(credentials: TokenCredentialsBase, subscriptionId: string) { - this.azureRmClient = new ResourceManagementClient(credentials, subscriptionId); - } - - public async getResourceList(resourceType: string, followNextLink: boolean = true): Promise { - let resourceListResult: ResourceManagementModels.ResourceListResult = await this.azureRmClient.resources.list({ filter: `resourceType eq '${resourceType}'` }); - - if (followNextLink) { - let nextLink: string = resourceListResult.nextLink; - while (!!nextLink) { - let nextResourceListResult = await this.azureRmClient.resources.listNext(nextLink); - resourceListResult = resourceListResult.concat(nextResourceListResult); - nextLink = nextResourceListResult.nextLink; - } - } - - return resourceListResult; - } - - public async getResource(resourceId: string, apiVersion: string): Promise { - let resource: ResourceManagementModels.GenericResource = await this.azureRmClient.resources.getById(resourceId, apiVersion); - return resource; - } -} diff --git a/src/configure/clients/devOps/operationsClient.ts b/src/configure/clients/devOps/operationsClient.ts index 9b1b5fae..a4e65386 100644 --- a/src/configure/clients/devOps/operationsClient.ts +++ b/src/configure/clients/devOps/operationsClient.ts @@ -30,11 +30,11 @@ export class OperationsClient { } const operation = response.result; - if (operation.status === OperationStatus.Succeeded) { + if (operation?.status === OperationStatus.Succeeded) { return; } - if (operation.status === OperationStatus.Failed) { + if (operation?.status === OperationStatus.Failed) { // OperationReference is missing some properties so cast it to any throw new Error(util.format(Messages.failedToCreateAzureDevOpsProject, (operation as any).detailedMessage)); } diff --git a/src/configure/clients/devOps/organizationsClient.ts b/src/configure/clients/devOps/organizationsClient.ts index 8d3dfdd6..a8d6e63a 100644 --- a/src/configure/clients/devOps/organizationsClient.ts +++ b/src/configure/clients/devOps/organizationsClient.ts @@ -1,12 +1,9 @@ import { RequestPrepareOptions } from '@azure/ms-rest-js'; import { TokenCredentialsBase } from '@azure/ms-rest-nodeauth'; import { ConnectionData } from 'azure-devops-node-api/interfaces/LocationsInterfaces'; -import * as util from 'util'; import { RestClient } from '../restClient'; -import { Organization, OrganizationAvailability } from '../../model/models'; -import { ReservedHostNames } from '../../resources/constants'; -import { Messages } from '../../../messages'; +import { Organization } from '../../model/models'; import { telemetryHelper } from '../../../helpers/telemetryHelper'; export class OrganizationsClient { @@ -28,27 +25,16 @@ export class OrganizationsClient { return this.restClient.sendRequest(requestPrepareOptions); } - public async createOrganization(organizationName: string): Promise { - return this.sendRequest({ - url: "https://app.vsaex.visualstudio.com/_apis/HostAcquisition/collections", - headers: { - "Content-Type": "application/json" - }, - method: "POST", - queryParameters: { - "collectionName": organizationName, - "api-version": "5.0-preview.2", - "preferredRegion": "CUS" - }, - }); - } - public async listOrganizations(forceRefresh?: boolean): Promise { if (this.organizations && !forceRefresh) { return this.organizations; } - const connectionData = await this.getUserData(); + const { authenticatedUser } = await this.getUserData(); + if (authenticatedUser === undefined) { + return []; + } + const response = await this.sendRequest<{ value: Organization[] }>({ url: "https://app.vssps.visualstudio.com/_apis/accounts", headers: { @@ -56,7 +42,7 @@ export class OrganizationsClient { }, method: "GET", queryParameters: { - "memberId": connectionData.authenticatedUser.id, + "memberId": authenticatedUser.id, "api-version": "7.0", }, }); @@ -75,56 +61,6 @@ export class OrganizationsClient { return this.organizations; } - public async validateOrganizationName(organizationName: string): Promise { - let accountNameRegex = new RegExp(/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$|^[a-zA-Z]$/); - - if (!organizationName || /^\\s/.test(organizationName) || /\\s$/.test(organizationName) || organizationName.indexOf("-") === 0 || !accountNameRegex.test(organizationName)) { - return Messages.organizationNameStaticValidationMessage; - } else if (ReservedHostNames.indexOf(organizationName) >= 0) { - return util.format(Messages.organizationNameReservedMessage, organizationName); - } else { - const url = `https://app.vsaex.visualstudio.com/_apis/HostAcquisition/NameAvailability/${organizationName}`; - - try { - const response = await this.sendRequest({ - url, - headers: { - "Content-Type": "application/json", - "Accept": "api-version=5.0-preview.1" - }, - method: "GET", - }); - - if (response.name === organizationName && !response.isAvailable) { - return util.format(Messages.organizationNameReservedMessage, organizationName); - } - return ""; - } catch (e) { - return ""; - } - } - } - - public async getOrganizationIdFromName(organizationName: string) { - let organizations = await this.listOrganizations(); - let organization = organizations.find((org) => { - return org.accountName.toLowerCase() === organizationName.toLowerCase(); - }); - - if(!organizationName) { - organizations = await this.listOrganizations(true); - organization = organizations.find((org) => { - return org.accountName.toLowerCase() === organizationName.toLowerCase(); - }); - - if (!organization) { - throw new Error(Messages.cannotFindOrganizationWithName); - } - } - - return organization.accountId; - } - private async getUserData(): Promise { try { return this.getConnectionData(); diff --git a/src/configure/configure.ts b/src/configure/configure.ts index f7eb5332..f92b09e2 100644 --- a/src/configure/configure.ts +++ b/src/configure/configure.ts @@ -2,12 +2,9 @@ import { v4 as uuid } from 'uuid'; import { AppServiceClient } from './clients/azure/appServiceClient'; import { OrganizationsClient } from './clients/devOps/organizationsClient'; import { AzureDevOpsHelper } from './helper/devOps/azureDevOpsHelper'; -import { OperationsClient } from './clients/devOps/operationsClient'; -import { ResourceManagementModels } from '@azure/arm-resources'; -import { GraphHelper } from './helper/graphHelper'; import { Messages } from '../messages'; import { ServiceConnectionHelper } from './helper/devOps/serviceConnectionHelper'; -import { SourceOptions, RepositoryProvider, WizardInputs, WebAppKind, PipelineTemplate, QuickPickItemWithData, GitRepositoryParameters, TargetResourceType } from './model/models'; +import { SourceOptions, RepositoryProvider, QuickPickItemWithData, GitRepositoryDetails, PipelineTemplate, AzureDevOpsDetails, ValidatedBuild, ValidatedProject, WebAppKind, TargetResourceType, ValidatedSite } from './model/models'; import * as constants from './resources/constants'; import { TracePoints } from './resources/tracePoints'; import { getAzureAccountExtensionApi, getGitExtensionApi } from '../extensionApis'; @@ -16,17 +13,18 @@ import { TelemetryKeys } from '../helpers/telemetryKeys'; import * as utils from 'util'; import * as vscode from 'vscode'; import { URI, Utils } from 'vscode-uri'; -import * as azdev from 'azure-devops-node-api'; import * as templateHelper from './helper/templateHelper'; import { getAvailableFileName } from './helper/commonHelper'; -import { showQuickPick, showInputBox } from './helper/controlProvider'; -import { GitHubProvider } from './helper/gitHubHelper'; -import { getSubscriptionSession } from './helper/azureSessionHelper'; -import { UserCancelledError } from './helper/userCancelledError'; +import { showInputBox, showQuickPick } from './helper/controlProvider'; import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; -import { ProjectVisibility } from 'azure-devops-node-api/interfaces/CoreInterfaces'; -import { AzureAccount, AzureSubscription } from '../typings/azure-account.api'; +import { AzureAccount, AzureSession } from '../typings/azure-account.api'; import { Repository } from '../typings/git'; +import { AzureSiteDetails } from './model/models'; +import { GraphHelper } from './helper/graphHelper'; +import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { WebApi, getBearerHandler } from 'azure-devops-node-api'; +import { GitHubProvider } from './helper/gitHubHelper'; +import { WebSiteManagementModels } from '@azure/arm-appservice'; const Layer: string = 'configure'; @@ -37,20 +35,23 @@ export async function configurePipeline(): Promise { const signIn = await vscode.window.showInformationMessage(Messages.azureLoginRequired, Messages.signInLabel); if (signIn?.toLowerCase() === Messages.signInLabel.toLowerCase()) { - await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: Messages.waitForAzureSignIn }, - async () => { - await vscode.commands.executeCommand("azure-account.login"); - }); + await vscode.commands.executeCommand("azure-account.login"); } else { - throw new Error(Messages.azureLoginRequired); + vscode.window.showWarningMessage(Messages.azureLoginRequired); + return; } } const gitExtension = await getGitExtensionApi(); const workspaceUri = await getWorkspace(); + if (workspaceUri === undefined) { + return; + } + const repo = gitExtension.getRepository(workspaceUri); if (repo === null) { - throw new Error(Messages.notAGitRepository); + vscode.window.showWarningMessage(Messages.notAGitRepository); + return; } // Refresh the repo status so that we have accurate info. @@ -60,9 +61,9 @@ export async function configurePipeline(): Promise { await configurer.configure(); } -async function getWorkspace(): Promise { - const workspaceFolders = vscode.workspace?.workspaceFolders; - if (workspaceFolders?.length > 0) { +async function getWorkspace(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders !== undefined) { telemetryHelper.setTelemetry(TelemetryKeys.SourceRepoLocation, SourceOptions.CurrentWorkspace); if (workspaceFolders.length === 1) { @@ -70,12 +71,17 @@ async function getWorkspace(): Promise { return workspaceFolders[0].uri; } else { telemetryHelper.setTelemetry(TelemetryKeys.MultipleWorkspaceFolders, 'true'); + const workspaceFolderOptions: QuickPickItemWithData[] = workspaceFolders.map(folder => ({ label: folder.name, data: folder })); const selectedWorkspaceFolder = await showQuickPick( constants.SelectFromMultipleWorkSpace, workspaceFolderOptions, { placeHolder: Messages.selectWorkspaceFolder }); + if (selectedWorkspaceFolder === undefined) { + return undefined; + } + return selectedWorkspaceFolder.data.uri; } } else { @@ -87,135 +93,154 @@ async function getWorkspace(): Promise { canSelectMany: false, }); - if (selectedFolders?.length > 0) { - return selectedFolders[0]; - } else { - throw new Error(Messages.noWorkSpaceSelectedError); + if (selectedFolders === undefined) { + return undefined; } + + return selectedFolders[0]; } } class PipelineConfigurer { - private inputs = new WizardInputs(); - private azureDevOpsClient: azdev.WebApi; - private organizationsClient: OrganizationsClient; - private serviceConnectionHelper: ServiceConnectionHelper; - private appServiceClient: AppServiceClient; + private azureDevOpsClient: WebApi | undefined; private uniqueResourceNameSuffix: string; public constructor( private workspaceUri: URI, private repo: Repository, private azureAccount: AzureAccount) { - this.uniqueResourceNameSuffix = uuid().substr(0, 5); + this.uniqueResourceNameSuffix = uuid().substring(0, 5); } - public async configure() { + public async configure(): Promise { telemetryHelper.setCurrentStep('GetAllRequiredInputs'); - await this.getAllRequiredInputs(); + const repoDetails = await this.getGitDetailsFromRepository(); + if (repoDetails === undefined) { + return; + } + + const template = await this.getSelectedPipeline(); + if (template === undefined) { + return; + } + + const adoDetails = await this.getAzureDevOpsDetails(repoDetails); + if (adoDetails === undefined) { + return; + } + + let azureSiteDetails: AzureSiteDetails | undefined; + if (template.target.type !== TargetResourceType.None) { + azureSiteDetails = await this.getAzureResourceDetails(adoDetails.session, template.target.kind); + if (azureSiteDetails === undefined) { + return; + } + } telemetryHelper.setCurrentStep('CreatePreRequisites'); - await this.createPreRequisites(); + const serviceConnectionHelper = new ServiceConnectionHelper( + adoDetails.organizationName, + adoDetails.project.name, + adoDetails.adoClient); + + let repositoryProperties: Record | undefined; + if (repoDetails.repositoryProvider === RepositoryProvider.Github) { + const gitHubServiceConnection = await this.createGitHubServiceConnection( + serviceConnectionHelper, + repoDetails, + this.uniqueResourceNameSuffix); + if (gitHubServiceConnection === undefined) { + return; + } + + repositoryProperties = { + apiUrl: `https://api.github.com/repos/${repoDetails.ownerName}/${repoDetails.repositoryName}`, + branchesUrl: `https://api.github.com/repos/${repoDetails.ownerName}/${repoDetails.repositoryName}/branches`, + cloneUrl: repoDetails.remoteUrl, + connectedServiceId: gitHubServiceConnection, + defaultBranch: repoDetails.branch, + fullName: `${repoDetails.ownerName}/${repoDetails.repositoryName}`, + refsUrl: `https://api.github.com/repos/${repoDetails.ownerName}/${repoDetails.repositoryName}/git/refs` + }; + } + + let azureServiceConnection: string | undefined; + if (azureSiteDetails !== undefined) { + azureServiceConnection = await this.createAzureServiceConnection( + serviceConnectionHelper, + adoDetails, + azureSiteDetails, + this.uniqueResourceNameSuffix); + if (azureServiceConnection === undefined) { + return; + } + } telemetryHelper.setCurrentStep('CheckInPipeline'); - await this.checkInPipelineFileToRepository(); + const pipelineFileName = await this.createPipelineFile( + template, + repoDetails.branch, + azureSiteDetails, + azureServiceConnection); + if (pipelineFileName === undefined) { + return; + } + + const commit = await this.checkInPipelineFileToRepository(pipelineFileName, repoDetails); + if (commit === undefined) { + return; + } telemetryHelper.setCurrentStep('CreateAndRunPipeline'); - const queuedPipeline = await this.createAndRunPipeline(); + const queuedPipeline = await this.createAndRunPipeline( + repoDetails, + adoDetails, + template, + azureSiteDetails, + repositoryProperties, + pipelineFileName, + commit); + if (queuedPipeline === undefined) { + return; + } telemetryHelper.setCurrentStep('PostPipelineCreation'); - // This step should be determined by the resoruce target provider (azure app service, function app, aks) type and pipelineProvider(azure pipeline vs github) - this.updateScmType(queuedPipeline); + if (azureSiteDetails !== undefined) { + // This step should be determined by the + // - resource target provider type (azure app service, function app, aks) + // - pipeline provider (azure pipeline vs github) + await this.updateScmType(queuedPipeline, adoDetails, azureSiteDetails); + } telemetryHelper.setCurrentStep('DisplayCreatedPipeline'); vscode.window.showInformationMessage(Messages.pipelineSetupSuccessfully, Messages.browsePipeline) - .then((action: string) => { - if (action && action.toLowerCase() === Messages.browsePipeline.toLowerCase()) { + .then(action => { + if (action?.toLowerCase() === Messages.browsePipeline.toLowerCase()) { telemetryHelper.setTelemetry(TelemetryKeys.BrowsePipelineClicked, 'true'); vscode.env.openExternal(URI.parse(queuedPipeline._links.web.href)); } }); } - private async getAllRequiredInputs() { - try { - await this.getGitDetailsFromRepository(); - } catch (error) { - telemetryHelper.logError(Layer, TracePoints.GetSourceRepositoryDetailsFailed, error); - throw error; - } - - await this.getSelectedPipeline(); - - if (this.inputs.sourceRepository.repositoryProvider === RepositoryProvider.Github) { - this.inputs.githubPatToken = await this.getGitHubPatToken(); - } - - if (!this.inputs.targetResource.resource) { - await this.getAzureResourceDetails(); - } - - await this.getAzureDevOpsDetails(); - } - - private async createPreRequisites(): Promise { - if (this.inputs.isNewOrganization) { - this.inputs.project = { - id: "", - name: AzureDevOpsHelper.generateDevOpsProjectName(this.inputs.sourceRepository.repositoryName) - }; - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: Messages.creatingAzureDevOpsOrganization - }, - async () => { - try { - await this.organizationsClient.createOrganization(this.inputs.organizationName); - this.organizationsClient.listOrganizations(true); - - const azureDevOpsClient = await this.getAzureDevOpsClient(); - const coreApi = await azureDevOpsClient.getCoreApi(); - const operation = await coreApi.queueCreateProject({ - name: this.inputs.project.name, - visibility: ProjectVisibility.Private, - capabilities: { - versionControl: { - sourceControlType: "Git" - }, - processTemplate: { - templateTypeId: "adcc42ab-9882-485e-a3ed-7678f01f66bc" // Agile - } - }, - }); - - const operationsClient = new OperationsClient(this.inputs.organizationName, azureDevOpsClient); - await operationsClient.waitForOperationSuccess(operation.id); - this.inputs.project = await coreApi.getProject(this.inputs.project.name); - } catch (error) { - telemetryHelper.logError(Layer, TracePoints.CreateNewOrganizationAndProjectFailure, error); - throw error; - } - }); - } - - if (this.inputs.sourceRepository.repositoryProvider === RepositoryProvider.Github) { - await this.createGithubServiceConnection(); + private async getGitDetailsFromRepository(): Promise { + const { HEAD } = this.repo.state; + if (!HEAD) { + vscode.window.showWarningMessage(Messages.branchHeadMissing); + return undefined; } - if(this.inputs.pipelineParameters.pipelineTemplate.targetType != TargetResourceType.None) { - await this.createAzureRMServiceConnection(); + let { name, remote } = HEAD; + if (!name) { + vscode.window.showWarningMessage(Messages.branchNameMissing); + return undefined; } - } - - private async getGitDetailsFromRepository(): Promise { - let { name, remote } = this.repo.state.HEAD; if (!remote) { // Remote tracking branch is not set, see if we have any remotes we can use. const remotes = this.repo.state.remotes; if (remotes.length === 0) { - throw new Error(Messages.branchRemoteMissing); + vscode.window.showWarningMessage(Messages.branchRemoteMissing); + return undefined; } else if (remotes.length === 1) { remote = remotes[0].name; } else { @@ -224,384 +249,552 @@ class PipelineConfigurer { constants.SelectRemoteForRepo, remotes.map(remote => ({ label: remote.name })), { placeHolder: Messages.selectRemoteForBranch }); + if (selectedRemote === undefined) { + return undefined; + } + remote = selectedRemote.label; } } - this.inputs.sourceRepository = await this.getGitRepositoryParameters(name, remote); - - // set telemetry - telemetryHelper.setTelemetry(TelemetryKeys.RepoProvider, this.inputs.sourceRepository.repositoryProvider); - } - - private async getGitRepositoryParameters(branch: string, remoteName: string): Promise { - let remoteUrl = this.repo.state.remotes.find(remote => remote.name === remoteName).fetchUrl; - if (remoteUrl) { + let repoDetails: GitRepositoryDetails; + let remoteUrl = this.repo.state.remotes.find(remoteObj => remoteObj.name === remote)?.fetchUrl; + if (remoteUrl !== undefined) { if (AzureDevOpsHelper.isAzureReposUrl(remoteUrl)) { remoteUrl = AzureDevOpsHelper.getFormattedRemoteUrl(remoteUrl); - return { + const { + organizationName, + projectName, + repositoryName + } = AzureDevOpsHelper.getRepositoryDetailsFromRemoteUrl(remoteUrl); + repoDetails = { repositoryProvider: RepositoryProvider.AzureRepos, - repositoryId: "", - repositoryName: AzureDevOpsHelper.getRepositoryDetailsFromRemoteUrl(remoteUrl).repositoryName, - remoteName: remoteName, - remoteUrl: remoteUrl, - branch: branch, - commitId: "" + organizationName, + projectName, + repositoryName, + remoteName: remote, + remoteUrl, + branch: name, }; - } - else if (GitHubProvider.isGitHubUrl(remoteUrl)) { + } else if (GitHubProvider.isGitHubUrl(remoteUrl)) { remoteUrl = GitHubProvider.getFormattedRemoteUrl(remoteUrl); - let repoId = GitHubProvider.getRepositoryIdFromUrl(remoteUrl); - return { + const { ownerName, repositoryName } = GitHubProvider.getRepositoryDetailsFromRemoteUrl(remoteUrl); + repoDetails = { repositoryProvider: RepositoryProvider.Github, - repositoryId: repoId, - repositoryName: repoId, - remoteName: remoteName, - remoteUrl: remoteUrl, - branch: branch, - commitId: "" + ownerName, + repositoryName, + remoteName: remote, + remoteUrl, + branch: name, }; + } else { + vscode.window.showWarningMessage(Messages.cannotIdentifyRepositoryDetails); + return undefined; } - else { - throw new Error(Messages.cannotIdentifyRespositoryDetails); - } - } - else { - throw new Error(Messages.remoteRepositoryNotConfigured); + } else { + vscode.window.showWarningMessage(Messages.remoteRepositoryNotConfigured); + return undefined; } - } - private async getGitHubPatToken(): Promise { - return await telemetryHelper.executeFunctionWithTimeTelemetry( - async () => { - return await showInputBox( - constants.GitHubPat, - { - placeHolder: Messages.enterGitHubPat, - prompt: Messages.githubPatTokenHelpMessage, - validateInput: inputValue => { - return inputValue.length === 0 ? Messages.gitHubPatTokenErrorMessage : null; - } - }); - }, - TelemetryKeys.GitHubPatDuration); - } + telemetryHelper.setTelemetry(TelemetryKeys.RepoProvider, repoDetails.repositoryProvider); - private async getAzureDevOpsDetails(): Promise { - try { - this.organizationsClient = new OrganizationsClient(this.inputs.azureSession.credentials2); - if (this.inputs.sourceRepository.repositoryProvider === RepositoryProvider.AzureRepos) { - const repoDetails = AzureDevOpsHelper.getRepositoryDetailsFromRemoteUrl(this.inputs.sourceRepository.remoteUrl); - this.inputs.organizationName = repoDetails.organizationName; - - const azureDevOpsClient = await this.getAzureDevOpsClient(); - const gitApi = await azureDevOpsClient.getGitApi(); - const repository = await gitApi.getRepository(this.inputs.sourceRepository.repositoryName, repoDetails.projectName); - this.inputs.sourceRepository.repositoryId = repository.id; - this.inputs.project = { - id: repository.project.id, - name: repository.project.name - }; - } else { - this.inputs.isNewOrganization = false; - let devOpsOrganizations = await this.organizationsClient.listOrganizations(); - - if (devOpsOrganizations && devOpsOrganizations.length > 0) { - let selectedOrganization = await showQuickPick( - constants.SelectOrganization, - devOpsOrganizations.map(organization => { return { label: organization.accountName }; }), - { placeHolder: Messages.selectOrganization }, - TelemetryKeys.OrganizationListCount); - this.inputs.organizationName = selectedOrganization.label; - - const azureDevOpsClient = await this.getAzureDevOpsClient(); - const coreApi = await azureDevOpsClient.getCoreApi(); - const projects = await coreApi.getProjects(); - - // FIXME: It _is_ possible for an organization to have no projects. - // We need to guard against this and create a project for them. - const selectedProject = await showQuickPick( - constants.SelectProject, - projects.map(project => { return { label: project.name, data: project }; }), - { placeHolder: Messages.selectProject }, - TelemetryKeys.ProjectListCount); - this.inputs.project = selectedProject.data; - } else { - telemetryHelper.setTelemetry(TelemetryKeys.NewOrganization, 'true'); - - this.inputs.isNewOrganization = true; - let userName = this.inputs.azureSession.userId.substring(0, this.inputs.azureSession.userId.indexOf("@")); - let organizationName = AzureDevOpsHelper.generateDevOpsOrganizationName(userName, this.inputs.sourceRepository.repositoryName); - - let validationErrorMessage = await this.organizationsClient.validateOrganizationName(organizationName); - if (validationErrorMessage) { - this.inputs.organizationName = await showInputBox( - constants.EnterOrganizationName, - { - placeHolder: Messages.enterAzureDevOpsOrganizationName, - validateInput: (organizationName) => this.organizationsClient.validateOrganizationName(organizationName) - }); - } - else { - this.inputs.organizationName = organizationName; - } - } - } - } - catch (error) { - telemetryHelper.logError(Layer, TracePoints.GetAzureDevOpsDetailsFailed, error); - throw error; - } + return repoDetails; } - private async getSelectedPipeline(): Promise { - let appropriatePipelines: PipelineTemplate[] = await vscode.window.withProgress( + private async getSelectedPipeline(): Promise { + const appropriateTemplates: PipelineTemplate[] = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: Messages.analyzingRepo }, () => templateHelper.analyzeRepoAndListAppropriatePipeline(this.workspaceUri) ); - // TO:DO- Get applicable pipelines for the repo type and azure target type if target already selected - let selectedOption = await showQuickPick( + // TODO: Get applicable pipelines for the repo type and azure target type if target already selected + const template = await showQuickPick( constants.SelectPipelineTemplate, - appropriatePipelines.map((pipeline) => { return { label: pipeline.label }; }), + appropriateTemplates.map((template) => { return { label: template.label, data: template }; }), { placeHolder: Messages.selectPipelineTemplate }, TelemetryKeys.PipelineTempateListCount); - this.inputs.pipelineParameters.pipelineTemplate = appropriatePipelines.find((pipeline) => { - return pipeline.label === selectedOption.label; - }); - telemetryHelper.setTelemetry(TelemetryKeys.ChosenTemplate, this.inputs.pipelineParameters.pipelineTemplate.label); + + if (template === undefined) { + return undefined; + } + + telemetryHelper.setTelemetry(TelemetryKeys.ChosenTemplate, template.data.label); + return template.data; } - private async getAzureResourceDetails(): Promise { - // show available subscriptions and get the chosen one - const azureAccountApi = await getAzureAccountExtensionApi(); - const subscriptionList = azureAccountApi.filters.map(subscriptionObject => { - return >{ - label: `${subscriptionObject.subscription.displayName}`, - data: subscriptionObject, - description: `${subscriptionObject.subscription.subscriptionId}` - }; - }); + private async getAzureDevOpsDetails(repoDetails: GitRepositoryDetails): Promise { + if (repoDetails.repositoryProvider === RepositoryProvider.AzureRepos) { + for (const session of this.azureAccount.filters.map(({ session }) => session)) { + const organizationsClient = new OrganizationsClient(session.credentials2); + const organizations = await organizationsClient.listOrganizations(); + if (organizations.find(org => + org.accountName.toLowerCase() === repoDetails.organizationName.toLowerCase())) { + const adoClient = await this.getAzureDevOpsClient(repoDetails.organizationName, session); + const coreApi = await adoClient.getCoreApi(); + const project = await coreApi.getProject(repoDetails.projectName); + if (this.isValidProject(project)) { + return { + session, + adoClient, + organizationName: repoDetails.organizationName, + project, + }; + } + } + } - if (this.inputs.pipelineParameters.pipelineTemplate.targetType != TargetResourceType.None) { - const selectedSubscription: QuickPickItemWithData = - await showQuickPick(constants.SelectSubscription, subscriptionList, { placeHolder: Messages.selectSubscription }); - this.inputs.targetResource.subscriptionId = selectedSubscription.data.subscription.subscriptionId; - this.inputs.azureSession = await getSubscriptionSession(this.azureAccount, this.inputs.targetResource.subscriptionId); - - // show available resources and get the chosen one - this.appServiceClient = new AppServiceClient(this.inputs.azureSession.credentials2, this.inputs.azureSession.tenantId, this.inputs.azureSession.environment.portalUrl, this.inputs.targetResource.subscriptionId); - - let resourceArray: Promise> = null; - let selectAppText: string = ""; - let placeHolderText: string = ""; - - switch(this.inputs.pipelineParameters.pipelineTemplate.targetType) { - case TargetResourceType.WebApp: - default: - resourceArray = this.appServiceClient.GetAppServices(this.inputs.pipelineParameters.pipelineTemplate.targetKind) - .then((webApps) => webApps.map(x => { return { label: x.name, data: x }; })); - selectAppText = this.getSelectAppText(this.inputs.pipelineParameters.pipelineTemplate.targetKind); - placeHolderText = this.getPlaceholderText(this.inputs.pipelineParameters.pipelineTemplate.targetKind); - break; + vscode.window.showWarningMessage("You are not signed in to the Azure DevOps organization that contains this repository."); + return undefined; + } else { + // Lazily construct list of organizations so that we can immediately show the quick pick, + // then fill in the choices as they come in. + const organizationAndSessionsPromise = new Promise< + QuickPickItemWithData[] + >(async resolve => { + const organizationAndSessions: QuickPickItemWithData[] = []; + + for (const session of this.azureAccount.filters.map(({ session }) => session)) { + const organizationsClient = new OrganizationsClient(session.credentials2); + const organizations = await organizationsClient.listOrganizations(); + organizationAndSessions.push(...organizations.map(organization => ({ + label: organization.accountName, + data: session, + }))); + } + + organizationAndSessions.push({ + // This is safe because ADO orgs can't have spaces in them. + label: "Create new Azure DevOps organization...", + data: undefined, + }); + + resolve(organizationAndSessions); + }); + + const result = await showQuickPick( + 'organization', + organizationAndSessionsPromise, { + placeHolder: "Select the Azure DevOps organization to create this pipeline in", + }); + if (result === undefined) { + return undefined; } - const selectedResource: QuickPickItemWithData = await showQuickPick( - selectAppText, - resourceArray, - { placeHolder: placeHolderText }, - TelemetryKeys.WebAppListCount); + const { label: organizationName, data: session } = result; + if (session === undefined) { + // Special flag telling us to create a new organization. + await vscode.env.openExternal(vscode.Uri.parse("https://dev.azure.com/")); + return undefined; + } - this.inputs.targetResource.resource = selectedResource.data; - } else if (subscriptionList.length > 0) { - this.inputs.targetResource.subscriptionId = subscriptionList[0].data.subscription.subscriptionId; - this.inputs.azureSession = await getSubscriptionSession(this.azureAccount, this.inputs.targetResource.subscriptionId); - } - } + const adoClient = await this.getAzureDevOpsClient(organizationName, session); - private getSelectAppText(appKind: WebAppKind) : string { - switch(appKind) { - case WebAppKind.FunctionApp: - case WebAppKind.FunctionAppLinux: - return constants.SelectFunctionApp; - case WebAppKind.WindowsApp: - case WebAppKind.LinuxApp: - default: - return constants.SelectWebApp; - } - } + // Ditto for the projects. + const projectsPromise = new Promise< + QuickPickItemWithData[] + >(async resolve => { + const validatedProjects: QuickPickItemWithData[] = []; + + const coreApi = await adoClient.getCoreApi(); + const projects = await coreApi.getProjects(); + validatedProjects.push(...projects + .filter(this.isValidProject) + .map(project => { return { label: project.name, data: project }; })); + + validatedProjects.push({ + // This is safe because ADO projects can't end with periods. + label: "Create new project...", + data: undefined, + }); + + resolve(validatedProjects); + }); - private getPlaceholderText(appKind: WebAppKind) : string { - switch(appKind) { - case WebAppKind.FunctionApp: - case WebAppKind.FunctionAppLinux: - return Messages.selectFunctionApp; - case WebAppKind.WindowsApp: - case WebAppKind.LinuxApp: - default: - return Messages.selectWebApp; + const selectedProject = await showQuickPick( + constants.SelectProject, + projectsPromise, + { placeHolder: Messages.selectProject }, + TelemetryKeys.ProjectListCount); + if (selectedProject === undefined) { + return undefined; + } + + const project = selectedProject.data; + if (project === undefined) { + // Special flag telling us to create a new project. + await vscode.env.openExternal(vscode.Uri.parse(`https://dev.azure.com/${organizationName}`)); + return undefined; + } + + return { + session, + adoClient, + organizationName, + project, + }; } } - private async updateScmType(queuedPipeline: Build): Promise { - try { - if(!this.inputs.targetResource.resource) { - return; - } - // update SCM type - this.appServiceClient.updateScmType(this.inputs.targetResource.resource.id); + private async getAzureResourceDetails( + session: AzureSession, + kind: WebAppKind): Promise { + // show available subscriptions and get the chosen one + const subscriptionList = this.azureAccount.filters + .filter(filter => + // session is actually an AzureSessionInternal which makes a naive === check fail. + filter.session.environment === session.environment && + filter.session.tenantId === session.tenantId && + filter.session.userId === session.userId) + .map(subscriptionObject => { + return { + label: subscriptionObject.subscription.displayName ?? "Unknown subscription", + data: subscriptionObject, + description: subscriptionObject.subscription.subscriptionId ?? undefined + }; + }); - let buildDefinitionUrl = AzureDevOpsHelper.getOldFormatBuildDefinitionUrl(this.inputs.organizationName, this.inputs.project.id, queuedPipeline.definition.id); - let buildUrl = AzureDevOpsHelper.getOldFormatBuildUrl(this.inputs.organizationName, this.inputs.project.id, queuedPipeline.id); + const selectedSubscription = await showQuickPick( + constants.SelectSubscription, + subscriptionList, + { placeHolder: Messages.selectSubscription }); + if (selectedSubscription === undefined) { + return undefined; + } - // update metadata of app service to store information about the pipeline deploying to web app. - let metadata = await this.appServiceClient.getAppServiceMetadata(this.inputs.targetResource.resource.id); - metadata["properties"] = metadata["properties"] ? metadata["properties"] : {}; - metadata["properties"]["VSTSRM_ProjectId"] = this.inputs.project.id; - metadata["properties"]["VSTSRM_AccountId"] = await this.organizationsClient.getOrganizationIdFromName(this.inputs.organizationName); - metadata["properties"]["VSTSRM_BuildDefinitionId"] = queuedPipeline.definition.id.toString(); - metadata["properties"]["VSTSRM_BuildDefinitionWebAccessUrl"] = buildDefinitionUrl; - metadata["properties"]["VSTSRM_ConfiguredCDEndPoint"] = ''; - metadata["properties"]["VSTSRM_ReleaseDefinitionId"] = ''; + const { subscriptionId } = selectedSubscription.data.subscription; + if (subscriptionId === undefined) { + vscode.window.showErrorMessage("Unable to get ID for subscription, please file a bug at https://github.com/microsoft/azure-pipelines-vscode/issues/new"); + return undefined; + } - this.appServiceClient.updateAppServiceMetadata(this.inputs.targetResource.resource.id, metadata); + // show available resources and get the chosen one + const appServiceClient = new AppServiceClient( + session.credentials2, + session.tenantId, + session.environment.portalUrl, + subscriptionId); + + // TODO: Refactor kind so we don't need three kind.includes + + const sites = await appServiceClient.getAppServices(kind); + const items: QuickPickItemWithData[] = sites + .filter(this.isValidSite) + .map(site => { return { label: site.name, data: site }; }); + const appType = kind.includes("functionapp") ? "Function App" : "Web App"; + + items.push({ + // This is safe because apps can't have spaces in them. + label: `Create new ${appType.toLowerCase()}...`, + data: undefined, + }); - // send a deployment log with information about the setup pipeline and links. - this.appServiceClient.publishDeploymentToAppService( - this.inputs.targetResource.resource.id, - buildDefinitionUrl, - buildDefinitionUrl, - buildUrl); + const selectedResource = await showQuickPick( + kind.includes("functionapp") ? "selectFunctionApp" : "selectWebApp", + items, + { placeHolder: `Select ${appType}` }, + TelemetryKeys.WebAppListCount); + if (selectedResource === undefined) { + return undefined; } - catch (error) { - telemetryHelper.logError(Layer, TracePoints.PostDeploymentActionFailed, error); + + const { data: site } = selectedResource; + if (site === undefined) { + // Special flag telling us to create a new app. + // URL format is documented at + // https://github.com/Azure/portaldocs/blob/main/portal-sdk/generated/portalfx-links.md#create-blades + const packageId = kind.includes("functionapp") ? "Microsoft.FunctionApp" : "Microsoft.WebSite"; + await vscode.env.openExternal(vscode.Uri.parse(`https://portal.azure.com/#create/${packageId}`)); + return undefined; } + + return { + appServiceClient, + site, + subscriptionId, + }; } - private async createGithubServiceConnection(): Promise { - if (!this.serviceConnectionHelper) { - this.serviceConnectionHelper = new ServiceConnectionHelper(this.inputs.organizationName, this.inputs.project.name, this.azureDevOpsClient); + private async createGitHubServiceConnection( + serviceConnectionHelper: ServiceConnectionHelper, + repoDetails: GitRepositoryDetails, + uniqueResourceNameSuffix: string, + ): Promise { + const token = await telemetryHelper.executeFunctionWithTimeTelemetry( + async () => showInputBox( + constants.GitHubPat, { + placeHolder: Messages.enterGitHubPat, + prompt: Messages.githubPatHelpMessage, + validateInput: input => input.length === 0 ? Messages.gitHubPatErrorMessage : null + } + ), TelemetryKeys.GitHubPatDuration + ); + + if (token === undefined) { + return undefined; } - // Create GitHub service connection in Azure DevOps - await vscode.window.withProgress( + return vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: Messages.creatingGitHubServiceConnection }, async () => { + const serviceConnectionName = `${repoDetails.repositoryName}-github-${uniqueResourceNameSuffix}`; try { - let serviceConnectionName = `${this.inputs.sourceRepository.repositoryName}-${this.uniqueResourceNameSuffix}`; - this.inputs.sourceRepository.serviceConnectionId = await this.serviceConnectionHelper.createGitHubServiceConnection(serviceConnectionName, this.inputs.githubPatToken); - } - catch (error) { - telemetryHelper.logError(Layer, TracePoints.GitHubServiceConnectionError, error); + return serviceConnectionHelper.createGitHubServiceConnection(serviceConnectionName, token); + } catch (error) { + telemetryHelper.logError(Layer, TracePoints.GitHubServiceConnectionError, error as Error); throw error; } - }); + }); } - private async createAzureRMServiceConnection(): Promise { - if (!this.serviceConnectionHelper) { - this.serviceConnectionHelper = new ServiceConnectionHelper(this.inputs.organizationName, this.inputs.project.name, this.azureDevOpsClient); - } - // TODO: show notification while setup is being done. - // ?? should SPN created be scoped to resource group of target azure resource. - this.inputs.targetResource.serviceConnectionId = await vscode.window.withProgress( + private async createAzureServiceConnection( + serviceConnectionHelper: ServiceConnectionHelper, + adoDetails: AzureDevOpsDetails, + azureSiteDetails: AzureSiteDetails, + uniqueResourceNameSuffix: string, + ): Promise { + // TODO: should SPN created be scoped to resource group of target azure resource. + return vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: utils.format(Messages.creatingAzureServiceConnection, this.inputs.targetResource.subscriptionId) + title: utils.format(Messages.creatingAzureServiceConnection, azureSiteDetails.subscriptionId) }, async () => { + const scope = azureSiteDetails.site.id; try { - let scope = this.inputs.targetResource.resource.id; - let aadAppName = GraphHelper.generateAadApplicationName(this.inputs.organizationName, this.inputs.project.name); - let aadApp = await GraphHelper.createSpnAndAssignRole(this.inputs.azureSession, aadAppName, scope); - let serviceConnectionName = `${this.inputs.targetResource.resource.name}-${this.uniqueResourceNameSuffix}`; - return await this.serviceConnectionHelper.createAzureServiceConnection(serviceConnectionName, this.inputs.azureSession.tenantId, this.inputs.targetResource.subscriptionId, scope, aadApp); + const aadAppName = GraphHelper.generateAadApplicationName( + adoDetails.organizationName, + adoDetails.project.name); + const aadApp = await GraphHelper.createSpnAndAssignRole(adoDetails.session, aadAppName, scope); + const serviceConnectionName = `${azureSiteDetails.site.name}-${uniqueResourceNameSuffix}`; + return serviceConnectionHelper.createAzureServiceConnection( + serviceConnectionName, + adoDetails.session.tenantId, + azureSiteDetails.subscriptionId, + scope, + aadApp); } catch (error) { - telemetryHelper.logError(Layer, TracePoints.AzureServiceConnectionCreateFailure, error); + telemetryHelper.logError(Layer, TracePoints.AzureServiceConnectionCreateFailure, error as Error); throw error; } }); } - private async checkInPipelineFileToRepository(): Promise { + private async createPipelineFile( + template: PipelineTemplate, + branch: string, + azureSiteDetails: AzureSiteDetails | undefined, + azureServiceConnection: string | undefined, + ): Promise { try { - this.inputs.pipelineParameters.pipelineFileName = await getAvailableFileName("azure-pipelines.yml", this.workspaceUri); - const fileUri = Utils.joinPath(this.workspaceUri, this.inputs.pipelineParameters.pipelineFileName); - const content = await templateHelper.renderContent(this.inputs.pipelineParameters.pipelineTemplate.path, this.inputs); + const pipelineFileName = await getAvailableFileName("azure-pipelines.yml", this.workspaceUri); + const fileUri = Utils.joinPath(this.workspaceUri, pipelineFileName); + const content = await templateHelper.renderContent( + template.path, + branch, + azureSiteDetails?.site.name, + azureServiceConnection); await vscode.workspace.fs.writeFile(fileUri, Buffer.from(content)); await vscode.window.showTextDocument(fileUri); + return pipelineFileName; } catch (error) { - telemetryHelper.logError(Layer, TracePoints.AddingContentToPipelineFileFailed, error); + telemetryHelper.logError(Layer, TracePoints.AddingContentToPipelineFileFailed, error as Error); throw error; } + } + private async checkInPipelineFileToRepository( + pipelineFileName: string, + repoDetails: GitRepositoryDetails, + ): Promise { try { - while (!this.inputs.sourceRepository.commitId) { - const commitOrDiscard = await vscode.window.showInformationMessage(utils.format(Messages.modifyAndCommitFile, Messages.commitAndPush, this.inputs.sourceRepository.branch, this.inputs.sourceRepository.remoteName), Messages.commitAndPush, Messages.discardPipeline); - if (commitOrDiscard?.toLowerCase() === Messages.commitAndPush.toLowerCase()) { - await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: Messages.configuringPipelineAndDeployment }, async (progress) => { - try { - // handle when the branch is not upto date with remote branch and push fails - await this.repo.add([Utils.joinPath(this.workspaceUri, this.inputs.pipelineParameters.pipelineFileName).fsPath]); - await this.repo.commit(Messages.addYmlFile); // TODO: Only commit the YAML file. Need to file a feature request on VS Code for this. - await this.repo.push(this.inputs.sourceRepository.remoteName); - this.inputs.sourceRepository.commitId = this.repo.state.HEAD.commit; - } catch (error) { - telemetryHelper.logError(Layer, TracePoints.CheckInPipelineFailure, error); - vscode.window.showErrorMessage(utils.format(Messages.commitFailedErrorMessage, error.stderr)); + const commitOrDiscard = await vscode.window.showInformationMessage( + utils.format( + Messages.modifyAndCommitFile, + Messages.commitAndPush, + repoDetails.branch, + repoDetails.remoteName), + Messages.commitAndPush, + Messages.discardPipeline); + if (commitOrDiscard?.toLowerCase() === Messages.commitAndPush.toLowerCase()) { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: Messages.pushingPipelineFile + }, async () => { + try { + // TODO: Only commit the YAML file. Need to file a feature request on VS Code for this. + await this.repo.add([Utils.joinPath(this.workspaceUri, pipelineFileName).fsPath]); + await this.repo.commit(Messages.addYmlFile); + await this.repo.push(repoDetails.remoteName); + + const commit = this.repo.state.HEAD?.commit; + if (commit === undefined) { + vscode.window.showErrorMessage("Unable to get commit after pushing pipeline, please file a bug at https://github.com/microsoft/azure-pipelines-vscode/issues/new"); + return undefined; } - }); - } else { - telemetryHelper.setTelemetry(TelemetryKeys.PipelineDiscarded, 'true'); - throw new UserCancelledError(); - } + + return commit; + } catch (error) { + telemetryHelper.logError(Layer, TracePoints.CheckInPipelineFailure, error as Error); + vscode.window.showErrorMessage( + utils.format(Messages.commitFailedErrorMessage, (error as Error).message)); + return undefined; + } + }); + } else { + telemetryHelper.setTelemetry(TelemetryKeys.PipelineDiscarded, 'true'); + return undefined; } - } - catch (error) { - telemetryHelper.logError(Layer, TracePoints.PipelineFileCheckInFailed, error); + } catch (error) { + telemetryHelper.logError(Layer, TracePoints.PipelineFileCheckInFailed, error as Error); throw error; } } - private async createAndRunPipeline(): Promise { - return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: Messages.configuringPipelineAndDeployment }, async () => { + private async createAndRunPipeline( + repoDetails: GitRepositoryDetails, + adoDetails: AzureDevOpsDetails, + template: PipelineTemplate, + azureSiteDetails: AzureSiteDetails | undefined, + repositoryProperties: Record | undefined, + pipelineFileName: string, + commit: string, + ): Promise { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: Messages.configuringPipelineAndDeployment + }, async () => { try { - const taskAgentApi = await this.azureDevOpsClient.getTaskAgentApi(); - const queues = await taskAgentApi.getAgentQueuesByNames([constants.HostedVS2017QueueName], this.inputs.project.name); + const taskAgentApi = await adoDetails.adoClient.getTaskAgentApi(); + const queues = await taskAgentApi.getAgentQueuesByNames( + [constants.HostedVS2017QueueName], + adoDetails.project.name); if (queues.length === 0) { - throw new Error(utils.format(Messages.noAgentQueueFound, constants.HostedVS2017QueueName)); + vscode.window.showErrorMessage( + utils.format(Messages.noAgentQueueFound, constants.HostedVS2017QueueName)); + return undefined; + } + + const pipelineName = `${(azureSiteDetails?.site.name ?? template.label)}-${this.uniqueResourceNameSuffix}`; + const definitionPayload = AzureDevOpsHelper.getBuildDefinitionPayload( + pipelineName, + queues[0], + repoDetails, + adoDetails, + repositoryProperties, + pipelineFileName + ); + const buildApi = await adoDetails.adoClient.getBuildApi(); + const definition = await buildApi.createDefinition(definitionPayload, adoDetails.project.name); + const build = await buildApi.queueBuild({ + definition, + project: adoDetails.project, + sourceBranch: repoDetails.branch, + sourceVersion: commit + }, adoDetails.project.name); + + if (!this.isValidBuild(build)) { + return undefined; } - const pipelineName = `${(this.inputs.targetResource.resource ? this.inputs.targetResource.resource.name : this.inputs.pipelineParameters.pipelineTemplate.label)}-${this.uniqueResourceNameSuffix}`; - const definitionPayload = AzureDevOpsHelper.getBuildDefinitionPayload(pipelineName, queues[0], this.inputs); - const buildApi = await this.azureDevOpsClient.getBuildApi(); - const definition = await buildApi.createDefinition(definitionPayload, this.inputs.project.name); - return await buildApi.queueBuild({ - definition: definition, - project: this.inputs.project, - sourceBranch: this.inputs.sourceRepository.branch, - sourceVersion: this.inputs.sourceRepository.commitId - }, this.inputs.project.name); + return build; } catch (error) { - telemetryHelper.logError(Layer, TracePoints.CreateAndQueuePipelineFailed, error); + telemetryHelper.logError(Layer, TracePoints.CreateAndQueuePipelineFailed, error as Error); throw error; } }); } - private async getAzureDevOpsClient(): Promise { + private async updateScmType( + queuedPipeline: ValidatedBuild, + adoDetails: AzureDevOpsDetails, + azureSiteDetails: AzureSiteDetails, + ): Promise { + try { + // update SCM type + azureSiteDetails.appServiceClient.updateScmType(azureSiteDetails.site); + + const buildDefinitionUrl = AzureDevOpsHelper.getOldFormatBuildDefinitionUrl( + adoDetails.organizationName, + adoDetails.project.id, + queuedPipeline.definition.id); + const buildUrl = AzureDevOpsHelper.getOldFormatBuildUrl( + adoDetails.organizationName, + adoDetails.project.id, + queuedPipeline.id); + + const locationsApi = await adoDetails.adoClient.getLocationsApi(); + const { instanceId } = await locationsApi.getConnectionData(); + if (instanceId === undefined) { + vscode.window.showErrorMessage("Unable to determine the organization ID, please file a bug at https://github.com/microsoft/azure-pipelines-vscode/issues/new"); + return; + } + + // update metadata of app service to store information about the pipeline deploying to web app. + const metadata = await azureSiteDetails.appServiceClient.getAppServiceMetadata(azureSiteDetails.site); + metadata.properties = { + ...metadata.properties, + VSTSRM_ProjectId: adoDetails.project.id, + VSTSRM_AccountId: instanceId, + VSTSRM_BuildDefinitionId: queuedPipeline.definition.id.toString(), + VSTSRM_BuildDefinitionWebAccessUrl: buildDefinitionUrl, + VSTSRM_ConfiguredCDEndPoint: '', + VSTSRM_ReleaseDefinitionId: '', + }; + + azureSiteDetails.appServiceClient.updateAppServiceMetadata(azureSiteDetails.site, metadata); + + // send a deployment log with information about the setup pipeline and links. + azureSiteDetails.appServiceClient.publishDeploymentToAppService( + azureSiteDetails.site, + buildDefinitionUrl, + buildDefinitionUrl, + buildUrl); + } catch (error) { + telemetryHelper.logError(Layer, TracePoints.PostDeploymentActionFailed, error as Error); + throw error; + } + } + + private async getAzureDevOpsClient(organization: string, session: AzureSession): Promise { if (this.azureDevOpsClient) { return this.azureDevOpsClient; } - const token = await this.inputs.azureSession.credentials2.getToken(); - const authHandler = azdev.getBearerHandler(token.accessToken); - this.azureDevOpsClient = new azdev.WebApi(`https://dev.azure.com/${this.inputs.organizationName}`, authHandler); + const { accessToken } = await session.credentials2.getToken(); + const authHandler = getBearerHandler(accessToken); + this.azureDevOpsClient = new WebApi(`https://dev.azure.com/${organization}`, authHandler); return this.azureDevOpsClient; } + + private isValidProject(project: TeamProject): project is ValidatedProject { + if (project.name === undefined || project.id === undefined) { + vscode.window.showErrorMessage("Unable to get name or ID for project, please file a bug at https://github.com/microsoft/azure-pipelines-vscode/issues/new"); + return false; + } + + return true; + } + + private isValidSite(resource: WebSiteManagementModels.Site): resource is ValidatedSite { + if (resource.name === undefined || resource.id === undefined) { + vscode.window.showErrorMessage("Unable to get name or ID for resource, please file a bug at https://github.com/microsoft/azure-pipelines-vscode/issues/new"); + return false; + } + + return true; + } + + private isValidBuild(build: Build): build is ValidatedBuild { + if (build.definition === undefined || build.definition.id === undefined || build.id === undefined) { + vscode.window.showErrorMessage("Unable to get definition or ID for build, please file a bug at https://github.com/microsoft/azure-pipelines-vscode/issues/new"); + return false; + } + + return true; + } } diff --git a/src/configure/helper/azureSessionHelper.ts b/src/configure/helper/azureSessionHelper.ts deleted file mode 100644 index 0918c11f..00000000 --- a/src/configure/helper/azureSessionHelper.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AzureAccount, AzureSession } from "../../typings/azure-account.api"; - -export async function getSubscriptionSession(azureAccount: AzureAccount, subscriptionId: string): Promise { - let currentSubscription = azureAccount.subscriptions - .find(subscription => subscription.subscription.subscriptionId.toLowerCase() === subscriptionId.toLowerCase()); - - // Fallback to first element - if (!currentSubscription) { - currentSubscription = azureAccount.subscriptions[0]; - } - - return currentSubscription.session; -} diff --git a/src/configure/helper/controlProvider.ts b/src/configure/helper/controlProvider.ts index e0a044db..af2f3dc0 100644 --- a/src/configure/helper/controlProvider.ts +++ b/src/configure/helper/controlProvider.ts @@ -3,7 +3,7 @@ import { telemetryHelper } from '../../helpers/telemetryHelper'; import { TelemetryKeys } from '../../helpers/telemetryKeys'; import { UserCancelledError } from './userCancelledError'; -export async function showQuickPick(listName: string, listItems: T[] | Thenable, options: QuickPickOptions, itemCountTelemetryKey?: string): Promise { +export async function showQuickPick(listName: string, listItems: T[] | Thenable, options: QuickPickOptions, itemCountTelemetryKey?: string): Promise { try { telemetryHelper.setTelemetry(TelemetryKeys.CurrentUserInput, listName); return window.showQuickPick(listItems, { @@ -18,7 +18,7 @@ export async function showQuickPick(listName: string, l } } -export async function showInputBox(inputName: string, options: InputBoxOptions): Promise { +export async function showInputBox(inputName: string, options: InputBoxOptions): Promise { telemetryHelper.setTelemetry(TelemetryKeys.CurrentUserInput, inputName); return window.showInputBox({ ignoreFocusOut: true, @@ -26,7 +26,7 @@ export async function showInputBox(inputName: string, options: InputBoxOptions): }); } -export async function showInformationBox(informationIdentifier: string, informationMessage: string, ...actions: string[]): Promise { +export async function showInformationBox(informationIdentifier: string, informationMessage: string, ...actions: string[]): Promise { telemetryHelper.setTelemetry(TelemetryKeys.CurrentUserInput, informationIdentifier); if (!!actions && actions.length > 0) { let result = await window.showInformationMessage(informationMessage, ...actions); diff --git a/src/configure/helper/devOps/azureDevOpsHelper.ts b/src/configure/helper/devOps/azureDevOpsHelper.ts index c34793dc..9a272b68 100644 --- a/src/configure/helper/devOps/azureDevOpsHelper.ts +++ b/src/configure/helper/devOps/azureDevOpsHelper.ts @@ -1,7 +1,7 @@ import { BuildDefinition, ContinuousIntegrationTrigger, DefinitionQuality, DefinitionTriggerType, DefinitionType, YamlProcess } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { TaskAgentQueue } from 'azure-devops-node-api/interfaces/TaskAgentInterfaces'; -import { WizardInputs, RepositoryProvider } from '../../model/models'; +import { RepositoryProvider, GitRepositoryDetails, AzureDevOpsDetails } from '../../model/models'; import { Messages } from '../../../messages'; export class AzureDevOpsHelper { @@ -85,28 +85,23 @@ export class AzureDevOpsHelper { } } - public static getBuildDefinitionPayload(pipelineName: string, queue: TaskAgentQueue, inputs: WizardInputs): BuildDefinition { - const repositoryProperties = inputs.sourceRepository.repositoryProvider === RepositoryProvider.Github ? { - apiUrl: `https://api.github.com/repos/${inputs.sourceRepository.repositoryId}`, - branchesUrl: `https://api.github.com/repos/${inputs.sourceRepository.repositoryId}/branches`, - cloneUrl: inputs.sourceRepository.remoteUrl, - connectedServiceId: inputs.sourceRepository.serviceConnectionId, - defaultBranch: inputs.sourceRepository.branch, - fullName: inputs.sourceRepository.repositoryName, - refsUrl: `https://api.github.com/repos/${inputs.sourceRepository.repositoryId}/git/refs` - } : null; - - const properties = { 'source': 'ms-azure-devops.azure-pipelines' }; - + public static getBuildDefinitionPayload( + pipelineName: string, + queue: TaskAgentQueue, + repoDetails: GitRepositoryDetails, + adoDetails: AzureDevOpsDetails, + repositoryProperties: Record | undefined, + pipelineFileName: string, + ): BuildDefinition { return { name: pipelineName, type: DefinitionType.Build, quality: DefinitionQuality.Definition, path: "\\", //Folder path of build definition. Root folder in this case - project: inputs.project, + project: adoDetails.project, process: { type: 2, - yamlFileName: inputs.pipelineParameters.pipelineFileName, + yamlFileName: pipelineFileName, } as YamlProcess, queue: { id: queue.id, @@ -119,14 +114,20 @@ export class AzureDevOpsHelper { } as ContinuousIntegrationTrigger, ], repository: { - id: inputs.sourceRepository.repositoryId, - name: inputs.sourceRepository.repositoryName, - type: inputs.sourceRepository.repositoryProvider, - defaultBranch: inputs.sourceRepository.branch, - url: inputs.sourceRepository.remoteUrl, + id: repoDetails.repositoryProvider === RepositoryProvider.Github + ? `${repoDetails.ownerName}/${repoDetails.repositoryName}` + : undefined, + name: repoDetails.repositoryProvider === RepositoryProvider.Github + ? `${repoDetails.ownerName}/${repoDetails.repositoryName}` + : repoDetails.repositoryName, + type: repoDetails.repositoryProvider, + defaultBranch: repoDetails.branch, + url: repoDetails.remoteUrl, properties: repositoryProperties, }, - properties: properties, + properties: { + source: 'ms-azure-devops.azure-pipelines', + }, }; } @@ -138,32 +139,4 @@ export class AzureDevOpsHelper { public static getOldFormatBuildUrl(accountName: string, projectName: string, buildId: number) { return `https://${accountName}.visualstudio.com/${projectName}/_build/results?buildId=${buildId}&view=results`; } - - public static generateDevOpsOrganizationName(userName: string, repositoryName: string): string { - let repositoryNameSuffix = repositoryName.replace("/", "-").trim(); - let organizationName = `${userName}-${repositoryNameSuffix}`; - - // Name cannot start or end with whitespaces, cannot start with '-', cannot contain characters other than a-z|A-Z|0-9 - organizationName = organizationName.trim().replace(/^[-]+/, '').replace(/[^a-zA-Z0-9-]/g, ''); - if(organizationName.length > 50) { - organizationName = organizationName.substr(0, 50); - } - - return organizationName; - } - - public static generateDevOpsProjectName(repositoryName?: string): string { - // I don't believe this can be hit based on the caller paths. - // Verify to make sure and then make repositoryName required. - if (!repositoryName) { - return "AzurePipelines"; - } - - const repoParts = repositoryName.split("/"); - const suffix = repoParts[repoParts.length - 1] - .trim() - .replace(/[._]+$/, ''); // project name cannot end with . or _ - - return `AzurePipelines-${suffix}`.substring(0, 64); - } } diff --git a/src/configure/helper/gitHubHelper.ts b/src/configure/helper/gitHubHelper.ts index 5e799b32..11e33cf7 100644 --- a/src/configure/helper/gitHubHelper.ts +++ b/src/configure/helper/gitHubHelper.ts @@ -9,17 +9,20 @@ export class GitHubProvider { return remoteUrl.startsWith(GitHubProvider.GitHubUrl) || remoteUrl.startsWith(GitHubProvider.SSHGitHubUrl); } - public static getRepositoryIdFromUrl(remoteUrl: string): string { - let endCount = remoteUrl.indexOf('.git'); - if (endCount < 0) { - endCount = remoteUrl.length; - } - - if (remoteUrl.startsWith(GitHubProvider.SSHGitHubUrl)) { - return remoteUrl.substring(GitHubProvider.SSHGitHubUrl.length, endCount); - } - - return remoteUrl.substring(GitHubProvider.GitHubUrl.length, endCount); + public static getRepositoryDetailsFromRemoteUrl(remoteUrl: string): { ownerName: string, repositoryName: string } { + // https://github.com/microsoft/azure-pipelines-vscode.git + // => ['https:', '', 'github.com', 'microsoft', 'azure-pipelines-vscode.git'] + // => { ownerName: 'microsoft', repositoryName: 'azure-pipelines-vscode'} + // =============================================== + // git@github.com:microsoft/azure-pipelines-vscode + // => microsoft/zure-pipelines-vscode + // => ['microsoft', 'azure-pipelines-vscode'] + // => { ownerName: 'microsoft', repositoryName: 'azure-pipelines-vscode'} + const parts = remoteUrl.replace(GitHubProvider.SSHGitHubUrl, '').split('/'); + return { + ownerName: parts[parts.length - 2], + repositoryName: parts[parts.length - 1].replace(/\.git$/, '') + }; } public static getFormattedRemoteUrl(remoteUrl: string): string { diff --git a/src/configure/helper/graphHelper.ts b/src/configure/helper/graphHelper.ts index 07f6e42e..08fc426f 100644 --- a/src/configure/helper/graphHelper.ts +++ b/src/configure/helper/graphHelper.ts @@ -19,11 +19,11 @@ export class GraphHelper { private static retryCount = 20; public static async createSpnAndAssignRole(session: AzureSession, aadAppName: string, scope: string): Promise { - let accessToken = await this.getGraphToken(session); - let tokenCredentials = new TokenCredentials(accessToken); - let graphClient = new RestClient(tokenCredentials); - let tenantId = session.tenantId; - var aadApp: AadApplication; + const accessToken = await this.getGraphToken(session); + const tokenCredentials = new TokenCredentials(accessToken); + const graphClient = new RestClient(tokenCredentials); + const tenantId = session.tenantId; + let aadApp: AadApplication; return this.createAadApp(graphClient, aadAppName, tenantId) .then((aadApplication) => { @@ -50,11 +50,11 @@ export class GraphHelper { } public static generateAadApplicationName(accountName: string, projectName: string): string { - var spnLengthAllowed = 92; - var guid = uuid(); - var projectName = projectName.replace(/[^a-zA-Z0-9_-]/g, ""); - var accountName = accountName.replace(/[^a-zA-Z0-9_-]/g, ""); - var spnName = accountName + "-" + projectName + "-" + guid; + let spnLengthAllowed = 92; + const guid = uuid(); + projectName = projectName.replace(/[^a-zA-Z0-9_-]/g, ""); + accountName = accountName.replace(/[^a-zA-Z0-9_-]/g, ""); + const spnName = accountName + "-" + projectName + "-" + guid; if (spnName.length <= spnLengthAllowed) { return spnName; } @@ -76,9 +76,14 @@ export class GraphHelper { } private static async getGraphToken(session: AzureSession): Promise { + const { activeDirectoryGraphResourceId } = session.environment; + if (activeDirectoryGraphResourceId === undefined) { + throw new Error(util.format(Messages.acquireAccessTokenFailed, "Active Directory Graph resource ID is undefined.")); + } + return new Promise((resolve, reject) => { const credentials = session.credentials2; - credentials.authContext.acquireToken(session.environment.activeDirectoryGraphResourceId, session.userId, credentials.clientId, function (err, tokenResponse) { + credentials.authContext.acquireToken(activeDirectoryGraphResourceId, session.userId, credentials.clientId, function (err, tokenResponse) { if (err) { reject(new Error(util.format(Messages.acquireAccessTokenFailed, err.message))); } else if (tokenResponse.error) { diff --git a/src/configure/helper/templateHelper.ts b/src/configure/helper/templateHelper.ts index f882bc11..d73b5ab0 100644 --- a/src/configure/helper/templateHelper.ts +++ b/src/configure/helper/templateHelper.ts @@ -1,6 +1,6 @@ -import { PipelineTemplate, TargetResourceType, WizardInputs, WebAppKind } from '../model/models'; +import { PipelineTemplate, TargetResourceType, WebAppKind } from '../model/models'; import * as fs from 'fs/promises'; -import * as Mustache from 'mustache'; +import Mustache from 'mustache'; import * as path from 'path'; import * as vscode from 'vscode'; import { URI } from 'vscode-uri'; @@ -32,27 +32,35 @@ export async function analyzeRepoAndListAppropriatePipeline(repoUri: URI): Promi return templateList; } -export async function renderContent(templateFilePath: string, context: WizardInputs): Promise { +export async function renderContent(templateFilePath: string, branch: string, resource: string | undefined, azureServiceConnection: string | undefined): Promise { const data = await fs.readFile(templateFilePath, { encoding: "utf8" }); - return Mustache.render(data, context); + return Mustache.render(data, { + branch, + resource, + azureServiceConnection, + }); } async function analyzeRepo(repoUri: URI): Promise<{ isNodeApplication: boolean, isFunctionApplication: boolean, isPythonApplication: boolean, isDotnetApplication: boolean }> { let contents: [string, vscode.FileType][]; - let err = false; try { contents = await vscode.workspace.fs.readDirectory(repoUri); - } catch (e) { - err = true; + } catch { + return { + isNodeApplication: true, + isFunctionApplication: true, + isPythonApplication: true, + isDotnetApplication: true, + }; } const files = contents.filter(file => file[1] !== vscode.FileType.Directory).map(file => file[0]); return { - isNodeApplication: err ? true : isNodeRepo(files), - isFunctionApplication: err ? true : isFunctionApp(files), - isPythonApplication: err ? true : isPythonRepo(files), - isDotnetApplication: err ? true : isDotnetApplication(files) + isNodeApplication: isNodeRepo(files), + isFunctionApplication: isFunctionApp(files), + isPythonApplication: isPythonRepo(files), + isDotnetApplication: isDotnetApplication(files), // isContainerApplication: isDockerRepo(files) }; @@ -91,36 +99,46 @@ const nodeTemplates: Array = [ label: 'Node.js with npm to Windows Web App', path: path.join(__dirname, 'configure/templates/nodejsWindowsWebApp.yml'), language: 'node', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, }, { label: 'Node.js with Angular to Windows Web App', path: path.join(__dirname, 'configure/templates/nodejsWindowsWebAppAngular.yml'), language: 'node', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, }, { label: 'Node.js with Gulp to Windows Web App', path: path.join(__dirname, 'configure/templates/nodejsWindowsWebAppGulp.yml'), language: 'node', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, }, { label: 'Node.js with Grunt to Windows Web App', path: path.join(__dirname, 'configure/templates/nodejsWindowsWebAppGrunt.yml'), language: 'node', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, }, { label: 'Node.js with Webpack to Windows Web App', path: path.join(__dirname, 'configure/templates/nodejsWindowsWebAppWebpack.yml'), language: 'node', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, } ]; @@ -129,15 +147,18 @@ const pythonTemplates: Array = [ label: 'Python to Linux Web App on Azure', path: path.join(__dirname, 'configure/templates/pythonLinuxWebApp.yml'), language: 'python', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.LinuxApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.LinuxApp, + }, }, { label: 'Build and Test Python Django App', path: path.join(__dirname, 'configure/templates/pythonDjango.yml'), language: 'python', - targetType: TargetResourceType.None, - targetKind: null + target: { + type: TargetResourceType.None, + }, } ]; @@ -146,15 +167,19 @@ const dotnetTemplates: Array = [ label: '.NET Web App to Windows on Azure', path: path.join(__dirname, 'configure/templates/dotnetWindowsWebApp.yml'), language: 'dotnet', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, }, { label: '.NET Web App to Linux on Azure', path: path.join(__dirname, 'configure/templates/dotnetLinuxWebApp.yml'), language: 'dotnet', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.LinuxApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.LinuxApp, + }, } ] @@ -163,8 +188,10 @@ const simpleWebAppTemplates: Array = [ label: 'Simple application to Windows Web App', path: path.join(__dirname, 'configure/templates/simpleWebApp.yml'), language: 'none', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.WindowsApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.WindowsApp, + }, } ]; @@ -173,21 +200,27 @@ const functionTemplates: Array = [ label: 'Python Function App to Linux Azure Function', path: path.join(__dirname, 'configure/templates/pythonLinuxFunctionApp.yml'), language: 'python', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.FunctionAppLinux + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.FunctionAppLinux, + }, }, { label: 'Node.js Function App to Linux Azure Function', path: path.join(__dirname, 'configure/templates/nodejsLinuxFunctionApp.yml'), language: 'node', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.FunctionAppLinux + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.FunctionAppLinux, + }, }, { label: '.NET Function App to Windows Azure Function', path: path.join(__dirname, 'configure/templates/dotnetWindowsFunctionApp.yml'), language: 'dotnet', - targetType: TargetResourceType.WebApp, - targetKind: WebAppKind.FunctionApp + target: { + type: TargetResourceType.WebApp, + kind: WebAppKind.FunctionApp, + }, }, ] diff --git a/src/configure/model/models.ts b/src/configure/model/models.ts index 2076c78b..ccbfab6a 100644 --- a/src/configure/model/models.ts +++ b/src/configure/model/models.ts @@ -1,20 +1,11 @@ -import { ResourceManagementModels } from '@azure/arm-resources'; import { QuickPickItem } from 'vscode'; -import { Messages } from '../../messages'; -import { TeamProjectReference } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { WebApi } from 'azure-devops-node-api'; +import { AppServiceClient } from '../clients/azure/appServiceClient'; +import { Build, BuildDefinition } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { WebSiteManagementModels } from '@azure/arm-appservice'; import { AzureSession } from '../../typings/azure-account.api'; -export class WizardInputs { - organizationName: string; - isNewOrganization: boolean; - project: TeamProjectReference; - sourceRepository: GitRepositoryParameters; - targetResource: AzureParameters = new AzureParameters(); - pipelineParameters: PipelineParameters = new PipelineParameters(); - azureSession: AzureSession; - githubPatToken?: string; -} - export interface Organization { accountId: string; accountName: string; @@ -22,6 +13,31 @@ export interface Organization { properties: {}; } +/** + * Identical to @see {TeamProject} except with name & id verified. + */ +export interface ValidatedProject extends TeamProject { + name: string; + id: string; +} + +/** + * Identical to @see {WebSiteManagementModels.Site} except with name, id, & resourceGroup verified. + */ +export interface ValidatedSite extends WebSiteManagementModels.Site { + name: string; + id: string; + resourceGroup: string; +} + +/** + * Identical to @see {Build} except with definition & id verified. + */ +export interface ValidatedBuild extends Build { + definition: Required; + id: number; +} + export type OrganizationAvailability = { isAvailable: true; name: string; @@ -32,34 +48,38 @@ export type OrganizationAvailability = { unavailabilityReason: string; }; -export class AzureParameters { +export interface AzureSiteDetails { + appServiceClient: AppServiceClient; subscriptionId: string; - resource: ResourceManagementModels.GenericResource; - serviceConnectionId: string; -} - -export class PipelineParameters { - pipelineFileName: string; - pipelineTemplate: PipelineTemplate; + site: ValidatedSite; } -export interface GitRepositoryParameters { - repositoryProvider: RepositoryProvider; +export type GitRepositoryDetails = { repositoryName: string; - repositoryId: string; remoteName: string; remoteUrl: string; branch: string; - commitId: string; - serviceConnectionId?: string; // Id of the service connection in Azure DevOps +} & ({ + repositoryProvider: RepositoryProvider.AzureRepos; + organizationName: string; + projectName: string; +} | { + repositoryProvider: RepositoryProvider.Github; + ownerName: string; +}); + +export interface AzureDevOpsDetails { + session: AzureSession; + adoClient: WebApi; + organizationName: string; + project: ValidatedProject; } export interface PipelineTemplate { path: string; label: string; language: string; - targetType: TargetResourceType; - targetKind: WebAppKind; + target: TargetResource; } export enum SourceOptions { @@ -73,16 +93,18 @@ export enum RepositoryProvider { AzureRepos = 'tfsgit' } +export type TargetResource = { + type: TargetResourceType.None; +} | { + type: TargetResourceType.WebApp; + kind: WebAppKind; +}; + export enum TargetResourceType { None = 'none', WebApp = 'Microsoft.Web/sites' } -export enum ServiceConnectionType { - GitHub = 'github', - AzureRM = 'azurerm' -} - export enum WebAppKind { WindowsApp = 'app', FunctionApp = 'functionapp', @@ -95,58 +117,6 @@ export interface QuickPickItemWithData extends QuickPickItem { data: T; } -export class ParsedAzureResourceId { - public resourceId: string; - public subscriptionId: string; - public resourceGroup: string; - public resourceType: string; - public resourceProvider: string; - public resourceName: string; - public childResourceType?: string; - public childResource?: string; - - constructor(resourceId: string) { - if (!resourceId) { - throw new Error(Messages.resourceIdMissing); - } - - this.resourceId = resourceId; - this.parseId(); - } - - private parseId() { - // remove all empty parts in the resource to avoid failing in case there are leading/trailing/extra '/' - let parts = this.resourceId.split('/').filter((part) => !!part); - if (!!parts) { - for (let i = 0; i < parts.length; i++) { - switch (i) { - case 1: - this.subscriptionId = parts[i]; - break; - case 3: - this.resourceGroup = parts[i]; - break; - case 5: - this.resourceProvider = parts[i]; - break; - case 6: - this.resourceType = parts[i]; - break; - case 7: - this.resourceName = parts[i]; - break; - case 8: - this.childResourceType = parts[i]; - break; - case 9: - this.childResource = parts[i]; - break; - } - } - } - } -} - export interface AadApplication { appId: string; secret: string; diff --git a/src/configure/resources/constants.ts b/src/configure/resources/constants.ts index 7fa67379..835a8bbb 100644 --- a/src/configure/resources/constants.ts +++ b/src/configure/resources/constants.ts @@ -87,9 +87,9 @@ export const ReservedHostNames: string[] = [ "servicehosts", "sps", "sqlazure", - "ssh", , + "ssh", "start", - "status", , + "status", "statusalt1", "status-alt1", "support", @@ -139,8 +139,6 @@ export const SelectProject = 'selectProject'; export const EnterOrganizationName = 'enterOrganizationName'; export const SelectPipelineTemplate = 'selectPipelineTemplate'; export const SelectSubscription = 'selectSubscription'; -export const SelectWebApp = 'selectWebApp'; -export const SelectFunctionApp = 'selectFunctionApp'; export const GitHubPat = 'gitHubPat'; export const SelectFromMultipleWorkSpace = 'selectFromMultipleWorkSpace'; export const SelectRemoteForRepo = 'selectRemoteForRepo'; diff --git a/src/configure/resources/tracePoints.ts b/src/configure/resources/tracePoints.ts index 9d1f4b35..6629daa4 100644 --- a/src/configure/resources/tracePoints.ts +++ b/src/configure/resources/tracePoints.ts @@ -9,8 +9,7 @@ export class TracePoints { public static ExtractAzureResourceFromNodeFailed = 'extractAzureResourceFromNodeFailed'; public static GetAzureDevOpsDetailsFailed = 'GetAzureDevOpsDetailsFailed'; public static GetRepositoryDetailsFromRemoteUrlFailed = 'GetRepositoryDetailsFromRemoteUrlFailed'; - public static GetSourceRepositoryDetailsFailed = 'getSourceRepositoryDetailsFailed'; public static GitHubServiceConnectionError = 'gitHubServiceConnectionError'; public static PipelineFileCheckInFailed = 'PipelineFileCheckInFailed'; public static PostDeploymentActionFailed = 'PostDeploymentActionFailed'; -} \ No newline at end of file +} diff --git a/src/configure/templates/dotnetLinuxWebApp.yml b/src/configure/templates/dotnetLinuxWebApp.yml index 88ee80c9..6bb64f9b 100644 --- a/src/configure/templates/dotnetLinuxWebApp.yml +++ b/src/configure/templates/dotnetLinuxWebApp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/en-us/azure/devops/pipelines/languages/dotnet-core trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection created during pipeline creation - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web App name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'ubuntu-latest' diff --git a/src/configure/templates/dotnetWindowsFunctionApp.yml b/src/configure/templates/dotnetWindowsFunctionApp.yml index 23127345..03ae9dd0 100644 --- a/src/configure/templates/dotnetWindowsFunctionApp.yml +++ b/src/configure/templates/dotnetWindowsFunctionApp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/en-us/azure/devops/pipelines/languages/dotnet-core trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection created during pipeline creation - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Function app name - functionAppName: '{{{ targetResource.resource.name }}}' + functionAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/dotnetWindowsWebApp.yml b/src/configure/templates/dotnetWindowsWebApp.yml index eebecd8b..ae237f96 100644 --- a/src/configure/templates/dotnetWindowsWebApp.yml +++ b/src/configure/templates/dotnetWindowsWebApp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/en-us/azure/devops/pipelines/languages/dotnet-core trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection created during pipeline creation - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web App name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/nodejsLinuxFunctionApp.yml b/src/configure/templates/nodejsLinuxFunctionApp.yml index 7405332d..c6e09f80 100644 --- a/src/configure/templates/nodejsLinuxFunctionApp.yml +++ b/src/configure/templates/nodejsLinuxFunctionApp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - functionAppName: '{{{ targetResource.resource.name }}}' + functionAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'ubuntu-latest' diff --git a/src/configure/templates/nodejsWindowsWebApp.yml b/src/configure/templates/nodejsWindowsWebApp.yml index d97af0f5..b85247b7 100644 --- a/src/configure/templates/nodejsWindowsWebApp.yml +++ b/src/configure/templates/nodejsWindowsWebApp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/nodejsWindowsWebAppAngular.yml b/src/configure/templates/nodejsWindowsWebAppAngular.yml index 578e307a..4cabaec5 100644 --- a/src/configure/templates/nodejsWindowsWebAppAngular.yml +++ b/src/configure/templates/nodejsWindowsWebAppAngular.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/nodejsWindowsWebAppGrunt.yml b/src/configure/templates/nodejsWindowsWebAppGrunt.yml index ace36cfb..171e5fd1 100644 --- a/src/configure/templates/nodejsWindowsWebAppGrunt.yml +++ b/src/configure/templates/nodejsWindowsWebAppGrunt.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/nodejsWindowsWebAppGulp.yml b/src/configure/templates/nodejsWindowsWebAppGulp.yml index d0f28f18..43d21ab9 100644 --- a/src/configure/templates/nodejsWindowsWebAppGulp.yml +++ b/src/configure/templates/nodejsWindowsWebAppGulp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/nodejsWindowsWebAppWebpack.yml b/src/configure/templates/nodejsWindowsWebAppWebpack.yml index e414a8ef..22c09148 100644 --- a/src/configure/templates/nodejsWindowsWebAppWebpack.yml +++ b/src/configure/templates/nodejsWindowsWebAppWebpack.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/configure/templates/pythonDjango.yml b/src/configure/templates/pythonDjango.yml index 318b3090..9a533ed9 100644 --- a/src/configure/templates/pythonDjango.yml +++ b/src/configure/templates/pythonDjango.yml @@ -4,7 +4,7 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/python trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} pool: vmImage: 'ubuntu-latest' diff --git a/src/configure/templates/pythonLinuxFunctionApp.yml b/src/configure/templates/pythonLinuxFunctionApp.yml index cb0a3599..fc4b73e1 100644 --- a/src/configure/templates/pythonLinuxFunctionApp.yml +++ b/src/configure/templates/pythonLinuxFunctionApp.yml @@ -4,14 +4,14 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/python trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - functionAppName: '{{{ targetResource.resource.name }}}' + functionAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'ubuntu-latest' diff --git a/src/configure/templates/pythonLinuxWebApp.yml b/src/configure/templates/pythonLinuxWebApp.yml index 9f425024..2267bf81 100644 --- a/src/configure/templates/pythonLinuxWebApp.yml +++ b/src/configure/templates/pythonLinuxWebApp.yml @@ -4,20 +4,20 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/python trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection created during pipeline creation - azureServiceConnectionId: '{{{ targetResource.serviceConnectionId }}}' + azureServiceConnectionId: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'ubuntu-latest' # Environment name - environmentName: '{{{ targetResource.resource.name }}}' + environmentName: '{{{ resource }}}' stages: - stage: Build diff --git a/src/configure/templates/simpleWebApp.yml b/src/configure/templates/simpleWebApp.yml index 0e05805b..940a3794 100644 --- a/src/configure/templates/simpleWebApp.yml +++ b/src/configure/templates/simpleWebApp.yml @@ -2,14 +2,14 @@ # Package and deploy a simple web application and deploy it to Azure as Windows web app. trigger: -- {{{ sourceRepository.branch }}} +- {{{ branch }}} variables: # Azure Resource Manager connection - azureSubscription: '{{{ targetResource.serviceConnectionId }}}' + azureSubscription: '{{{ azureServiceConnection }}}' # Web app name - webAppName: '{{{ targetResource.resource.name }}}' + webAppName: '{{{ resource }}}' # Agent VM image name vmImageName: 'windows-latest' diff --git a/src/extension.ts b/src/extension.ts index 1e274acc..05cf8441 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,11 +28,9 @@ const DOCUMENT_SELECTOR = [ export async function activate(context: vscode.ExtensionContext) { const configurePipelineEnabled = vscode.workspace.getConfiguration(LANGUAGE_IDENTIFIER).get('configure', true); - telemetryHelper.initialize('azurePipelines.activate', { - isActivationEvent: 'true', - configurePipelineEnabled: `${configurePipelineEnabled}`, - }); - await telemetryHelper.callWithTelemetryAndErrorHandling(async () => { + telemetryHelper.setTelemetry('isActivationEvent', 'true'); + telemetryHelper.setTelemetry('configurePipelineEnabled', `${configurePipelineEnabled}`); + await telemetryHelper.callWithTelemetryAndErrorHandling('azurePipelines.activate', async () => { await activateYmlContributor(context); if (configurePipelineEnabled) { const { activateConfigurePipeline } = await import('./configure/activate'); @@ -40,6 +38,8 @@ export async function activate(context: vscode.ExtensionContext) { } }); + context.subscriptions.push(telemetryHelper); + logger.log('Extension has been activated!', 'ExtensionActivated'); return schemaContributor; } diff --git a/src/helpers/telemetryHelper.ts b/src/helpers/telemetryHelper.ts index fdadc2b9..39df35be 100644 --- a/src/helpers/telemetryHelper.ts +++ b/src/helpers/telemetryHelper.ts @@ -8,7 +8,7 @@ import { parseError } from './parseError'; import { v4 as uuid } from 'uuid'; const extensionName = 'ms-azure-devops.azure-pipelines'; -const packageJSON = vscode.extensions.getExtension(extensionName).packageJSON; +const packageJSON = vscode.extensions.getExtension(extensionName)!.packageJSON; // Guaranteed to exist const extensionVersion = packageJSON.version; const aiKey = packageJSON.aiKey; @@ -16,28 +16,22 @@ interface TelemetryProperties { [key: string]: string; } -interface TelemetryOptions { - suppressIfSuccessful: boolean; +enum Result { + 'Succeeded' = 'Succeeded', + 'Failed' = 'Failed', + 'Canceled' = 'Canceled' } + class TelemetryHelper { - private journeyId: string; - private command: string; - private properties: TelemetryProperties; - private options: TelemetryOptions; + private journeyId: string = uuid(); - private static reporter = new TelemetryReporter(extensionName, extensionVersion, aiKey); + private properties: TelemetryProperties = { + [TelemetryKeys.JourneyId]: this.journeyId, + [TelemetryKeys.Result]: Result.Succeeded, + }; - public initialize(command: string, properties: TelemetryProperties = {}) { - this.journeyId = uuid(); - this.command = command; - this.properties = properties; - this.options = { - suppressIfSuccessful: false, - }; - this.setTelemetry(TelemetryKeys.JourneyId, this.journeyId); - this.setTelemetry(TelemetryKeys.Result, Result.Succeeded); - } + private static reporter = new TelemetryReporter(extensionName, extensionVersion, aiKey); public dispose() { TelemetryHelper.reporter.dispose(); @@ -47,13 +41,6 @@ class TelemetryHelper { return this.journeyId; } - public setOptions(options: Partial): void { - this.options = { - ...this.options, - ...options, - }; - } - public setTelemetry(key: string, value: string): void { this.properties[key] = value; } @@ -71,25 +58,12 @@ class TelemetryHelper { TelemetryHelper.reporter.sendTelemetryErrorEvent( tracePoint, { [TelemetryKeys.JourneyId]: this.journeyId, - 'command': this.command, - 'layer': layer, - 'errorMessage': error.message, - 'stack': error.stack ?? '', + layer, + errorMessage: error.message, + stack: error.stack ?? '', }, undefined, ['errorMesage', 'stack']); } - // Log an informational message. - // No custom properties are logged alongside the message. - public logInfo(layer: string, tracePoint: string, info: string): void { - TelemetryHelper.reporter.sendTelemetryEvent( - tracePoint, { - [TelemetryKeys.JourneyId]: this.journeyId, - 'command': this.command, - 'layer': layer, - 'info': info - }); - } - // Executes the given function, timing how long it takes. // This *does NOT* send any telemetry and must be called within the context // of an ongoing `callWithTelemetryAndErrorHandling` session to do anything useful. @@ -110,7 +84,7 @@ class TelemetryHelper { // supplied through initialize() or setTelemetry(). // If the function errors, the telemetry event will additionally contain metadata about the error that occurred. // https://github.com/microsoft/vscode-azuretools/blob/5999c2ad4423e86f22d2c648027242d8816a50e4/ui/src/callWithTelemetryAndErrorHandling.ts - public async callWithTelemetryAndErrorHandling(callback: () => Promise): Promise { + public async callWithTelemetryAndErrorHandling(command: string, callback: () => Promise): Promise { try { return await this.executeFunctionWithTimeTelemetry(callback, 'duration'); } catch (error) { @@ -122,9 +96,6 @@ class TelemetryHelper { this.setTelemetry('error', parsedError.errorType); this.setTelemetry('errorMessage', parsedError.message); this.setTelemetry('stack', parsedError.stack ?? ''); - if (this.options.suppressIfSuccessful) { - this.setTelemetry('suppressTelemetry', 'true'); - } logger.log(parsedError.message); if (parsedError.message.includes('\n')) { @@ -136,13 +107,13 @@ class TelemetryHelper { } finally { if (this.properties.result === Result.Failed) { TelemetryHelper.reporter.sendTelemetryErrorEvent( - this.command, { + command, { ...this.properties, [TelemetryKeys.JourneyId]: this.journeyId, }, undefined, ['error', 'errorMesage', 'stack']); - } else if (!(this.options.suppressIfSuccessful && this.properties.result === Result.Succeeded)) { + } else { TelemetryHelper.reporter.sendTelemetryEvent( - this.command, { + command, { ...this.properties, [TelemetryKeys.JourneyId]: this.journeyId, }); @@ -152,9 +123,3 @@ class TelemetryHelper { } export const telemetryHelper = new TelemetryHelper(); - -enum Result { - 'Succeeded' = 'Succeeded', - 'Failed' = 'Failed', - 'Canceled' = 'Canceled' -} diff --git a/src/messages.ts b/src/messages.ts index f4f3edd1..0ffcf06b 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -7,12 +7,15 @@ export class Messages { public static gitExtensionUnavailable: string = 'Git extension could not be fetched. Please ensure it\'s installed and activated.'; public static gitExtensionNotEnabled: string = 'Git extension is not enabled. Please change the `git.enabled` setting to true.'; public static azureLoginRequired: string = 'Please sign in to your Azure account first.'; + public static branchHeadMissing: string = `The current repository doesn't have any commits. Please [create a commit](https://git-scm.com/docs/git-commit) first, and then try this again.`; + public static branchNameMissing: string = `The current repository isn't on a branch. Please [checkout a branch](https://git-scm.com/docs/git-checkout) first, and then try this again.`; public static branchRemoteMissing: string = `The current branch doesn't have a tracking branch, and the selected repository has no remotes. We're unable to create a remote tracking branch. Please [set a remote tracking branch](https://git-scm.com/docs/git-branch#Documentation/git-branch.txt---track) first, and then try this again.`; public static browsePipeline: string = 'Browse Pipeline'; public static cannotAddFileRemoteMissing: string = 'Couldn\'t add YAML file to your repo because the remote isn\'t set'; - public static cannotIdentifyRespositoryDetails: string = 'Couldn\'t get repository details. Ensure your repo is hosted on [Azure Repos](https://docs.microsoft.com/azure/devops/repos/get-started) or [GitHub](https://guides.github.com/activities/hello-world/).'; + public static cannotIdentifyRepositoryDetails: string = 'Couldn\'t get repository details. Ensure your repo is hosted on [Azure Repos](https://docs.microsoft.com/azure/devops/repos/get-started) or [GitHub](https://guides.github.com/activities/hello-world/).'; public static commitAndPush: string = 'Commit & push'; public static commitFailedErrorMessage: string =`Commit failed due to error: %s`; + public static pushingPipelineFile: string = 'Pushing pipeline file...'; public static configuringPipelineAndDeployment: string = 'Configuring pipeline and proceeding to deployment...'; public static couldNotAuthorizeEndpoint: string = 'Couldn\'t authorize endpoint for use in Azure Pipelines.'; public static creatingAzureDevOpsOrganization: string = 'Creating Azure DevOps organization.'; @@ -24,20 +27,19 @@ export class Messages { public static failedToCreateAzureDevOpsProject: string = 'Couldn\'t create a project in the Azure DevOps organization. Error: %s.'; public static failedToCreateAzurePipeline: string = 'Couldn\'t configure pipeline. Error: %s'; public static failedToDetermineAzureRepoDetails: string = 'Failed to determine Azure Repo details from remote url. Please ensure that the remote points to a valid Azure Repos url.'; - public static gitHubPatTokenErrorMessage: string = 'GitHub PAT token cannot be empty.'; - public static githubPatTokenHelpMessage: string = 'GitHub personal access token (PAT) with following permissions: full access to repository webhooks and services, read and write access to all repository data.'; + public static gitHubPatErrorMessage: string = 'GitHub PAT cannot be empty.'; + public static noGitHubPatError: string = 'Please enter a GitHub PAT to configure pipeline'; + public static githubPatHelpMessage: string = 'GitHub personal access token (PAT) with following permissions: full access to repository webhooks and services, read and write access to all repository data.'; public static modifyAndCommitFile: string = 'Modify and save your YAML file. %s will commit this file, push the branch \'%s\' to remote \'%s\' and proceed with deployment.'; public static noAgentQueueFound: string = 'No agent pool found named "%s".'; public static noAvailableFileNames: string = 'No available filenames found.'; public static notAGitRepository: string = 'Selected workspace is not a [Git](https://git-scm.com/docs/git) repository. Please select a Git repository.'; public static notAzureRepoUrl: string = 'The repo isn\'t hosted with Azure Repos.'; - public static noWorkSpaceSelectedError: string = 'Please select a workspace folder to configure pipeline.'; public static operationTimedOut: string = 'Operation timed out.'; public static organizationNameReservedMessage: string = 'The organization name %s isn\'t available. Please try another organization name.'; public static organizationNameStaticValidationMessage: string = 'Organization names must start and end with a letter or number and can contain only letters, numbers, and hyphens.'; public static pipelineSetupSuccessfully: string = 'Pipeline set up successfully!'; public static remoteRepositoryNotConfigured: string = 'Remote repository is not configured. This extension is compatible with [Azure Repos](https://docs.microsoft.com/en-us/azure/devops/repos/get-started) or [GitHub](https://guides.github.com/activities/hello-world/).'; - public static resourceIdMissing: string = 'Required argument "resourceId" is missing. Please pass the argument for getting resource.'; public static resourceTypeIsNotSupported: string = '"%s" resources are not yet supported for configuring pipelines.'; public static selectFolderLabel: string = 'Select source folder for configuring pipeline'; public static selectOrganization: string = 'Select an Azure DevOps organization'; @@ -48,8 +50,6 @@ export class Messages { public static selectProject: string = 'Select an Azure DevOps project'; public static selectRemoteForBranch: string = 'Select the remote repository where you want to track your current branch'; public static selectSubscription: string = 'Select an Azure subscription'; - public static selectWebApp: string = 'Select Web App'; - public static selectFunctionApp: string = 'Select Function App'; public static selectWorkspaceFolder: string = 'Select a folder from your workspace to deploy'; public static signInLabel: string = 'Sign In'; public static unableToAccessOrganization: string = 'Unable to access the "%s" organization. Make sure you\'re signed into the right Azure account.'; diff --git a/src/schema-association-service-1espt.ts b/src/schema-association-service-1espt.ts index 9766f1e9..82edce17 100644 --- a/src/schema-association-service-1espt.ts +++ b/src/schema-association-service-1espt.ts @@ -12,16 +12,21 @@ import { Messages } from './messages'; const milliseconds24hours = 86400000; -export async function get1ESPTSchemaUri(azureDevOpsClient: azdev.WebApi, organizationName: string, session: AzureSession, context: vscode.ExtensionContext, repoId1espt: string): Promise { +export async function get1ESPTSchemaUri(azureDevOpsClient: azdev.WebApi, organizationName: string, session: AzureSession, context: vscode.ExtensionContext, repoId1espt: string): Promise { try { if (session.userId.endsWith("@microsoft.com")) { const gitApi = await azureDevOpsClient.getGitApi(); // Using getItem from GitApi: getItem(repositoryId: string, path: string, project?: string, scopePath?: string, recursionLevel?: GitInterfaces.VersionControlRecursionType, includeContentMetadata?: boolean, latestProcessedChange?: boolean, download?: boolean, versionDescriptor?: GitInterfaces.GitVersionDescriptor, includeContent?: boolean, resolveLfs?: boolean, sanitize?: boolean): Promise; const schemaFile = await gitApi.getItem(repoId1espt, "schema/1espt-base-schema.json", "1ESPipelineTemplates", undefined, undefined, true, true, true, undefined, true, true); - const schemaContent = schemaFile.content; + const { content } = schemaFile; + if (content === undefined) { + logger.log(`File was retrieved without content for org: ${organizationName}`, 'SchemaDetection'); + return undefined; + } + const schemaUri = Utils.joinPath(context.globalStorageUri, '1ESPTSchema', `${organizationName}-1espt-schema.json`); - await vscode.workspace.fs.writeFile(schemaUri, Buffer.from(schemaContent)); + await vscode.workspace.fs.writeFile(schemaUri, Buffer.from(content)); return schemaUri; } else { @@ -41,56 +46,59 @@ export async function get1ESPTSchemaUri(azureDevOpsClient: azdev.WebApi, organiz 2) 1ESPT schema is enabled 3) last fetched 1ESPT schema is less than 24 hours old 4) Schema file exists - * @param context - * @param organizationName - * @param session - * @param lastUpdated1ESPTSchema - * @param seen1ESPTOrganizations - * @returns + * @param context + * @param organizationName + * @param session + * @param lastUpdated1ESPTSchema + * @returns */ -export async function getCached1ESPTSchema(context: vscode.ExtensionContext, organizationName: string, session: AzureSession, lastUpdated1ESPTSchema: Map, seen1ESPTOrganizations: Set): Promise { - if (seen1ESPTOrganizations.has(organizationName)) { - const schemaUri1ESPT = Utils.joinPath(context.globalStorageUri, '1ESPTSchema', `${organizationName}-1espt-schema.json`); +export async function getCached1ESPTSchema(context: vscode.ExtensionContext, organizationName: string, session: AzureSession, lastUpdated1ESPTSchema: Map): Promise { + const lastUpdatedDate = lastUpdated1ESPTSchema.get(organizationName); + if (!lastUpdatedDate) { + return undefined; + } - try { - if (session.userId.endsWith("@microsoft.com")) { - if ((new Date().getTime() - lastUpdated1ESPTSchema.get(organizationName).getTime()) < milliseconds24hours) { - const schemaFileExists = await vscode.workspace.fs.stat(schemaUri1ESPT); - if (schemaFileExists) { - logger.log("Returning cached schema for 1ESPT", 'SchemaDetection'); - return schemaUri1ESPT; - } - } - // schema is older than 24 hours, fetch schema file again - else { - logger.log(`Skipping cached 1ESPT schema for ${organizationName} as it is older than 24 hours`, `SchemaDetection`); + const schemaUri1ESPT = Utils.joinPath(context.globalStorageUri, '1ESPTSchema', `${organizationName}-1espt-schema.json`); + + try { + if (session.userId.endsWith("@microsoft.com")) { + if ((new Date().getTime() - lastUpdatedDate.getTime()) < milliseconds24hours) { + const schemaFileExists = await vscode.workspace.fs.stat(schemaUri1ESPT); + if (schemaFileExists) { + logger.log("Returning cached schema for 1ESPT", 'SchemaDetection'); + return schemaUri1ESPT; } } + // schema is older than 24 hours, fetch schema file again else { - const signInAction = await vscode.window.showInformationMessage(Messages.notUsing1ESPTSchemaAsUserNotSignedInMessage, Messages.signInLabel); - if (signInAction == Messages.signInLabel) { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: Messages.waitForAzureSignIn, - }, async () => { - await vscode.commands.executeCommand("azure-account.login"); - }); - } - logger.log(`Skipping cached 1ESPT schema for ${organizationName} as user is not signed in with Microsoft account`, `SchemaDetection`); + logger.log(`Skipping cached 1ESPT schema for ${organizationName} as it is older than 24 hours`, `SchemaDetection`); } } - catch (error) { - logger.log(`Error : ${error} while fetching cached 1ESPT schema for org: ${organizationName}. It's possible that the schema does not exist.`, 'SchemaDetection'); + else { + const signInAction = await vscode.window.showInformationMessage(Messages.notUsing1ESPTSchemaAsUserNotSignedInMessage, Messages.signInLabel); + if (signInAction == Messages.signInLabel) { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: Messages.waitForAzureSignIn, + }, async () => { + await vscode.commands.executeCommand("azure-account.login"); + }); + } + logger.log(`Skipping cached 1ESPT schema for ${organizationName} as user is not signed in with Microsoft account`, `SchemaDetection`); } } + catch (error) { + logger.log(`Error : ${error} while fetching cached 1ESPT schema for org: ${organizationName}. It's possible that the schema does not exist.`, 'SchemaDetection'); + } + return undefined; } /** * User is eligible for 1ESPT schema if 1ESPT schema is available in ADO organization - * @param azureDevOpsClient - * @param organizationName - * @returns + * @param azureDevOpsClient + * @param organizationName + * @returns */ export async function get1ESPTRepoIdIfAvailable(azureDevOpsClient: azdev.WebApi, organizationName: string): Promise { try { @@ -102,7 +110,7 @@ export async function get1ESPTRepoIdIfAvailable(azureDevOpsClient: azdev.WebApi, } const repository = repositories.find(repo => repo.name === "1ESPipelineTemplates"); - if (!repository) { + if (repository?.id === undefined) { logger.log(`1ESPipelineTemplates repo not found for org ${organizationName}`, `SchemaDetection`); return ""; // 1ESPT repo not found } diff --git a/src/schema-association-service.ts b/src/schema-association-service.ts index 2509ff35..63e18c8c 100644 --- a/src/schema-association-service.ts +++ b/src/schema-association-service.ts @@ -26,10 +26,9 @@ export const onDidSelectOrganization = selectOrganizationEvent.event; * A session-level cache of all the organizations we've saved the schema for. */ const seenOrganizations = new Set(); -const seen1ESPTOrganizations = new Set(); const lastUpdated1ESPTSchema = new Map(); -let repoId1espt = undefined; +let repoId1espt: string | undefined = undefined; export async function locateSchemaFile( context: vscode.ExtensionContext, @@ -188,7 +187,7 @@ async function autoDetectSchema( session = azureAccountApi.sessions.find(session => session.tenantId === details.tenant); logger.log( - `Using cached information for ${workspaceFolder.name}: ${organizationName}, ${session.tenantId}`, + `Using cached information for ${workspaceFolder.name}: ${organizationName}, ${session?.tenantId}`, 'SchemaDetection'); } else { logger.log(`Prompting for organization for ${workspaceFolder.name}`, 'SchemaDetection'); @@ -225,7 +224,7 @@ async function autoDetectSchema( const selectedOrganizationAndSession = await showQuickPick( 'organization', organizationAndSessionsPromise, { - placeHolder: format(Messages.selectOrganizationPlaceholder, workspaceFolder.name), + placeHolder: format(Messages.selectOrganizationPlaceholder, workspaceFolder.name), }); if (selectedOrganizationAndSession === undefined) { @@ -280,16 +279,15 @@ async function autoDetectSchema( if (repoId1espt?.length > 0) { // user has enabled 1ESPT schema if (vscode.workspace.getConfiguration('azure-pipelines', workspaceFolder).get('1ESPipelineTemplatesSchemaFile', false)) { - const cachedSchemaUri1ESPT = await getCached1ESPTSchema(context, organizationName, session, lastUpdated1ESPTSchema, seen1ESPTOrganizations); + const cachedSchemaUri1ESPT = await getCached1ESPTSchema(context, organizationName, session, lastUpdated1ESPTSchema); if (cachedSchemaUri1ESPT) { return cachedSchemaUri1ESPT; } else { // if user is signed in with microsoft account and has enabled 1ESPipeline Template Schema, then give preference to 1ESPT schema - const schemaUri1ESPT = await get1ESPTSchemaUri(azureDevOpsClient, organizationName,session, context, repoId1espt); + const schemaUri1ESPT = await get1ESPTSchemaUri(azureDevOpsClient, organizationName, session, context, repoId1espt); if (schemaUri1ESPT) { lastUpdated1ESPTSchema.set(organizationName, new Date()); - seen1ESPTOrganizations.add(organizationName); return schemaUri1ESPT; } } @@ -335,9 +333,9 @@ async function autoDetectSchema( // Mapping of glob pattern -> schemas interface ISchemaAssociations { - [pattern: string]: string[]; + [pattern: string]: string[]; } export namespace SchemaAssociationNotification { - export const type = new languageclient.NotificationType('json/schemaAssociations'); + export const type = new languageclient.NotificationType('json/schemaAssociations'); } diff --git a/src/schema-contributor.ts b/src/schema-contributor.ts index f2e55cb5..9a4884c0 100644 --- a/src/schema-contributor.ts +++ b/src/schema-contributor.ts @@ -76,8 +76,7 @@ class SchemaContributor { public requestCustomSchemaContent(uri: string): string { if (uri) { const { scheme } = URI.parse(uri); - if (scheme && this._customSchemaContributors[scheme] && - this._customSchemaContributors[scheme].requestSchemaContent) { + if (scheme && this._customSchemaContributors[scheme]) { return this._customSchemaContributors[scheme].requestSchemaContent(uri); } } diff --git a/src/test/index.ts b/src/test/index.ts index 4f4558c2..ee90e5ee 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,6 +1,6 @@ import * as path from 'path'; -import * as Mocha from 'mocha'; -import * as glob from 'glob'; +import Mocha from 'mocha'; +import glob from 'glob'; export function run(): Promise { // Create the mocha test diff --git a/src/unittest/configure/azureDevOpsHelper.test.ts b/src/test/suite/configure/azureDevOpsHelper.test.ts similarity index 75% rename from src/unittest/configure/azureDevOpsHelper.test.ts rename to src/test/suite/configure/azureDevOpsHelper.test.ts index 45d76a08..4772975a 100644 --- a/src/unittest/configure/azureDevOpsHelper.test.ts +++ b/src/test/suite/configure/azureDevOpsHelper.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { AzureDevOpsHelper } from '../../configure/helper/devOps/azureDevOpsHelper'; +import { AzureDevOpsHelper } from '../../../configure/helper/devOps/azureDevOpsHelper'; suite('Azure DevOps Helper', () => { suite('isAzureReposUrl', () => { @@ -155,52 +155,4 @@ suite('Azure DevOps Helper', () => { 'https://ms.visualstudio.com/example/_build/results?buildId=42&view=results'); }); }); - - suite('generateDevOpsProjectName', () => { - test('Returns repository name prefixed by AzurePipelines-', () => { - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName('repository'), - 'AzurePipelines-repository'); - }); - - test('Strips owner name from repository name', () => { - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName('owner/repository'), - 'AzurePipelines-repository'); - }); - - test('Strips owner name from repository name', () => { - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName('owner/repository'), - 'AzurePipelines-repository'); - }); - - test('Strips trailing periods from repository name', () => { - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName('owner/repos.itory...'), - 'AzurePipelines-repos.itory'); - }); - - test('Strips trailing underscores from repository name', () => { - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName('owner/repos_itory___'), - 'AzurePipelines-repos_itory'); - }); - - test('Strips trailing periods and underscores from repository name', () => { - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName('owner/repos._itory_._._'), - 'AzurePipelines-repos._itory'); - }); - - test('Keeps final project name within 64 characters', () => { - // 70 characters long - const name = '0123456789012345678901234567890123456789012345678901234567890123456789'; - const projectName = 'AzurePipelines-0123456789012345678901234567890123456789012345678'; - assert.strictEqual(projectName.length, 64); - assert.strictEqual( - AzureDevOpsHelper.generateDevOpsProjectName(`owner/${name}`), - projectName); - }); - }); }); diff --git a/src/unittest/configure/gitHubHelper.test.ts b/src/test/suite/configure/gitHubHelper.test.ts similarity index 63% rename from src/unittest/configure/gitHubHelper.test.ts rename to src/test/suite/configure/gitHubHelper.test.ts index c99770be..6e814211 100644 --- a/src/unittest/configure/gitHubHelper.test.ts +++ b/src/test/suite/configure/gitHubHelper.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { GitHubProvider } from '../../configure/helper/gitHubHelper'; +import { GitHubProvider } from '../../../configure/helper/gitHubHelper'; suite('GitHub Helper', () => { suite('isGitHubUrl', () => { @@ -28,23 +28,23 @@ suite('GitHub Helper', () => { }); }); - suite('getRepositoryIdFromUrl', () => { - test('Returns username/repository from an HTTPS URL', () => { - assert.strictEqual( - GitHubProvider.getRepositoryIdFromUrl('https://github.com/microsoft/azure-pipelines-vscode'), - 'microsoft/azure-pipelines-vscode'); + suite('getRepositoryDetailsFromRemoteUrl', () => { + test('Returns owner and repo from an HTTPS URL', () => { + assert.deepStrictEqual( + GitHubProvider.getRepositoryDetailsFromRemoteUrl('https://github.com/microsoft/azure-pipelines-vscode'), + { ownerName: 'microsoft', repositoryName: 'azure-pipelines-vscode' }); }); - test('Returns username/repository from an HTTPS URL with trailing .git', () => { - assert.strictEqual( - GitHubProvider.getRepositoryIdFromUrl('https://github.com/microsoft/azure-pipelines-vscode.git'), - 'microsoft/azure-pipelines-vscode'); + test('Returns owner from an HTTPS URL with trailing .git', () => { + assert.deepStrictEqual( + GitHubProvider.getRepositoryDetailsFromRemoteUrl('https://github.com/microsoft/azure-pipelines-vscode.git'), + { ownerName: 'microsoft', repositoryName: 'azure-pipelines-vscode' }); }); - test('Returns username/repository from a SSH URL', () => { - assert.strictEqual( - GitHubProvider.getRepositoryIdFromUrl('git@github.com:microsoft/azure-pipelines-vscode.git'), - 'microsoft/azure-pipelines-vscode'); + test('Returns owner from a SSH URL', () => { + assert.deepStrictEqual( + GitHubProvider.getRepositoryDetailsFromRemoteUrl('git@github.com:microsoft/azure-pipelines-vscode.git'), + { ownerName: 'microsoft', repositoryName: 'azure-pipelines-vscode' }); }); }); diff --git a/src/unittest/testdata/schemas/all-inputs-schema.json b/src/test/suite/testdata/schemas/all-inputs-schema.json similarity index 100% rename from src/unittest/testdata/schemas/all-inputs-schema.json rename to src/test/suite/testdata/schemas/all-inputs-schema.json diff --git a/src/unittest/testdata/schemas/npm-schema.json b/src/test/suite/testdata/schemas/npm-schema.json similarity index 100% rename from src/unittest/testdata/schemas/npm-schema.json rename to src/test/suite/testdata/schemas/npm-schema.json diff --git a/src/unittest/testdata/schemas/special-characters-schema.json b/src/test/suite/testdata/schemas/special-characters-schema.json similarity index 100% rename from src/unittest/testdata/schemas/special-characters-schema.json rename to src/test/suite/testdata/schemas/special-characters-schema.json diff --git a/src/unittest/testdata/tasks/all-inputs-task.json b/src/test/suite/testdata/tasks/all-inputs-task.json similarity index 100% rename from src/unittest/testdata/tasks/all-inputs-task.json rename to src/test/suite/testdata/tasks/all-inputs-task.json diff --git a/src/unittest/testdata/tasks/missing-input-mapping-exception-task.json b/src/test/suite/testdata/tasks/missing-input-mapping-exception-task.json similarity index 100% rename from src/unittest/testdata/tasks/missing-input-mapping-exception-task.json rename to src/test/suite/testdata/tasks/missing-input-mapping-exception-task.json diff --git a/src/unittest/testdata/tasks/npm-task.json b/src/test/suite/testdata/tasks/npm-task.json similarity index 100% rename from src/unittest/testdata/tasks/npm-task.json rename to src/test/suite/testdata/tasks/npm-task.json diff --git a/src/unittest/testdata/tasks/special-characters-task.json b/src/test/suite/testdata/tasks/special-characters-task.json similarity index 100% rename from src/unittest/testdata/tasks/special-characters-task.json rename to src/test/suite/testdata/tasks/special-characters-task.json diff --git a/tsconfig.json b/tsconfig.json index b9284b88..2022a3a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,13 @@ { "compilerOptions": { - /* Parse in strict mode and emit "use strict" for each source file. */ - "alwaysStrict": true, - /* List of library files to be included in the compilation. */ "lib": [ "es2019" ], /* Specify module code generation. */ - "module": "commonjs", + "module": "ES2022", /* */ - "moduleResolution": "node", + "moduleResolution": "Bundler", /* Report errors for fallthrough cases in switch statement. */ "noFallthroughCasesInSwitch": true, @@ -42,17 +39,17 @@ --strictFunctionTypes --strictPropertyInitialization. */ - //"strict": true, + "strict": true, /* Specify ECMAScript target version. */ "target": "es2019", - /* */ - "allowJs": true, - "skipLibCheck": true // https://github.com/Azure/ms-rest-js/issues/367 }, "include": [ "src" + ], + "exclude": [ + "src/test" ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..a59b5072 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": [ + "./tsconfig.json" + ], + "compilerOptions": { + // Tests are run directly by VS Code, so we need them in CommonJS format. + // Node16 will look at package.json to determine the actual module format, + // and since we don't have a "type" field, it will default to CommonJS. + "module": "Node16", + "moduleResolution": "Node16" + }, + "include": [ + "src/test" + ], + // Clear exclude from the base config. + "exclude": [] +}