pr-sizewise
Version:
A CLI tool that measures and reports pull request sizes for GitHub and GitLab, helping teams maintain manageable code changes.
253 lines • 12.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UniversalSizeWiseAnalyzer = void 0;
const path_1 = __importDefault(require("path"));
const providers_1 = require("./providers");
const diff_parser_1 = require("./utils/diff-parser");
const logger_1 = require("./utils/logger");
const errors_1 = require("./utils/errors");
/**
* Platform-agnostic analyzer class that processes pull/merge requests and determines their size.
*/
class UniversalSizeWiseAnalyzer {
constructor(config, providerConfig) {
this.provider = (0, providers_1.createProvider)(providerConfig);
this.config = config;
this.logger = (0, logger_1.createLogger)(config);
}
/**
* Initialize the analyzer with provider configuration
*/
async initialize(providerConfig) {
await this.provider.initialize(providerConfig);
}
/**
* Create analyzer with auto-detected platform
*/
static async createWithAutoDetect(config, overrides = {}) {
const platform = overrides.platform ?? (0, providers_1.detectPlatform)();
if (!platform) {
throw new errors_1.PlatformError('Could not auto-detect platform. Please specify platform explicitly.');
}
const providerConfig = {
platform,
token: overrides.token ?? process.env.GITLAB_TOKEN ?? process.env.GITHUB_TOKEN ?? '',
host: overrides.host ?? process.env.GITLAB_HOST ?? process.env.GITHUB_SERVER_URL ?? '',
projectId: overrides.projectId ?? process.env.CI_PROJECT_ID ?? process.env.GITHUB_REPOSITORY ?? '',
...overrides,
};
if (!providerConfig.token) {
throw new errors_1.AuthError(`${platform.toUpperCase()}_TOKEN environment variable is required`);
}
if (!providerConfig.host) {
throw new errors_1.ValidationError(`${platform.toUpperCase()}_HOST environment variable is required`);
}
if (!providerConfig.projectId) {
throw new errors_1.ValidationError('Project ID is required');
}
const analyzer = new UniversalSizeWiseAnalyzer(config, providerConfig);
await analyzer.initialize(providerConfig);
return analyzer;
}
/**
* Determines the size category of a pull request based on its metrics.
*/
determineSize(metrics) {
const { thresholds } = this.config;
// Get all threshold entries and sort them by their values (ascending - smallest to largest)
const sortedThresholds = Object.entries(thresholds).sort(([, a], [, b]) => {
// Sort by the maximum of files, lines, or directories to get overall "size"
const aMax = Math.max(a.files, a.lines / 10, a.directories * 5); // Weighted comparison
const bMax = Math.max(b.files, b.lines / 10, b.directories * 5);
return aMax - bMax;
});
// Check each threshold from smallest to largest
let resultSize = sortedThresholds[0][0]; // Start with the smallest threshold
for (const [sizeName, threshold] of sortedThresholds) {
resultSize = sizeName; // Update to current threshold as we iterate
// A pull request fits this size category only if ALL conditions are met
if (metrics.filesChanged <= threshold.files &&
metrics.totalLines <= threshold.lines &&
metrics.directoriesAffected <= threshold.directories) {
return sizeName;
}
}
return resultSize;
}
/**
* Retrieves and processes changes from a pull/merge request.
*/
async getPullRequestChanges(prId) {
try {
const diffs = await this.provider.getDiffs(prId);
const metrics = {
filesChanged: diffs.length,
linesAdded: 0,
linesRemoved: 0,
totalLines: 0,
directoriesAffected: new Set(diffs.map((diff) => path_1.default.dirname(diff.newPath))).size,
renamedFiles: diffs.filter((diff) => diff.isRenamedFile).length,
newFiles: diffs.filter((diff) => diff.isNewFile).length,
deletedFiles: diffs.filter((diff) => diff.isDeletedFile).length,
};
// Process each diff to count lines added/removed
for (const diff of diffs) {
if (diff.diff) {
// Skip excluded files
const isExcluded = this.config.excludePatterns.some((pattern) => {
const regex = (0, diff_parser_1.globToRegex)(pattern);
return regex.test(diff.newPath) || regex.test(diff.oldPath);
});
if (isExcluded) {
continue;
}
const { additions, deletions } = (0, diff_parser_1.parseDiff)(diff.diff);
metrics.linesAdded += additions;
metrics.linesRemoved += deletions;
}
}
metrics.totalLines = metrics.linesAdded + metrics.linesRemoved;
return metrics;
}
catch (error) {
throw new errors_1.PlatformError(`Error getting pull request changes: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Analyzes a pull/merge request and returns detailed metrics and size classification.
*/
async analyzePullRequest(prId) {
this.logger.info(`🔍 Analyzing PR/MR #${prId}...`);
const metrics = await this.getPullRequestChanges(prId);
const size = this.determineSize(metrics);
const details = this.generateDetails(metrics);
this.logger.info(`📊 PR/MR Analysis: ${metrics.filesChanged} files, ${metrics.totalLines} lines changed → Size: ${size.toUpperCase()}`);
// Handle PR/MR commenting if enabled
await this.handlePullRequestComment(prId, size);
// Handle PR/MR labeling if enabled
await this.handlePullRequestLabels(prId, size);
return {
metrics,
size,
details,
thresholds: this.config.thresholds,
};
}
/**
* Legacy method name for backward compatibility
*/
async analyzeMergeRequest(mrId) {
return this.analyzePullRequest(mrId);
}
/**
* Handles adding or updating PR/MR comments based on configuration
*/
async handlePullRequestComment(prId, size) {
if (!this.config.comment?.enabled) {
return;
}
try {
this.logger.info('📝 Checking for existing sizewise comments...');
// Get existing comments to find sizewise comment
const comments = await this.provider.getComments(prId);
const COMMENT_MARKER = '<!-- sizewise-comment -->';
const sizeWiseComment = comments.find((comment) => comment.body?.includes(COMMENT_MARKER));
if (sizeWiseComment) {
this.logger.info(`📝 Found existing sizewise comment: ${sizeWiseComment.id}`);
}
const updateExisting = this.config.comment.updateExisting ?? true;
// Always include the marker, regardless of custom template (for tracking)
const userTemplate = this.config.comment.template ?? '🔍 **Pull Request Size:** {size}';
const commentBody = `${COMMENT_MARKER}\n${userTemplate.replace(/\{size\}/g, size)}`;
this.logger.info(`📝 Update existing: ${updateExisting}`);
this.logger.info(`📝 Comment content: "${userTemplate.replace(/\{size\}/g, size)}"`);
// Decide action based on updateExisting setting
if (updateExisting && sizeWiseComment) {
// Update existing comment
this.logger.info(`📝 Updating existing comment ID: ${sizeWiseComment.id}`);
try {
await this.provider.updateComment(prId, sizeWiseComment.id, commentBody);
this.logger.info(`✅ Updated existing comment (ID: ${sizeWiseComment.id}) with size: ${size}`);
}
catch {
// Try without HTML marker as fallback
this.logger.info('📝 Edit failed, trying without HTML marker...');
const fallbackBody = userTemplate.replace(/\{size\}/g, size);
await this.provider.updateComment(prId, sizeWiseComment.id, fallbackBody);
this.logger.info(`✅ Updated existing comment (ID: ${sizeWiseComment.id}) with fallback content`);
}
}
else {
// Create new comment
if (!updateExisting && sizeWiseComment) {
this.logger.info('📝 Creating new comment (updateExisting=false, ignoring existing)');
}
else {
this.logger.info('📝 Creating new comment (no existing comment found)');
}
await this.provider.createComment(prId, commentBody);
this.logger.info(`✅ Created new comment with size: ${size}`);
}
}
catch (error) {
this.logger.logError('Failed to handle PR/MR comment', error);
}
}
/**
* Handles adding or updating PR/MR labels based on configuration
*/
async handlePullRequestLabels(prId, size) {
if (!this.config.label?.enabled) {
return;
}
try {
this.logger.info('🏷️ Checking current PR/MR labels...');
const currentLabels = await this.provider.getLabels(prId);
this.logger.info(`🏷️ Current labels: [${currentLabels.length > 0 ? currentLabels.join(', ') : 'none'}]`);
const labelPrefix = this.config.label.prefix ?? 'size:';
const newSizeLabel = `${labelPrefix}${size}`;
// Check if correct label already exists
if (currentLabels.includes(newSizeLabel)) {
this.logger.info(`🏷️ Label "${newSizeLabel}" already exists - no changes needed`);
return;
}
// Find and remove existing sizewise labels (using the prefix)
const sizewiseLabels = currentLabels.filter((label) => label.startsWith(labelPrefix));
const filteredLabels = currentLabels.filter((label) => !label.startsWith(labelPrefix));
// Show what's being changed
if (sizewiseLabels.length > 0) {
this.logger.info(`🏷️ Removing existing size labels: [${sizewiseLabels.join(', ')}]`);
}
const updatedLabels = [...filteredLabels, newSizeLabel];
this.logger.info(`🏷️ Setting new labels: [${updatedLabels.join(', ')}]`);
await this.provider.setLabels(prId, updatedLabels);
// Log what actually changed
if (sizewiseLabels.length > 0) {
this.logger.info(`✅ Replaced label "${sizewiseLabels[0]}" with "${newSizeLabel}"`);
}
else {
this.logger.info(`✅ Added label "${newSizeLabel}"`);
}
}
catch (error) {
this.logger.logError('Failed to update PR/MR labels', error);
}
}
generateDetails(metrics) {
return [
`Files changed: ${metrics.filesChanged}`,
`Lines added: ${metrics.linesAdded}`,
`Lines removed: ${metrics.linesRemoved}`,
`Total lines changed: ${metrics.totalLines}`,
`Directories affected: ${metrics.directoriesAffected}`,
`Renamed files: ${metrics.renamedFiles}`,
`New files: ${metrics.newFiles}`,
`Deleted files: ${metrics.deletedFiles}`,
];
}
}
exports.UniversalSizeWiseAnalyzer = UniversalSizeWiseAnalyzer;
//# sourceMappingURL=analyzer.js.map