diff --git a/README.md b/README.md index 6718209..ec075f4 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,14 @@ The authentication use a Github Token. # 10- Generate the security contributors leaderboard (research + remediation) $ php bin/console traces:generate:topsecurity --config="config.yml" ## A file top_security.json is generated + + # 11- Fetch QA label events (all QA-prefixed validation labels) from merged PRs + $ php bin/console traces:fetch:qaevents + ## A file gh_qa_events.json is generated + + # 12- Generate the QA contributors leaderboard + $ php bin/console traces:generate:topqa --config="config.yml" + ## A file top_qa.json is generated ``` ## Configuring diff --git a/bin/console b/bin/console index d9b3fc9..543f383 100644 --- a/bin/console +++ b/bin/console @@ -6,10 +6,12 @@ use PrestaShop\Traces\Command\FetchContributorsCommand; use PrestaShop\Traces\Command\FetchIssuesCommand; use PrestaShop\Traces\Command\FetchPullRequestsAllCommand; use PrestaShop\Traces\Command\FetchPullRequestsMergedCommand; +use PrestaShop\Traces\Command\FetchQaEventsCommand; use PrestaShop\Traces\Command\FetchRepositoriesCommand; use PrestaShop\Traces\Command\FetchSecurityAdvisoriesCommand; use PrestaShop\Traces\Command\GenerateNewContributorsCommand; use PrestaShop\Traces\Command\GenerateTopCompaniesCommand; +use PrestaShop\Traces\Command\GenerateTopQaCommand; use PrestaShop\Traces\Command\GenerateTopSecurityCommand; use PrestaShop\Traces\Command\GenerateTopStatsCommand; use Symfony\Component\Console\Application; @@ -28,10 +30,12 @@ $app->addCommand(new FetchContributorsCommand()); $app->addCommand(new FetchIssuesCommand()); $app->addCommand(new FetchPullRequestsAllCommand()); $app->addCommand(new FetchPullRequestsMergedCommand()); +$app->addCommand(new FetchQaEventsCommand()); $app->addCommand(new FetchRepositoriesCommand()); $app->addCommand(new FetchSecurityAdvisoriesCommand()); $app->addCommand(new GenerateNewContributorsCommand()); $app->addCommand(new GenerateTopCompaniesCommand()); +$app->addCommand(new GenerateTopQaCommand()); $app->addCommand(new GenerateTopSecurityCommand()); $app->addCommand(new GenerateTopStatsCommand()); $app->run(); diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 08f84ed..d1c71c1 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -39,6 +39,10 @@ class AbstractCommand extends Command protected const FILE_TOP_SECURITY = 'top_security.json'; + protected const FILE_QA_EVENTS = 'gh_qa_events.json'; + + protected const FILE_TOP_QA = 'top_qa.json'; + protected const FILE_GHLOGIN_WO_COMPANY = 'gh_loginsWOCompany.json'; protected const FILE_DATA_COMPANIES = 'var/data/companies.json'; diff --git a/src/Command/FetchQaEventsCommand.php b/src/Command/FetchQaEventsCommand.php new file mode 100644 index 0000000..deb75b5 --- /dev/null +++ b/src/Command/FetchQaEventsCommand.php @@ -0,0 +1,163 @@ +setName('traces:fetch:qaevents') + ->setDescription('Fetch QA label events from merged PRs across the PrestaShop org') + ->addOption( + 'ghtoken', + null, + InputOption::VALUE_OPTIONAL, + '', + isset($_ENV['GH_TOKEN']) ? (string) $_ENV['GH_TOKEN'] : null + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + if (!file_exists(self::FILE_REPOSITORIES)) { + $this->output->writeLn(self::FILE_REPOSITORIES . ' is missing. Please execute `php bin/console traces:fetch:repositories`'); + + return 1; + } + + $repositories = json_decode(file_get_contents(self::FILE_REPOSITORIES) ?: '', true); + $time = time(); + $events = []; + + foreach (self::QA_LABELS as $label) { + $this->output->writeLn(['', 'Label: ' . $label]); + foreach ($repositories as $repository) { + $repoEvents = $this->fetchLabelEventsForRepo($label, $repository); + if (count($repoEvents) > 0) { + $this->output->writeLn([' PrestaShop/' . $repository . ': ' . count($repoEvents) . ' events']); + } + $events = array_merge($events, $repoEvents); + } + } + + $events = $this->dedup($events); + + file_put_contents(self::FILE_QA_EVENTS, json_encode([ + 'events' => array_values($events), + 'fetchedAt' => date('c'), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + $this->output->writeLn(['', count($events) . ' unique QA events written to ' . self::FILE_QA_EVENTS . ' in ' . (time() - $time) . 's.']); + + return 0; + } + + /** + * Search PRs by label WITHIN a single repository. The GitHub search API + * caps results at 1000 per query — segmenting by repo keeps every module + * well under that ceiling (the only volume risk was the core repo, which + * does not use these labels). + * + * @return array + */ + private function fetchLabelEventsForRepo(string $label, string $repository): array + { + $labelEscaped = str_replace('"', '\\"', $label); + $queryTpl = 'search(query: "repo:PrestaShop/' . $repository . ' label:\"' . $labelEscaped . '\" is:pr is:merged", type: ISSUE, first: 100, after: %s) { + pageInfo { endCursor hasNextPage } + nodes { + ... on PullRequest { + number + repository { name } + timelineItems(itemTypes: [LABELED_EVENT], first: 100) { + nodes { + ... on LabeledEvent { + actor { login } + label { name } + createdAt + } + } + } + } + } + }'; + + $events = []; + $after = 'null'; + do { + $data = $this->github->apiSearchGraphQL('query { ' . sprintf($queryTpl, $after) . ' }'); + $search = $data['data']['search'] ?? null; + if ($search === null) { + break; + } + foreach ($search['nodes'] as $pr) { + if (empty($pr['number'])) { + continue; + } + foreach ($pr['timelineItems']['nodes'] as $ev) { + if (($ev['label']['name'] ?? null) !== $label) { + continue; + } + if (empty($ev['actor']['login'])) { + continue; + } + $events[] = [ + 'repo' => $pr['repository']['name'], + 'pr_number' => $pr['number'], + 'actor' => $ev['actor']['login'], + 'label' => $label, + 'createdAt' => $ev['createdAt'], + ]; + } + } + $endCursor = $search['pageInfo']['endCursor'] ?? null; + $after = $endCursor === null ? 'null' : '"' . $endCursor . '"'; + } while (($search['pageInfo']['hasNextPage'] ?? false) === true); + + return $events; + } + + /** + * @param array $events + * + * @return array + */ + private function dedup(array $events): array + { + $keyed = []; + foreach ($events as $ev) { + $key = $ev['repo'] . '#' . $ev['pr_number'] . '@' . $ev['actor'] . '|' . $ev['label']; + if (!isset($keyed[$key]) || strcmp($ev['createdAt'], $keyed[$key]['createdAt']) < 0) { + $keyed[$key] = $ev; + } + } + + return $keyed; + } +} diff --git a/src/Command/GenerateTopQaCommand.php b/src/Command/GenerateTopQaCommand.php new file mode 100644 index 0000000..961d393 --- /dev/null +++ b/src/Command/GenerateTopQaCommand.php @@ -0,0 +1,310 @@ +setName('traces:generate:topqa') + ->setDescription('Generate the QA contributors leaderboard from fetched QA label events') + ->addOption( + 'ghtoken', + null, + InputOption::VALUE_OPTIONAL, + '', + isset($_ENV['GH_TOKEN']) ? (string) $_ENV['GH_TOKEN'] : null + ) + ->addOption('config', 'c', InputOption::VALUE_OPTIONAL, '', 'config.dist.yml'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + if (!file_exists(self::FILE_QA_EVENTS)) { + $this->output->writeLn(self::FILE_QA_EVENTS . ' is missing. Please execute `php bin/console traces:fetch:qaevents`'); + + return 1; + } + + $this->fetchConfiguration($input->getOption('config')); + + /** @var array{events?: array} $payload */ + $payload = json_decode(file_get_contents(self::FILE_QA_EVENTS) ?: '', true) ?: []; + $events = $payload['events'] ?? []; + + /** @var array $contributors */ + $contributors = file_exists(self::FILE_CONTRIBUTORS_PRS) + ? json_decode(file_get_contents(self::FILE_CONTRIBUTORS_PRS) ?: '', true) + : []; + + $employeeStints = $this->loadInternalEmployeeStints(); + + file_put_contents(self::FILE_TOP_QA, json_encode($this->buildRanking($events, $contributors, $employeeStints), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + $this->output->writeLn(['', 'Top QA generated.']); + + return 0; + } + + /** + * @param array> $events raw event rows from gh_qa_events.json — keys are not statically guaranteed + * @param array $contributors + * @param array $employeeStints login -> raw employment periods at PrestaShop (isCommunityEvent validates the shape) + * + * @return array{updatedAt: string, items: array} + */ + public function buildRanking(array $events, array $contributors, array $employeeStints = []): array + { + $counts = []; + foreach ($events as $ev) { + $rawLogin = $ev['actor'] ?? null; + $rawLabel = $ev['label'] ?? null; + $rawCreated = $ev['createdAt'] ?? null; + if (!is_string($rawLogin) || $rawLogin === '') { + continue; + } + $login = $rawLogin; + $label = is_string($rawLabel) ? $rawLabel : ''; + $createdAt = is_string($rawCreated) ? $rawCreated : null; + if (!isset($counts[$login])) { + $counts[$login] = [ + 'qa' => 0, 'qa_community' => 0, + 'countByYear' => [], 'qaByYear' => [], 'qaCommunityByYear' => [], + ]; + } + $year = $this->extractYear($createdAt); + $isCommunity = $this->isCommunityEvent($label, $login, $createdAt, $employeeStints); + if ($isCommunity) { + ++$counts[$login]['qa_community']; + } else { + ++$counts[$login]['qa']; + } + if ($year !== null) { + $counts[$login]['countByYear'][$year] = ($counts[$login]['countByYear'][$year] ?? 0) + 1; + if ($isCommunity) { + $counts[$login]['qaCommunityByYear'][$year] = ($counts[$login]['qaCommunityByYear'][$year] ?? 0) + 1; + } else { + $counts[$login]['qaByYear'][$year] = ($counts[$login]['qaByYear'][$year] ?? 0) + 1; + } + } + } + + $logins = array_keys($counts); + usort($logins, static function (string $a, string $b) use ($counts): int { + $totalA = $counts[$a]['qa'] + $counts[$a]['qa_community']; + $totalB = $counts[$b]['qa'] + $counts[$b]['qa_community']; + + return ($totalB <=> $totalA) + ?: ($counts[$b]['qa'] <=> $counts[$a]['qa']) + ?: strcmp($a, $b); + }); + + $items = []; + $rank = 1; + foreach ($logins as $login) { + if (!$this->configKeepExcludedUsers && in_array($login, $this->configExclusions, true)) { + continue; + } + $contributor = isset($contributors[$login]) && is_array($contributors[$login]) ? $contributors[$login] : []; + + if (empty($contributor['avatar_url']) || empty($contributor['name'])) { + $profile = $this->resolveProfile($login); + $contributor = array_merge($profile, $contributor); + } + + $total = $counts[$login]['qa'] + $counts[$login]['qa_community']; + // Sort year maps descending — same convention as top_security's byYear + // sub-maps, so consumers can display the most recent year first. + $countByYear = $counts[$login]['countByYear']; + $qaByYear = $counts[$login]['qaByYear']; + $qaCommunityByYear = $counts[$login]['qaCommunityByYear']; + krsort($countByYear); + krsort($qaByYear); + krsort($qaCommunityByYear); + + $items[] = [ + 'rank' => $rank, + 'login' => $login, + 'name' => (string) ($contributor['name'] ?? $login), + 'avatar_url' => (string) ($contributor['avatar_url'] ?? ''), + 'html_url' => (string) ($contributor['html_url'] ?? 'https://github.com/' . $login), + 'count' => $total, + 'countByYear' => $countByYear, + 'qa' => $counts[$login]['qa'], + 'qaByYear' => $qaByYear, + 'qa_community' => $counts[$login]['qa_community'], + 'qaCommunityByYear' => $qaCommunityByYear, + ]; + ++$rank; + } + + return ['updatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), 'items' => $items]; + } + + /** + * Hybrid rule. When the label explicitly declares its origin (Community + * or Dev), trust it. Otherwise (neutral label used on modules for both + * kinds of validators), check whether the actor was a PrestaShop + * employee at the time of the event (via var/data/companies.json). + * Time-aware: historic events by someone who has since left the company + * still count as internal. + * + * @param array $employeeStints login -> raw stints array (validated inline) + */ + private function isCommunityEvent(string $label, string $login, ?string $createdAt, array $employeeStints): bool + { + if (in_array($label, self::COMMUNITY_LABELS, true)) { + return true; + } + if (in_array($label, self::DEV_LABELS, true)) { + return false; + } + + $stints = $employeeStints[$login] ?? null; + if (!is_array($stints) || $stints === []) { + // Not listed as an internal employee at any time — treat as + // community. The default under-counts internal (unlisted team + // members) rather than over-attributing to the team. + return true; + } + + $eventTs = ($createdAt !== null && $createdAt !== '') ? strtotime($createdAt) : false; + // Undated event: if the actor was ever a PrestaShop employee, credit + // it as internal — better than dropping the internal signal entirely. + if ($eventTs === false) { + return false; + } + + foreach ($stints as $stint) { + if (!is_array($stint)) { + continue; + } + $rawStart = $stint['startDate'] ?? ''; + $rawEnd = $stint['endDate'] ?? ''; + if (!is_string($rawStart) || !is_string($rawEnd)) { + continue; + } + $start = strtotime($rawStart); + if ($start === false) { + continue; + } + // Empty endDate = currently employed. + $end = ($rawEnd === '') ? PHP_INT_MAX : strtotime($rawEnd); + if ($end === false) { + continue; + } + if ($eventTs >= $start && $eventTs <= $end) { + return false; + } + } + + return true; + } + + /** + * Load the employees map for the internal PrestaShop company from + * var/data/companies.json. Returns login -> list of employment stints. + * + * @return array login -> raw stints (shape validated in isCommunityEvent) + */ + private function loadInternalEmployeeStints(): array + { + if (!file_exists(self::FILE_DATA_COMPANIES)) { + return []; + } + $companies = json_decode(file_get_contents(self::FILE_DATA_COMPANIES) ?: '', true); + if (!is_array($companies)) { + return []; + } + foreach ($companies as $company) { + if (!is_array($company) || ($company['name'] ?? null) !== self::INTERNAL_CANONICAL_COMPANY) { + continue; + } + $employees = $company['employees'] ?? []; + + return is_array($employees) ? $employees : []; + } + + return []; + } + + /** + * Best-effort year extraction from an ISO-8601 createdAt. A malformed or + * missing value returns null — the caller then skips the year bump but + * still counts the scalar (same policy as GenerateTopSecurityCommand). + */ + private function extractYear(?string $rawDate): ?string + { + if ($rawDate === null || $rawDate === '') { + return null; + } + $ts = strtotime($rawDate); + if ($ts === false) { + return null; + } + + return date('Y', $ts); + } + + /** + * Resolve a GitHub profile for a login absent from contributors_prs.json + * (e.g. QA-ers who never contributed code). Cached per-run. + * + * @return array{name?: string, avatar_url?: string, html_url?: string} + */ + private function resolveProfile(string $login): array + { + static $cache = []; + if (isset($cache[$login])) { + return $cache[$login]; + } + try { + $user = $this->github->getUser($login); + } catch (Throwable $e) { + $this->output->writeLn([' Failed to resolve GitHub user ' . $login . ': ' . $e->getMessage()]); + + return $cache[$login] = []; + } + + return $cache[$login] = [ + 'name' => (string) ($user['name'] ?? $login), + 'avatar_url' => (string) ($user['avatar_url'] ?? ''), + 'html_url' => (string) ($user['html_url'] ?? 'https://github.com/' . $login), + ]; + } +}