app/classes/ReleaseInsights/Beta.php (199 lines of code) (raw):
<?php
declare(strict_types=1);
namespace ReleaseInsights;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils as Promise;
use GuzzleHttp\HandlerStack;
use Kevinrob\GuzzleCache\CacheMiddleware;
use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage;
use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use ReleaseInsights\Bugzilla;
use ReleaseInsights\Release;
use ReleaseInsights\Json;
use ReleaseInsights\URL;
readonly class Beta
{
public int $count;
public int $number_betas;
public bool $beta_cycle_ended;
public function __construct(public int $release = BETA) {
$this->count = (int) explode('b', FIREFOX_BETA)[1];
// We get the number of betas from the planned schedule
$schedule = new Release((string) $release)->getSchedule();
$schedule = array_keys($schedule);
$schedule = array_filter($schedule, fn($label) => str_starts_with($label, 'beta_'));
$this->number_betas = count($schedule);
// Check if the beta cycle is over, this avoids a miscount for RC builds
if ($this->count >= $this->number_betas && ! defined('TESTING_CONTEXT')) {
// @codeCoverageIgnoreStart
// TODO: cache this request as it can take multiple seconds when hg is slow
$this->beta_cycle_ended = str_contains((string) get_headers(
URL::Mercurial->value . 'releases/mozilla-beta/json-pushes?fromchange=' . 'FIREFOX_BETA_' . BETA . '_END')[0],
'200');
// @codeCoverageIgnoreEnd
} else {
$this->beta_cycle_ended = false;
}
}
/**
* @return array<mixed>
*/
public function getLogEndpoints(): array
{
$hg_end_points = [];
[$have_rc, $number_rc_builds] = $this->RCStatus();
/*
Analyse Beta logs first
*/
foreach (range(0, $this->count) as $beta_number) {
$beta_start = ($beta_number == 0)
? 'FIREFOX_BETA_' . BETA . '_BASE'
: 'FIREFOX_' . BETA . '_0b' . $beta_number . '_RELEASE';
$beta_end = 'FIREFOX_' . BETA . '_0b' . (string) ($beta_number + 1) . '_RELEASE';
if ($beta_number == $this->count) {
$beta_end = 'tip';
// Just after merge day, we don't want to use tip for beta_end but the newly created tag
if ($this->beta_cycle_ended) {
$beta_end = 'FIREFOX_BETA_' . BETA . '_END'; // @codeCoverageIgnore
}
}
$beta_version = (string) BETA . '.0b' . (string) ($beta_number + 1);
// This is what landed on mozilla-beta after the last beta but before the merge and RC1
$beta_version = ($beta_number == $this->number_betas)
? (string) BETA . '.0rc0' // @codeCoverageIgnore
: (string) BETA . '.0b' . (string) ($beta_number + 1);
$hg_end_points[$beta_version] =
'releases/mozilla-beta/json-pushes?fromchange='
. $beta_start
. '&tochange='
. $beta_end
. '&full&version=2';
}
/*
Analyse Release logs for RCs if we are in RC week
Check if we have already shipped a Release Candidate build to the beta channel
Remote balrog API can give a 404, we have a fallback to N/A
*/
if ($have_rc) {
foreach (range(1, $number_rc_builds) as $rc_number) {
if ($rc_number == 1) {
$rc_start = 'FIREFOX_RELEASE_' . BETA . '_BASE';
$rc_end = 'FIREFOX_' . BETA . '_0_BUILD1';
} else {
// @codeCoverageIgnoreStart
$rc_start = 'FIREFOX_' . BETA . '_0_BUILD' . (string) ($rc_number - 1);
$rc_end = 'FIREFOX_' . BETA . '_0_BUILD' . (string) ($rc_number);
// @codeCoverageIgnoreEnd
}
$rc_version = (string) BETA . '.0rc' . (string) $rc_number;
// This is what landed on mozilla-beta after the last beta but before the merge and RC1
$hg_end_points[$rc_version] =
'releases/mozilla-release/json-pushes?fromchange='
. $rc_start
. '&tochange='
. $rc_end
. '&full&version=2';
}
}
return $hg_end_points;
}
/**
* We don't unit test this function as this is all http requests
*
* @return array<mixed>
* @codeCoverageIgnore
*/
public function getBugsFromLogs(): array
{
// Create a HandlerStack
$stack = HandlerStack::create();
$TTL = 600;
$cache_storage = new Psr6CacheStorage(
new FilesystemAdapter(
'guzzle', // Cache folder name
$TTL,
CACHE_PATH
)
);
// Add Cache Method
$stack->push(
new CacheMiddleware(
new GreedyCacheStrategy(
$cache_storage,
$TTL // the TTL in seconds
)
),
'greedy-cache'
);
// Initialize the client with the handler option
$client = new Client(['handler' => $stack, 'base_uri' => URL::Mercurial->value]);
// Initiate each request but do not block
$promises = [];
foreach ($this->getLogEndpoints() as $beta => $query) {
$promises[$beta] = $client->getAsync($query);
}
$responses = Promise::settle($promises)->wait();
$beta_logs = [];
foreach ($responses as $key => $json_log) {
$data = $json_log['value']->getBody()->getContents();
$beta_logs[$key] = Bugzilla::getBugsFromHgWeb(
query: $data,
detect_backouts: true,
cache_ttl: 3600*24
);
}
// Wait for the requests to complete, even if some of them fail
return $beta_logs;
}
/**
* Function relies heavily on external data, hard to unit test
* @return array<mixed>
* @codeCoverageIgnore
*/
public function uplifts(): array
{
$uplifts_per_beta = $this->getBugsFromLogs();
$log_links = array_map(fn($query) => URL::Mercurial->value . $query, $this->getLogEndpoints());
$log_links = array_map(fn($query) => str_replace('json-pushes', 'pushloghtml', $query), $log_links);
foreach ($log_links as $beta => $url) {
$uplifts_per_beta[$beta]['hg_link'] = $url;
}
foreach ($log_links as $beta => $url) {
$uplifts_per_beta[$beta]['bugzilla'] = Bugzilla::getBugListLink($uplifts_per_beta[$beta]['total']);
}
// We use a natural sort to avoid having a beta 10 listed after beta 1
ksort($uplifts_per_beta, SORT_NATURAL);
return $uplifts_per_beta;
}
/**
* Return all beta crashes
* @return array<mixed>
*/
public function crashes(): array
{
$data = [];
foreach (range(1, $this->count) as $beta_number) {
$beta_number = (string) $this->release . '.0b' . (string) $beta_number;
if (defined('TESTING_CONTEXT')) {
$beta_number = str_replace('94', '131', $beta_number);
$target = URL::Socorro->target() . 'crash-stats.mozilla.org_' . $beta_number . '.json';
} else {
$target = URL::Socorro->value . 'SuperSearch/?version=' . $beta_number . '&_facets=signature&product=Firefox'; // @codeCoverageIgnore
}
$temp = Json::load($target, 3600);
// In local tests, we always have test data
// @codeCoverageIgnoreStart
if (empty($temp)) {
$data[$beta_number] = [
'total' => 0,
'signatures' => [],
];
continue;
}
// @codeCoverageIgnoreEnd
$data[$beta_number] = [
'total' => $temp['total'],
'signatures' => $temp['facets']['signature'],
];
}
// Add crashes per RC
[$have_rc, $number_rc_builds] = $this->RCStatus();
if ($have_rc) {
foreach (range(1, $number_rc_builds) as $rc_number) {
$rc_number = (string) $this->release . '.0rc' . (string) $rc_number;
if (defined('TESTING_CONTEXT')) {
$rc_number = str_replace('94', '131', $rc_number);
$target = URL::Socorro->target() . 'crash-stats.mozilla.org_' . $rc_number . '.json';
} else {
$target = URL::Socorro->value . 'SuperSearch/?version=' . $rc_number . '&_facets=signature&product=Firefox'; // @codeCoverageIgnore
}
$temp = Json::load($target, 3600);
// In local tests, we always have test data
// @codeCoverageIgnoreStart
if (empty($temp)) {
$data[$rc_number] = [
'total' => 0,
'signatures' => [],
];
continue;
}
// @codeCoverageIgnoreEnd
$data[$rc_number] = [
'total' => $temp['total'],
'signatures' => $temp['facets']['signature'],
];
}
}
// Create a summary of the crashes across betas
$data['summary'] = [
'total' => array_sum(array_column($data, 'total')),
];
return $data;
}
/**
* Get the status of release candidates
*
* @return array<mixed>
*/
public function RCStatus() : array
{
if (defined('TESTING_CONTEXT')) {
$shipping_build = 'Firefox-94.0-build1';
} else {
$shipping_build = Json::load(URL::Balrog->value . 'rules/firefox-beta', 900)['mapping'] ?? 'N/A';// @codeCoverageIgnore
}
if ($shipping_build !== 'N/A') {
// We have Release candidates
[$product, $version, $build_number] = explode('-', (string) $shipping_build);
$is_rc_build = ! str_contains($version, 'b');
$number_rc_builds = $is_rc_build ? (int) str_replace('build', '', $build_number) : 0;
return [$is_rc_build, $number_rc_builds];
}
return [false, 0];// @codeCoverageIgnore
}
}