diff --git a/lib/modules/platform/default-scm.ts b/lib/modules/platform/default-scm.ts index edf27c2e391566..aa50e2fddfb469 100644 --- a/lib/modules/platform/default-scm.ts +++ b/lib/modules/platform/default-scm.ts @@ -1,6 +1,6 @@ import * as git from '../../util/git'; import type { CommitFilesConfig, LongCommitSha } from '../../util/git/types'; -import type { PlatformScm } from './types'; +import type { PlatformScm, ScmStats } from './types'; export class DefaultGitScm implements PlatformScm { branchExists(branchName: string): Promise { @@ -48,4 +48,8 @@ export class DefaultGitScm implements PlatformScm { mergeToLocal(branchName: string): Promise { return git.mergeToLocal(branchName); } + + getStats(): Promise { + return git.getStats(); + } } diff --git a/lib/modules/platform/local/scm.ts b/lib/modules/platform/local/scm.ts index 30c51d01d1f626..b56e006ab08731 100644 --- a/lib/modules/platform/local/scm.ts +++ b/lib/modules/platform/local/scm.ts @@ -2,7 +2,7 @@ import { execSync } from 'node:child_process'; import { glob } from 'glob'; import { logger } from '../../../logger'; import type { CommitFilesConfig, LongCommitSha } from '../../../util/git/types'; -import type { PlatformScm } from '../types'; +import type { PlatformScm, ScmStats } from '../types'; let fileList: string[] | undefined; export class LocalFs implements PlatformScm { @@ -59,4 +59,8 @@ export class LocalFs implements PlatformScm { mergeToLocal(branchName: string): Promise { return Promise.resolve(); } + + getStats(): Promise { + return Promise.resolve(null); + } } diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts index 752d98d779b274..1dff8ebaba300d 100644 --- a/lib/modules/platform/types.ts +++ b/lib/modules/platform/types.ts @@ -282,6 +282,14 @@ export interface Platform { maxBodyLength(): number; } +export interface ScmStats { + defaultBranchSha: string; + last90Days: { + committerHashList: string[]; + renovateCommitCount: number; + }; +} + export interface PlatformScm { isBranchBehindBase(branchName: string, baseBranch: string): Promise; isBranchModified(branchName: string, baseBranch: string): Promise; @@ -294,4 +302,5 @@ export interface PlatformScm { checkoutBranch(branchName: string): Promise; mergeToLocal(branchName: string): Promise; mergeAndPush(branchName: string): Promise; + getStats(): Promise; } diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index 3341418e23064e..f29d10998908e1 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -4,6 +4,7 @@ import type { UpdateType, } from '../../../config/types'; import type { PackageFile } from '../../../modules/manager/types'; +import type { ScmStats } from '../../../modules/platform'; import type { RepoInitConfig } from '../../../workers/repository/init/types'; import type { PrBlockedBy } from '../../../workers/types'; @@ -42,6 +43,21 @@ export interface OnboardingBranchCache { configFileParsed?: string; } +export interface RepoStats { + scm: ScmStats; + /* + renovatePrs?: { + counts: { + open: number; + closed: number; + merged: number; + }; + lastMerged?: string; + }; + stargazerCount?: number; + */ +} + export interface ReconfigureBranchCache { reconfigureBranchSha: string; isConfigValid: boolean; @@ -149,6 +165,7 @@ export interface RepoCacheData { prComments?: Record>; onboardingBranchCache?: OnboardingBranchCache; reconfigureBranchCache?: ReconfigureBranchCache; + repoStats?: RepoStats; } export interface RepoCache { diff --git a/lib/util/git/index.ts b/lib/util/git/index.ts index 4b6a5bf453124a..620a7cfd5d892f 100644 --- a/lib/util/git/index.ts +++ b/lib/util/git/index.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import URL from 'node:url'; import { setTimeout } from 'timers/promises'; import is from '@sindresorhus/is'; @@ -19,6 +20,7 @@ import { TEMPORARY_ERROR, } from '../../constants/error-messages'; import { logger } from '../../logger'; +import type { ScmStats } from '../../modules/platform/types'; import { ExternalHostError } from '../../types/errors/external-host-error'; import type { GitProtocol } from '../../types/git'; import { incLimitedValue } from '../../workers/global/limits'; @@ -1370,3 +1372,80 @@ export async function listCommitTree( } return result; } + +function parseGitShortlog(inputText: string): Map { + const emailMap = new Map(); + + if (!inputText.trim().length) { + return emailMap; + } + + // Split the input text into individual lines + const lines = inputText.trim().split('\n'); + + // Iterate over each line using "for...of" + for (const line of lines) { + // Match the pattern " " or " " + const match = line.match(/^\s*(\d+)\s+.*?<(.+?)>|^\s*(\d+)\s+(.+@.+)$/); + + if (match) { + // If the line matches, extract the count and the email + const count = parseInt(match[1] || match[3], 10); + const email = match[2] || match[4]; + + // Add or update the email's commit count in the map + if (emailMap.has(email)) { + emailMap.set(email, emailMap.get(email) + count); + } else { + emailMap.set(email, count); + } + } else { + logger.once.warn('Failed to parse shortlog line'); + } + } + + return emailMap; +} + +export async function getStats(): Promise { + if (!config.defaultBranch) { + logger.warn('No default branch found'); + return null; + } + const defaultBranchSha = getBranchCommit(config.defaultBranch); + if (!defaultBranchSha) { + logger.warn('No default branch sha found'); + return null; + } + await syncGit(); + await resetToBranch(config.defaultBranch); + logger.debug('Checking git committers in last 90 days'); + const rawCommitters = await git.raw([ + 'shortlog', + '-sne', + '--since="last 90 days"', + 'HEAD', + ]); + logger.trace({ rawCommitters }, 'rawCommitters'); + const emailMap = parseGitShortlog(rawCommitters); + // Find Renovate's email address and commit count + let renovateCommitCount = 0; + if (config.gitAuthorEmail) { + renovateCommitCount = emailMap.get(config.gitAuthorEmail) ?? 0; + } + // Convert each email to a base64-encoded SHA256 hash and produce a sorted list, + // excluding Renovate's email address + const committerHashList = Array.from(emailMap.keys()) + .filter((email) => email !== config.gitAuthorEmail) + .map((email) => crypto.createHash('sha256').update(email).digest('base64')) + .sort(); + + const scmStats: ScmStats = { + defaultBranchSha, + last90Days: { + committerHashList, + renovateCommitCount, + }, + }; + return scmStats; +} diff --git a/lib/workers/repository/finalize/index.ts b/lib/workers/repository/finalize/index.ts index e530834c7ef705..a6d34c0aca44dc 100644 --- a/lib/workers/repository/finalize/index.ts +++ b/lib/workers/repository/finalize/index.ts @@ -1,8 +1,9 @@ import type { RenovateConfig } from '../../../config/types'; import { logger } from '../../../logger'; import { platform } from '../../../modules/platform'; +import { scm } from '../../../modules/platform/scm'; import * as repositoryCache from '../../../util/cache/repository'; -import { clearRenovateRefs } from '../../../util/git'; +import { clearRenovateRefs, getBranchCommit } from '../../../util/git'; import { PackageFiles } from '../package-files'; import { validateReconfigureBranch } from '../reconfigure'; import { pruneStaleBranches } from './prune'; @@ -11,12 +12,33 @@ import { runRenovateRepoStats, } from './repository-statistics'; +export async function calculateScmStats(config: RenovateConfig): Promise { + const defaultBranchSha = getBranchCommit(config.defaultBranch!); + // istanbul ignore if: shouldn't happen + if (!defaultBranchSha) { + logger.debug('No default branch sha found'); + } + const repoCache = repositoryCache.getCache(); + if (repoCache.repoStats?.scm.defaultBranchSha === defaultBranchSha) { + logger.debug('Default branch sha unchanged - scm stats are up to date'); + } else { + logger.debug('Recalculating repo scm stats'); + const repoStats = await scm.getStats(); + if (repoStats) { + repoCache.repoStats = { scm: repoStats }; + } else { + logger.debug(`Could not calcualte repo stats`); + } + } +} + // istanbul ignore next export async function finalizeRepo( config: RenovateConfig, branchList: string[], ): Promise { await validateReconfigureBranch(config); + await calculateScmStats(config); await repositoryCache.saveCache(); await pruneStaleBranches(config, branchList); await ensureIssuesClosing(); diff --git a/lib/workers/repository/finalize/repository-statistics.ts b/lib/workers/repository/finalize/repository-statistics.ts index 36d450c92950dd..5306cd31a99b17 100644 --- a/lib/workers/repository/finalize/repository-statistics.ts +++ b/lib/workers/repository/finalize/repository-statistics.ts @@ -42,6 +42,13 @@ export function runRenovateRepoStats( } } logger.debug({ stats: prStats }, `Renovate repository PR statistics`); + const repoCache = getCache(); + const scmStats = repoCache?.repoStats?.scm?.last90Days; + if (scmStats) { + logger.debug( + `Repository has ${scmStats.renovateCommitCount} Renovate commits in the last 90 days plus ${scmStats.committerHashList?.length} other committers`, + ); + } } function branchCacheToMetadata({