From 1bf79303bddacd8e8df580cb8af77407678e3288 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 12:20:28 +0200 Subject: [PATCH 1/7] feat(qa): add file constants for QA events and top QA outputs --- src/Command/AbstractCommand.php | 4 ++++ 1 file changed, 4 insertions(+) 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'; From ddadc8168c506cffa1da1fd321466cafdaa1557f Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 12:21:16 +0200 Subject: [PATCH 2/7] feat(qa): add FetchQaEventsCommand to collect QA label events --- bin/console | 2 + src/Command/FetchQaEventsCommand.php | 121 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/Command/FetchQaEventsCommand.php diff --git a/bin/console b/bin/console index d9b3fc9..6bb4e56 100644 --- a/bin/console +++ b/bin/console @@ -6,6 +6,7 @@ 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; @@ -28,6 +29,7 @@ $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()); diff --git a/src/Command/FetchQaEventsCommand.php b/src/Command/FetchQaEventsCommand.php new file mode 100644 index 0000000..5be7839 --- /dev/null +++ b/src/Command/FetchQaEventsCommand.php @@ -0,0 +1,121 @@ +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); + + $time = time(); + $events = []; + + foreach (self::QA_LABELS as $label) { + $this->output->writeLn(['', 'Label: ' . $label]); + $events = array_merge($events, $this->fetchLabelEvents($label)); + } + + $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; + } + + private function fetchLabelEvents(string $label): array + { + $labelEscaped = str_replace('"', '\\"', $label); + $queryTpl = 'search(query: "label:\"' . $labelEscaped . '\" is:pr is:merged org:PrestaShop", 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) { + $this->output->writeLn([' GraphQL returned no data for label ' . $label]); + 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 . '"'; + $this->output->writeLn([' page fetched — running total for label: ' . count($events)]); + } while (($search['pageInfo']['hasNextPage'] ?? false) === true); + + return $events; + } + + 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; + } +} From dd71eae19829ba1f70cebb0dde221d3baf437ae6 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 12:22:14 +0200 Subject: [PATCH 3/7] feat(qa): add GenerateTopQaCommand producing top_qa.json --- bin/console | 2 + src/Command/GenerateTopQaCommand.php | 149 +++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/Command/GenerateTopQaCommand.php diff --git a/bin/console b/bin/console index 6bb4e56..543f383 100644 --- a/bin/console +++ b/bin/console @@ -11,6 +11,7 @@ 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; @@ -34,6 +35,7 @@ $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/GenerateTopQaCommand.php b/src/Command/GenerateTopQaCommand.php new file mode 100644 index 0000000..3f36943 --- /dev/null +++ b/src/Command/GenerateTopQaCommand.php @@ -0,0 +1,149 @@ +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) + : []; + + file_put_contents(self::FILE_TOP_QA, json_encode($this->buildRanking($events, $contributors), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + $this->output->writeLn(['', 'Top QA generated.']); + + return 0; + } + + /** + * @param array $events + * @param array $contributors + * + * @return array{updatedAt: string, items: array} + */ + public function buildRanking(array $events, array $contributors): array + { + $counts = []; + foreach ($events as $ev) { + $login = $ev['actor'] ?? ''; + $label = $ev['label'] ?? ''; + if ($login === '') { + continue; + } + if (!isset($counts[$login])) { + $counts[$login] = ['qa' => 0, 'qa_community' => 0]; + } + if ($label === self::LABEL_QA) { + ++$counts[$login]['qa']; + } elseif ($label === self::LABEL_QA_COMMUNITY) { + ++$counts[$login]['qa_community']; + } + } + + $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']; + $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, + 'qa' => $counts[$login]['qa'], + 'qa_community' => $counts[$login]['qa_community'], + ]; + ++$rank; + } + + return ['updatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), 'items' => $items]; + } + + /** + * 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), + ]; + } +} From 644eff3c06cb3c93df382c628cbc3dbf98bad713 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 12:22:28 +0200 Subject: [PATCH 4/7] docs(qa): document QA fetch/generate commands --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 6718209..21431fd 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 (QA ✅ / QA by community ✅) 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 From 259b09ff7c39292fbeac07d5d895a509d0feacbc Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 13:02:07 +0200 Subject: [PATCH 5/7] fix(qa): correct label names and segment search per repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes surfaced during first dry-run: - Actual labels are "QA ✔️" and "QA ✔️ by Community" (U+2714 heavy check mark), not "QA ✅" / "QA by community ✅" (U+2705). - GitHub search API caps results at 1000 per query; a single org-wide search silently truncated 9827 matching PRs to ~627 events. Iterate over gh_repositories.json and search per-repo instead — each module stays well under the cap. Dry-run now yields 2857 unique events across 55 QA contributors. --- README.md | 2 +- src/Command/FetchQaEventsCommand.php | 31 ++++++++++++++++++++++------ src/Command/GenerateTopQaCommand.php | 4 ++-- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 21431fd..b723192 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The authentication use a Github Token. $ php bin/console traces:generate:topsecurity --config="config.yml" ## A file top_security.json is generated - # 11- Fetch QA label events (QA ✅ / QA by community ✅) from merged PRs + # 11- Fetch QA label events (QA ✔️ / QA ✔️ by Community) from merged PRs $ php bin/console traces:fetch:qaevents ## A file gh_qa_events.json is generated diff --git a/src/Command/FetchQaEventsCommand.php b/src/Command/FetchQaEventsCommand.php index 5be7839..97e0412 100644 --- a/src/Command/FetchQaEventsCommand.php +++ b/src/Command/FetchQaEventsCommand.php @@ -8,7 +8,7 @@ class FetchQaEventsCommand extends AbstractCommand { - private const QA_LABELS = ['QA ✅', 'QA by community ✅']; + private const QA_LABELS = ['QA ✔️', 'QA ✔️ by Community']; protected function configure(): void { @@ -27,12 +27,25 @@ 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]); - $events = array_merge($events, $this->fetchLabelEvents($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); @@ -47,10 +60,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - private function fetchLabelEvents(string $label): array + /** + * 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: "label:\"' . $labelEscaped . '\" is:pr is:merged org:PrestaShop", type: ISSUE, first: 100, after: %s) { + $queryTpl = 'search(query: "repo:PrestaShop/' . $repository . ' label:\"' . $labelEscaped . '\" is:pr is:merged", type: ISSUE, first: 100, after: %s) { pageInfo { endCursor hasNextPage } nodes { ... on PullRequest { @@ -75,7 +96,6 @@ private function fetchLabelEvents(string $label): array $data = $this->github->apiSearchGraphQL('query { ' . sprintf($queryTpl, $after) . ' }'); $search = $data['data']['search'] ?? null; if ($search === null) { - $this->output->writeLn([' GraphQL returned no data for label ' . $label]); break; } foreach ($search['nodes'] as $pr) { @@ -100,7 +120,6 @@ private function fetchLabelEvents(string $label): array } $endCursor = $search['pageInfo']['endCursor'] ?? null; $after = $endCursor === null ? 'null' : '"' . $endCursor . '"'; - $this->output->writeLn([' page fetched — running total for label: ' . count($events)]); } while (($search['pageInfo']['hasNextPage'] ?? false) === true); return $events; diff --git a/src/Command/GenerateTopQaCommand.php b/src/Command/GenerateTopQaCommand.php index 3f36943..8a4b8ab 100644 --- a/src/Command/GenerateTopQaCommand.php +++ b/src/Command/GenerateTopQaCommand.php @@ -9,9 +9,9 @@ class GenerateTopQaCommand extends AbstractCommand { - private const LABEL_QA = 'QA ✅'; + private const LABEL_QA = 'QA ✔️'; - private const LABEL_QA_COMMUNITY = 'QA by community ✅'; + private const LABEL_QA_COMMUNITY = 'QA ✔️ by Community'; protected function configure(): void { From 30c4ecc55ac3e2e5af889e2fdb0eb8659b5644dd Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 13:33:44 +0200 Subject: [PATCH 6/7] feat(qa): cover all QA label variants and add time-aware bucketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refinements after a first end-to-end dry-run against the live API: 1. Broaden label coverage. Scanning every repo's labels surfaced eight more variants beyond the two original ones — including plain-text emoji codes (":heavy_check_mark:"), the U+2713 check mark, "QA by Dev", "QA by Community", "QA with AI", and "QA approved". They now all feed the same events stream. Result: 3146 unique events (vs 656 initially) across ~55 QA-ers. 2. Time-aware internal/community bucketing. The original label-based split fell apart on modules, where the same neutral label (`QA ✔️` etc.) is used by both team members and community reviewers. The fix hybridises the rule: - Labels that explicitly declare their origin (Community / Dev) win. - Neutral labels fall back to whether the actor was a PrestaShop employee AT THE TIME of the event, via the employees map in var/data/companies.json (login -> [{startDate, endDate}]). Historic events stay attributed to `qa` even for people who have since left the company, and events after someone's endDate cross over to `qa_community` — visible on Progi1984 (328 internal for the Jul 2019 - Aug 2026 stint, 3 community after that). Employees missing from the map still fall through to `qa_community` — that's a data gap in companies.json, not something this command can fix. Also emit countByYear / qaByYear / qaCommunityByYear on each item, mirroring the byYear convention of top_security.json so the front can plot per-year contributions. --- README.md | 2 +- src/Command/FetchQaEventsCommand.php | 20 +++- src/Command/GenerateTopQaCommand.php | 165 +++++++++++++++++++++++++-- 3 files changed, 177 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b723192..ec075f4 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The authentication use a Github Token. $ php bin/console traces:generate:topsecurity --config="config.yml" ## A file top_security.json is generated - # 11- Fetch QA label events (QA ✔️ / QA ✔️ by Community) from merged PRs + # 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 diff --git a/src/Command/FetchQaEventsCommand.php b/src/Command/FetchQaEventsCommand.php index 97e0412..5c2e5d9 100644 --- a/src/Command/FetchQaEventsCommand.php +++ b/src/Command/FetchQaEventsCommand.php @@ -8,7 +8,25 @@ class FetchQaEventsCommand extends AbstractCommand { - private const QA_LABELS = ['QA ✔️', 'QA ✔️ by Community']; + /** + * Every label variant used across the org that signals a QA validation. + * Discovered by scanning every repo's label list — the naming has drifted + * over time (different check marks, unrendered emoji codes, "by Dev" vs + * "by Community", etc.). We keep them all here and bucket them into + * `qa` / `qa_community` in GenerateTopQaCommand. + */ + private const QA_LABELS = [ + 'QA ✔️', + 'QA ✔️ by Community', + 'QA ✓', + 'QA by Community ✓', + 'QA by Dev ✓', + 'QA by dev ✔️', + 'QA with AI ✓', + 'QA :heavy_check_mark:', + 'QA :white_check_mark:', + 'QA approved', + ]; protected function configure(): void { diff --git a/src/Command/GenerateTopQaCommand.php b/src/Command/GenerateTopQaCommand.php index 8a4b8ab..90000e8 100644 --- a/src/Command/GenerateTopQaCommand.php +++ b/src/Command/GenerateTopQaCommand.php @@ -9,9 +9,32 @@ class GenerateTopQaCommand extends AbstractCommand { - private const LABEL_QA = 'QA ✔️'; + /** + * Labels that EXPLICITLY declare a community-led validation. When the + * label carries the signal, we trust it — no company lookup needed. + */ + private const COMMUNITY_LABELS = [ + 'QA ✔️ by Community', + 'QA by Community ✓', + ]; - private const LABEL_QA_COMMUNITY = 'QA ✔️ by Community'; + /** + * Labels that EXPLICITLY declare an internal (dev/team) validation. + */ + private const DEV_LABELS = [ + 'QA by Dev ✓', + 'QA by dev ✔️', + ]; + + /** + * Every OTHER tracked label is neutral (`QA ✔️`, `QA ✓`, `QA :heavy_check_mark:`, + * etc.). On modules these are used by both team members and community + * reviewers indistinguishably, so we fall back to whether the actor was + * a PrestaShop employee AT THE TIME of the event (via the employees map + * in var/data/companies.json). Historic PRs stay attributed correctly + * even if the employee has since left. + */ + private const INTERNAL_CANONICAL_COMPANY = 'PrestaShop'; protected function configure(): void { @@ -48,7 +71,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int ? json_decode(file_get_contents(self::FILE_CONTRIBUTORS_PRS) ?: '', true) : []; - file_put_contents(self::FILE_TOP_QA, json_encode($this->buildRanking($events, $contributors), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $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.']); @@ -58,10 +83,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * @param array $events * @param array $contributors + * @param array> $employeeStints login -> employment periods at PrestaShop * * @return array{updatedAt: string, items: array} */ - public function buildRanking(array $events, array $contributors): array + public function buildRanking(array $events, array $contributors, array $employeeStints = []): array { $counts = []; foreach ($events as $ev) { @@ -71,12 +97,25 @@ public function buildRanking(array $events, array $contributors): array continue; } if (!isset($counts[$login])) { - $counts[$login] = ['qa' => 0, 'qa_community' => 0]; + $counts[$login] = [ + 'qa' => 0, 'qa_community' => 0, + 'countByYear' => [], 'qaByYear' => [], 'qaCommunityByYear' => [], + ]; } - if ($label === self::LABEL_QA) { - ++$counts[$login]['qa']; - } elseif ($label === self::LABEL_QA_COMMUNITY) { + $year = $this->extractYear($ev['createdAt'] ?? null); + $isCommunity = $this->isCommunityEvent($label, $login, $ev['createdAt'] ?? null, $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; + } } } @@ -104,6 +143,15 @@ public function buildRanking(array $events, array $contributors): array } $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, @@ -111,8 +159,11 @@ public function buildRanking(array $events, array $contributors): array '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; } @@ -120,6 +171,104 @@ public function buildRanking(array $events, array $contributors): array 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 + */ + 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) { + $start = strtotime($stint['startDate'] ?? ''); + if ($start === false) { + continue; + } + $rawEnd = $stint['endDate'] ?? ''; + // 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> + */ + private function loadInternalEmployeeStints(): array + { + if (!file_exists(self::FILE_DATA_COMPANIES)) { + return []; + } + /** @var array>}>|null $companies */ + $companies = json_decode(file_get_contents(self::FILE_DATA_COMPANIES) ?: '', true); + if (!is_array($companies)) { + return []; + } + foreach ($companies as $company) { + if (($company['name'] ?? null) === self::INTERNAL_CANONICAL_COMPANY) { + $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. From 44bab443f8c4f7417abb3eaa3da247e519157c98 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 28 Aug 2026 16:50:36 +0200 Subject: [PATCH 7/7] fix(qa): satisfy phpstan and php-cs-fixer - Import Throwable and drop the leading backslash (CS Fixer's global_namespace_import rule). - Loosen the docblock types on rows coming from json_decode to `array` and validate the shape inline; strict `array{...}` types were tripping "always exists / always evaluates to true" phpstan errors that reflected the docblock's optimism rather than runtime reality. - Add a proper docblock to FetchQaEventsCommand::dedup() (no value type in iterable). --- src/Command/FetchQaEventsCommand.php | 5 +++ src/Command/GenerateTopQaCommand.php | 46 ++++++++++++++++++---------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/Command/FetchQaEventsCommand.php b/src/Command/FetchQaEventsCommand.php index 5c2e5d9..deb75b5 100644 --- a/src/Command/FetchQaEventsCommand.php +++ b/src/Command/FetchQaEventsCommand.php @@ -143,6 +143,11 @@ private function fetchLabelEventsForRepo(string $label, string $repository): arr return $events; } + /** + * @param array $events + * + * @return array + */ private function dedup(array $events): array { $keyed = []; diff --git a/src/Command/GenerateTopQaCommand.php b/src/Command/GenerateTopQaCommand.php index 90000e8..961d393 100644 --- a/src/Command/GenerateTopQaCommand.php +++ b/src/Command/GenerateTopQaCommand.php @@ -6,6 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; class GenerateTopQaCommand extends AbstractCommand { @@ -81,9 +82,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * @param array $events + * @param array> $events raw event rows from gh_qa_events.json — keys are not statically guaranteed * @param array $contributors - * @param array> $employeeStints login -> employment periods at PrestaShop + * @param array $employeeStints login -> raw employment periods at PrestaShop (isCommunityEvent validates the shape) * * @return array{updatedAt: string, items: array} */ @@ -91,19 +92,23 @@ public function buildRanking(array $events, array $contributors, array $employee { $counts = []; foreach ($events as $ev) { - $login = $ev['actor'] ?? ''; - $label = $ev['label'] ?? ''; - if ($login === '') { + $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($ev['createdAt'] ?? null); - $isCommunity = $this->isCommunityEvent($label, $login, $ev['createdAt'] ?? null, $employeeStints); + $year = $this->extractYear($createdAt); + $isCommunity = $this->isCommunityEvent($label, $login, $createdAt, $employeeStints); if ($isCommunity) { ++$counts[$login]['qa_community']; } else { @@ -179,7 +184,7 @@ public function buildRanking(array $events, array $contributors, array $employee * Time-aware: historic events by someone who has since left the company * still count as internal. * - * @param array> $employeeStints + * @param array $employeeStints login -> raw stints array (validated inline) */ private function isCommunityEvent(string $label, string $login, ?string $createdAt, array $employeeStints): bool { @@ -206,11 +211,18 @@ private function isCommunityEvent(string $label, string $login, ?string $created } foreach ($stints as $stint) { - $start = strtotime($stint['startDate'] ?? ''); - if ($start === false) { + 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) { @@ -228,24 +240,24 @@ private function isCommunityEvent(string $label, string $login, ?string $created * Load the employees map for the internal PrestaShop company from * var/data/companies.json. Returns login -> list of employment stints. * - * @return array> + * @return array login -> raw stints (shape validated in isCommunityEvent) */ private function loadInternalEmployeeStints(): array { if (!file_exists(self::FILE_DATA_COMPANIES)) { return []; } - /** @var array>}>|null $companies */ $companies = json_decode(file_get_contents(self::FILE_DATA_COMPANIES) ?: '', true); if (!is_array($companies)) { return []; } foreach ($companies as $company) { - if (($company['name'] ?? null) === self::INTERNAL_CANONICAL_COMPANY) { - $employees = $company['employees'] ?? []; - - return is_array($employees) ? $employees : []; + if (!is_array($company) || ($company['name'] ?? null) !== self::INTERNAL_CANONICAL_COMPANY) { + continue; } + $employees = $company['employees'] ?? []; + + return is_array($employees) ? $employees : []; } return []; @@ -283,7 +295,7 @@ private function resolveProfile(string $login): array } try { $user = $this->github->getUser($login); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->output->writeLn([' Failed to resolve GitHub user ' . $login . ': ' . $e->getMessage()]); return $cache[$login] = [];