diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b8041ec..8cb7a5daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Note: Can be used with `sfdx plugins:install sfdx-hardis@beta` and docker image `hardisgroupcom/sfdx-hardis@beta` +- New command **hardis:git:pull-requests:extract**: Extract Pull Requests from Git Server into CSV/XLS (Azure only for now) - Make markdown-links-check not blocking by default in MegaLinter base config - Make yamllint not blocking by default in MegaLinter base config diff --git a/src/commands/hardis/git/pull-requests/extract.ts b/src/commands/hardis/git/pull-requests/extract.ts new file mode 100644 index 000000000..e647602f2 --- /dev/null +++ b/src/commands/hardis/git/pull-requests/extract.ts @@ -0,0 +1,149 @@ +/* jscpd:ignore-start */ +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; +import { AnyJson } from '@salesforce/ts-types'; +import c from 'chalk'; +import { isCI, selectGitBranch, uxLog } from '../../../../common/utils/index.js'; +import { generateCsvFile, generateReportPath } from '../../../../common/utils/filesUtils.js'; +import { GitProvider } from '../../../../common/gitProvider/index.js'; +import moment from 'moment'; +import { prompts } from '../../../../common/utils/prompts.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('sfdx-hardis', 'org'); + +export default class GitPullRequestsExtract extends SfCommand { + public static title = 'Extract pull requests'; + + public static description = `Extract pull requests with filtering criteria`; + + public static examples = [ + '$ sf hardis:git:pull-requests:extract', + '$ sf hardis:git:pull-requests:extract --target-branch main --status merged', + ]; + + public static flags: any = { + "target-branch": Flags.string({ + char: 't', + description: 'Target branch of PRs', + }), + "status": Flags.string({ + char: 'x', + options: [ + "open", + "merged", + "abandoned" + ], + description: 'Status of the PR', + }), + "min-date": Flags.string({ + char: 'm', + description: 'Minimum date for PR', + }), + outputfile: Flags.string({ + char: 'f', + description: 'Force the path and name of output report file. Must end with .csv', + }), + debug: Flags.boolean({ + char: 'd', + default: false, + description: messages.getMessage('debugMode'), + }), + websocket: Flags.string({ + description: messages.getMessage('websocket'), + }), + skipauth: Flags.boolean({ + description: 'Skip authentication check when a default username is required', + }) + }; + + // Set this to true if your command requires a project workspace; 'requiresProject' is false by default + public static requiresProject = true; + + protected outputFile; + protected outputFilesRes: any = {}; + protected pullRequests: any[]; + protected targetBranch: string | null = null; + protected minDateStr: Date | null = null; + protected minDate: Date | null = null; + protected prStatus: string | null = null; + protected debugMode = false; + + /* jscpd:ignore-end */ + + public async run(): Promise { + const { flags } = await this.parse(GitPullRequestsExtract); + this.targetBranch = flags["target-branch"] || null; + this.minDateStr = flags["min-date"] || null; + this.prStatus = flags["status"] || null; + this.outputFile = flags.outputfile || null; + this.debugMode = flags.debug || false; + if (this.minDateStr) { + this.minDate = moment(this.minDateStr).toDate() + } + + // Startup + uxLog(this, c.cyan(`This command will extract pull request from Git Server`)); + + const gitProvider = await GitProvider.getInstance(true); + if (gitProvider == null) { + throw new SfError("Unable to identify a GitProvider") + } + + // Prompt branch & PR status if not sent + await this.handleUserInput(); + + // Build constraint + const prConstraint: any = {}; + if (this.targetBranch) { + prConstraint.targetBranch = this.targetBranch; + } + if (this.minDate) { + prConstraint.minDate = this.minDate; + } + if (this.prStatus) { + prConstraint.pullRequestStatus = this.prStatus; + } + + // Process call to git provider API + this.pullRequests = await gitProvider.listPullRequests(prConstraint, { formatted: true }); + + this.outputFile = await generateReportPath('pull-requests', this.outputFile); + this.outputFilesRes = await generateCsvFile(this.pullRequests, this.outputFile); + + return { + outputString: `Extracted ${this.pullRequests.length} Pull Requests`, + pullRequests: this.pullRequests, + }; + } + + private async handleUserInput() { + if (!isCI && !this.targetBranch) { + const gitBranch = await selectGitBranch({ + remote: true, + checkOutPull: false, + allowAll: true, + message: "Please select the target branch of PUll Requests" + }); + if (gitBranch && gitBranch !== "ALL BRANCHES") { + this.targetBranch = gitBranch; + } + } + + if (!isCI && !this.prStatus) { + const statusRes = await prompts({ + message: "Please select a status criteria, or all", + type: "select", + choices: [ + { title: "All status", value: "all" }, + { title: "Merged", value: "merged" }, + { title: "Open", value: "open" }, + { title: "Abandoned", value: "abandoned" } + ] + }); + if (statusRes && statusRes.value !== "all") { + this.prStatus = statusRes.value; + } + } + } +} diff --git a/src/common/gitProvider/azureDevops.ts b/src/common/gitProvider/azureDevops.ts index 904d18a4f..dbff4a5ac 100644 --- a/src/common/gitProvider/azureDevops.ts +++ b/src/common/gitProvider/azureDevops.ts @@ -1,10 +1,12 @@ import { GitProviderRoot } from "./gitProviderRoot.js"; import * as azdev from "azure-devops-node-api"; import c from "chalk"; -import { getCurrentGitBranch, git, uxLog } from "../utils/index.js"; +import { getCurrentGitBranch, getGitRepoUrl, git, isGitRepo, uxLog } from "../utils/index.js"; import { PullRequestMessageRequest, PullRequestMessageResult } from "./index.js"; -import { CommentThreadStatus, GitPullRequestCommentThread, PullRequestAsyncStatus, PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import { CommentThreadStatus, GitPullRequest, GitPullRequestCommentThread, GitPullRequestSearchCriteria, PullRequestAsyncStatus, PullRequestStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; import { CONSTANTS } from "../../config/index.js"; +import { SfError } from "@salesforce/core"; +import { prompts } from "../utils/prompts.js"; export class AzureDevopsProvider extends GitProviderRoot { private azureApi: InstanceType; @@ -21,6 +23,38 @@ export class AzureDevopsProvider extends GitProviderRoot { this.azureApi = new azdev.WebApi(this.serverUrl, authHandler); } + public static async handleLocalIdentification() { + if (!isGitRepo()) { + uxLog(this, c.yellow("[Azure Integration] You must be in a git repository context")); + return; + } + if (!process.env.SYSTEM_COLLECTIONURI) { + const repoUrl = await getGitRepoUrl() || ""; + if (!repoUrl) { + uxLog(this, c.yellow("[Azure Integration] An git origin must be set")); + return; + } + const parseUrlRes = this.parseAzureRepoUrl(repoUrl); + if (!parseUrlRes) { + uxLog(this, c.yellow(`[Azure Integration] Unable to parse ${repoUrl} to get SYSTEM_COLLECTIONURI and BUILD_REPOSITORY_ID`)); + return; + } + process.env.SYSTEM_COLLECTIONURI = parseUrlRes.collectionUri; + process.env.SYSTEM_TEAMPROJECT = parseUrlRes.teamProject; + process.env.BUILD_REPOSITORY_ID = parseUrlRes.repositoryId; + } + if (!process.env.SYSTEM_ACCESSTOKEN) { + uxLog(this, c.yellow("If you need an Azure Personal Access Token, create one following this documentation: https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows")); + uxLog(this, c.yellow("Then please save it in a secured password tracker !")); + const accessTokenResp = await prompts({ + name: "token", + message: "Please input an Azure Personal Access Token (it won't be stored)", + type: "text" + }); + process.env.SYSTEM_ACCESSTOKEN = accessTokenResp.token; + } + } + public getLabel(): string { return "sfdx-hardis Azure Devops connector"; } @@ -113,6 +147,107 @@ ${this.getPipelineVariablesConfig()} return null; } + public async listPullRequests(filters: { + pullRequestStatus?: "open" | "merged" | "abandoned", + targetBranch?: string, + minDate?: Date + } = {}, + options: { + formatted?: boolean + } = { formatted: false } + ): Promise { + // Get Azure Git API + const azureGitApi = await this.azureApi.getGitApi(); + const repositoryId = process.env.BUILD_REPOSITORY_ID || null; + if (repositoryId == null) { + uxLog(this, c.yellow("[Azure Integration] Unable to find BUILD_REPOSITORY_ID")); + return []; + } + const teamProject = process.env.SYSTEM_TEAMPROJECT || null; + if (teamProject == null) { + uxLog(this, c.yellow("[Azure Integration] Unable to find SYSTEM_TEAMPROJECT")); + return []; + } + // Build search criteria + const queryConstraint: GitPullRequestSearchCriteria = {}; + if (filters.pullRequestStatus) { + const azurePrStatusValue = + filters.pullRequestStatus === "open" ? PullRequestStatus.Active : + filters.pullRequestStatus === "abandoned" ? PullRequestStatus.Abandoned : + filters.pullRequestStatus === "merged" ? PullRequestStatus.Completed : + null; + if (azurePrStatusValue == null) { + throw new SfError(`[Azure Integration] No matching status for ${filters.pullRequestStatus} in ${JSON.stringify(PullRequestStatus)}`); + } + queryConstraint.status = azurePrStatusValue + } + else { + queryConstraint.status = PullRequestStatus.All + } + if (filters.targetBranch) { + queryConstraint.targetRefName = `refs/heads/${filters.targetBranch}` + } + if (filters.minDate) { + queryConstraint.minTime = filters.minDate + } + // Process request + uxLog(this, c.cyan("Calling Azure API to list Pull Requests...")); + uxLog(this, c.grey(`Constraint:\n${JSON.stringify(queryConstraint, null, 2)}`)); + + // List pull requests + const pullRequests = await azureGitApi.getPullRequests(repositoryId, queryConstraint, teamProject); + + // Complete results with PR comments + const pullRequestsWithComments: any[] = []; + for (const pullRequest of pullRequests) { + const pr: any = Object.assign({}, pullRequest); + uxLog(this, c.grey(`Getting threads for PR ${pullRequest.pullRequestId}...`)); + const existingThreads = await azureGitApi.getThreads(pullRequest.repository?.id || "", pullRequest.pullRequestId || 0, teamProject); + pr.threads = existingThreads.filter(thread => !thread.isDeleted); + pullRequestsWithComments.push(pr); + } + + // Format if requested + if (options.formatted) { + uxLog(this, c.cyan(`Formatting ${pullRequestsWithComments.length} results...`)); + const pullRequestsFormatted = pullRequestsWithComments.map(pr => { + const prFormatted: any = {}; + let tickets = ""; + // Find sfdx-hardis deployment simulation status comment and extract tickets part + for (const thread of pr.threads) { + for (const comment of thread?.comments || []) { + if ((comment?.content || "").includes(`