diff --git a/src/Command/FetchIssuesCommand.php b/src/Command/FetchIssuesCommand.php index fe0215d..fb3dea5 100644 --- a/src/Command/FetchIssuesCommand.php +++ b/src/Command/FetchIssuesCommand.php @@ -71,6 +71,7 @@ protected function fetchOrgIssues(): void } nodes { number + createdAt author { login avatarUrl @@ -104,6 +105,7 @@ protected function fetchOrgIssues(): void 'avatar_url' => $author['avatarUrl'] ?? null, 'html_url' => $author['url'] ?? null, 'repository' => $node['repository']['name'], + 'createdAt' => $node['createdAt'] ?? null, ]; } diff --git a/src/Command/FetchPullRequestsAllCommand.php b/src/Command/FetchPullRequestsAllCommand.php index 896237a..c62ff86 100644 --- a/src/Command/FetchPullRequestsAllCommand.php +++ b/src/Command/FetchPullRequestsAllCommand.php @@ -71,6 +71,7 @@ protected function fetchOrgPullRequests(): void } nodes { number + createdAt author { login avatarUrl @@ -81,6 +82,7 @@ protected function fetchOrgPullRequests(): void } reviews(first: 100) { nodes { + submittedAt author { login avatarUrl @@ -115,6 +117,7 @@ protected function fetchOrgPullRequests(): void 'name' => $reviewer['name'] ?? null, 'avatar_url' => $reviewer['avatarUrl'] ?? null, 'html_url' => $reviewer['url'] ?? null, + 'submittedAt' => $review['submittedAt'] ?? null, ]; } } @@ -125,6 +128,7 @@ protected function fetchOrgPullRequests(): void 'name' => $author['name'] ?? null, 'avatar_url' => $author['avatarUrl'] ?? null, 'html_url' => $author['url'] ?? null, + 'createdAt' => $node['createdAt'] ?? null, 'reviewers' => array_values($reviewers), ]; } diff --git a/src/Command/GenerateTopCompaniesCommand.php b/src/Command/GenerateTopCompaniesCommand.php index 68e242c..40ef4a4 100644 --- a/src/Command/GenerateTopCompaniesCommand.php +++ b/src/Command/GenerateTopCompaniesCommand.php @@ -84,17 +84,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int unset($contributors['updatedAt']); foreach ($contributors as $key => $contributor) { $contributors[$key]['mergedPullRequests'] = 0; + $contributors[$key]['mergedPullRequestsByYear'] = []; foreach (self::REPOSITORIES_CATEGORIES as $section => $repositories) { $contributors[$key]['categories'][$section] = [ 'total' => 0, + 'byYear' => [], 'repositories' => [], + 'repositoriesByYear' => [], ]; foreach ($repositories as $repository) { $categories[$section]['repositories'][$repository] = 0; } } $contributors[$key]['repositories'] = []; + $contributors[$key]['repositoriesByYear'] = []; } $companiesData = json_decode(\file_get_contents(self::FILE_DATA_COMPANIES), true); @@ -175,20 +179,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int return in_array($repository, self::REPOSITORIES_CATEGORIES[$item]) ? $item : $carry; }, 'others'); - ++$contributors[$authorLogin]['mergedPullRequests']; + self::bumpPair( + $contributors[$authorLogin]['mergedPullRequests'], + $contributors[$authorLogin]['mergedPullRequestsByYear'], + $yearMerged + ); // Repositoies if (!array_key_exists($repository, $contributors[$authorLogin]['repositories'])) { $contributors[$authorLogin]['repositories'][$repository] = 0; + $contributors[$authorLogin]['repositoriesByYear'][$repository] = []; } - ++$contributors[$authorLogin]['repositories'][$repository]; + self::bumpPair( + $contributors[$authorLogin]['repositories'][$repository], + $contributors[$authorLogin]['repositoriesByYear'][$repository], + $yearMerged + ); // Section : Total - ++$contributors[$authorLogin]['categories'][$section]['total']; + $cat = &$contributors[$authorLogin]['categories'][$section]; + self::bumpPair($cat['total'], $cat['byYear'], $yearMerged); // Section : Repositories - if (!array_key_exists($repository, $contributors[$authorLogin]['categories'][$section]['repositories'])) { - $contributors[$authorLogin]['categories'][$section]['repositories'][$repository] = 0; + if (!array_key_exists($repository, $cat['repositories'])) { + $cat['repositories'][$repository] = 0; + $cat['repositoriesByYear'][$repository] = []; } - ++$contributors[$authorLogin]['categories'][$section]['repositories'][$repository]; + self::bumpPair($cat['repositories'][$repository], $cat['repositoriesByYear'][$repository], $yearMerged); + unset($cat); } $company = $this->extractCompany($pullRequestData); @@ -197,6 +213,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } else { $objCompany = $community; } + if ($company && $authorLogin !== '') { + $company->contributors[] = $authorLogin; + } // Company : Total PRs ++$objCompany->mergedPullRequests; // Company; Total contributions @@ -483,6 +502,7 @@ protected function writeFileTopCompaniesByPRs(array $rankedCompanies, Company $c } \file_put_contents(self::FILE_TOP_COMPANIES_PRS, json_encode([ + 'updatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), 'community' => $community->toArray(false), 'companies' => array_map(function (Company $company) { return $company->toArray(false); @@ -581,6 +601,22 @@ protected function writeFileContributorsPRs(array $contributors): void return ($contributorA['mergedPullRequests'] > $contributorB['mergedPullRequests']) ? -1 : 1; }); + $contributors['updatedAt'] = (new DateTimeImmutable())->format(DATE_ATOM); \file_put_contents(self::FILE_CONTRIBUTORS_PRS, json_encode($contributors, JSON_PRETTY_PRINT)); } + + /** + * Increments a scalar counter AND its parallel year map (additive-strict pattern). + * + * @param array $byYear + */ + private static function bumpPair(int &$scalar, array &$byYear, string $year): void + { + ++$scalar; + if (!isset($byYear[$year])) { + $byYear[$year] = 0; + krsort($byYear); + } + ++$byYear[$year]; + } } diff --git a/src/Command/GenerateTopSecurityCommand.php b/src/Command/GenerateTopSecurityCommand.php index 0326fe5..2094815 100644 --- a/src/Command/GenerateTopSecurityCommand.php +++ b/src/Command/GenerateTopSecurityCommand.php @@ -2,6 +2,7 @@ namespace PrestaShop\Traces\Command; +use DateTimeImmutable; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -45,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->fetchConfiguration($input->getOption('config')); - /** @var array}> $advisories */ + /** @var array}> $advisories */ $advisories = json_decode(file_get_contents(self::FILE_SECURITY_ADVISORIES) ?: '', true); /** @var array $contributors */ $contributors = file_exists(self::FILE_CONTRIBUTORS_PRS) @@ -60,10 +61,45 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @param array}> $advisories + * Increments a scalar counter AND its parallel year map (additive-strict pattern). + * + * @param array $byYear + */ + private static function bumpPair(int &$scalar, array &$byYear, string $year): void + { + ++$scalar; + if (!isset($byYear[$year])) { + $byYear[$year] = 0; + krsort($byYear); + } + ++$byYear[$year]; + } + + /** + * Increments the scalar counter unconditionally, and bumps the year map only + * if $rawDate parses to a valid timestamp. An empty/malformed date must NOT + * fall back to "now" — that would silently misattribute old items to the + * current year and corrupt the byYear breakdown. Skipping the year bump + * (while still counting the scalar) keeps totals correct and honest. + * + * @param array $byYear + */ + private static function bumpPairFromDate(int &$scalar, array &$byYear, ?string $rawDate): void + { + $ts = ($rawDate !== null && $rawDate !== '') ? strtotime($rawDate) : false; + if ($ts === false) { + ++$scalar; + + return; + } + self::bumpPair($scalar, $byYear, date('Y', $ts)); + } + + /** + * @param array}> $advisories * @param array $contributors * - * @return array{updatedAt: string, items: array} + * @return array{updatedAt: string, items: array, research:int, researchByYear:array, remediation:int, remediationByYear:array}>} */ public function buildRanking(array $advisories, array $contributors): array { @@ -73,6 +109,7 @@ public function buildRanking(array $advisories, array $contributors): array $counts = []; $identities = []; foreach ($advisories as $advisory) { + $publishedAt = $advisory['published_at'] ?? null; $perLogin = []; foreach ($advisory['credits'] as $credit) { $type = $credit['type'] ?? ''; @@ -92,9 +129,23 @@ public function buildRanking(array $advisories, array $contributors): array } } foreach ($perLogin as $login => $families) { - $counts[$login]['count'] = ($counts[$login]['count'] ?? 0) + 1; - $counts[$login]['research'] = ($counts[$login]['research'] ?? 0) + ($families['research'] ? 1 : 0); - $counts[$login]['remediation'] = ($counts[$login]['remediation'] ?? 0) + ($families['remediation'] ? 1 : 0); + if (!isset($counts[$login])) { + $counts[$login] = [ + 'count' => 0, + 'countByYear' => [], + 'research' => 0, + 'researchByYear' => [], + 'remediation' => 0, + 'remediationByYear' => [], + ]; + } + self::bumpPairFromDate($counts[$login]['count'], $counts[$login]['countByYear'], $publishedAt); + if ($families['research']) { + self::bumpPairFromDate($counts[$login]['research'], $counts[$login]['researchByYear'], $publishedAt); + } + if ($families['remediation']) { + self::bumpPairFromDate($counts[$login]['remediation'], $counts[$login]['remediationByYear'], $publishedAt); + } } } @@ -117,12 +168,15 @@ public function buildRanking(array $advisories, array $contributors): array 'avatar_url' => (string) ($contributor['avatar_url'] ?? $identities[$login]['avatar_url']), 'html_url' => (string) ($contributor['html_url'] ?? $identities[$login]['html_url']), 'count' => $counts[$login]['count'], + 'countByYear' => $counts[$login]['countByYear'], 'research' => $counts[$login]['research'], + 'researchByYear' => $counts[$login]['researchByYear'], 'remediation' => $counts[$login]['remediation'], + 'remediationByYear' => $counts[$login]['remediationByYear'], ]; ++$rank; } - return ['updatedAt' => date('Y-m-d'), 'items' => $items]; + return ['updatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), 'items' => $items]; } } diff --git a/src/Command/GenerateTopStatsCommand.php b/src/Command/GenerateTopStatsCommand.php index 62e2118..6f64efa 100644 --- a/src/Command/GenerateTopStatsCommand.php +++ b/src/Command/GenerateTopStatsCommand.php @@ -2,6 +2,7 @@ namespace PrestaShop\Traces\Command; +use DateTimeImmutable; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -46,9 +47,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->fetchConfiguration($input->getOption('config')); - /** @var array}> $pullRequests */ + /** @var array}> $pullRequests */ $pullRequests = json_decode(file_get_contents(self::FILE_PULLREQUESTS_ALL) ?: '', true); - /** @var array $issues */ + /** @var array $issues */ $issues = json_decode(file_get_contents(self::FILE_ISSUES) ?: '', true); /** @var array $contributors */ $contributors = json_decode(file_get_contents(self::FILE_CONTRIBUTORS_PRS) ?: '', true); @@ -59,9 +60,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->enrichContributors($contributors, $aggregate); file_put_contents(self::FILE_CONTRIBUTORS_PRS, json_encode($contributors, JSON_PRETTY_PRINT)); - file_put_contents(self::FILE_TOP_REVIEWERS, json_encode($this->buildRanking($aggregate['reviews'], $contributors, $identities), JSON_PRETTY_PRINT)); - file_put_contents(self::FILE_TOP_ISSUES, json_encode($this->buildRanking($aggregate['issuesOpened'], $contributors, $identities), JSON_PRETTY_PRINT)); - file_put_contents(self::FILE_TOP_PULLREQUESTS, json_encode($this->buildRanking($aggregate['pullRequestsOpened'], $contributors, $identities), JSON_PRETTY_PRINT)); + file_put_contents(self::FILE_TOP_REVIEWERS, json_encode($this->buildRanking($aggregate['reviews'], $aggregate['reviewsByYear'], $contributors, $identities), JSON_PRETTY_PRINT)); + file_put_contents(self::FILE_TOP_ISSUES, json_encode($this->buildRanking($aggregate['issuesOpened'], $aggregate['issuesOpenedByYear'], $contributors, $identities), JSON_PRETTY_PRINT)); + file_put_contents(self::FILE_TOP_PULLREQUESTS, json_encode($this->buildRanking($aggregate['pullRequestsOpened'], $aggregate['pullRequestsOpenedByYear'], $contributors, $identities), JSON_PRETTY_PRINT)); $this->output->writeLn(['', 'Top stats generated.']); @@ -69,21 +70,63 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @param array}> $pullRequests - * @param array $issues + * Increments a scalar counter AND its parallel year map (additive-strict pattern). + * + * @param array $byYear + */ + private static function bumpPair(int &$scalar, array &$byYear, string $year): void + { + ++$scalar; + if (!isset($byYear[$year])) { + $byYear[$year] = 0; + krsort($byYear); + } + ++$byYear[$year]; + } + + /** + * Increments the scalar counter unconditionally, and bumps the year map only + * if $rawDate parses to a valid timestamp. An empty/malformed date must NOT + * fall back to "now" — that would silently misattribute old items to the + * current year and corrupt the byYear breakdown. Skipping the year bump + * (while still counting the scalar) keeps totals correct and honest. + * + * @param array $byYear + */ + private static function bumpPairFromDate(int &$scalar, array &$byYear, ?string $rawDate): void + { + $ts = ($rawDate !== null && $rawDate !== '') ? strtotime($rawDate) : false; + if ($ts === false) { + ++$scalar; + + return; + } + self::bumpPair($scalar, $byYear, date('Y', $ts)); + } + + /** + * @param array}> $pullRequests + * @param array $issues * - * @return array{reviews: array, issuesOpened: array, pullRequestsOpened: array} + * @return array{reviews: array, reviewsByYear: array>, issuesOpened: array, issuesOpenedByYear: array>, pullRequestsOpened: array, pullRequestsOpenedByYear: array>} */ public function aggregate(array $pullRequests, array $issues): array { $reviews = []; + $reviewsByYear = []; $pullRequestsOpened = []; + $pullRequestsOpenedByYear = []; $issuesOpened = []; + $issuesOpenedByYear = []; foreach ($pullRequests as $pullRequest) { $author = $pullRequest['login'] ?? null; if ($author !== null) { - $pullRequestsOpened[$author] = ($pullRequestsOpened[$author] ?? 0) + 1; + if (!isset($pullRequestsOpened[$author])) { + $pullRequestsOpened[$author] = 0; + $pullRequestsOpenedByYear[$author] = []; + } + self::bumpPairFromDate($pullRequestsOpened[$author], $pullRequestsOpenedByYear[$author], $pullRequest['createdAt'] ?? null); } // A review counts for its reviewer regardless of the PR author (even a // deleted/null author) — only self-reviews are excluded. @@ -92,21 +135,32 @@ public function aggregate(array $pullRequests, array $issues): array if ($reviewerLogin === $author) { continue; } - $reviews[$reviewerLogin] = ($reviews[$reviewerLogin] ?? 0) + 1; + if (!isset($reviews[$reviewerLogin])) { + $reviews[$reviewerLogin] = 0; + $reviewsByYear[$reviewerLogin] = []; + } + self::bumpPairFromDate($reviews[$reviewerLogin], $reviewsByYear[$reviewerLogin], $reviewer['submittedAt'] ?? null); } } foreach ($issues as $issue) { $author = $issue['login'] ?? null; if ($author !== null) { - $issuesOpened[$author] = ($issuesOpened[$author] ?? 0) + 1; + if (!isset($issuesOpened[$author])) { + $issuesOpened[$author] = 0; + $issuesOpenedByYear[$author] = []; + } + self::bumpPairFromDate($issuesOpened[$author], $issuesOpenedByYear[$author], $issue['createdAt'] ?? null); } } return [ 'reviews' => $reviews, + 'reviewsByYear' => $reviewsByYear, 'issuesOpened' => $issuesOpened, + 'issuesOpenedByYear' => $issuesOpenedByYear, 'pullRequestsOpened' => $pullRequestsOpened, + 'pullRequestsOpenedByYear' => $pullRequestsOpenedByYear, ]; } @@ -145,7 +199,7 @@ public function buildIdentities(array $pullRequests, array $issues): array /** * @param array $contributors - * @param array{reviews: array, issuesOpened: array, pullRequestsOpened: array} $aggregate + * @param array{reviews: array, reviewsByYear: array>, issuesOpened: array, issuesOpenedByYear: array>, pullRequestsOpened: array, pullRequestsOpenedByYear: array>} $aggregate */ private function enrichContributors(array &$contributors, array $aggregate): void { @@ -154,20 +208,24 @@ private function enrichContributors(array &$contributors, array $aggregate): voi continue; } $entry['reviews'] = $aggregate['reviews'][$login] ?? 0; + $entry['reviewsByYear'] = $aggregate['reviewsByYear'][$login] ?? []; $entry['issuesOpened'] = $aggregate['issuesOpened'][$login] ?? 0; + $entry['issuesOpenedByYear'] = $aggregate['issuesOpenedByYear'][$login] ?? []; $entry['pullRequestsOpened'] = $aggregate['pullRequestsOpened'][$login] ?? 0; + $entry['pullRequestsOpenedByYear'] = $aggregate['pullRequestsOpenedByYear'][$login] ?? []; } unset($entry); } /** * @param array $counts + * @param array> $countsByYear * @param array $contributors * @param array $identities * - * @return array{updatedAt: string, items: array} + * @return array{updatedAt: string, items: array}>} */ - private function buildRanking(array $counts, array $contributors, array $identities): array + private function buildRanking(array $counts, array $countsByYear, array $contributors, array $identities): array { $logins = array_keys($counts); usort($logins, function (string $a, string $b) use ($counts): int { @@ -191,11 +249,12 @@ private function buildRanking(array $counts, array $contributors, array $identit 'avatar_url' => $identity['avatar_url'], 'html_url' => $identity['html_url'], 'count' => $counts[$login], + 'countByYear' => $countsByYear[$login] ?? [], ]; ++$rank; } - return ['updatedAt' => date('Y-m-d'), 'items' => $items]; + return ['updatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), 'items' => $items]; } /** diff --git a/src/DTO/Company.php b/src/DTO/Company.php index 4394567..9f8a61b 100644 --- a/src/DTO/Company.php +++ b/src/DTO/Company.php @@ -26,6 +26,12 @@ class Company * @var array */ public array $mergedContributionsByVersion = []; + /** + * @var string[] + */ + public array $contributors = []; + + public readonly string $slug; public function __construct( public readonly string $name, @@ -40,15 +46,25 @@ public function __construct( public readonly string $avatarUrl, public readonly string $htmlUrl, ) { + $this->slug = self::slugify($this->name); + } + + private static function slugify(string $value): string + { + $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + $ascii = preg_replace('/[^A-Za-z0-9]+/', '-', $ascii ?: $value) ?? $value; + + return strtolower(trim($ascii, '-')) ?: 'company'; } /** - * @return array|float|int|string> + * @return array|float|int|string|string[]> */ public function toArray(bool $rankByContributions): array { return [ 'name' => $this->name, + 'slug' => $this->slug, 'rank' => $rankByContributions ? $this->rankByContributions : $this->rankByPR, 'rank_contributions' => $this->rankByContributions, 'rank_pull_requests' => $this->rankByPR, @@ -62,6 +78,7 @@ public function toArray(bool $rankByContributions): array 'contributions_percent' => $this->contributionsPercent, 'avatar_url' => $this->avatarUrl, 'html_url' => $this->htmlUrl, + 'contributors' => array_values(array_unique($this->contributors)), ]; } }