mirror of
https://github.com/ivuorinen/branch-usage-checker.git
synced 2026-02-25 12:52:34 +00:00
The spatie/data-transfer-object package is abandoned. Replace it with a native PHP 8.4 final readonly class using a static factory method, removing the dependency entirely.
275 lines
8.2 KiB
PHP
275 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Commands;
|
|
|
|
use App\Dto\PackagistApiPackagePayload;
|
|
use Illuminate\Http\Client\Factory as HttpFactory;
|
|
use Illuminate\Http\Client\Pool;
|
|
use LaravelZero\Framework\Commands\Command;
|
|
|
|
class CheckCommand extends Command
|
|
{
|
|
protected $signature = 'check
|
|
{vendor : Package vendor or vendor/package}
|
|
{package? : Package name}
|
|
{months=9 : How many months should we return for review (optional)}
|
|
';
|
|
protected $description = 'Check package branch usage';
|
|
|
|
private string $vendor = '';
|
|
private string $package = '';
|
|
private string $filter = '';
|
|
private int $totalBranches = 0;
|
|
|
|
private const NAME_PATTERN = '/^[a-z0-9]([_.\-]?[a-z0-9]+)*$/';
|
|
private const TIMEOUT_SECONDS = 10;
|
|
private const PACKAGIST_URL = 'https://packagist.org/packages/%s/%s';
|
|
|
|
private HttpFactory $http;
|
|
|
|
/** Execute the check command. */
|
|
public function handle(): int
|
|
{
|
|
$this->http = resolve(HttpFactory::class);
|
|
|
|
if (!$this->resolveInput()) {
|
|
return 1;
|
|
}
|
|
|
|
$months = (int) $this->argument('months');
|
|
|
|
$this->info(sprintf('Checking: %s/%s', $this->vendor, $this->package));
|
|
$this->info('Months: ' . $months);
|
|
|
|
$payload = $this->fetchPackageMetadata();
|
|
if ($payload === null) {
|
|
return 1;
|
|
}
|
|
$this->filter = now()->subMonths($months)->day(1)->toDateString();
|
|
|
|
try {
|
|
$pkg = PackagistApiPackagePayload::fromResponse($payload->json());
|
|
$this->info('Found the package. Type: ' . $pkg->type);
|
|
|
|
$versions = collect($pkg->versions ?? [])
|
|
->keys()
|
|
->filter(fn ($version) => \str_starts_with($version, 'dev-'))
|
|
->sort()
|
|
->values();
|
|
|
|
$this->totalBranches = $versions->count();
|
|
|
|
$this->info(
|
|
sprintf(
|
|
'Package has %d branches. Starting to download statistics.',
|
|
$this->totalBranches
|
|
)
|
|
);
|
|
|
|
$responses = $this->http->pool(
|
|
fn (Pool $pool) => $versions->map(
|
|
fn ($branch) => $pool->as($branch)->timeout(self::TIMEOUT_SECONDS)->get($this->getStatsUrl($branch))
|
|
)->toArray()
|
|
);
|
|
|
|
$statistics = $this->collectBranchStats($versions, $responses);
|
|
|
|
$this->info('Downloaded statistics...');
|
|
|
|
if ($this->outputTable($statistics)) {
|
|
$this->outputSuggestions($statistics);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
if ($e instanceof \TypeError) {
|
|
throw $e;
|
|
}
|
|
$this->error($e->getMessage());
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/** Parse and validate vendor/package input arguments. */
|
|
private function resolveInput(): bool
|
|
{
|
|
$vendor = strtolower((string) $this->argument('vendor'));
|
|
$package = $this->argument('package');
|
|
|
|
if (str_contains($vendor, '/')) {
|
|
if ($package !== null) {
|
|
$this->error(
|
|
'Conflicting arguments: vendor/package format'
|
|
. ' and separate package argument cannot be used together.'
|
|
);
|
|
return false;
|
|
}
|
|
[$vendor, $package] = explode('/', $vendor, 2);
|
|
}
|
|
|
|
if ($package === null || $package === '') {
|
|
$this->error('Missing package name. Usage: check vendor/package or check vendor package');
|
|
return false;
|
|
}
|
|
|
|
$package = strtolower((string) $package);
|
|
|
|
if (!preg_match(self::NAME_PATTERN, $vendor)) {
|
|
$this->error("Invalid vendor name: {$vendor}");
|
|
return false;
|
|
}
|
|
|
|
if (!preg_match(self::NAME_PATTERN, $package)) {
|
|
$this->error("Invalid package name: {$package}");
|
|
return false;
|
|
}
|
|
|
|
$this->vendor = $vendor;
|
|
$this->package = $package;
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Fetch package metadata from Packagist. */
|
|
private function fetchPackageMetadata(): ?\Illuminate\Http\Client\Response
|
|
{
|
|
$payload = $this->http->timeout(self::TIMEOUT_SECONDS)->get(
|
|
sprintf(
|
|
self::PACKAGIST_URL . '.json',
|
|
$this->vendor,
|
|
$this->package
|
|
)
|
|
);
|
|
|
|
if ($payload->failed()) {
|
|
if ($payload->status() === 404) {
|
|
$this->error("Package not found: {$this->vendor}/{$this->package}");
|
|
return null;
|
|
}
|
|
$this->error("Failed to fetch package metadata (HTTP {$payload->status()})");
|
|
return null;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/** Build the Packagist stats API URL for a branch. */
|
|
private function getStatsUrl(string $branch): string
|
|
{
|
|
return sprintf(
|
|
self::PACKAGIST_URL . '/stats/%s.json?average=monthly&from=%s',
|
|
$this->vendor,
|
|
$this->package,
|
|
$branch,
|
|
$this->filter
|
|
);
|
|
}
|
|
|
|
/** Parse pooled responses into branch download statistics. */
|
|
private function collectBranchStats($versions, array $responses): array
|
|
{
|
|
$statistics = [];
|
|
foreach ($versions as $branch) {
|
|
$response = $responses[$branch];
|
|
|
|
if ($response instanceof \Throwable) {
|
|
$this->warn("Failed to fetch stats for {$branch}, skipping.");
|
|
continue;
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
$this->warn("Failed to fetch stats for {$branch} (HTTP {$response->status()}), skipping.");
|
|
continue;
|
|
}
|
|
|
|
$data = collect($response->json());
|
|
$labels = collect($data->get('labels', []))->toArray();
|
|
$values = collect($data->get('values', []))->flatten()->toArray();
|
|
|
|
$labels[] = 'Total';
|
|
$values[] = array_sum($values);
|
|
|
|
if (count($labels) !== count($values)) {
|
|
$this->warn(sprintf(
|
|
'Malformed stats for %s (labels: %d, values: %d), skipping.',
|
|
$branch,
|
|
count($labels),
|
|
count($values)
|
|
));
|
|
continue;
|
|
}
|
|
$statistics[$branch] = \array_combine($labels, $values);
|
|
}
|
|
|
|
return $statistics;
|
|
}
|
|
|
|
/** Render the download statistics table. */
|
|
private function outputTable(array $statistics): bool
|
|
{
|
|
if (empty($statistics)) {
|
|
$this->info('No statistics found... Stopping.');
|
|
return false;
|
|
}
|
|
|
|
$tableHeaders = ['' => 'Branch'];
|
|
$tableBranches = [];
|
|
|
|
foreach ($statistics as $branch => $stats) {
|
|
foreach ($stats as $m => $v) {
|
|
$tableHeaders[$m] = (string) $m;
|
|
$tableBranches[$branch][$branch] = $branch;
|
|
$tableBranches[$branch][$m] = (string) $v;
|
|
}
|
|
}
|
|
|
|
$this->line('');
|
|
$this->table($tableHeaders, $tableBranches);
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Render suggestions for zero-download branches. */
|
|
private function outputSuggestions(array $statistics): void
|
|
{
|
|
$deletable = [];
|
|
|
|
foreach ($statistics as $k => $values) {
|
|
if (!empty($values['Total'])) {
|
|
continue;
|
|
}
|
|
$deletable[] = $k;
|
|
}
|
|
|
|
if (empty($deletable)) {
|
|
$this->info('No suggestions available. Good job!');
|
|
return;
|
|
}
|
|
|
|
$branches = collect($deletable)->mapWithKeys(function ($branch) {
|
|
return [
|
|
$branch => [
|
|
$branch,
|
|
sprintf(
|
|
self::PACKAGIST_URL . '#%s',
|
|
$this->vendor,
|
|
$this->package,
|
|
$branch
|
|
),
|
|
],
|
|
];
|
|
});
|
|
|
|
$this->line('');
|
|
$this->info(
|
|
sprintf(
|
|
'Found %d branches (out of %d total) with no downloads since %s',
|
|
$branches->count(),
|
|
$this->totalBranches,
|
|
$this->filter
|
|
)
|
|
);
|
|
$this->table(['Branch', 'URL'], $branches);
|
|
}
|
|
}
|