Skip to content

Commit

Permalink
New command **hardis:git:pull-requests:extract**: Extract Pull Reques…
Browse files Browse the repository at this point in the history
…ts from Git Server into CSV/XLS (#892)

* New command **hardis:git:pull-requests:extract**: Extract Pull Requests from Git Server into CSV/XLS

Azure only for now

* Evols

* info

* linter fixes
  • Loading branch information
nvuillam authored Nov 22, 2024
1 parent 5bb4538 commit b7b27ba
Show file tree
Hide file tree
Showing 6 changed files with 378 additions and 12 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
149 changes: 149 additions & 0 deletions src/commands/hardis/git/pull-requests/extract.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<AnyJson> {
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;
}
}
}
}
178 changes: 176 additions & 2 deletions src/common/gitProvider/azureDevops.ts
Original file line number Diff line number Diff line change
@@ -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<typeof azdev.WebApi>;
Expand All @@ -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";
}
Expand Down Expand Up @@ -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<GitPullRequest[] | any[]> {
// 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(`<!-- sfdx-hardis deployment-id `)) {
const ticketsSplit = comment.content.split("## Tickets");
if (ticketsSplit.length === 2) {
tickets = ticketsSplit[1].split("## Commits summary")[0].trim();
}
break;
}
if (tickets !== "") {
break;
}
}
}
prFormatted.pullRequestId = pr.pullRequestId;
prFormatted.targetRefName = (pr.targetRefName || "").replace("refs/heads/", "");
prFormatted.sourceRefName = (pr.sourceRefName || "").replace("refs/heads/", "");
prFormatted.status = PullRequestStatus[pr.status || 0]
prFormatted.mergeStatus = PullRequestAsyncStatus[pr.mergeStatus || 0];
prFormatted.title = pr.title;
prFormatted.description = pr.description;
prFormatted.tickets = tickets;
prFormatted.closedBy = pr.closedBy?.uniqueName || pr.closedBy?.displayName;
prFormatted.closedDate = pr.closedDate;
prFormatted.createdBy = pr.createdBy?.uniqueName || pr.createdBy?.displayName;
prFormatted.creationDate = pr.creationDate;
prFormatted.reviewers = (pr.reviewers || []).map(reviewer => reviewer.uniqueName || reviewer.displayName).join(",");
return prFormatted;
});
return pullRequestsFormatted;
}
return pullRequestsWithComments;
}

public async getBranchDeploymentCheckId(gitBranch: string): Promise<string | null> {
let deploymentCheckId = null;
// Get Azure Git API
Expand Down Expand Up @@ -329,4 +464,43 @@ _Powered by [sfdx-hardis](${CONSTANTS.DOC_URL_ROOT}) from job [${azureJobName}](
}
return prResult;
}

public static parseAzureRepoUrl(remoteUrl: string): {
collectionUri: string;
teamProject: string;
repositoryId: string;
} | null {
let collectionUri: string;
let repositoryId: string;
let teamProject: string;

if (remoteUrl.startsWith("https://")) {
// Handle HTTPS URLs with or without username
const httpsRegex = /https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/;
const match = remoteUrl.match(httpsRegex);
if (match) {
const organization = match[1];
teamProject = decodeURIComponent(match[2]); // Decode URL-encoded project name
repositoryId = decodeURIComponent(match[3]); // Decode URL-encoded repository name
collectionUri = `https://dev.azure.com/${organization}/`;
return { collectionUri, teamProject, repositoryId };
}
} else if (remoteUrl.startsWith("git@")) {
/* jscpd:ignore-start */
// Handle SSH URLs
const sshRegex = /git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)/;
const match = remoteUrl.match(sshRegex);
if (match) {
const organization = match[1];
teamProject = decodeURIComponent(match[2]); // Decode URL-encoded project name
repositoryId = decodeURIComponent(match[3]); // Decode URL-encoded repository name
collectionUri = `https://dev.azure.com/${organization}/`;
return { collectionUri, teamProject, repositoryId };
}
/* jscpd:ignore-end */
}

// Return null if the URL doesn't match expected patterns
return null;
}
}
13 changes: 13 additions & 0 deletions src/common/gitProvider/gitProviderRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ export abstract class GitProviderRoot {
return null;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async listPullRequests(filters: {
status?: string,
targetBranch?: string,
minDate?: Date
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} = {}, options: {
formatted?: boolean
} = { formatted: false }): Promise<any> {
uxLog(this, `Method listPullRequests is not implemented yet on ${this.getLabel()}`);
return null;
}

public async postPullRequestMessage(prMessage: PullRequestMessageRequest): Promise<PullRequestMessageResult> {
uxLog(this, c.yellow("Method postPullRequestMessage is not yet implemented on " + this.getLabel() + " to post " + JSON.stringify(prMessage)));
return { posted: false, providerResult: { error: "Not implemented in sfdx-hardis" } };
Expand Down
Loading

0 comments on commit b7b27ba

Please sign in to comment.