Skip to content

Commit c091798

Browse files
authored
Merge pull request #246 from PrestaEdit/feat/top-qa-ranking
feat(qa): top QA contributors ranking
2 parents 03a719c + 44bab44 commit c091798

5 files changed

Lines changed: 489 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ The authentication use a Github Token.
6161
# 10- Generate the security contributors leaderboard (research + remediation)
6262
$ php bin/console traces:generate:topsecurity --config="config.yml"
6363
## A file top_security.json is generated
64+
65+
# 11- Fetch QA label events (all QA-prefixed validation labels) from merged PRs
66+
$ php bin/console traces:fetch:qaevents
67+
## A file gh_qa_events.json is generated
68+
69+
# 12- Generate the QA contributors leaderboard
70+
$ php bin/console traces:generate:topqa --config="config.yml"
71+
## A file top_qa.json is generated
6472
```
6573

6674
## Configuring

bin/console

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ use PrestaShop\Traces\Command\FetchContributorsCommand;
66
use PrestaShop\Traces\Command\FetchIssuesCommand;
77
use PrestaShop\Traces\Command\FetchPullRequestsAllCommand;
88
use PrestaShop\Traces\Command\FetchPullRequestsMergedCommand;
9+
use PrestaShop\Traces\Command\FetchQaEventsCommand;
910
use PrestaShop\Traces\Command\FetchRepositoriesCommand;
1011
use PrestaShop\Traces\Command\FetchSecurityAdvisoriesCommand;
1112
use PrestaShop\Traces\Command\GenerateNewContributorsCommand;
1213
use PrestaShop\Traces\Command\GenerateTopCompaniesCommand;
14+
use PrestaShop\Traces\Command\GenerateTopQaCommand;
1315
use PrestaShop\Traces\Command\GenerateTopSecurityCommand;
1416
use PrestaShop\Traces\Command\GenerateTopStatsCommand;
1517
use Symfony\Component\Console\Application;
@@ -28,10 +30,12 @@ $app->addCommand(new FetchContributorsCommand());
2830
$app->addCommand(new FetchIssuesCommand());
2931
$app->addCommand(new FetchPullRequestsAllCommand());
3032
$app->addCommand(new FetchPullRequestsMergedCommand());
33+
$app->addCommand(new FetchQaEventsCommand());
3134
$app->addCommand(new FetchRepositoriesCommand());
3235
$app->addCommand(new FetchSecurityAdvisoriesCommand());
3336
$app->addCommand(new GenerateNewContributorsCommand());
3437
$app->addCommand(new GenerateTopCompaniesCommand());
38+
$app->addCommand(new GenerateTopQaCommand());
3539
$app->addCommand(new GenerateTopSecurityCommand());
3640
$app->addCommand(new GenerateTopStatsCommand());
3741
$app->run();

src/Command/AbstractCommand.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class AbstractCommand extends Command
3939

4040
protected const FILE_TOP_SECURITY = 'top_security.json';
4141

42+
protected const FILE_QA_EVENTS = 'gh_qa_events.json';
43+
44+
protected const FILE_TOP_QA = 'top_qa.json';
45+
4246
protected const FILE_GHLOGIN_WO_COMPANY = 'gh_loginsWOCompany.json';
4347

4448
protected const FILE_DATA_COMPANIES = 'var/data/companies.json';
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
namespace PrestaShop\Traces\Command;
4+
5+
use Symfony\Component\Console\Input\InputInterface;
6+
use Symfony\Component\Console\Input\InputOption;
7+
use Symfony\Component\Console\Output\OutputInterface;
8+
9+
class FetchQaEventsCommand extends AbstractCommand
10+
{
11+
/**
12+
* Every label variant used across the org that signals a QA validation.
13+
* Discovered by scanning every repo's label list — the naming has drifted
14+
* over time (different check marks, unrendered emoji codes, "by Dev" vs
15+
* "by Community", etc.). We keep them all here and bucket them into
16+
* `qa` / `qa_community` in GenerateTopQaCommand.
17+
*/
18+
private const QA_LABELS = [
19+
'QA ✔️',
20+
'QA ✔️ by Community',
21+
'QA ✓',
22+
'QA by Community ✓',
23+
'QA by Dev ✓',
24+
'QA by dev ✔️',
25+
'QA with AI ✓',
26+
'QA :heavy_check_mark:',
27+
'QA :white_check_mark:',
28+
'QA approved',
29+
];
30+
31+
protected function configure(): void
32+
{
33+
$this->setName('traces:fetch:qaevents')
34+
->setDescription('Fetch QA label events from merged PRs across the PrestaShop org')
35+
->addOption(
36+
'ghtoken',
37+
null,
38+
InputOption::VALUE_OPTIONAL,
39+
'',
40+
isset($_ENV['GH_TOKEN']) ? (string) $_ENV['GH_TOKEN'] : null
41+
);
42+
}
43+
44+
protected function execute(InputInterface $input, OutputInterface $output): int
45+
{
46+
parent::execute($input, $output);
47+
48+
if (!file_exists(self::FILE_REPOSITORIES)) {
49+
$this->output->writeLn(self::FILE_REPOSITORIES . ' is missing. Please execute `php bin/console traces:fetch:repositories`');
50+
51+
return 1;
52+
}
53+
54+
$repositories = json_decode(file_get_contents(self::FILE_REPOSITORIES) ?: '', true);
55+
$time = time();
56+
$events = [];
57+
58+
foreach (self::QA_LABELS as $label) {
59+
$this->output->writeLn(['', 'Label: ' . $label]);
60+
foreach ($repositories as $repository) {
61+
$repoEvents = $this->fetchLabelEventsForRepo($label, $repository);
62+
if (count($repoEvents) > 0) {
63+
$this->output->writeLn([' PrestaShop/' . $repository . ': ' . count($repoEvents) . ' events']);
64+
}
65+
$events = array_merge($events, $repoEvents);
66+
}
67+
}
68+
69+
$events = $this->dedup($events);
70+
71+
file_put_contents(self::FILE_QA_EVENTS, json_encode([
72+
'events' => array_values($events),
73+
'fetchedAt' => date('c'),
74+
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
75+
76+
$this->output->writeLn(['', count($events) . ' unique QA events written to ' . self::FILE_QA_EVENTS . ' in ' . (time() - $time) . 's.']);
77+
78+
return 0;
79+
}
80+
81+
/**
82+
* Search PRs by label WITHIN a single repository. The GitHub search API
83+
* caps results at 1000 per query — segmenting by repo keeps every module
84+
* well under that ceiling (the only volume risk was the core repo, which
85+
* does not use these labels).
86+
*
87+
* @return array<array{repo:string, pr_number:int, actor:string, label:string, createdAt:string}>
88+
*/
89+
private function fetchLabelEventsForRepo(string $label, string $repository): array
90+
{
91+
$labelEscaped = str_replace('"', '\\"', $label);
92+
$queryTpl = 'search(query: "repo:PrestaShop/' . $repository . ' label:\"' . $labelEscaped . '\" is:pr is:merged", type: ISSUE, first: 100, after: %s) {
93+
pageInfo { endCursor hasNextPage }
94+
nodes {
95+
... on PullRequest {
96+
number
97+
repository { name }
98+
timelineItems(itemTypes: [LABELED_EVENT], first: 100) {
99+
nodes {
100+
... on LabeledEvent {
101+
actor { login }
102+
label { name }
103+
createdAt
104+
}
105+
}
106+
}
107+
}
108+
}
109+
}';
110+
111+
$events = [];
112+
$after = 'null';
113+
do {
114+
$data = $this->github->apiSearchGraphQL('query { ' . sprintf($queryTpl, $after) . ' }');
115+
$search = $data['data']['search'] ?? null;
116+
if ($search === null) {
117+
break;
118+
}
119+
foreach ($search['nodes'] as $pr) {
120+
if (empty($pr['number'])) {
121+
continue;
122+
}
123+
foreach ($pr['timelineItems']['nodes'] as $ev) {
124+
if (($ev['label']['name'] ?? null) !== $label) {
125+
continue;
126+
}
127+
if (empty($ev['actor']['login'])) {
128+
continue;
129+
}
130+
$events[] = [
131+
'repo' => $pr['repository']['name'],
132+
'pr_number' => $pr['number'],
133+
'actor' => $ev['actor']['login'],
134+
'label' => $label,
135+
'createdAt' => $ev['createdAt'],
136+
];
137+
}
138+
}
139+
$endCursor = $search['pageInfo']['endCursor'] ?? null;
140+
$after = $endCursor === null ? 'null' : '"' . $endCursor . '"';
141+
} while (($search['pageInfo']['hasNextPage'] ?? false) === true);
142+
143+
return $events;
144+
}
145+
146+
/**
147+
* @param array<int, array{repo:string, pr_number:int, actor:string, label:string, createdAt:string}> $events
148+
*
149+
* @return array<string, array{repo:string, pr_number:int, actor:string, label:string, createdAt:string}>
150+
*/
151+
private function dedup(array $events): array
152+
{
153+
$keyed = [];
154+
foreach ($events as $ev) {
155+
$key = $ev['repo'] . '#' . $ev['pr_number'] . '@' . $ev['actor'] . '|' . $ev['label'];
156+
if (!isset($keyed[$key]) || strcmp($ev['createdAt'], $keyed[$key]['createdAt']) < 0) {
157+
$keyed[$key] = $ev;
158+
}
159+
}
160+
161+
return $keyed;
162+
}
163+
}

0 commit comments

Comments
 (0)