diff --git a/.github/workflows/extension.yml b/.github/workflows/extension.yml index 5e1006dc..38872471 100644 --- a/.github/workflows/extension.yml +++ b/.github/workflows/extension.yml @@ -50,6 +50,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: '18.x' + cache: npm + + - name: Install (root) + run: npm install + + - name: Format check (root) + run: npm run format:check - name: Install run: npm install diff --git a/extension/tasks/dependabot/dependabotV2/index.ts b/extension/tasks/dependabot/dependabotV2/index.ts index b899edf7..d58c3c45 100644 --- a/extension/tasks/dependabot/dependabotV2/index.ts +++ b/extension/tasks/dependabot/dependabotV2/index.ts @@ -1,10 +1,13 @@ -import { which, setResult, TaskResult } from "azure-pipelines-task-lib/task" -import { debug, warning, error } from "azure-pipelines-task-lib/task" +import { debug, error, setResult, TaskResult, warning, which } from 'azure-pipelines-task-lib/task'; +import { AzureDevOpsWebApiClient } from './utils/azure-devops/AzureDevOpsWebApiClient'; import { DependabotCli } from './utils/dependabot-cli/DependabotCli'; -import { AzureDevOpsWebApiClient } from "./utils/azure-devops/AzureDevOpsWebApiClient"; -import { IDependabotUpdate } from "./utils/dependabot/interfaces/IDependabotConfig"; -import { DependabotOutputProcessor, parseProjectDependencyListProperty, parsePullRequestProperties } from "./utils/dependabot-cli/DependabotOutputProcessor"; -import { DependabotJobBuilder } from "./utils/dependabot-cli/DependabotJobBuilder"; +import { DependabotJobBuilder } from './utils/dependabot-cli/DependabotJobBuilder'; +import { + DependabotOutputProcessor, + parseProjectDependencyListProperty, + parsePullRequestProperties, +} from './utils/dependabot-cli/DependabotOutputProcessor'; +import { IDependabotUpdate } from './utils/dependabot/interfaces/IDependabotConfig'; import parseDependabotConfigFile from './utils/dependabot/parseConfigFile'; import parseTaskInputConfiguration from './utils/getSharedVariables'; @@ -12,7 +15,6 @@ async function run() { let dependabot: DependabotCli = undefined; let failedJobs: number = 0; try { - // Check if required tools are installed debug('Checking for `docker` install...'); which('docker', true); @@ -33,25 +35,35 @@ async function run() { // Initialise the DevOps API clients // There are two clients; one for authoring pull requests and one for auto-approving pull requests (if configured) - const prAuthorClient = new AzureDevOpsWebApiClient(taskInputs.organizationUrl.toString(), taskInputs.systemAccessToken); - const prApproverClient = taskInputs.autoApprove ? new AzureDevOpsWebApiClient(taskInputs.organizationUrl.toString(), taskInputs.autoApproveUserToken || taskInputs.systemAccessToken) : null; + const prAuthorClient = new AzureDevOpsWebApiClient( + taskInputs.organizationUrl.toString(), + taskInputs.systemAccessToken, + ); + const prApproverClient = taskInputs.autoApprove + ? new AzureDevOpsWebApiClient( + taskInputs.organizationUrl.toString(), + taskInputs.autoApproveUserToken || taskInputs.systemAccessToken, + ) + : null; // Fetch the active pull requests created by the author user const prAuthorActivePullRequests = await prAuthorClient.getActivePullRequestProperties( - taskInputs.project, taskInputs.repository, await prAuthorClient.getUserId() + taskInputs.project, + taskInputs.repository, + await prAuthorClient.getUserId(), ); // Initialise the Dependabot updater dependabot = new DependabotCli( DependabotCli.CLI_IMAGE_LATEST, // TODO: Add config for this? new DependabotOutputProcessor(taskInputs, prAuthorClient, prApproverClient, prAuthorActivePullRequests), - taskInputs.debug + taskInputs.debug, ); const dependabotUpdaterOptions = { collectorImage: undefined, // TODO: Add config for this? proxyImage: undefined, // TODO: Add config for this? - updaterImage: undefined // TODO: Add config for this? + updaterImage: undefined, // TODO: Add config for this? }; // If update identifiers are specified, select them; otherwise handle all @@ -75,49 +87,61 @@ async function run() { const dependencyList = parseProjectDependencyListProperty( await prAuthorClient.getProjectProperties(taskInputs.project), taskInputs.repository, - update["package-ecosystem"] + update['package-ecosystem'], ); // Parse the Dependabot metadata for the existing pull requests that are related to this update // Dependabot will use this to determine if we need to create new pull requests or update/close existing ones - const existingPullRequests = parsePullRequestProperties(prAuthorActivePullRequests, update["package-ecosystem"]); + const existingPullRequests = parsePullRequestProperties(prAuthorActivePullRequests, update['package-ecosystem']); const existingPullRequestDependencies = Object.entries(existingPullRequests).map(([id, deps]) => deps); // Run an update job for "all dependencies"; this will create new pull requests for dependencies that need updating - const allDependenciesJob = DependabotJobBuilder.newUpdateAllJob(taskInputs, updateId, update, dependabotConfig.registries, dependencyList['dependencies'], existingPullRequestDependencies); + const allDependenciesJob = DependabotJobBuilder.newUpdateAllJob( + taskInputs, + updateId, + update, + dependabotConfig.registries, + dependencyList['dependencies'], + existingPullRequestDependencies, + ); const allDependenciesUpdateOutputs = await dependabot.update(allDependenciesJob, dependabotUpdaterOptions); - if (!allDependenciesUpdateOutputs || allDependenciesUpdateOutputs.filter(u => !u.success).length > 0) { - allDependenciesUpdateOutputs.filter(u => !u.success).forEach(u => exception(u.error)); + if (!allDependenciesUpdateOutputs || allDependenciesUpdateOutputs.filter((u) => !u.success).length > 0) { + allDependenciesUpdateOutputs.filter((u) => !u.success).forEach((u) => exception(u.error)); failedJobs++; } // Run an update job for each existing pull request; this will resolve merge conflicts and close pull requests that are no longer needed if (!taskInputs.skipPullRequests) { for (const pullRequestId in existingPullRequests) { - const updatePullRequestJob = DependabotJobBuilder.newUpdatePullRequestJob(taskInputs, pullRequestId, update, dependabotConfig.registries, existingPullRequestDependencies, existingPullRequests[pullRequestId]); + const updatePullRequestJob = DependabotJobBuilder.newUpdatePullRequestJob( + taskInputs, + pullRequestId, + update, + dependabotConfig.registries, + existingPullRequestDependencies, + existingPullRequests[pullRequestId], + ); const updatePullRequestOutputs = await dependabot.update(updatePullRequestJob, dependabotUpdaterOptions); - if (!updatePullRequestOutputs || updatePullRequestOutputs.filter(u => !u.success).length > 0) { - updatePullRequestOutputs.filter(u => !u.success).forEach(u => exception(u.error)); + if (!updatePullRequestOutputs || updatePullRequestOutputs.filter((u) => !u.success).length > 0) { + updatePullRequestOutputs.filter((u) => !u.success).forEach((u) => exception(u.error)); failedJobs++; } } } else if (existingPullRequests.keys.length > 0) { warning(`Skipping update of existing pull requests as 'skipPullRequests' is set to 'true'`); } - } setResult( failedJobs ? TaskResult.Failed : TaskResult.Succeeded, - failedJobs ? `${failedJobs} update job(s) failed, check logs for more information` : `All update jobs completed successfully` + failedJobs + ? `${failedJobs} update job(s) failed, check logs for more information` + : `All update jobs completed successfully`, ); - - } - catch (e) { + } catch (e) { setResult(TaskResult.Failed, e?.message); exception(e); - } - finally { + } finally { dependabot?.cleanup(); } } diff --git a/extension/tasks/dependabot/dependabotV2/task.json b/extension/tasks/dependabot/dependabotV2/task.json index 978e213a..dc2e1300 100644 --- a/extension/tasks/dependabot/dependabotV2/task.json +++ b/extension/tasks/dependabot/dependabotV2/task.json @@ -48,7 +48,6 @@ } ], "inputs": [ - { "name": "skipPullRequests", "type": "boolean", @@ -131,7 +130,7 @@ "helpMarkDown": "A personal access token of the user of that shall be used to approve the created PR automatically. If the same user that creates the PR should approve, this can be left empty. This won't work with if the Build Service with the build service account!", "visibleRule": "autoApprove=true" }, - { + { "name": "authorEmail", "type": "string", "groupName": "pull_requests", @@ -194,7 +193,7 @@ "required": false, "helpMarkDown": "The raw Personal Access Token for accessing GitHub repositories. Use this in place of `gitHubConnection` such as when it is not possible to create a service connection." }, - + { "name": "storeDependencyList", "type": "boolean", diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts index a7288517..7091661e 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/AzureDevOpsWebApiClient.ts @@ -1,556 +1,559 @@ -import { debug, warning, error } from "azure-pipelines-task-lib/task" -import { WebApi, getPersonalAccessTokenHandler } from "azure-devops-node-api"; -import { CommentThreadStatus, CommentType, IdentityRefWithVote, ItemContentType, PullRequestAsyncStatus, PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces"; -import { IPullRequestProperties } from "./interfaces/IPullRequestProperties"; -import { IPullRequest } from "./interfaces/IPullRequest"; -import { IFileChange } from "./interfaces/IFileChange"; -import { resolveAzureDevOpsIdentities } from "./resolveAzureDevOpsIdentities"; +import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; +import { + CommentThreadStatus, + CommentType, + IdentityRefWithVote, + ItemContentType, + PullRequestAsyncStatus, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { error, warning } from 'azure-pipelines-task-lib/task'; +import { IFileChange } from './interfaces/IFileChange'; +import { IPullRequest } from './interfaces/IPullRequest'; +import { IPullRequestProperties } from './interfaces/IPullRequestProperties'; +import { resolveAzureDevOpsIdentities } from './resolveAzureDevOpsIdentities'; /** * Wrapper for DevOps WebApi client with helper methods for easier management of dependabot pull requests */ export class AzureDevOpsWebApiClient { - - private readonly organisationApiUrl: string; - private readonly accessToken: string; - private readonly connection: WebApi; - private cachedUserIds: Record; - - constructor(organisationApiUrl: string, accessToken: string) { - this.organisationApiUrl = organisationApiUrl; - this.accessToken = accessToken; - this.connection = new WebApi( - organisationApiUrl, - getPersonalAccessTokenHandler(accessToken) - ); - this.cachedUserIds = {}; + private readonly organisationApiUrl: string; + private readonly accessToken: string; + private readonly connection: WebApi; + private cachedUserIds: Record; + + constructor(organisationApiUrl: string, accessToken: string) { + this.organisationApiUrl = organisationApiUrl; + this.accessToken = accessToken; + this.connection = new WebApi(organisationApiUrl, getPersonalAccessTokenHandler(accessToken)); + this.cachedUserIds = {}; + } + + /** + * Get the identity of a user by email address. If no email is provided, the identity of the authenticated user is returned. + * @param email + * @returns + */ + public async getUserId(email?: string): Promise { + // If no email is provided, resolve to the authenticated user + if (!email) { + this.cachedUserIds[this.accessToken] ||= (await this.connection.connect())?.authenticatedUser?.id || ''; + return this.cachedUserIds[this.accessToken]; } - /** - * Get the identity of a user by email address. If no email is provided, the identity of the authenticated user is returned. - * @param email - * @returns - */ - public async getUserId(email?: string): Promise { - - // If no email is provided, resolve to the authenticated user - if (!email) { - this.cachedUserIds[this.accessToken] ||= ((await this.connection.connect())?.authenticatedUser?.id || ""); - return this.cachedUserIds[this.accessToken]; - } - - // Otherwise, do a cached identity lookup of the supplied email address - // TODO: When azure-devops-node-api supports Graph API, use that instead of the REST API - else if (!this.cachedUserIds[email]) { - const identities = await resolveAzureDevOpsIdentities(new URL(this.organisationApiUrl), [email]); - identities.forEach(i => this.cachedUserIds[i.input] ||= i.id); - } - - return this.cachedUserIds[email]; + // Otherwise, do a cached identity lookup of the supplied email address + // TODO: When azure-devops-node-api supports Graph API, use that instead of the REST API + else if (!this.cachedUserIds[email]) { + const identities = await resolveAzureDevOpsIdentities(new URL(this.organisationApiUrl), [email]); + identities.forEach((i) => (this.cachedUserIds[i.input] ||= i.id)); } - /** - * Get the default branch for a repository - * @param project - * @param repository - * @returns - */ - public async getDefaultBranch(project: string, repository: string): Promise { - try { - const git = await this.connection.getGitApi(); - const repo = await git.getRepository(repository, project); - if (!repo) { - throw new Error(`Repository '${project}/${repository}' not found`); - } - - return repo.defaultBranch; - } - catch (e) { - error(`Failed to get default branch for '${project}/${repository}': ${e}`); - console.error(e); - return undefined; - } + return this.cachedUserIds[email]; + } + + /** + * Get the default branch for a repository + * @param project + * @param repository + * @returns + */ + public async getDefaultBranch(project: string, repository: string): Promise { + try { + const git = await this.connection.getGitApi(); + const repo = await git.getRepository(repository, project); + if (!repo) { + throw new Error(`Repository '${project}/${repository}' not found`); + } + + return repo.defaultBranch; + } catch (e) { + error(`Failed to get default branch for '${project}/${repository}': ${e}`); + console.error(e); + return undefined; } - - /** - * Get the properties for all active pull request created by the supplied user - * @param project - * @param repository - * @param creator - * @returns - */ - public async getActivePullRequestProperties(project: string, repository: string, creator: string): Promise { - console.info(`Fetching active pull request properties in '${project}/${repository}' for user id '${creator}'...`); - try { - const git = await this.connection.getGitApi(); - const pullRequests = await git.getPullRequests( - repository, - { - creatorId: isGuid(creator) ? creator : await this.getUserId(creator), - status: PullRequestStatus.Active - }, - project - ); - - return await Promise.all( - pullRequests?.map(async pr => { - const properties = (await git.getPullRequestProperties(repository, pr.pullRequestId, project))?.value; - return { - id: pr.pullRequestId, - properties: Object.keys(properties)?.map(key => { - return { - name: key, - value: properties[key].$value - }; - }) || [] - }; - }) - ); - } - catch (e) { - error(`Failed to list active pull request properties: ${e}`); - console.error(e); - return []; - } + } + + /** + * Get the properties for all active pull request created by the supplied user + * @param project + * @param repository + * @param creator + * @returns + */ + public async getActivePullRequestProperties( + project: string, + repository: string, + creator: string, + ): Promise { + console.info(`Fetching active pull request properties in '${project}/${repository}' for user id '${creator}'...`); + try { + const git = await this.connection.getGitApi(); + const pullRequests = await git.getPullRequests( + repository, + { + creatorId: isGuid(creator) ? creator : await this.getUserId(creator), + status: PullRequestStatus.Active, + }, + project, + ); + + return await Promise.all( + pullRequests?.map(async (pr) => { + const properties = (await git.getPullRequestProperties(repository, pr.pullRequestId, project))?.value; + return { + id: pr.pullRequestId, + properties: + Object.keys(properties)?.map((key) => { + return { + name: key, + value: properties[key].$value, + }; + }) || [], + }; + }), + ); + } catch (e) { + error(`Failed to list active pull request properties: ${e}`); + console.error(e); + return []; } - - /** - * Create a new pull request - * @param pr - * @returns - */ - public async createPullRequest(pr: IPullRequest): Promise { - console.info(`Creating pull request '${pr.title}'...`); - try { - const userId = await this.getUserId(); - const git = await this.connection.getGitApi(); - - // Create the source branch and commit the file changes - console.info(` - Pushing ${pr.changes.length} change(s) to branch '${pr.source.branch}'...`); - const push = await git.createPush( - { - refUpdates: [ - { - name: `refs/heads/${pr.source.branch}`, - oldObjectId: pr.source.commit - } - ], - commits: [ - { - comment: pr.commitMessage, - author: pr.author, - changes: pr.changes.map(change => { - return { - changeType: change.changeType, - item: { - path: normalizeDevOpsPath(change.path) - }, - newContent: { - content: Buffer.from(change.content, change.encoding).toString('base64'), - contentType: ItemContentType.Base64Encoded - } - }; - }) - } - ] - }, - pr.repository, - pr.project - ); - - // Build the list of the pull request reviewers - // NOTE: Azure DevOps does not have a concept of assignees, only reviewers. - // We treat assignees as required reviewers and all other reviewers as optional. - const allReviewers: IdentityRefWithVote[] = []; - if (pr.assignees?.length > 0) { - for (const assignee of pr.assignees) { - const identityId = isGuid(assignee) ? assignee : await this.getUserId(assignee); - if (identityId) { - allReviewers.push({ - id: identityId, - isRequired: true, - isFlagged: true, - }); - } - else { - warning(` - Unable to resolve assignee identity '${assignee}'`); - } - } - } - if (pr.reviewers?.length > 0) { - for (const reviewer of pr.reviewers) { - const identityId = isGuid(reviewer) ? reviewer : await this.getUserId(reviewer); - if (identityId) { - allReviewers.push({ - id: identityId, - }); - } - else { - warning(` - Unable to resolve reviewer identity '${reviewer}'`); - } - } - } - - // Create the pull request - console.info(` - Creating pull request to merge '${pr.source.branch}' into '${pr.target.branch}'...`); - const pullRequest = await git.createPullRequest( - { - sourceRefName: `refs/heads/${pr.source.branch}`, - targetRefName: `refs/heads/${pr.target.branch}`, - title: pr.title, - description: pr.description, - reviewers: allReviewers, - workItemRefs: pr.workItems?.map(id => { return { id: id }; }), - labels: pr.labels?.map(label => { return { name: label }; }), - isDraft: false // TODO: Add config for this? - }, - pr.repository, - pr.project, - true - ); - - // Add the pull request properties - if (pr.properties?.length > 0) { - console.info(` - Adding dependency metadata to pull request properties...`); - await git.updatePullRequestProperties( - null, - pr.properties.map(property => { - return { - op: "add", - path: "/" + property.name, - value: property.value - }; - }), - pr.repository, - pullRequest.pullRequestId, - pr.project - ); - } - - // TODO: Upload the pull request description as a 'changes.md' file attachment? - // This might be a way to work around the 4000 character limit for PR descriptions, but needs more investigation. - // https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-attachments/create?view=azure-devops-rest-7.1 - - // Set the pull request auto-complete status - if (pr.autoComplete) { - console.info(` - Setting auto-complete...`); - await git.updatePullRequest( - { - autoCompleteSetBy: { - id: userId - }, - completionOptions: { - autoCompleteIgnoreConfigIds: pr.autoComplete.ignorePolicyConfigIds, - deleteSourceBranch: true, - mergeCommitMessage: mergeCommitMessage(pullRequest.pullRequestId, pr.title, pr.description), - mergeStrategy: pr.autoComplete.mergeStrategy, - transitionWorkItems: false, - } - }, - pr.repository, - pullRequest.pullRequestId, - pr.project - ); - } - - console.info(` - Pull request #${pullRequest.pullRequestId} was created successfully.`); - return pullRequest.pullRequestId; + } + + /** + * Create a new pull request + * @param pr + * @returns + */ + public async createPullRequest(pr: IPullRequest): Promise { + console.info(`Creating pull request '${pr.title}'...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Create the source branch and commit the file changes + console.info(` - Pushing ${pr.changes.length} change(s) to branch '${pr.source.branch}'...`); + const push = await git.createPush( + { + refUpdates: [ + { + name: `refs/heads/${pr.source.branch}`, + oldObjectId: pr.source.commit, + }, + ], + commits: [ + { + comment: pr.commitMessage, + author: pr.author, + changes: pr.changes.map((change) => { + return { + changeType: change.changeType, + item: { + path: normalizeDevOpsPath(change.path), + }, + newContent: { + content: Buffer.from(change.content, change.encoding).toString('base64'), + contentType: ItemContentType.Base64Encoded, + }, + }; + }), + }, + ], + }, + pr.repository, + pr.project, + ); + + // Build the list of the pull request reviewers + // NOTE: Azure DevOps does not have a concept of assignees, only reviewers. + // We treat assignees as required reviewers and all other reviewers as optional. + const allReviewers: IdentityRefWithVote[] = []; + if (pr.assignees?.length > 0) { + for (const assignee of pr.assignees) { + const identityId = isGuid(assignee) ? assignee : await this.getUserId(assignee); + if (identityId) { + allReviewers.push({ + id: identityId, + isRequired: true, + isFlagged: true, + }); + } else { + warning(` - Unable to resolve assignee identity '${assignee}'`); + } } - catch (e) { - error(`Failed to create pull request: ${e}`); - console.error(e); - return null; + } + if (pr.reviewers?.length > 0) { + for (const reviewer of pr.reviewers) { + const identityId = isGuid(reviewer) ? reviewer : await this.getUserId(reviewer); + if (identityId) { + allReviewers.push({ + id: identityId, + }); + } else { + warning(` - Unable to resolve reviewer identity '${reviewer}'`); + } } + } + + // Create the pull request + console.info(` - Creating pull request to merge '${pr.source.branch}' into '${pr.target.branch}'...`); + const pullRequest = await git.createPullRequest( + { + sourceRefName: `refs/heads/${pr.source.branch}`, + targetRefName: `refs/heads/${pr.target.branch}`, + title: pr.title, + description: pr.description, + reviewers: allReviewers, + workItemRefs: pr.workItems?.map((id) => { + return { id: id }; + }), + labels: pr.labels?.map((label) => { + return { name: label }; + }), + isDraft: false, // TODO: Add config for this? + }, + pr.repository, + pr.project, + true, + ); + + // Add the pull request properties + if (pr.properties?.length > 0) { + console.info(` - Adding dependency metadata to pull request properties...`); + await git.updatePullRequestProperties( + null, + pr.properties.map((property) => { + return { + op: 'add', + path: '/' + property.name, + value: property.value, + }; + }), + pr.repository, + pullRequest.pullRequestId, + pr.project, + ); + } + + // TODO: Upload the pull request description as a 'changes.md' file attachment? + // This might be a way to work around the 4000 character limit for PR descriptions, but needs more investigation. + // https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-attachments/create?view=azure-devops-rest-7.1 + + // Set the pull request auto-complete status + if (pr.autoComplete) { + console.info(` - Setting auto-complete...`); + await git.updatePullRequest( + { + autoCompleteSetBy: { + id: userId, + }, + completionOptions: { + autoCompleteIgnoreConfigIds: pr.autoComplete.ignorePolicyConfigIds, + deleteSourceBranch: true, + mergeCommitMessage: mergeCommitMessage(pullRequest.pullRequestId, pr.title, pr.description), + mergeStrategy: pr.autoComplete.mergeStrategy, + transitionWorkItems: false, + }, + }, + pr.repository, + pullRequest.pullRequestId, + pr.project, + ); + } + + console.info(` - Pull request #${pullRequest.pullRequestId} was created successfully.`); + return pullRequest.pullRequestId; + } catch (e) { + error(`Failed to create pull request: ${e}`); + console.error(e); + return null; } - - /** - * Update a pull request - * @param options - * @returns - */ - public async updatePullRequest(options: { - project: string, - repository: string, - pullRequestId: number, - changes: IFileChange[], - skipIfCommitsFromUsersOtherThan?: string, - skipIfNoConflicts?: boolean - }): Promise { - console.info(`Updating pull request #${options.pullRequestId}...`); - try { - const userId = await this.getUserId(); - const git = await this.connection.getGitApi(); - - // Get the pull request details - const pullRequest = await git.getPullRequest(options.repository, options.pullRequestId, options.project); - if (!pullRequest) { - throw new Error(`Pull request #${options.pullRequestId} not found`); - } - - // Skip if no merge conflicts - if (options.skipIfNoConflicts && pullRequest.mergeStatus !== PullRequestAsyncStatus.Conflicts) { - console.info(` - Skipping update as pull request has no merge conflicts.`); - return true; - } - - // Skip if the pull request has been modified by another user - const commits = await git.getPullRequestCommits(options.repository, options.pullRequestId, options.project); - if (options.skipIfCommitsFromUsersOtherThan && commits.some(c => c.author?.email !== options.skipIfCommitsFromUsersOtherThan)) { - console.info(` - Skipping update as pull request has been modified by another user.`); - return true; - } - - // Push changes to the source branch - console.info(` - Pushing ${options.changes.length} change(s) branch '${pullRequest.sourceRefName}'...`); - const push = await git.createPush( - { - refUpdates: [ - { - name: pullRequest.sourceRefName, - oldObjectId: pullRequest.lastMergeSourceCommit.commitId - } - ], - commits: [ - { - comment: (pullRequest.mergeStatus === PullRequestAsyncStatus.Conflicts) - ? "Resolve merge conflicts" - : "Update dependency files", - changes: options.changes.map(change => { - return { - changeType: change.changeType, - item: { - path: normalizeDevOpsPath(change.path) - }, - newContent: { - content: Buffer.from(change.content, change.encoding).toString('base64'), - contentType: ItemContentType.Base64Encoded - } - }; - }) - } - ] - }, - options.repository, - options.project - ); - - console.info(` - Pull request #${options.pullRequestId} was updated successfully.`); - return true; - } - catch (e) { - error(`Failed to update pull request: ${e}`); - console.error(e); - return false; - } + } + + /** + * Update a pull request + * @param options + * @returns + */ + public async updatePullRequest(options: { + project: string; + repository: string; + pullRequestId: number; + changes: IFileChange[]; + skipIfCommitsFromUsersOtherThan?: string; + skipIfNoConflicts?: boolean; + }): Promise { + console.info(`Updating pull request #${options.pullRequestId}...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Get the pull request details + const pullRequest = await git.getPullRequest(options.repository, options.pullRequestId, options.project); + if (!pullRequest) { + throw new Error(`Pull request #${options.pullRequestId} not found`); + } + + // Skip if no merge conflicts + if (options.skipIfNoConflicts && pullRequest.mergeStatus !== PullRequestAsyncStatus.Conflicts) { + console.info(` - Skipping update as pull request has no merge conflicts.`); + return true; + } + + // Skip if the pull request has been modified by another user + const commits = await git.getPullRequestCommits(options.repository, options.pullRequestId, options.project); + if ( + options.skipIfCommitsFromUsersOtherThan && + commits.some((c) => c.author?.email !== options.skipIfCommitsFromUsersOtherThan) + ) { + console.info(` - Skipping update as pull request has been modified by another user.`); + return true; + } + + // Push changes to the source branch + console.info(` - Pushing ${options.changes.length} change(s) branch '${pullRequest.sourceRefName}'...`); + const push = await git.createPush( + { + refUpdates: [ + { + name: pullRequest.sourceRefName, + oldObjectId: pullRequest.lastMergeSourceCommit.commitId, + }, + ], + commits: [ + { + comment: + pullRequest.mergeStatus === PullRequestAsyncStatus.Conflicts + ? 'Resolve merge conflicts' + : 'Update dependency files', + changes: options.changes.map((change) => { + return { + changeType: change.changeType, + item: { + path: normalizeDevOpsPath(change.path), + }, + newContent: { + content: Buffer.from(change.content, change.encoding).toString('base64'), + contentType: ItemContentType.Base64Encoded, + }, + }; + }), + }, + ], + }, + options.repository, + options.project, + ); + + console.info(` - Pull request #${options.pullRequestId} was updated successfully.`); + return true; + } catch (e) { + error(`Failed to update pull request: ${e}`); + console.error(e); + return false; } - - /** - * Approve a pull request - * @param options - * @returns - */ - public async approvePullRequest(options: { - project: string, - repository: string, - pullRequestId: number - }): Promise { - console.info(`Approving pull request #${options.pullRequestId}...`); - try { - const userId = await this.getUserId(); - const git = await this.connection.getGitApi(); - - // Approve the pull request - console.info(` - Creating reviewer vote on pull request...`); - await git.createPullRequestReviewer( - { - vote: 10, // 10 - approved 5 - approved with suggestions 0 - no vote -5 - waiting for author -10 - rejected - isReapprove: true - }, - options.repository, - options.pullRequestId, - userId, - options.project - ); - - console.info(` - Pull request #${options.pullRequestId} was approved.`); - } - catch (e) { - error(`Failed to approve pull request: ${e}`); - console.error(e); - return false; - } + } + + /** + * Approve a pull request + * @param options + * @returns + */ + public async approvePullRequest(options: { + project: string; + repository: string; + pullRequestId: number; + }): Promise { + console.info(`Approving pull request #${options.pullRequestId}...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Approve the pull request + console.info(` - Creating reviewer vote on pull request...`); + await git.createPullRequestReviewer( + { + vote: 10, // 10 - approved 5 - approved with suggestions 0 - no vote -5 - waiting for author -10 - rejected + isReapprove: true, + }, + options.repository, + options.pullRequestId, + userId, + options.project, + ); + + console.info(` - Pull request #${options.pullRequestId} was approved.`); + } catch (e) { + error(`Failed to approve pull request: ${e}`); + console.error(e); + return false; } - - /** - * Close a pull request - * @param options - * @returns - */ - public async closePullRequest(options: { - project: string, - repository: string, - pullRequestId: number, - comment: string, - deleteSourceBranch: boolean - }): Promise { - console.info(`Closing pull request #${options.pullRequestId}...`); - try { - const userId = await this.getUserId(); - const git = await this.connection.getGitApi(); - - // Add a comment to the pull request, if supplied - if (options.comment) { - console.info(` - Adding comment to pull request...`); - await git.createThread( - { - status: CommentThreadStatus.Closed, - comments: [ - { - author: { - id: userId - }, - content: options.comment, - commentType: CommentType.System - } - ] - }, - options.repository, - options.pullRequestId, - options.project - ); - } - - // Close the pull request - console.info(` - Abandoning pull request...`); - const pullRequest = await git.updatePullRequest( - { - status: PullRequestStatus.Abandoned, - closedBy: { - id: userId - } + } + + /** + * Close a pull request + * @param options + * @returns + */ + public async closePullRequest(options: { + project: string; + repository: string; + pullRequestId: number; + comment: string; + deleteSourceBranch: boolean; + }): Promise { + console.info(`Closing pull request #${options.pullRequestId}...`); + try { + const userId = await this.getUserId(); + const git = await this.connection.getGitApi(); + + // Add a comment to the pull request, if supplied + if (options.comment) { + console.info(` - Adding comment to pull request...`); + await git.createThread( + { + status: CommentThreadStatus.Closed, + comments: [ + { + author: { + id: userId, }, - options.repository, - options.pullRequestId, - options.project - ); - - // Delete the source branch if required - if (options.deleteSourceBranch) { - console.info(` - Deleting source branch...`); - await git.updateRef( - { - name: `refs/heads/${pullRequest.sourceRefName}`, - oldObjectId: pullRequest.lastMergeSourceCommit.commitId, - newObjectId: "0000000000000000000000000000000000000000", - isLocked: false - }, - options.repository, - '', - options.project - ); - } - - console.info(` - Pull request #${options.pullRequestId} was closed successfully.`); - return true; - } - catch (e) { - error(`Failed to close pull request: ${e}`); - console.error(e); - return false; - } + content: options.comment, + commentType: CommentType.System, + }, + ], + }, + options.repository, + options.pullRequestId, + options.project, + ); + } + + // Close the pull request + console.info(` - Abandoning pull request...`); + const pullRequest = await git.updatePullRequest( + { + status: PullRequestStatus.Abandoned, + closedBy: { + id: userId, + }, + }, + options.repository, + options.pullRequestId, + options.project, + ); + + // Delete the source branch if required + if (options.deleteSourceBranch) { + console.info(` - Deleting source branch...`); + await git.updateRef( + { + name: `refs/heads/${pullRequest.sourceRefName}`, + oldObjectId: pullRequest.lastMergeSourceCommit.commitId, + newObjectId: '0000000000000000000000000000000000000000', + isLocked: false, + }, + options.repository, + '', + options.project, + ); + } + + console.info(` - Pull request #${options.pullRequestId} was closed successfully.`); + return true; + } catch (e) { + error(`Failed to close pull request: ${e}`); + console.error(e); + return false; } - - /** - * Get project properties - * @param project - * @param valueBuilder - * @returns - */ - public async getProjectProperties(project: string): Promise | undefined> { - try { - const core = await this.connection.getCoreApi(); - const projects = await core.getProjects(); - const projectGuid = projects?.find(p => p.name === project)?.id; - const properties = await core.getProjectProperties(projectGuid); - return properties - .map(p => ({ [p.name]: p.value })) - .reduce((a, b) => ({ ...a, ...b }), {}); - - } - catch (e) { - error(`Failed to get project properties: ${e}`); - console.error(e); - return undefined; - } + } + + /** + * Get project properties + * @param project + * @param valueBuilder + * @returns + */ + public async getProjectProperties(project: string): Promise | undefined> { + try { + const core = await this.connection.getCoreApi(); + const projects = await core.getProjects(); + const projectGuid = projects?.find((p) => p.name === project)?.id; + const properties = await core.getProjectProperties(projectGuid); + return properties.map((p) => ({ [p.name]: p.value })).reduce((a, b) => ({ ...a, ...b }), {}); + } catch (e) { + error(`Failed to get project properties: ${e}`); + console.error(e); + return undefined; } - - /** - * Update a project property - * @param project - * @param name - * @param valueBuilder - * @returns - */ - public async updateProjectProperty(project: string, name: string, valueBuilder: (existingValue: string) => string): Promise { - try { - - // Get the existing project property value - const core = await this.connection.getCoreApi(); - const projects = await core.getProjects(); - const projectGuid = projects?.find(p => p.name === project)?.id; - const properties = await core.getProjectProperties(projectGuid); - const propertyValue = properties?.find(p => p.name === name)?.value; - - // Update the project property - await core.setProjectProperties( - undefined, - projectGuid, - [ - { - op: "add", - path: "/" + name, - value: valueBuilder(propertyValue || "") - } - ] - ); - - } - catch (e) { - error(`Failed to update project property '${name}': ${e}`); - console.error(e); - } + } + + /** + * Update a project property + * @param project + * @param name + * @param valueBuilder + * @returns + */ + public async updateProjectProperty( + project: string, + name: string, + valueBuilder: (existingValue: string) => string, + ): Promise { + try { + // Get the existing project property value + const core = await this.connection.getCoreApi(); + const projects = await core.getProjects(); + const projectGuid = projects?.find((p) => p.name === project)?.id; + const properties = await core.getProjectProperties(projectGuid); + const propertyValue = properties?.find((p) => p.name === name)?.value; + + // Update the project property + await core.setProjectProperties(undefined, projectGuid, [ + { + op: 'add', + path: '/' + name, + value: valueBuilder(propertyValue || ''), + }, + ]); + } catch (e) { + error(`Failed to update project property '${name}': ${e}`); + console.error(e); } + } } function normalizeDevOpsPath(path: string): string { - // Convert backslashes to forward slashes, convert './' => '/' and ensure the path starts with a forward slash if it doesn't already, this is how DevOps paths are formatted - return path.replace(/\\/g, "/").replace(/^\.\//, "/").replace(/^([^/])/, "/$1"); + // Convert backslashes to forward slashes, convert './' => '/' and ensure the path starts with a forward slash if it doesn't already, this is how DevOps paths are formatted + return path + .replace(/\\/g, '/') + .replace(/^\.\//, '/') + .replace(/^([^/])/, '/$1'); } function mergeCommitMessage(id: number, title: string, description: string): string { - // - // The merge commit message should contain the PR number and title for tracking. - // This is the default behaviour in Azure DevOps. - // Example: - // Merged PR 24093: Bump Tingle.Extensions.Logging.LogAnalytics from 3.4.2-ci0005 to 3.4.2-ci0006 - // - // Bumps [Tingle.Extensions.Logging.LogAnalytics](...) from 3.4.2-ci0005 to 3.4.2-ci0006 - // - [Release notes](....) - // - [Changelog](....) - // - [Commits](....) - // - // There appears to be a DevOps bug when setting "completeOptions" with a "mergeCommitMessage" even when truncated to 4000 characters. - // The error message is: - // Invalid argument value. - // Parameter name: Completion options have exceeded the maximum encoded length (4184/4000) - // - // The effective limit seems to be about 3500 characters: - // https://developercommunity.visualstudio.com/t/raise-the-character-limit-for-pull-request-descrip/365708#T-N424531 - // - return `Merged PR ${id}: ${title}\n\n${description}`.slice(0, 3500); + // + // The merge commit message should contain the PR number and title for tracking. + // This is the default behaviour in Azure DevOps. + // Example: + // Merged PR 24093: Bump Tingle.Extensions.Logging.LogAnalytics from 3.4.2-ci0005 to 3.4.2-ci0006 + // + // Bumps [Tingle.Extensions.Logging.LogAnalytics](...) from 3.4.2-ci0005 to 3.4.2-ci0006 + // - [Release notes](....) + // - [Changelog](....) + // - [Commits](....) + // + // There appears to be a DevOps bug when setting "completeOptions" with a "mergeCommitMessage" even when truncated to 4000 characters. + // The error message is: + // Invalid argument value. + // Parameter name: Completion options have exceeded the maximum encoded length (4184/4000) + // + // The effective limit seems to be about 3500 characters: + // https://developercommunity.visualstudio.com/t/raise-the-character-limit-for-pull-request-descrip/365708#T-N424531 + // + return `Merged PR ${id}: ${title}\n\n${description}`.slice(0, 3500); } function isGuid(guid: string): boolean { - const regex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - return regex.test(guid); + const regex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + return regex.test(guid); } diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts index bb2f06cf..5d6ccbef 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IFileChange.ts @@ -1,11 +1,11 @@ -import { VersionControlChangeType } from "azure-devops-node-api/interfaces/TfvcInterfaces"; +import { VersionControlChangeType } from 'azure-devops-node-api/interfaces/TfvcInterfaces'; /** * File change */ export interface IFileChange { - changeType: VersionControlChangeType, - path: string, - content: string, - encoding: string + changeType: VersionControlChangeType; + path: string; + content: string; + encoding: string; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts index 11f00c12..aaa21a02 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequest.ts @@ -1,37 +1,37 @@ -import { GitPullRequestMergeStrategy } from "azure-devops-node-api/interfaces/GitInterfaces"; -import { IFileChange } from "./IFileChange"; +import { GitPullRequestMergeStrategy } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { IFileChange } from './IFileChange'; /** * Pull request creation */ export interface IPullRequest { - project: string, - repository: string, - source: { - commit: string, - branch: string - }, - target: { - branch: string - }, - author?: { - email: string, - name: string - }, - title: string, - description: string, - commitMessage: string, - autoComplete?: { - ignorePolicyConfigIds?: number[], - mergeStrategy?: GitPullRequestMergeStrategy - }, - assignees?: string[], - reviewers?: string[], - labels?: string[], - workItems?: string[], - changes: IFileChange[], - properties?: { - name: string, - value: string - }[] -}; + project: string; + repository: string; + source: { + commit: string; + branch: string; + }; + target: { + branch: string; + }; + author?: { + email: string; + name: string; + }; + title: string; + description: string; + commitMessage: string; + autoComplete?: { + ignorePolicyConfigIds?: number[]; + mergeStrategy?: GitPullRequestMergeStrategy; + }; + assignees?: string[]; + reviewers?: string[]; + labels?: string[]; + workItems?: string[]; + changes: IFileChange[]; + properties?: { + name: string; + value: string; + }[]; +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts index d09c5ea3..2fd2e6fb 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/azure-devops/interfaces/IPullRequestProperties.ts @@ -1,11 +1,10 @@ - /** * Pull request properties */ export interface IPullRequestProperties { - id: number, - properties?: { - name: string, - value: string - }[] + id: number; + properties?: { + name: string; + value: string; + }[]; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts index f3781689..923fd585 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotCli.ts @@ -1,195 +1,191 @@ -import { debug, warning, error } from "azure-pipelines-task-lib/task" -import { which, tool } from "azure-pipelines-task-lib/task" -import { ToolRunner } from "azure-pipelines-task-lib/toolrunner" -import { IDependabotUpdateOutputProcessor } from "./interfaces/IDependabotUpdateOutputProcessor"; -import { IDependabotUpdateOperationResult } from "./interfaces/IDependabotUpdateOperationResult"; -import { IDependabotUpdateOperation } from "./interfaces/IDependabotUpdateOperation"; -import * as yaml from 'js-yaml'; -import * as path from 'path'; +import { debug, error, tool, which } from 'azure-pipelines-task-lib/task'; +import { ToolRunner } from 'azure-pipelines-task-lib/toolrunner'; import * as fs from 'fs'; +import * as yaml from 'js-yaml'; import * as os from 'os'; -import { IDependabotUpdateJobConfig } from "./interfaces/IDependabotUpdateJobConfig"; +import * as path from 'path'; +import { IDependabotUpdateJobConfig } from './interfaces/IDependabotUpdateJobConfig'; +import { IDependabotUpdateOperation } from './interfaces/IDependabotUpdateOperation'; +import { IDependabotUpdateOperationResult } from './interfaces/IDependabotUpdateOperationResult'; +import { IDependabotUpdateOutputProcessor } from './interfaces/IDependabotUpdateOutputProcessor'; /** * Wrapper class for running updates using dependabot-cli */ export class DependabotCli { - private readonly jobsPath: string; - private readonly toolImage: string; - private readonly outputProcessor: IDependabotUpdateOutputProcessor; - private readonly debug: boolean; - - private toolPath: string; - - public static readonly CLI_IMAGE_LATEST = "github.com/dependabot/cli/cmd/dependabot@latest"; - - constructor(cliToolImage: string, outputProcessor: IDependabotUpdateOutputProcessor, debug: boolean) { - this.jobsPath = path.join(os.tmpdir(), 'dependabot-jobs'); - this.toolImage = cliToolImage; - this.outputProcessor = outputProcessor; - this.debug = debug; - this.ensureJobsPathExists(); + private readonly jobsPath: string; + private readonly toolImage: string; + private readonly outputProcessor: IDependabotUpdateOutputProcessor; + private readonly debug: boolean; + + private toolPath: string; + + public static readonly CLI_IMAGE_LATEST = 'github.com/dependabot/cli/cmd/dependabot@latest'; + + constructor(cliToolImage: string, outputProcessor: IDependabotUpdateOutputProcessor, debug: boolean) { + this.jobsPath = path.join(os.tmpdir(), 'dependabot-jobs'); + this.toolImage = cliToolImage; + this.outputProcessor = outputProcessor; + this.debug = debug; + this.ensureJobsPathExists(); + } + + /** + * Run dependabot update job + * @param operation + * @param options + * @returns + */ + public async update( + operation: IDependabotUpdateOperation, + options?: { + collectorImage?: string; + proxyImage?: string; + updaterImage?: string; + }, + ): Promise { + // Find the dependabot tool path, or install it if missing + const dependabotPath = await this.getDependabotToolPath(); + + // Create the job directory + const jobId = operation.job.id; + const jobPath = path.join(this.jobsPath, jobId.toString()); + const jobInputPath = path.join(jobPath, 'job.yaml'); + const jobOutputPath = path.join(jobPath, 'scenario.yaml'); + this.ensureJobsPathExists(); + if (!fs.existsSync(jobPath)) { + fs.mkdirSync(jobPath); } - /** - * Run dependabot update job - * @param operation - * @param options - * @returns - */ - public async update( - operation: IDependabotUpdateOperation, - options?: { - collectorImage?: string, - proxyImage?: string, - updaterImage?: string - } - ): Promise { - - // Find the dependabot tool path, or install it if missing - const dependabotPath = await this.getDependabotToolPath(); - - // Create the job directory - const jobId = operation.job.id; - const jobPath = path.join(this.jobsPath, jobId.toString()); - const jobInputPath = path.join(jobPath, 'job.yaml'); - const jobOutputPath = path.join(jobPath, 'scenario.yaml'); - this.ensureJobsPathExists(); - if (!fs.existsSync(jobPath)) { - fs.mkdirSync(jobPath); - } - - // Compile dependabot cmd arguments - // See: https://github.com/dependabot/cli/blob/main/cmd/dependabot/internal/cmd/root.go - // https://github.com/dependabot/cli/blob/main/cmd/dependabot/internal/cmd/update.go - let dependabotArguments = [ - "update", "-f", jobInputPath, "-o", jobOutputPath - ]; - if (options?.collectorImage) { - dependabotArguments.push("--collector-image", options.collectorImage); - } - if (options?.proxyImage) { - dependabotArguments.push("--proxy-image", options.proxyImage); - } - if (options?.updaterImage) { - dependabotArguments.push("--updater-image", options.updaterImage); - } - - // Generate the job input file - writeJobConfigFile(jobInputPath, operation); - - // Run dependabot update - if (!fs.existsSync(jobOutputPath) || fs.statSync(jobOutputPath)?.size == 0) { - console.info(`Running Dependabot update job '${jobInputPath}'...`); - const dependabotTool = tool(dependabotPath).arg(dependabotArguments); - const dependabotResultCode = await dependabotTool.execAsync({ - failOnStdErr: false, - ignoreReturnCode: true - }); - if (dependabotResultCode != 0) { - error(`Dependabot failed with exit code ${dependabotResultCode}`); - } - } - - // Process the job output - const operationResults = Array(); - if (fs.existsSync(jobOutputPath)) { - const jobOutputs = readJobScenarioOutputFile(jobOutputPath); - if (jobOutputs?.length > 0) { - console.info(`Processing outputs from '${jobOutputPath}'...`); - for (const output of jobOutputs) { - // Documentation on the scenario model can be found here: - // https://github.com/dependabot/cli/blob/main/internal/model/scenario.go - const type = output['type']; - const data = output['expect']?.['data']; - var operationResult = { - success: true, - error: null, - output: { - type: type, - data: data - } - }; - try { - operationResult.success = await this.outputProcessor.process(operation, type, data); - } - catch (e) { - operationResult.success = false; - operationResult.error = e; - } - finally { - operationResults.push(operationResult); - } - } - } - } - - return operationResults.length > 0 ? operationResults : undefined; + // Compile dependabot cmd arguments + // See: https://github.com/dependabot/cli/blob/main/cmd/dependabot/internal/cmd/root.go + // https://github.com/dependabot/cli/blob/main/cmd/dependabot/internal/cmd/update.go + let dependabotArguments = ['update', '-f', jobInputPath, '-o', jobOutputPath]; + if (options?.collectorImage) { + dependabotArguments.push('--collector-image', options.collectorImage); + } + if (options?.proxyImage) { + dependabotArguments.push('--proxy-image', options.proxyImage); + } + if (options?.updaterImage) { + dependabotArguments.push('--updater-image', options.updaterImage); } - // Get the dependabot tool path and install if missing - private async getDependabotToolPath(installIfMissing: boolean = true): Promise { + // Generate the job input file + writeJobConfigFile(jobInputPath, operation); + + // Run dependabot update + if (!fs.existsSync(jobOutputPath) || fs.statSync(jobOutputPath)?.size == 0) { + console.info(`Running Dependabot update job '${jobInputPath}'...`); + const dependabotTool = tool(dependabotPath).arg(dependabotArguments); + const dependabotResultCode = await dependabotTool.execAsync({ + failOnStdErr: false, + ignoreReturnCode: true, + }); + if (dependabotResultCode != 0) { + error(`Dependabot failed with exit code ${dependabotResultCode}`); + } + } - debug('Checking for `dependabot` install...'); - this.toolPath ||= which("dependabot", false); - if (this.toolPath) { - return this.toolPath; - } - if (!installIfMissing) { - throw new Error("Dependabot CLI install not found"); + // Process the job output + const operationResults = Array(); + if (fs.existsSync(jobOutputPath)) { + const jobOutputs = readJobScenarioOutputFile(jobOutputPath); + if (jobOutputs?.length > 0) { + console.info(`Processing outputs from '${jobOutputPath}'...`); + for (const output of jobOutputs) { + // Documentation on the scenario model can be found here: + // https://github.com/dependabot/cli/blob/main/internal/model/scenario.go + const type = output['type']; + const data = output['expect']?.['data']; + var operationResult = { + success: true, + error: null, + output: { + type: type, + data: data, + }, + }; + try { + operationResult.success = await this.outputProcessor.process(operation, type, data); + } catch (e) { + operationResult.success = false; + operationResult.error = e; + } finally { + operationResults.push(operationResult); + } } + } + } - console.info("Dependabot CLI install was not found, installing now with `go install dependabot`..."); - const goTool: ToolRunner = tool(which("go", true)); - goTool.arg(["install", this.toolImage]); - goTool.execSync(); + return operationResults.length > 0 ? operationResults : undefined; + } - // Depending on how Go is configured on the host agent, the "go/bin" path may not be in the PATH environment variable. - // If dependabot still cannot be found using `which()` after install, we must manually resolve the path; - // It will either be "$GOPATH/bin/dependabot" or "$HOME/go/bin/dependabot", if GOPATH is not set. - const goBinPath = process.env.GOPATH ? path.join(process.env.GOPATH, 'bin') : path.join(os.homedir(), 'go', 'bin'); - return this.toolPath ||= which("dependabot", false) || path.join(goBinPath, 'dependabot'); + // Get the dependabot tool path and install if missing + private async getDependabotToolPath(installIfMissing: boolean = true): Promise { + debug('Checking for `dependabot` install...'); + this.toolPath ||= which('dependabot', false); + if (this.toolPath) { + return this.toolPath; } - - // Create the jobs directory if it does not exist - private ensureJobsPathExists(): void { - if (!fs.existsSync(this.jobsPath)) { - fs.mkdirSync(this.jobsPath); - } + if (!installIfMissing) { + throw new Error('Dependabot CLI install not found'); } - // Clean up the jobs directory and its contents - public cleanup(): void { - if (fs.existsSync(this.jobsPath)) { - fs.rmSync(this.jobsPath, { - recursive: true, - force: true - }); - } + console.info('Dependabot CLI install was not found, installing now with `go install dependabot`...'); + const goTool: ToolRunner = tool(which('go', true)); + goTool.arg(['install', this.toolImage]); + goTool.execSync(); + + // Depending on how Go is configured on the host agent, the "go/bin" path may not be in the PATH environment variable. + // If dependabot still cannot be found using `which()` after install, we must manually resolve the path; + // It will either be "$GOPATH/bin/dependabot" or "$HOME/go/bin/dependabot", if GOPATH is not set. + const goBinPath = process.env.GOPATH ? path.join(process.env.GOPATH, 'bin') : path.join(os.homedir(), 'go', 'bin'); + return (this.toolPath ||= which('dependabot', false) || path.join(goBinPath, 'dependabot')); + } + + // Create the jobs directory if it does not exist + private ensureJobsPathExists(): void { + if (!fs.existsSync(this.jobsPath)) { + fs.mkdirSync(this.jobsPath); + } + } + + // Clean up the jobs directory and its contents + public cleanup(): void { + if (fs.existsSync(this.jobsPath)) { + fs.rmSync(this.jobsPath, { + recursive: true, + force: true, + }); } + } } // Documentation on the job model can be found here: // https://github.com/dependabot/cli/blob/main/internal/model/job.go function writeJobConfigFile(path: string, config: IDependabotUpdateJobConfig): void { - fs.writeFileSync(path, yaml.dump({ - job: config.job, - credentials: config.credentials - })); + fs.writeFileSync( + path, + yaml.dump({ + job: config.job, + credentials: config.credentials, + }), + ); } // Documentation on the scenario model can be found here: // https://github.com/dependabot/cli/blob/main/internal/model/scenario.go function readJobScenarioOutputFile(path: string): any[] { - const scenarioContent = fs.readFileSync(path, 'utf-8'); - if (!scenarioContent || typeof scenarioContent !== 'string') { - return []; // No outputs or failed scenario - } + const scenarioContent = fs.readFileSync(path, 'utf-8'); + if (!scenarioContent || typeof scenarioContent !== 'string') { + return []; // No outputs or failed scenario + } - const scenario: any = yaml.load(scenarioContent); - if (scenario === null || typeof scenario !== 'object') { - throw new Error('Invalid scenario object'); - } + const scenario: any = yaml.load(scenarioContent); + if (scenario === null || typeof scenario !== 'object') { + throw new Error('Invalid scenario object'); + } - return scenario['output'] || []; -} \ No newline at end of file + return scenario['output'] || []; +} diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts index d355c6bd..af322ed2 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotJobBuilder.ts @@ -1,243 +1,260 @@ -import { error, warning, debug } from "azure-pipelines-task-lib"; -import { ISharedVariables } from "../getSharedVariables"; -import { IDependabotAllowCondition, IDependabotGroup, IDependabotRegistry, IDependabotUpdate } from "../dependabot/interfaces/IDependabotConfig"; -import { IDependabotUpdateOperation } from "./interfaces/IDependabotUpdateOperation"; -import * as crypto from 'crypto'; +import { warning } from 'azure-pipelines-task-lib'; +import { + IDependabotAllowCondition, + IDependabotGroup, + IDependabotRegistry, + IDependabotUpdate, +} from '../dependabot/interfaces/IDependabotConfig'; +import { ISharedVariables } from '../getSharedVariables'; +import { IDependabotUpdateOperation } from './interfaces/IDependabotUpdateOperation'; /** * Wrapper class for building dependabot update job objects */ export class DependabotJobBuilder { - - /** - * Create a dependabot update job that updates all dependencies for a package ecyosystem - * @param taskInputs - * @param update - * @param registries - * @param dependencyList - * @param existingPullRequests - * @returns - */ - public static newUpdateAllJob( - taskInputs: ISharedVariables, - id: string, - update: IDependabotUpdate, - registries: Record, - dependencyList: any[], - existingPullRequests: any[] - ): IDependabotUpdateOperation { - const packageEcosystem = update["package-ecosystem"]; - const securityUpdatesOnly = update["open-pull-requests-limit"] == 0; - const updateDependencyNames = securityUpdatesOnly ? mapDependenciesForSecurityUpdate(dependencyList) : undefined; - return buildUpdateJobConfig( - `update-${id}-${packageEcosystem}-${securityUpdatesOnly ? 'security-only' : 'all'}`, - taskInputs, - update, - registries, - false, - undefined, - updateDependencyNames, - existingPullRequests - ); - } - - /** - * Create a dependabot update job that updates a single pull request - * @param taskInputs - * @param update - * @param registries - * @param existingPullRequests - * @param pullRequestToUpdate - * @returns - */ - public static newUpdatePullRequestJob( - taskInputs: ISharedVariables, - id: string, - update: IDependabotUpdate, - registries: Record, - existingPullRequests: any[], - pullRequestToUpdate: any - ): IDependabotUpdateOperation { - const dependencyGroupName = pullRequestToUpdate['dependency-group-name']; - const dependencies = (dependencyGroupName ? pullRequestToUpdate['dependencies'] : pullRequestToUpdate)?.map(d => d['dependency-name']); - return buildUpdateJobConfig( - `update-pr-${id}`, - taskInputs, - update, - registries, - true, - dependencyGroupName, - dependencies, - existingPullRequests - ); - } - -} - -function buildUpdateJobConfig( + /** + * Create a dependabot update job that updates all dependencies for a package ecyosystem + * @param taskInputs + * @param update + * @param registries + * @param dependencyList + * @param existingPullRequests + * @returns + */ + public static newUpdateAllJob( + taskInputs: ISharedVariables, id: string, + update: IDependabotUpdate, + registries: Record, + dependencyList: any[], + existingPullRequests: any[], + ): IDependabotUpdateOperation { + const packageEcosystem = update['package-ecosystem']; + const securityUpdatesOnly = update['open-pull-requests-limit'] == 0; + const updateDependencyNames = securityUpdatesOnly ? mapDependenciesForSecurityUpdate(dependencyList) : undefined; + return buildUpdateJobConfig( + `update-${id}-${packageEcosystem}-${securityUpdatesOnly ? 'security-only' : 'all'}`, + taskInputs, + update, + registries, + false, + undefined, + updateDependencyNames, + existingPullRequests, + ); + } + + /** + * Create a dependabot update job that updates a single pull request + * @param taskInputs + * @param update + * @param registries + * @param existingPullRequests + * @param pullRequestToUpdate + * @returns + */ + public static newUpdatePullRequestJob( taskInputs: ISharedVariables, + id: string, update: IDependabotUpdate, registries: Record, - updatingPullRequest: boolean, - updateDependencyGroupName: string | undefined, - updateDependencyNames: string[] | undefined, - existingPullRequests: any[]) { - const hasMultipleDirectories = update.directories?.length > 1; - return { - config: update, - job: { - 'id': id, - 'package-manager': update["package-ecosystem"], - 'update-subdependencies': true, // TODO: add config for this? - 'updating-a-pull-request': updatingPullRequest, - 'dependency-group-to-refresh': updateDependencyGroupName, - 'dependency-groups': mapGroupsFromDependabotConfigToJobConfig(update.groups), - 'dependencies': updateDependencyNames, - 'allowed-updates': mapAllowedUpdatesFromDependabotConfigToJobConfig(update.allow), - 'ignore-conditions': mapIgnoreConditionsFromDependabotConfigToJobConfig(update.ignore), - 'security-updates-only': update["open-pull-requests-limit"] == 0, - 'security-advisories': [], // TODO: add config for this! - 'source': { - 'provider': 'azure', - 'api-endpoint': taskInputs.apiEndpointUrl, - 'hostname': taskInputs.hostname, - 'repo': `${taskInputs.organization}/${taskInputs.project}/_git/${taskInputs.repository}`, - 'branch': update["target-branch"], - 'commit': undefined, // use latest commit of target branch - 'directory': hasMultipleDirectories ? undefined : update.directory || '/', - 'directories': hasMultipleDirectories ? update.directories : undefined - }, - 'existing-pull-requests': existingPullRequests.filter(pr => !pr['dependency-group-name']), - 'existing-group-pull-requests': existingPullRequests.filter(pr => pr['dependency-group-name']), - 'commit-message-options': update["commit-message"] === undefined ? undefined : { - 'prefix': update["commit-message"]?.["prefix"], - 'prefix-development': update["commit-message"]?.["prefix-development"], - 'include-scope': update["commit-message"]?.["include"], + existingPullRequests: any[], + pullRequestToUpdate: any, + ): IDependabotUpdateOperation { + const dependencyGroupName = pullRequestToUpdate['dependency-group-name']; + const dependencies = (dependencyGroupName ? pullRequestToUpdate['dependencies'] : pullRequestToUpdate)?.map( + (d) => d['dependency-name'], + ); + return buildUpdateJobConfig( + `update-pr-${id}`, + taskInputs, + update, + registries, + true, + dependencyGroupName, + dependencies, + existingPullRequests, + ); + } +} + +function buildUpdateJobConfig( + id: string, + taskInputs: ISharedVariables, + update: IDependabotUpdate, + registries: Record, + updatingPullRequest: boolean, + updateDependencyGroupName: string | undefined, + updateDependencyNames: string[] | undefined, + existingPullRequests: any[], +) { + const hasMultipleDirectories = update.directories?.length > 1; + return { + config: update, + job: { + 'id': id, + 'package-manager': update['package-ecosystem'], + 'update-subdependencies': true, // TODO: add config for this? + 'updating-a-pull-request': updatingPullRequest, + 'dependency-group-to-refresh': updateDependencyGroupName, + 'dependency-groups': mapGroupsFromDependabotConfigToJobConfig(update.groups), + 'dependencies': updateDependencyNames, + 'allowed-updates': mapAllowedUpdatesFromDependabotConfigToJobConfig(update.allow), + 'ignore-conditions': mapIgnoreConditionsFromDependabotConfigToJobConfig(update.ignore), + 'security-updates-only': update['open-pull-requests-limit'] == 0, + 'security-advisories': [], // TODO: add config for this! + 'source': { + 'provider': 'azure', + 'api-endpoint': taskInputs.apiEndpointUrl, + 'hostname': taskInputs.hostname, + 'repo': `${taskInputs.organization}/${taskInputs.project}/_git/${taskInputs.repository}`, + 'branch': update['target-branch'], + 'commit': undefined, // use latest commit of target branch + 'directory': hasMultipleDirectories ? undefined : update.directory || '/', + 'directories': hasMultipleDirectories ? update.directories : undefined, + }, + 'existing-pull-requests': existingPullRequests.filter((pr) => !pr['dependency-group-name']), + 'existing-group-pull-requests': existingPullRequests.filter((pr) => pr['dependency-group-name']), + 'commit-message-options': + update['commit-message'] === undefined + ? undefined + : { + 'prefix': update['commit-message']?.['prefix'], + 'prefix-development': update['commit-message']?.['prefix-development'], + 'include-scope': update['commit-message']?.['include'], }, - 'experiments': taskInputs.experiments, - 'max-updater-run-time': undefined, // TODO: add config for this? - 'reject-external-code': (update["insecure-external-code-execution"]?.toLocaleLowerCase() == "allow"), - 'repo-private': undefined, // TODO: add config for this? - 'repo-contents-path': undefined, // TODO: add config for this? - 'requirements-update-strategy': mapVersionStrategyToRequirementsUpdateStrategy(update["versioning-strategy"]), - 'lockfile-only': update["versioning-strategy"] === 'lockfile-only', - 'vendor-dependencies': update.vendor, - 'debug': taskInputs.debug - }, - credentials: mapRegistryCredentialsFromDependabotConfigToJobConfig(taskInputs, registries) - }; + 'experiments': taskInputs.experiments, + 'max-updater-run-time': undefined, // TODO: add config for this? + 'reject-external-code': update['insecure-external-code-execution']?.toLocaleLowerCase() == 'allow', + 'repo-private': undefined, // TODO: add config for this? + 'repo-contents-path': undefined, // TODO: add config for this? + 'requirements-update-strategy': mapVersionStrategyToRequirementsUpdateStrategy(update['versioning-strategy']), + 'lockfile-only': update['versioning-strategy'] === 'lockfile-only', + 'vendor-dependencies': update.vendor, + 'debug': taskInputs.debug, + }, + credentials: mapRegistryCredentialsFromDependabotConfigToJobConfig(taskInputs, registries), + }; } function mapDependenciesForSecurityUpdate(dependencyList: any[]): string[] { - if (!dependencyList || dependencyList.length == 0) { - // This happens when no previous dependency list snapshot exists yet; - // TODO: Find a way to discover dependencies for a first-time security-only update (no existing dependency list snapshot). - // It would be nice if we could use dependabot-cli for this (e.g. `dependabot --discover-only`), but this is not supported currently. - // TODO: Open a issue in dependabot-cli project, ask how we should handle this scenario. - warning( - "Security updates can only be performed if there is a previous dependency list snapshot available, but there is none as you have not completed a successful update job yet. " + - "Dependabot does not currently support discovering vulnerable dependencies during security-only updates and it is likely that this update operation will fail." - ); + if (!dependencyList || dependencyList.length == 0) { + // This happens when no previous dependency list snapshot exists yet; + // TODO: Find a way to discover dependencies for a first-time security-only update (no existing dependency list snapshot). + // It would be nice if we could use dependabot-cli for this (e.g. `dependabot --discover-only`), but this is not supported currently. + // TODO: Open a issue in dependabot-cli project, ask how we should handle this scenario. + warning( + 'Security updates can only be performed if there is a previous dependency list snapshot available, but there is none as you have not completed a successful update job yet. ' + + 'Dependabot does not currently support discovering vulnerable dependencies during security-only updates and it is likely that this update operation will fail.', + ); - // Attempt to do a security update for "all dependencies"; it will probably fail this is not supported in dependabot-updater yet, but it is best we can do... - return []; - } + // Attempt to do a security update for "all dependencies"; it will probably fail this is not supported in dependabot-updater yet, but it is best we can do... + return []; + } - // Return only dependencies that are vulnerable, ignore the rest - const dependencyNames = dependencyList.map(dependency => dependency["name"]); - const dependencyVulnerabilities = {}; // TODO: getGitHubSecurityAdvisoriesForDependencies(dependencyNames); - return dependencyNames.filter(dependency => dependencyVulnerabilities[dependency]?.length > 0); + // Return only dependencies that are vulnerable, ignore the rest + const dependencyNames = dependencyList.map((dependency) => dependency['name']); + const dependencyVulnerabilities = {}; // TODO: getGitHubSecurityAdvisoriesForDependencies(dependencyNames); + return dependencyNames.filter((dependency) => dependencyVulnerabilities[dependency]?.length > 0); } function mapGroupsFromDependabotConfigToJobConfig(dependencyGroups: Record): any[] { - if (!dependencyGroups) { - return undefined; - } - return Object.keys(dependencyGroups).map(name => { - const group = dependencyGroups[name]; - return { - 'name': name, - 'applies-to': group["applies-to"], - 'rules': { - 'patterns': group["patterns"], - 'exclude-patterns': group["exclude-patterns"], - 'dependency-type': group["dependency-type"], - 'update-types': group["update-types"] - } - }; - }); + if (!dependencyGroups) { + return undefined; + } + return Object.keys(dependencyGroups).map((name) => { + const group = dependencyGroups[name]; + return { + 'name': name, + 'applies-to': group['applies-to'], + 'rules': { + 'patterns': group['patterns'], + 'exclude-patterns': group['exclude-patterns'], + 'dependency-type': group['dependency-type'], + 'update-types': group['update-types'], + }, + }; + }); } function mapAllowedUpdatesFromDependabotConfigToJobConfig(allowedUpdates: IDependabotAllowCondition[]): any[] { - if (!allowedUpdates) { - return [ - { 'dependency-type': 'all' } // if not explicitly configured, allow all updates - ]; - } - return allowedUpdates.map(allow => { - return { - 'dependency-name': allow["dependency-name"], - 'dependency-type': allow["dependency-type"], - //'update-type': allow["update-type"] // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? - }; - }); + if (!allowedUpdates) { + return [ + { 'dependency-type': 'all' }, // if not explicitly configured, allow all updates + ]; + } + return allowedUpdates.map((allow) => { + return { + 'dependency-name': allow['dependency-name'], + 'dependency-type': allow['dependency-type'], + //'update-type': allow["update-type"] // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? + }; + }); } function mapIgnoreConditionsFromDependabotConfigToJobConfig(ignoreConditions: IDependabotAllowCondition[]): any[] { - if (!ignoreConditions) { - return undefined; - } - return ignoreConditions.map(ignore => { - return { - 'dependency-name': ignore["dependency-name"], - //'source': ignore["source"], // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? - 'update-types': ignore["update-types"], - //'updated-at': ignore["updated-at"], // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? - 'version-requirement': (ignore["versions"])?.join(", "), // TODO: Test this, not sure how this should be parsed... - }; - }); + if (!ignoreConditions) { + return undefined; + } + return ignoreConditions.map((ignore) => { + return { + 'dependency-name': ignore['dependency-name'], + //'source': ignore["source"], // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? + 'update-types': ignore['update-types'], + //'updated-at': ignore["updated-at"], // TODO: This is missing from dependabot.ymal docs, but is used in the dependabot-core job model!? + 'version-requirement': (ignore['versions'])?.join(', '), // TODO: Test this, not sure how this should be parsed... + }; + }); } function mapVersionStrategyToRequirementsUpdateStrategy(versioningStrategy: string): string | undefined { - if (!versioningStrategy) { - return undefined; - } - switch (versioningStrategy) { - case 'auto': return undefined; - case 'increase': return 'bump_versions'; - case 'increase-if-necessary': return 'bump_versions_if_necessary'; - case 'lockfile-only': return 'lockfile_only'; - case 'widen': return 'widen_ranges'; - default: throw new Error(`Invalid dependabot.yaml versioning strategy option '${versioningStrategy}'`); - } + if (!versioningStrategy) { + return undefined; + } + switch (versioningStrategy) { + case 'auto': + return undefined; + case 'increase': + return 'bump_versions'; + case 'increase-if-necessary': + return 'bump_versions_if_necessary'; + case 'lockfile-only': + return 'lockfile_only'; + case 'widen': + return 'widen_ranges'; + default: + throw new Error(`Invalid dependabot.yaml versioning strategy option '${versioningStrategy}'`); + } } -function mapRegistryCredentialsFromDependabotConfigToJobConfig(taskInputs: ISharedVariables, registries: Record): any[] { - let registryCredentials = new Array(); - if (taskInputs.systemAccessToken) { - registryCredentials.push({ - type: 'git_source', - host: taskInputs.hostname, - username: taskInputs.systemAccessUser?.trim()?.length > 0 ? taskInputs.systemAccessUser : 'x-access-token', - password: taskInputs.systemAccessToken - }); - } - if (registries) { - for (const key in registries) { - const registry = registries[key]; - registryCredentials.push({ - type: registry.type, - host: registry.host, - url: registry.url, - registry: registry.registry, - username: registry.username, - password: registry.password, - token: registry.token, - 'replaces-base': registry["replaces-base"] - }); - } +function mapRegistryCredentialsFromDependabotConfigToJobConfig( + taskInputs: ISharedVariables, + registries: Record, +): any[] { + let registryCredentials = new Array(); + if (taskInputs.systemAccessToken) { + registryCredentials.push({ + type: 'git_source', + host: taskInputs.hostname, + username: taskInputs.systemAccessUser?.trim()?.length > 0 ? taskInputs.systemAccessUser : 'x-access-token', + password: taskInputs.systemAccessToken, + }); + } + if (registries) { + for (const key in registries) { + const registry = registries[key]; + registryCredentials.push({ + 'type': registry.type, + 'host': registry.host, + 'url': registry.url, + 'registry': registry.registry, + 'username': registry.username, + 'password': registry.password, + 'token': registry.token, + 'replaces-base': registry['replaces-base'], + }); } + } - return registryCredentials; + return registryCredentials; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts index 8e1a9308..d5571519 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/DependabotOutputProcessor.ts @@ -1,346 +1,407 @@ -import { debug, warning, error } from "azure-pipelines-task-lib/task" -import { ISharedVariables } from "../getSharedVariables"; -import { IDependabotUpdateOperation } from "./interfaces/IDependabotUpdateOperation"; -import { IDependabotUpdateOutputProcessor } from "./interfaces/IDependabotUpdateOutputProcessor"; -import { AzureDevOpsWebApiClient } from "../azure-devops/AzureDevOpsWebApiClient"; -import { GitPullRequestMergeStrategy, VersionControlChangeType } from "azure-devops-node-api/interfaces/GitInterfaces"; -import { IPullRequestProperties } from "../azure-devops/interfaces/IPullRequestProperties"; -import * as path from 'path'; +import { GitPullRequestMergeStrategy, VersionControlChangeType } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { error, warning } from 'azure-pipelines-task-lib/task'; import * as crypto from 'crypto'; +import * as path from 'path'; +import { AzureDevOpsWebApiClient } from '../azure-devops/AzureDevOpsWebApiClient'; +import { IPullRequestProperties } from '../azure-devops/interfaces/IPullRequestProperties'; +import { ISharedVariables } from '../getSharedVariables'; +import { IDependabotUpdateOperation } from './interfaces/IDependabotUpdateOperation'; +import { IDependabotUpdateOutputProcessor } from './interfaces/IDependabotUpdateOutputProcessor'; /** * Processes dependabot update outputs using the DevOps API */ export class DependabotOutputProcessor implements IDependabotUpdateOutputProcessor { - private readonly prAuthorClient: AzureDevOpsWebApiClient; - private readonly prApproverClient: AzureDevOpsWebApiClient; - private readonly existingPullRequests: IPullRequestProperties[]; - private readonly taskInputs: ISharedVariables; - - // Custom properties used to store dependabot metadata in projects. - // https://learn.microsoft.com/en-us/rest/api/azure/devops/core/projects/set-project-properties - public static PROJECT_PROPERTY_NAME_DEPENDENCY_LIST = "Dependabot.DependencyList"; - - // Custom properties used to store dependabot metadata in pull requests. - // https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-properties - public static PR_PROPERTY_NAME_PACKAGE_MANAGER = "Dependabot.PackageManager"; - public static PR_PROPERTY_NAME_DEPENDENCIES = "Dependabot.Dependencies"; - - public static PR_DEFAULT_AUTHOR_EMAIL = "noreply@github.com"; - public static PR_DEFAULT_AUTHOR_NAME = "dependabot[bot]"; - - constructor(taskInputs: ISharedVariables, prAuthorClient: AzureDevOpsWebApiClient, prApproverClient: AzureDevOpsWebApiClient, existingPullRequests: IPullRequestProperties[]) { - this.taskInputs = taskInputs; - this.prAuthorClient = prAuthorClient; - this.prApproverClient = prApproverClient; - this.existingPullRequests = existingPullRequests; - } + private readonly prAuthorClient: AzureDevOpsWebApiClient; + private readonly prApproverClient: AzureDevOpsWebApiClient; + private readonly existingPullRequests: IPullRequestProperties[]; + private readonly taskInputs: ISharedVariables; + + // Custom properties used to store dependabot metadata in projects. + // https://learn.microsoft.com/en-us/rest/api/azure/devops/core/projects/set-project-properties + public static PROJECT_PROPERTY_NAME_DEPENDENCY_LIST = 'Dependabot.DependencyList'; + + // Custom properties used to store dependabot metadata in pull requests. + // https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-request-properties + public static PR_PROPERTY_NAME_PACKAGE_MANAGER = 'Dependabot.PackageManager'; + public static PR_PROPERTY_NAME_DEPENDENCIES = 'Dependabot.Dependencies'; + + public static PR_DEFAULT_AUTHOR_EMAIL = 'noreply@github.com'; + public static PR_DEFAULT_AUTHOR_NAME = 'dependabot[bot]'; + + constructor( + taskInputs: ISharedVariables, + prAuthorClient: AzureDevOpsWebApiClient, + prApproverClient: AzureDevOpsWebApiClient, + existingPullRequests: IPullRequestProperties[], + ) { + this.taskInputs = taskInputs; + this.prAuthorClient = prAuthorClient; + this.prApproverClient = prApproverClient; + this.existingPullRequests = existingPullRequests; + } + + /** + * Process the appropriate DevOps API actions for the supplied dependabot update output + * @param update + * @param type + * @param data + * @returns + */ + public async process(update: IDependabotUpdateOperation, type: string, data: any): Promise { + console.debug(`Processing output '${type}' with data:`, data); + const sourceRepoParts = update.job.source.repo.split('/'); // "{organisation}/{project}/_git/{repository}"" + const project = sourceRepoParts[1]; + const repository = sourceRepoParts[3]; + switch (type) { + // Documentation on the 'data' model for each output type can be found here: + // See: https://github.com/dependabot/cli/blob/main/internal/model/update.go + + case 'update_dependency_list': + // Store the dependency list snapshot in project properties, if configured + if (this.taskInputs.storeDependencyList) { + console.info(`Storing the dependency list snapshot for project '${project}'...`); + await this.prAuthorClient.updateProjectProperty( + project, + DependabotOutputProcessor.PROJECT_PROPERTY_NAME_DEPENDENCY_LIST, + function (existingValue: string) { + const repoDependencyLists = JSON.parse(existingValue || '{}'); + repoDependencyLists[repository] = repoDependencyLists[repository] || {}; + repoDependencyLists[repository][update.job['package-manager']] = { + 'dependencies': data['dependencies'], + 'dependency-files': data['dependency_files'], + 'last-updated': new Date().toISOString(), + }; + + return JSON.stringify(repoDependencyLists); + }, + ); + console.info(`Dependency list snapshot was updated for project '${project}'`); + } + + return true; - /** - * Process the appropriate DevOps API actions for the supplied dependabot update output - * @param update - * @param type - * @param data - * @returns - */ - public async process(update: IDependabotUpdateOperation, type: string, data: any): Promise { - console.debug(`Processing output '${type}' with data:`, data); - const sourceRepoParts = update.job.source.repo.split('/'); // "{organisation}/{project}/_git/{repository}"" - const project = sourceRepoParts[1]; - const repository = sourceRepoParts[3]; - switch (type) { - - // Documentation on the 'data' model for each output type can be found here: - // See: https://github.com/dependabot/cli/blob/main/internal/model/update.go - - case 'update_dependency_list': - - // Store the dependency list snapshot in project properties, if configured - if (this.taskInputs.storeDependencyList) { - console.info(`Storing the dependency list snapshot for project '${project}'...`); - await this.prAuthorClient.updateProjectProperty( - project, - DependabotOutputProcessor.PROJECT_PROPERTY_NAME_DEPENDENCY_LIST, - function (existingValue: string) { - const repoDependencyLists = JSON.parse(existingValue || '{}'); - repoDependencyLists[repository] = repoDependencyLists[repository] || {}; - repoDependencyLists[repository][update.job["package-manager"]] = { - 'dependencies': data['dependencies'], - 'dependency-files': data['dependency_files'], - 'last-updated': new Date().toISOString() - }; - - return JSON.stringify(repoDependencyLists); - } - ); - console.info(`Dependency list snapshot was updated for project '${project}'`); - } - - return true; - - case 'create_pull_request': - if (this.taskInputs.skipPullRequests) { - warning(`Skipping pull request creation as 'skipPullRequests' is set to 'true'`); - return true; - } - - // Skip if active pull request limit reached. - const openPullRequestLimit = update.config["open-pull-requests-limit"]; - if (openPullRequestLimit > 0 && this.existingPullRequests.length >= openPullRequestLimit) { - warning(`Skipping pull request creation as the maximum number of active pull requests (${openPullRequestLimit}) has been reached`); - return true; - } - - // Create a new pull request - const dependencies = getPullRequestDependenciesPropertyValueForOutputData(data); - const targetBranch = update.config["target-branch"] || await this.prAuthorClient.getDefaultBranch(project, repository); - const newPullRequestId = await this.prAuthorClient.createPullRequest({ - project: project, - repository: repository, - source: { - commit: data['base-commit-sha'] || update.job.source.commit, - branch: getSourceBranchNameForUpdate(update.job["package-manager"], targetBranch, dependencies) - }, - target: { - branch: targetBranch - }, - author: { - email: this.taskInputs.authorEmail || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_EMAIL, - name: this.taskInputs.authorName || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_NAME - }, - title: data['pr-title'], - description: data['pr-body'], - commitMessage: data['commit-message'], - autoComplete: this.taskInputs.setAutoComplete ? { - ignorePolicyConfigIds: this.taskInputs.autoCompleteIgnoreConfigIds, - mergeStrategy: GitPullRequestMergeStrategy[this.taskInputs.mergeStrategy as keyof typeof GitPullRequestMergeStrategy] - } : undefined, - assignees: update.config.assignees, - reviewers: update.config.reviewers, - labels: update.config.labels?.map((label) => label?.trim()) || [], - workItems: update.config.milestone ? [update.config.milestone] : [], - changes: getPullRequestChangedFilesForOutputData(data), - properties: buildPullRequestProperties(update.job["package-manager"], dependencies) - }) - - // Auto-approve the pull request, if required - if (this.taskInputs.autoApprove && this.prApproverClient && newPullRequestId) { - await this.prApproverClient.approvePullRequest({ - project: project, - repository: repository, - pullRequestId: newPullRequestId - }); - } - - return newPullRequestId > 0; - - case 'update_pull_request': - if (this.taskInputs.skipPullRequests) { - warning(`Skipping pull request update as 'skipPullRequests' is set to 'true'`); - return true; - } - - // Find the pull request to update - const pullRequestToUpdate = this.getPullRequestForDependencyNames(update.job["package-manager"], data['dependency-names']); - if (!pullRequestToUpdate) { - error(`Could not find pull request to update for package manager '${update.job["package-manager"]}' and dependencies '${data['dependency-names'].join(', ')}'`); - return false; - } - - // Update the pull request - const pullRequestWasUpdated = await this.prAuthorClient.updatePullRequest({ - project: project, - repository: repository, - pullRequestId: pullRequestToUpdate.id, - changes: getPullRequestChangedFilesForOutputData(data), - skipIfCommitsFromUsersOtherThan: this.taskInputs.authorEmail || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_EMAIL, - skipIfNoConflicts: true, - }); - - // Re-approve the pull request, if required - if (this.taskInputs.autoApprove && this.prApproverClient && pullRequestWasUpdated) { - await this.prApproverClient.approvePullRequest({ - project: project, - repository: repository, - pullRequestId: pullRequestToUpdate.id - }); - } - - return pullRequestWasUpdated; - - case 'close_pull_request': - if (!this.taskInputs.abandonUnwantedPullRequests) { - warning(`Skipping pull request closure as 'abandonUnwantedPullRequests' is set to 'false'`); - return true; - } - - // Find the pull request to close - const pullRequestToClose = this.getPullRequestForDependencyNames(update.job["package-manager"], data['dependency-names']); - if (!pullRequestToClose) { - error(`Could not find pull request to close for package manager '${update.job["package-manager"]}' and dependencies '${data['dependency-names'].join(', ')}'`); - return false; - } - - // TODO: GitHub Dependabot will close with reason "Superseded by ${new_pull_request_id}" when another PR supersedes it. - // How do we detect this? Do we need to? - - // Close the pull request - return await this.prAuthorClient.closePullRequest({ - project: project, - repository: repository, - pullRequestId: pullRequestToClose.id, - comment: this.taskInputs.commentPullRequests ? getPullRequestCloseReasonForOutputData(data) : undefined, - deleteSourceBranch: true - }); - - case 'mark_as_processed': - // No action required - return true; - - case 'record_ecosystem_versions': - // No action required - break; - - case 'record_update_job_error': - error(`Update job error: ${data['error-type']}`); - console.log(data['error-details']); - return false; - - case 'record_update_job_unknown_error': - error(`Update job unknown error: ${data['error-type']}`); - console.log(data['error-details']); - return false; - - case 'increment_metric': - // No action required - return true; - - default: - warning(`Unknown dependabot output type '${type}', ignoring...`); - return true; + case 'create_pull_request': + if (this.taskInputs.skipPullRequests) { + warning(`Skipping pull request creation as 'skipPullRequests' is set to 'true'`); + return true; } - } - private getPullRequestForDependencyNames(packageManager: string, dependencyNames: string[]): IPullRequestProperties | undefined { - return this.existingPullRequests.find(pr => { - return pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER && p.value === packageManager) - && pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES && areEqual(getDependencyNames(JSON.parse(p.value)), dependencyNames)); + // Skip if active pull request limit reached. + const openPullRequestLimit = update.config['open-pull-requests-limit']; + if (openPullRequestLimit > 0 && this.existingPullRequests.length >= openPullRequestLimit) { + warning( + `Skipping pull request creation as the maximum number of active pull requests (${openPullRequestLimit}) has been reached`, + ); + return true; + } + + // Create a new pull request + const dependencies = getPullRequestDependenciesPropertyValueForOutputData(data); + const targetBranch = + update.config['target-branch'] || (await this.prAuthorClient.getDefaultBranch(project, repository)); + const newPullRequestId = await this.prAuthorClient.createPullRequest({ + project: project, + repository: repository, + source: { + commit: data['base-commit-sha'] || update.job.source.commit, + branch: getSourceBranchNameForUpdate(update.job['package-manager'], targetBranch, dependencies), + }, + target: { + branch: targetBranch, + }, + author: { + email: this.taskInputs.authorEmail || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_EMAIL, + name: this.taskInputs.authorName || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_NAME, + }, + title: data['pr-title'], + description: data['pr-body'], + commitMessage: data['commit-message'], + autoComplete: this.taskInputs.setAutoComplete + ? { + ignorePolicyConfigIds: this.taskInputs.autoCompleteIgnoreConfigIds, + mergeStrategy: + GitPullRequestMergeStrategy[ + this.taskInputs.mergeStrategy as keyof typeof GitPullRequestMergeStrategy + ], + } + : undefined, + assignees: update.config.assignees, + reviewers: update.config.reviewers, + labels: update.config.labels?.map((label) => label?.trim()) || [], + workItems: update.config.milestone ? [update.config.milestone] : [], + changes: getPullRequestChangedFilesForOutputData(data), + properties: buildPullRequestProperties(update.job['package-manager'], dependencies), }); - } + // Auto-approve the pull request, if required + if (this.taskInputs.autoApprove && this.prApproverClient && newPullRequestId) { + await this.prApproverClient.approvePullRequest({ + project: project, + repository: repository, + pullRequestId: newPullRequestId, + }); + } + + return newPullRequestId > 0; + + case 'update_pull_request': + if (this.taskInputs.skipPullRequests) { + warning(`Skipping pull request update as 'skipPullRequests' is set to 'true'`); + return true; + } + + // Find the pull request to update + const pullRequestToUpdate = this.getPullRequestForDependencyNames( + update.job['package-manager'], + data['dependency-names'], + ); + if (!pullRequestToUpdate) { + error( + `Could not find pull request to update for package manager '${update.job['package-manager']}' and dependencies '${data['dependency-names'].join(', ')}'`, + ); + return false; + } + + // Update the pull request + const pullRequestWasUpdated = await this.prAuthorClient.updatePullRequest({ + project: project, + repository: repository, + pullRequestId: pullRequestToUpdate.id, + changes: getPullRequestChangedFilesForOutputData(data), + skipIfCommitsFromUsersOtherThan: + this.taskInputs.authorEmail || DependabotOutputProcessor.PR_DEFAULT_AUTHOR_EMAIL, + skipIfNoConflicts: true, + }); + + // Re-approve the pull request, if required + if (this.taskInputs.autoApprove && this.prApproverClient && pullRequestWasUpdated) { + await this.prApproverClient.approvePullRequest({ + project: project, + repository: repository, + pullRequestId: pullRequestToUpdate.id, + }); + } + + return pullRequestWasUpdated; + + case 'close_pull_request': + if (!this.taskInputs.abandonUnwantedPullRequests) { + warning(`Skipping pull request closure as 'abandonUnwantedPullRequests' is set to 'false'`); + return true; + } + + // Find the pull request to close + const pullRequestToClose = this.getPullRequestForDependencyNames( + update.job['package-manager'], + data['dependency-names'], + ); + if (!pullRequestToClose) { + error( + `Could not find pull request to close for package manager '${update.job['package-manager']}' and dependencies '${data['dependency-names'].join(', ')}'`, + ); + return false; + } + + // TODO: GitHub Dependabot will close with reason "Superseded by ${new_pull_request_id}" when another PR supersedes it. + // How do we detect this? Do we need to? + + // Close the pull request + return await this.prAuthorClient.closePullRequest({ + project: project, + repository: repository, + pullRequestId: pullRequestToClose.id, + comment: this.taskInputs.commentPullRequests ? getPullRequestCloseReasonForOutputData(data) : undefined, + deleteSourceBranch: true, + }); + + case 'mark_as_processed': + // No action required + return true; + + case 'record_ecosystem_versions': + // No action required + break; + + case 'record_update_job_error': + error(`Update job error: ${data['error-type']}`); + console.log(data['error-details']); + return false; + + case 'record_update_job_unknown_error': + error(`Update job unknown error: ${data['error-type']}`); + console.log(data['error-details']); + return false; + + case 'increment_metric': + // No action required + return true; + + default: + warning(`Unknown dependabot output type '${type}', ignoring...`); + return true; + } + } + + private getPullRequestForDependencyNames( + packageManager: string, + dependencyNames: string[], + ): IPullRequestProperties | undefined { + return this.existingPullRequests.find((pr) => { + return ( + pr.properties.find( + (p) => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER && p.value === packageManager, + ) && + pr.properties.find( + (p) => + p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES && + areEqual(getDependencyNames(JSON.parse(p.value)), dependencyNames), + ) + ); + }); + } } export function buildPullRequestProperties(packageManager: string, dependencies: any): any[] { - return [ - { - name: DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER, - value: packageManager - }, - { - name: DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES, - value: JSON.stringify(dependencies) - } - ]; + return [ + { + name: DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER, + value: packageManager, + }, + { + name: DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES, + value: JSON.stringify(dependencies), + }, + ]; } -export function parseProjectDependencyListProperty(properties: Record, repository: string, packageManager: string): any { - const dependencyList = properties?.[DependabotOutputProcessor.PROJECT_PROPERTY_NAME_DEPENDENCY_LIST] || '{}'; - const repoDependencyLists = JSON.parse(dependencyList); - return repoDependencyLists[repository]?.[packageManager]; +export function parseProjectDependencyListProperty( + properties: Record, + repository: string, + packageManager: string, +): any { + const dependencyList = properties?.[DependabotOutputProcessor.PROJECT_PROPERTY_NAME_DEPENDENCY_LIST] || '{}'; + const repoDependencyLists = JSON.parse(dependencyList); + return repoDependencyLists[repository]?.[packageManager]; } -export function parsePullRequestProperties(pullRequests: IPullRequestProperties[], packageManager: string | null): Record { - return Object.fromEntries(pullRequests - .filter(pr => { - return pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER && (packageManager === null || p.value === packageManager)); - }) - .map(pr => { - return [ - pr.id, - JSON.parse( - pr.properties.find(p => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES)?.value - ) - ]; - }) - ); +export function parsePullRequestProperties( + pullRequests: IPullRequestProperties[], + packageManager: string | null, +): Record { + return Object.fromEntries( + pullRequests + .filter((pr) => { + return pr.properties.find( + (p) => + p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_PACKAGE_MANAGER && + (packageManager === null || p.value === packageManager), + ); + }) + .map((pr) => { + return [ + pr.id, + JSON.parse( + pr.properties.find((p) => p.name === DependabotOutputProcessor.PR_PROPERTY_NAME_DEPENDENCIES)?.value, + ), + ]; + }), + ); } function getSourceBranchNameForUpdate(packageEcosystem: string, targetBranch: string, dependencies: any): string { - const target = targetBranch?.replace(/^\/+|\/+$/g, ''); // strip leading/trailing slashes - if (dependencies['dependency-group-name']) { - // Group dependency update - // e.g. dependabot/nuget/main/microsoft-3b49c54d9e - const dependencyGroupName = dependencies['dependency-group-name']; - const dependencyHash = crypto.createHash('md5').update(dependencies['dependencies'].map(d => `${d['dependency-name']}-${d['dependency-version']}`).join(',')).digest('hex').substring(0, 10); - return `dependabot/${packageEcosystem}/${target}/${dependencyGroupName}-${dependencyHash}`; - } - else { - // Single dependency update - // e.g. dependabot/nuget/main/Microsoft.Extensions.Logging-1.0.0 - const leadDependency = dependencies.length === 1 ? dependencies[0] : null; - return `dependabot/${packageEcosystem}/${target}/${leadDependency['dependency-name']}-${leadDependency['dependency-version']}`; - } + const target = targetBranch?.replace(/^\/+|\/+$/g, ''); // strip leading/trailing slashes + if (dependencies['dependency-group-name']) { + // Group dependency update + // e.g. dependabot/nuget/main/microsoft-3b49c54d9e + const dependencyGroupName = dependencies['dependency-group-name']; + const dependencyHash = crypto + .createHash('md5') + .update(dependencies['dependencies'].map((d) => `${d['dependency-name']}-${d['dependency-version']}`).join(',')) + .digest('hex') + .substring(0, 10); + return `dependabot/${packageEcosystem}/${target}/${dependencyGroupName}-${dependencyHash}`; + } else { + // Single dependency update + // e.g. dependabot/nuget/main/Microsoft.Extensions.Logging-1.0.0 + const leadDependency = dependencies.length === 1 ? dependencies[0] : null; + return `dependabot/${packageEcosystem}/${target}/${leadDependency['dependency-name']}-${leadDependency['dependency-version']}`; + } } function getPullRequestChangedFilesForOutputData(data: any): any { - return data['updated-dependency-files'].filter((file) => file['type'] === 'file').map((file) => { - let changeType = VersionControlChangeType.None; - if (file['deleted'] === true) { - changeType = VersionControlChangeType.Delete; - } else if (file['operation'] === 'update') { - changeType = VersionControlChangeType.Edit; - } else { - changeType = VersionControlChangeType.Add; - } - return { - changeType: changeType, - path: path.join(file['directory'], file['name']), - content: file['content'], - encoding: file['content_encoding'] - } + return data['updated-dependency-files'] + .filter((file) => file['type'] === 'file') + .map((file) => { + let changeType = VersionControlChangeType.None; + if (file['deleted'] === true) { + changeType = VersionControlChangeType.Delete; + } else if (file['operation'] === 'update') { + changeType = VersionControlChangeType.Edit; + } else { + changeType = VersionControlChangeType.Add; + } + return { + changeType: changeType, + path: path.join(file['directory'], file['name']), + content: file['content'], + encoding: file['content_encoding'], + }; }); } function getPullRequestCloseReasonForOutputData(data: any): string { - // The first dependency is the "lead" dependency in a multi-dependency update - const leadDependencyName = data['dependency-names'][0]; - let reason: string = null; - switch (data['reason']) { - case 'dependencies_changed': reason = `Looks like the dependencies have changed`; break; - case 'dependency_group_empty': reason = `Looks like the dependencies in this group are now empty`; break; - case 'dependency_removed': reason = `Looks like ${leadDependencyName} is no longer a dependency`; break; - case 'up_to_date': reason = `Looks like ${leadDependencyName} is up-to-date now`; break; - case 'update_no_longer_possible': reason = `Looks like ${leadDependencyName} can no longer be updated`; break; - } - if (reason?.length > 0) { - reason += ', so this is no longer needed.'; - } - return reason; + // The first dependency is the "lead" dependency in a multi-dependency update + const leadDependencyName = data['dependency-names'][0]; + let reason: string = null; + switch (data['reason']) { + case 'dependencies_changed': + reason = `Looks like the dependencies have changed`; + break; + case 'dependency_group_empty': + reason = `Looks like the dependencies in this group are now empty`; + break; + case 'dependency_removed': + reason = `Looks like ${leadDependencyName} is no longer a dependency`; + break; + case 'up_to_date': + reason = `Looks like ${leadDependencyName} is up-to-date now`; + break; + case 'update_no_longer_possible': + reason = `Looks like ${leadDependencyName} can no longer be updated`; + break; + } + if (reason?.length > 0) { + reason += ', so this is no longer needed.'; + } + return reason; } function getPullRequestDependenciesPropertyValueForOutputData(data: any): any { - const dependencyGroupName = data['dependency-group']?.['name']; - let dependencies: any = data['dependencies']?.map((dep) => { - return { - 'dependency-name': dep['name'], - 'dependency-version': dep['version'], - 'directory': dep['directory'], - }; - }); - if (dependencyGroupName) { - dependencies = { - 'dependency-group-name': dependencyGroupName, - 'dependencies': dependencies - }; - } - return dependencies; + const dependencyGroupName = data['dependency-group']?.['name']; + let dependencies: any = data['dependencies']?.map((dep) => { + return { + 'dependency-name': dep['name'], + 'dependency-version': dep['version'], + 'directory': dep['directory'], + }; + }); + if (dependencyGroupName) { + dependencies = { + 'dependency-group-name': dependencyGroupName, + 'dependencies': dependencies, + }; + } + return dependencies; } function getDependencyNames(dependencies: any): string[] { - return (dependencies['dependency-group-name'] ? dependencies['dependencies'] : dependencies)?.map((dep) => dep['dependency-name']?.toString()); + return (dependencies['dependency-group-name'] ? dependencies['dependencies'] : dependencies)?.map((dep) => + dep['dependency-name']?.toString(), + ); } function areEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - return a.every((name) => b.includes(name)); + if (a.length !== b.length) return false; + return a.every((name) => b.includes(name)); } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts index 4f2fbe00..fa8b18e8 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateJobConfig.ts @@ -1,104 +1,101 @@ - /** * Represents the Dependabot CLI update job.yaml configuration file options. */ export interface IDependabotUpdateJobConfig { + // The dependabot "updater" job configuration + // See: https://github.com/dependabot/cli/blob/main/internal/model/job.go + // https://github.com/dependabot/dependabot-core/blob/main/updater/lib/dependabot/job.rb + job: { + 'id': string; + 'package-manager': string; + 'update-subdependencies'?: boolean; + 'updating-a-pull-request'?: boolean; + 'dependency-group-to-refresh'?: string; + 'dependency-groups'?: { + 'name': string; + 'applies-to'?: string; + 'rules': { + 'patterns'?: string[]; + 'exclude-patterns'?: string[]; + 'dependency-type'?: string; + 'update-types'?: string[]; + }; + }[]; + 'dependencies'?: string[]; + 'allowed-updates'?: { + 'dependency-name'?: string; + 'dependency-type'?: string; + 'update-type'?: string; + }[]; + 'ignore-conditions'?: { + 'dependency-name'?: string; + 'source'?: string; + 'update-types'?: string[]; + 'updated-at'?: string; + 'version-requirement'?: string; + }[]; + 'security-updates-only': boolean; + 'security-advisories'?: { + 'dependency-name': string; + 'affected-versions': string[]; + 'patched-versions': string[]; + 'unaffected-versions': string[]; + // TODO: The below configs are not in the dependabot-cli model, but are in the dependabot-core model + 'title'?: string; + 'description'?: string; + 'source-name'?: string; + 'source-url'?: string; + }[]; + 'source': { + 'provider': string; + 'api-endpoint'?: string; + 'hostname': string; + 'repo': string; + 'branch'?: string; + 'commit'?: string; + 'directory'?: string; + 'directories'?: string[]; + }; + 'existing-pull-requests'?: { + 'dependency-name': string; + 'dependency-version': string; + 'directory': string; + }[][]; + 'existing-group-pull-requests'?: { + 'dependency-group-name': string; + 'dependencies': { + 'dependency-name': string; + 'dependency-version': string; + 'directory': string; + }[]; + }[]; + 'commit-message-options'?: { + 'prefix'?: string; + 'prefix-development'?: string; + 'include-scope'?: string; + }; + 'experiments'?: Record; + 'max-updater-run-time'?: number; + 'reject-external-code'?: boolean; + 'repo-private'?: boolean; + 'repo-contents-path'?: string; + 'requirements-update-strategy'?: string; + 'lockfile-only'?: boolean; + 'vendor-dependencies'?: boolean; + 'debug'?: boolean; + }; - // The dependabot "updater" job configuration - // See: https://github.com/dependabot/cli/blob/main/internal/model/job.go - // https://github.com/dependabot/dependabot-core/blob/main/updater/lib/dependabot/job.rb - job: { - 'id': string, - 'package-manager': string, - 'update-subdependencies'?: boolean, - 'updating-a-pull-request'?: boolean, - 'dependency-group-to-refresh'?: string, - 'dependency-groups'?: { - 'name': string, - 'applies-to'?: string, - 'rules': { - 'patterns'?: string[] - 'exclude-patterns'?: string[], - 'dependency-type'?: string - 'update-types'?: string[] - } - }[], - 'dependencies'?: string[], - 'allowed-updates'?: { - 'dependency-name'?: string, - 'dependency-type'?: string, - 'update-type'?: string - }[], - 'ignore-conditions'?: { - 'dependency-name'?: string, - 'source'?: string, - 'update-types'?: string[], - 'updated-at'?: string, - 'version-requirement'?: string, - }[], - 'security-updates-only': boolean, - 'security-advisories'?: { - 'dependency-name': string, - 'affected-versions': string[], - 'patched-versions': string[], - 'unaffected-versions': string[], - // TODO: The below configs are not in the dependabot-cli model, but are in the dependabot-core model - 'title'?: string, - 'description'?: string, - 'source-name'?: string, - 'source-url'?: string - }[], - 'source': { - 'provider': string, - 'api-endpoint'?: string, - 'hostname': string, - 'repo': string, - 'branch'?: string, - 'commit'?: string, - 'directory'?: string, - 'directories'?: string[] - }, - 'existing-pull-requests'?: { - 'dependency-name': string, - 'dependency-version': string, - 'directory': string - }[][], - 'existing-group-pull-requests'?: { - 'dependency-group-name': string, - 'dependencies': { - 'dependency-name': string, - 'dependency-version': string, - 'directory': string - }[] - }[], - 'commit-message-options'?: { - 'prefix'?: string, - 'prefix-development'?: string, - 'include-scope'?: string, - }, - 'experiments'?: Record, - 'max-updater-run-time'?: number, - 'reject-external-code'?: boolean, - 'repo-private'?: boolean, - 'repo-contents-path'?: string, - 'requirements-update-strategy'?: string, - 'lockfile-only'?: boolean, - 'vendor-dependencies'?: boolean, - 'debug'?: boolean, - }, - - // The dependabot "proxy" registry credentials - // See: https://github.com/dependabot/dependabot-core/blob/main/common/lib/dependabot/credential.rb - credentials: { - 'type': string, - 'host'?: string, - 'url'?: string, - 'registry'?: string, - 'region'?: string, - 'username'?: string, - 'password'?: string, - 'token'?: string, - 'replaces-base'?: boolean - }[] - + // The dependabot "proxy" registry credentials + // See: https://github.com/dependabot/dependabot-core/blob/main/common/lib/dependabot/credential.rb + credentials: { + 'type': string; + 'host'?: string; + 'url'?: string; + 'registry'?: string; + 'region'?: string; + 'username'?: string; + 'password'?: string; + 'token'?: string; + 'replaces-base'?: boolean; + }[]; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts index fe8e0eea..8986619d 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperation.ts @@ -1,9 +1,9 @@ -import { IDependabotUpdate } from "../../dependabot/interfaces/IDependabotConfig" -import { IDependabotUpdateJobConfig } from "./IDependabotUpdateJobConfig" +import { IDependabotUpdate } from '../../dependabot/interfaces/IDependabotConfig'; +import { IDependabotUpdateJobConfig } from './IDependabotUpdateJobConfig'; /** * Represents a single Dependabot CLI update operation */ export interface IDependabotUpdateOperation extends IDependabotUpdateJobConfig { - config: IDependabotUpdate + config: IDependabotUpdate; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts index 3cc5afa0..8c565ca1 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOperationResult.ts @@ -1,12 +1,11 @@ - /** * Represents the output of a Dependabot CLI update operation */ export interface IDependabotUpdateOperationResult { - success: boolean, - error: Error, - output: { - type: string, - data: any - } + success: boolean; + error: Error; + output: { + type: string; + data: any; + }; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts index 7d37db9b..fc7da30e 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot-cli/interfaces/IDependabotUpdateOutputProcessor.ts @@ -1,16 +1,14 @@ -import { IDependabotUpdateOperation } from "./IDependabotUpdateOperation"; +import { IDependabotUpdateOperation } from './IDependabotUpdateOperation'; /** * Represents a processor for Dependabot update operation outputs */ export interface IDependabotUpdateOutputProcessor { - - /** - * Process the output of a Dependabot update operation - * @param update The update operation - * @param type The output type (e.g. "create-pull-request", "update-pull-request", etc.) - * @param data The output data object related to the type - */ - process(update: IDependabotUpdateOperation, type: string, data: any): Promise; - + /** + * Process the output of a Dependabot update operation + * @param update The update operation + * @param type The output type (e.g. "create-pull-request", "update-pull-request", etc.) + * @param data The output data object related to the type + */ + process(update: IDependabotUpdateOperation, type: string, data: any): Promise; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts index e7ed0faf..2f530a72 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot/interfaces/IDependabotConfig.ts @@ -1,105 +1,102 @@ - /** * Represents the dependabot.yaml configuration file options. * See: https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#configuration-options-for-dependabotyml */ export interface IDependabotConfig { - /** * Mandatory. configuration file version. **/ - version: number, + 'version': number; /** * Mandatory. Configure how Dependabot updates the versions or project dependencies. * Each entry configures the update settings for a particular package manager. */ - updates: IDependabotUpdate[], + 'updates': IDependabotUpdate[]; /** - * Optional. + * Optional. * Specify authentication details to access private package registries. */ - registries?: Record + 'registries'?: Record; /** * Optional. Enables updates for ecosystems that are not yet generally available. * https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#enable-beta-ecosystems */ - 'enable-beta-ecosystems'?: boolean - + 'enable-beta-ecosystems'?: boolean; } export interface IDependabotUpdate { - 'package-ecosystem': string, - 'directory': string, - 'directories': string[], - 'allow'?: IDependabotAllowCondition[], - 'assignees'?: string[], - 'commit-message'?: IDependabotCommitMessage, - 'groups'?: Record, - 'ignore'?: IDependabotIgnoreCondition[], - 'insecure-external-code-execution'?: string, - 'labels': string[], - 'milestone'?: string, - 'open-pull-requests-limit'?: number, - 'pull-request-branch-name'?: IDependabotPullRequestBranchName, - 'rebase-strategy'?: string, - 'registries'?: string[], - 'reviewers'?: string[], - 'schedule'?: IDependabotSchedule, - 'target-branch'?: string, - 'vendor'?: boolean, - 'versioning-strategy'?: string + 'package-ecosystem': string; + 'directory': string; + 'directories': string[]; + 'allow'?: IDependabotAllowCondition[]; + 'assignees'?: string[]; + 'commit-message'?: IDependabotCommitMessage; + 'groups'?: Record; + 'ignore'?: IDependabotIgnoreCondition[]; + 'insecure-external-code-execution'?: string; + 'labels': string[]; + 'milestone'?: string; + 'open-pull-requests-limit'?: number; + 'pull-request-branch-name'?: IDependabotPullRequestBranchName; + 'rebase-strategy'?: string; + 'registries'?: string[]; + 'reviewers'?: string[]; + 'schedule'?: IDependabotSchedule; + 'target-branch'?: string; + 'vendor'?: boolean; + 'versioning-strategy'?: string; } export interface IDependabotRegistry { - 'type': string, - 'url'?: string, - 'username'?: string, - 'password'?: string, - 'key'?: string, - 'token'?: string, - 'replaces-base'?: boolean, - 'host'?: string, // for terraform and composer only - 'registry'?: string, // for npm only - 'organization'?: string, // for hex-organisation only - 'repo'?: string, // for hex-repository only - 'public-key-fingerprint'?: string, // for hex-repository only + 'type': string; + 'url'?: string; + 'username'?: string; + 'password'?: string; + 'key'?: string; + 'token'?: string; + 'replaces-base'?: boolean; + 'host'?: string; // for terraform and composer only + 'registry'?: string; // for npm only + 'organization'?: string; // for hex-organisation only + 'repo'?: string; // for hex-repository only + 'public-key-fingerprint'?: string; // for hex-repository only } export interface IDependabotGroup { - 'applies-to'?: string, - 'dependency-type'?: string, - 'patterns'?: string[], - 'exclude-patterns'?: string[], - 'update-types'?: string[] + 'applies-to'?: string; + 'dependency-type'?: string; + 'patterns'?: string[]; + 'exclude-patterns'?: string[]; + 'update-types'?: string[]; } export interface IDependabotAllowCondition { - 'dependency-name'?: string, - 'dependency-type'?: string + 'dependency-name'?: string; + 'dependency-type'?: string; } export interface IDependabotIgnoreCondition { - 'dependency-name'?: string, - 'versions'?: string[], - 'update-types'?: string[], + 'dependency-name'?: string; + 'versions'?: string[]; + 'update-types'?: string[]; } export interface IDependabotSchedule { - 'interval'?: string, - 'day'?: string, - 'time'?: string, - 'timezone'?: string, + interval?: string; + day?: string; + time?: string; + timezone?: string; } export interface IDependabotCommitMessage { - 'prefix'?: string, - 'prefix-development'?: string, - 'include'?: string + 'prefix'?: string; + 'prefix-development'?: string; + 'include'?: string; } export interface IDependabotPullRequestBranchName { - 'separator'?: string + separator?: string; } diff --git a/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts b/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts index 8380e7f4..b9ff1517 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/dependabot/parseConfigFile.ts @@ -5,9 +5,9 @@ import * as fs from 'fs'; import { load } from 'js-yaml'; import * as path from 'path'; import { URL } from 'url'; -import { IDependabotConfig, IDependabotRegistry, IDependabotUpdate } from './interfaces/IDependabotConfig'; import { convertPlaceholder } from '../convertPlaceholder'; import { ISharedVariables } from '../getSharedVariables'; +import { IDependabotConfig, IDependabotRegistry, IDependabotUpdate } from './interfaces/IDependabotConfig'; /** * Parse the dependabot config YAML file to specify update configuration. @@ -302,4 +302,3 @@ const KnownRegistryTypes = [ 'rubygems-server', 'terraform-registry', ]; - diff --git a/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts b/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts index ee5010b1..3d33900d 100644 --- a/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts +++ b/extension/tasks/dependabot/dependabotV2/utils/getSharedVariables.ts @@ -40,7 +40,7 @@ export interface ISharedVariables { authorName?: string; storeDependencyList: boolean; - + /** Determines if the pull requests that dependabot creates should have auto complete set */ setAutoComplete: boolean; /** Merge strategies which can be used to complete a pull request */ @@ -78,7 +78,7 @@ export interface ISharedVariables { */ export default function getSharedVariables(): ISharedVariables { let organizationUrl = tl.getVariable('System.TeamFoundationCollectionUri'); - + //convert url string into a valid JS URL object let formattedOrganizationUrl = new URL(organizationUrl); let protocol: string = formattedOrganizationUrl.protocol.slice(0, -1); @@ -118,14 +118,17 @@ export default function getSharedVariables(): ISharedVariables { let autoApproveUserToken: string = tl.getInput('autoApproveUserToken'); // Convert experiments from comma separated key value pairs to a record - let experiments = tl.getInput('experiments', false)?.split(',')?.reduce( - (acc, cur) => { - let [key, value] = cur.split('=', 2); - acc[key] = value || true; - return acc; - }, - {} as Record - ); + let experiments = tl + .getInput('experiments', false) + ?.split(',') + ?.reduce( + (acc, cur) => { + let [key, value] = cur.split('=', 2); + acc[key] = value || true; + return acc; + }, + {} as Record, + ); let debug: boolean = tl.getVariable('System.Debug')?.match(/true/i) ? true : false; @@ -157,7 +160,7 @@ export default function getSharedVariables(): ISharedVariables { authorEmail, authorName, - + storeDependencyList, setAutoComplete,