diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..50eff41 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# For more information about the properties used in +# this file, please see the EditorConfig documentation: +# http://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf02210 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + ci: + name: CI + uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55940e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock \ No newline at end of file diff --git a/README.md b/README.md index f4537f4..721de9b 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,16 @@ Metadata and some supporting PHP logic for determining which branches of various GitHub repositories relate to which versions of Silverstripe CMS. > [!IMPORTANT] -> Only the main branch of this repository is maintained. +> Only the `main` branch of this repository is maintained. -You can fetch the JSON by simply fetching the raw copy of `repositories.json` file, e.g. - -It's known to be used in the following repositories: - -- [silverstripe/cow](https://github.com/silverstripe/cow) -- [silverstripe/tx-translator](https://github.com/silverstripe/silverstripe-tx-translator/) -- [bringyourownideas/silverstripe-maintainence](https://github.com/bringyourownideas/silverstripe-maintenance) -- [silverstripe/github-issue-search-client](https://github.com/silverstripe/github-issue-search-client) -- [silverstripe/module-standardiser](https://github.com/silverstripe/module-standardiser) +You can fetch the JSON by simply fetching the raw copy of `repositories.json` file, e.g. , though you're encouraged to use composer to pull in the data instead where appropriate. ## Format There are several sections in the `repositories.json` file, denoting different categories of repositories: - `supportedModules`: Repositories representing supported modules. If cow cares about it, it should probably be in this category. -- `workflow`: Repositories which hold GitHub actions and workflows. Note that this section omits the `packagist` key. +- `workflow`: Repositories which hold GitHub actions and workflows. - `tooling`: Repositories used to help streamline Silverstripe CMS maintenance - `misc`: All repositories we need to track which don't fit in one of the above categories. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5154dc0 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "silverstripe/supported-modules", + "description": "Metadata about Silverstripe CMS supported modules and other repositories maintained by Silverstripe", + "autoload": { + "psr-4": { + "SilverStripe\\SupportedModules\\": "src/", + "SilverStripe\\SupportedModules\\Tests\\": "tests/" + } + }, + "require": { + "php": "^8.1", + "composer/semver": "^3.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d797431 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/repositories.json b/repositories.json index 5ddd10a..aea52fc 100644 --- a/repositories.json +++ b/repositories.json @@ -14,8 +14,8 @@ } }, { - "github": "bringyourownideas/silverstripe-packagist-update-checker", - "packagist": "bringyourownideas/silverstripe-packagist-update-checker", + "github": "bringyourownideas/silverstripe-composer-update-checker", + "packagist": "bringyourownideas/silverstripe-composer-update-checker", "githubId": 41240800, "isCore": false, "lockstepped": false, @@ -296,7 +296,7 @@ }, { "github": "silverstripe/silverstripe-ckan-registry", - "composer": "silverstripe/ckan-registry", + "packagist": "silverstripe/ckan-registry", "githubId": 159571764, "isCore": false, "lockstepped": false, @@ -372,6 +372,7 @@ "lockstepped": false, "type": "module", "majorVersionMapping": { + "4": ["4"], "5": ["5"], "6": ["6"] } @@ -696,7 +697,7 @@ "githubId": 411910754, "isCore": false, "lockstepped": false, - "type": "module", + "type": "recipe", "majorVersionMapping": { "4": ["2"] } @@ -808,7 +809,7 @@ { "github": "silverstripe/recipe-solr-search", "packagist": "silverstripe/recipe-solr-search", - "githubId": 411910754, + "githubId": 411886231, "isCore": false, "lockstepped": true, "type": "recipe", @@ -1121,7 +1122,7 @@ } }, { - "github": "silverstripe-themes/silverstripe-simple", + "github": "silverstripe/silverstripe-simple", "packagist": "silverstripe-themes/simple", "githubId": 3712566, "isCore": true, @@ -1431,8 +1432,7 @@ "githubId": 702760633, "majorVersionMapping": { "*": [] - }, - "private": true + } }, { "github": "silverstripe/github-issue-search-client", @@ -1481,6 +1481,14 @@ "majorVersionMapping": { "*": [] } + }, + { + "github": "silverstripe/supported-modules", + "packagist": "silverstripe/supported-modules", + "githubId": 67956860, + "majorVersionMapping": { + "*": [] + } } ], "misc": [ @@ -1565,4 +1573,4 @@ } } ] -} \ No newline at end of file +} diff --git a/src/BranchLogic.php b/src/BranchLogic.php new file mode 100644 index 0000000..66367e7 --- /dev/null +++ b/src/BranchLogic.php @@ -0,0 +1,247 @@ + preg_match('#^[0-9]+\.?[0-9]*$#', $branch)); + + // If there are no relevant branches for a repository, there's nothing to merge up. + if (empty($repoBranches)) { + return []; + } + + $onlyMajorBranches = array_filter($repoBranches, fn ($branch) => ctype_digit((string) $branch)); + $majorDiff = static::getMajorDiff($repoMetaData, $onlyMajorBranches, $defaultBranch, $composerJson); + + $minorsWithStableTags = []; + foreach ($repoTags as $tag) { + if (!preg_match('#^([0-9]+)\.([0-9]+)\.([0-9]+)$#', $tag, $matches)) { + continue; + } + $major = $matches[1]; + $minor = $major. '.' . $matches[2]; + $minorsWithStableTags[$major][$minor] = true; + } + + $branches = []; + foreach ($repoBranches as $branch) { + // filter out majors that are too old - try getting the metadata CMS version first, + // since some repos have multiple branches for a given CMS major release line. + $cmsMajor = BranchLogic::getCmsMajor($repoMetaData, $branch); + if (!$cmsMajor) { + preg_match('#^([0-9]+)\.?[0-9]*$#', $branch, $matches); + $cmsMajor = $matches[1] + $majorDiff; + } + if ($cmsMajor < MetaData::LOWEST_SUPPORTED_CMS_MAJOR) { + continue; + } + // suffix a temporary .999 minor version to major branches so that it's sorted correctly later + if (preg_match('#^[0-9]+$#', $branch)) { + $branch .= '.999'; + } + $branches[] = $branch; + } + + // sort so that newest is first + usort($branches, 'version_compare'); + $branches = array_reverse($branches); + + // remove the temporary .999 + array_walk($branches, function(&$branch) { + $branch = preg_replace('#\.999$#', '', $branch); + }); + + // remove all branches except: + // - the latest major branch in each release line + // - the latest minor branch with a stable tag in each release line + // - any minor branches without stable tags with a higher minor version than the latest minor with a stable tag + $foundMinorInMajor = []; + $foundMinorBranchWithStableTag = []; + foreach ($branches as $i => $branch) { + // only remove minor branches, leave major branches in + if (!preg_match('#^([0-9]+)\.[0-9]+$#', $branch, $matches)) { + continue; + } + $major = $matches[1]; + if (isset($foundMinorBranchWithStableTag[$major]) && isset($foundMinorInMajor[$major])) { + unset($branches[$i]); + continue; + } + if (isset($minorsWithStableTags[$major][$branch])) { + $foundMinorBranchWithStableTag[$major] = true; + } + $foundMinorInMajor[$major] = true; + } + + // remove any branches less than or equal to DO_NOT_MERGE_UP_FROM_MAJOR + if (isset(MetaData::DO_NOT_MERGE_UP_FROM_MAJOR[$githubRepository])) { + $doNotMergeUpFromMajor = MetaData::DO_NOT_MERGE_UP_FROM_MAJOR[$githubRepository]; + $branches = array_filter($branches, function($branch) use ($doNotMergeUpFromMajor) { + return version_compare($branch, "$doNotMergeUpFromMajor.999999.999999", '>'); + }); + } + + // reverse the array so that oldest is first + return array_reverse($branches); + } + + private static function getCmsMajorFromBranch(array $repoMetaData, string $branch): string + { + $branchMajor = ''; + if (preg_match('#^[1-9]+$#', $branch)) { + $branchMajor = $branch; + } elseif (preg_match('#^([1-9]+)\.[0-9]+$#', $branch, $matches)) { + $branchMajor = $matches[1]; + } + foreach ($repoMetaData['majorVersionMapping'] ?? [] as $cmsMajor => $repoBranches) { + if (is_numeric($cmsMajor) && in_array($branchMajor, $repoBranches)) { + return $cmsMajor; + } + } + return ''; + } + + private static function getCmsMajorFromComposerJson(stdClass $composerJsonContent, bool $usePhpDepAsFallback): string + { + foreach (MetaData::getAllRepositoryMetaData() as $categoryData) { + foreach ($categoryData as $repoData) { + $composerName = $repoData['packagist'] ?? null; + if ($composerName === null || !isset($composerJsonContent->require->$composerName)) { + continue; + } + $parser = new VersionParser(); + $constraint = $parser->parseConstraints($composerJsonContent->require->$composerName); + $boundedVersion = explode('.', $constraint->getLowerBound()->getVersion()); + $composerVersionMajor = $boundedVersion[0]; + // If it's a non-numeric branch constraint or something unstable, don't use it + if ($composerVersionMajor === 0) { + continue; + } + foreach ($repoData['majorVersionMapping'] as $cmsMajor => $repoBranches) { + if (is_numeric($cmsMajor) && in_array($composerVersionMajor, $repoBranches)) { + return $cmsMajor; + } + } + } + } + // Fall back on PHP dependency if that's an option + if ($usePhpDepAsFallback && isset($composerJsonContent->require->php)) { + // Loop through in ascending order - the first result that matches is returned. + foreach (MetaData::PHP_VERSIONS_FOR_CMS_RELEASES as $cmsRelease => $phpVersions) { + // Ignore anything that's not a major release + if (!ctype_digit((string) $cmsRelease)) { + continue; + } + // Only look at the lowest-compatible PHP version of each major release, + // since there's some overlap between major releases + if (Semver::satisfies($phpVersions[0], $composerJsonContent->require->php)) { + return $cmsRelease; + } + } + } + return ''; + } + + /** + * Get the difference between the branch major and the CMS release major, e.g for silverstripe/admin CMS 5 => 5 - 2 = 3 + */ + private static function getMajorDiff(array $repoMetaData, array $onlyMajorBranches, string $defaultBranch, ?stdClass $composerJson): int + { + // work out default major + if (preg_match('#^([0-9]+)+\.?[0-9]*$#', $defaultBranch, $matches)) { + $defaultMajor = $matches[1]; + if (!in_array($defaultMajor, $onlyMajorBranches)) { + // Add default major to the end of the list, so it's checked last + $onlyMajorBranches[] = $defaultMajor; + } + } + + // Try to get diff from branch if we can + foreach ($onlyMajorBranches as $branch) { + $cmsMajor = (int) static::getCmsMajorFromBranch($repoMetaData, $branch); + if ($cmsMajor) { + return $cmsMajor - $branch; + } + } + + if ($composerJson !== null && isset($defaultMajor)) { + $cmsMajor = (int) static::getCmsMajorFromComposerJson($composerJson, true); + if ($cmsMajor) { + return $cmsMajor - $defaultMajor; + } + } + + // This is likely a maintenance-based respository such as silverstripe/eslint-config or silverstripe/gha-auto-tag + // Just treat them as though they're on the highest stable version. + if (isset($defaultMajor) && ($composerJson === null || array_key_exists('*', $repoMetaData['majorVersionMapping'] ?? []))) { + return MetaData::HIGHEST_STABLE_CMS_MAJOR - $defaultMajor; + } + + $repoName = static::getModuleName($repoMetaData, $composerJson) ?: 'this module'; + throw new RuntimeException("Could not work out what default CMS major version for $repoName"); + } + + private static function getModuleName(array $repoMetaData, ?stdClass $composerJson): string + { + if ($composerJson !== null && isset($composerJson->name)) { + return $composerJson->name; + } + if (isset($repoMetaData['packagist'])) { + return $repoMetaData['packagist']; + } + if (isset($repoMetaData['github'])) { + return $repoMetaData['github']; + } + return ''; + } +} diff --git a/src/MetaData.php b/src/MetaData.php new file mode 100644 index 0000000..a180199 --- /dev/null +++ b/src/MetaData.php @@ -0,0 +1,165 @@ + ['7.1', '7.2', '7.3', '7.4'], + '4.10' => ['7.3', '7.4', '8.0'], + '4.11' => ['7.4', '8.0', '8.1'], + '4' => ['7.4', '8.0', '8.1'], + '5.0' => ['8.1', '8.2'], + '5.1' => ['8.1', '8.2'], + '5.2' => ['8.1', '8.2', '8.3'], + '5' => ['8.1', '8.2', '8.3'], + '6' => ['8.1', '8.2', '8.3'], + ]; + + /** + * List of major branches to not merge up from + * + * Add repos in here where the repo was previously unsupported, where the repo has + * had gaps in its support history, or where we have had multiple supported modules + * for a given major release and want to omit one of those for merge-up purposes. + * + * Note these are actual major branches, not CMS major versions + */ + public const DO_NOT_MERGE_UP_FROM_MAJOR = [ + 'bringyourownideas/silverstripe-composer-update-checker' => '2', + 'silverstripe/silverstripe-graphql' => '3', + 'silverstripe/silverstripe-linkfield' => '3', + 'tractorcow-farm/silverstripe-fluent' => '4', + ]; + + /** + * List of repositories that should be outright skipped for merge-up purposes. + * Only list them if they're causing errors in the existing logic. + */ + public const SKIP_FOR_MERGE_UP = [ + 'silverstripe/cow', + ]; + + private static array $repositoryMetaData = []; + + /** + * Get metadata for a given repository, if we have any. + * + * @param string $gitHubReference The full GitHub reference for the repository + * e.g. `silverstripe/silverstripe-framework`. + * @param boolean $allowPartialMatch If no data is found for the full repository reference, + * check for repositories with the same name but a different organisation. + */ + public static function getMetaDataForRepository( + string $gitHubReference, + bool $allowPartialMatch = false + ): array { + $parts = explode('/', $gitHubReference); + if (count($parts) !== 2) { + throw new RuntimeException('$gitHubReference must be a valid org/repo reference.'); + } + $candidate = null; + foreach (self::getAllRepositoryMetaData() as $categoryData) { + foreach ($categoryData as $repoData) { + // Get data for the current repository + if ($repoData['github'] === $gitHubReference) { + // Exact match of org and repo name + return $repoData; + } elseif ($parts[1] === explode('/', $repoData['github'])[1]) { + // Partial match - repo name only + $candidate = $repoData; + } + } + } + if ($allowPartialMatch && $candidate !== null) { + return $candidate; + } + return []; + } + + /** + * Get metadata for a given repository based on the packagist name, if we have any. + * + * @param string $packagistName The full packagist reference for the repository + * e.g. `silverstripe/framework`. + */ + public static function getMetaDataByPackagistName(string $packagistName): array + { + if (!str_contains($packagistName, '/')) { + throw new RuntimeException('$packagistName must be a valid org/repo reference.'); + } + foreach (self::getAllRepositoryMetaData() as $categoryData) { + foreach ($categoryData as $repoData) { + // Get data for the packagist item + if (isset($repoData['packagist']) && $repoData['packagist'] === $packagistName) { + // Exact match of org and repo name + return $repoData; + } + } + } + return []; + } + + /** + * Get metadata for repositories that are released in lock-step with Silverstripe CMS minor releases. + */ + public static function getMetaDataForLocksteppedRepos(): array + { + $repos = []; + foreach (self::getAllRepositoryMetaData() as $category => $categoryData) { + // Skip anything that can't be lockstepped + if ($category !== self::CATEGORY_SUPPORTED) { + continue; + } + // Find lockstepped repos + foreach ($categoryData as $repoData) { + if (isset($repoData['lockstepped']) && $repoData['lockstepped'] && !empty($repoData['packagist'])) { + $repos[$repoData['packagist']] = $repoData['majorVersionMapping']; + } + } + } + return $repos; + } + + /** + * Get all metadata about all repositories we have information about + */ + public static function getAllRepositoryMetaData(): array + { + if (empty(self::$repositoryMetaData)) { + $rawJson = file_get_contents(__DIR__ . '/../repositories.json'); + $decodedJson = json_decode($rawJson, true); + if ($decodedJson === null) { + throw new RuntimeException('Could not parse repositories.json data: ' . json_last_error_msg()); + } + self::$repositoryMetaData = $decodedJson; + } + return self::$repositoryMetaData; + } +} diff --git a/tests/BranchLogicTest.php b/tests/BranchLogicTest.php new file mode 100644 index 0000000..04e200f --- /dev/null +++ b/tests/BranchLogicTest.php @@ -0,0 +1,846 @@ + [ + 'githubRepository' => 'some/module', + 'branch' => '', + 'composerJson' => null, + 'usePhpDepAsFallback' => false, + 'expected' => '', + ], + 'PR branch not used to find data' => [ + 'githubRepository' => 'silverstripe/silverstripe-framework', + 'branch' => 'pulls/5/mybugfix', + 'composerJson' => null, + 'usePhpDepAsFallback' => true, + 'expected' => '', + ], + 'lockstepped with matching major' => [ + 'githubRepository' => 'silverstripe/silverstripe-framework', + 'branch' => '5', + 'composerJson' => null, + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'lockstepped with matching major, use minor branch' => [ + 'githubRepository' => 'silverstripe/silverstripe-framework', + 'branch' => '5.2', + 'composerJson' => null, + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'lockstepped with different major' => [ + 'githubRepository' => 'silverstripe/silverstripe-admin', + 'branch' => '3', + 'composerJson' => null, + 'usePhpDepAsFallback' => false, + 'expected' => '6', + ], + 'non-lockstepped' => [ + 'githubRepository' => 'silverstripe/silverstripe-tagfield', + 'branch' => '3.9', + 'composerJson' => null, + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'non-module repo' => [ + 'githubRepository' => 'silverstripe/webpack-config', + 'branch' => '3', + 'composerJson' => null, + 'usePhpDepAsFallback' => false, + 'expected' => '6', + ], + 'n.x-dev constraint' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => ['silverstripe/framework' => '5.x-dev'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'n.m.x-dev constraint' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => ['silverstripe/framework' => '5.0.x-dev'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + '^n constraint' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => ['silverstripe/framework' => '^5'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'x.y.z constraint' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => ['silverstripe/framework' => '5.1.2'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'result is actual cms major, not just the dep major' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => ['silverstripe/admin' => '^2'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'If branch matches, composerjson is ignored' => [ + 'githubRepository' => 'silverstripe/silverstripe-framework', + 'branch' => '5.2', + 'composerJson' => [ + 'require' => ['silverstripe/admin' => '1.x-dev'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'composerjson used even for known modules if needed' => [ + 'githubRepository' => 'silverstripe/silverstripe-framework', + 'branch' => 'main', + 'composerJson' => [ + 'require' => ['silverstripe/admin' => '1.x-dev'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '4', + ], + 'composer plugins are valid deps' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => ['silverstripe/vendor-plugin' => '^1'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '4', + ], + 'branch is ignored when we lack metadata' => [ + 'githubRepository' => 'some/module', + 'branch' => '3', + 'composerJson' => [ + 'require' => ['silverstripe/framework' => '^5'], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '5', + ], + 'framework takes presedence over composer plugins' => [ + 'githubRepository' => 'some/module', + 'branch' => '3', + 'composerJson' => [ + 'require' => [ + 'silverstripe/vendor-plugin' => '^1', + 'silverstripe/recipe-plugin' => '^1', + 'silverstripe/framework' => '^6' + ], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '6', + ], + 'PHP only used if explicitly asked for' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => [ + 'php' => '^8.1', + 'unknown/dependency' => '^1' + ], + ], + 'usePhpDepAsFallback' => false, + 'expected' => '', + ], + 'PHP matches minimum allowed cms4' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => [ + 'php' => '^7.4', + 'unknown/dependency' => '^1' + ], + ], + 'usePhpDepAsFallback' => true, + 'expected' => '4', + ], + 'PHP matches minimum allowed cms5' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => [ + 'php' => '^8.1', + 'unknown/dependency' => '^1' + ], + ], + 'usePhpDepAsFallback' => true, + 'expected' => '5', + ], + 'PHP doesnt have to be exactly the same as installer constraint' => [ + 'githubRepository' => 'some/module', + 'branch' => 'mybranch', + 'composerJson' => [ + 'require' => [ + 'php' => '^8.0', + 'unknown/dependency' => '^1' + ], + ], + 'usePhpDepAsFallback' => true, + 'expected' => '5', + ], + 'tried everything, no match' => [ + 'githubRepository' => 'some/module', + 'branch' => '3', + 'composerJson' => [ + 'require' => [ + 'php' => '^5.4', + 'silverstripe/framework' => '^2', + ], + ], + 'usePhpDepAsFallback' => true, + 'expected' => '', + ], + ]; + } + + /** + * @dataProvider provideGetCmsMajor + */ + public function testGetCmsMajor( + string $githubRepository, + string $branch, + ?array $composerJson, + bool $usePhpDepAsFallback, + string $expected + ): void { + if (is_array($composerJson)) { + // Convert array json into stdClass + $composerJson = json_decode(json_encode($composerJson)); + } + $repoMetaData = MetaData::getMetaDataForRepository($githubRepository); + $cmsMajor = BranchLogic::getCmsMajor($repoMetaData, $branch, $composerJson, $usePhpDepAsFallback); + $this->assertSame($expected, $cmsMajor); + } + + public function provideGetBranchesForMergeUp(): array + { + return [ + 'no branches' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [ + '5.1.0-beta1', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [], + 'composerJson' => [ + 'require' => [ + 'silverstripe/framework' => '^5.0' + ] + ], + 'expected' => [], + ], + 'no tags' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1', + '6' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/framework' => '^5.0' + ] + ], + // Note that this would result in an exception in the merge-ups action itself. + 'expected' => ['4.10', '4.11', '4.12', '4.13', '4', '5.0', '5.1', '5', '6'], + ], + '5.1.0-beta1, CMS 6 branch detected on silverstripe/framework' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [ + '5.1.0-beta1', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1', + '6' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/framework' => '^5.0' + ] + ], + 'expected' => ['4.13', '4', '5.0', '5.1', '5', '6'], + ], + '5.1.0 stable and match on silverstripe/cms' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [ + '5.1.0', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/cms' => '^5.1' + ] + ], + 'expected' => ['4.13', '4', '5.1', '5'], + ], + 'match on silverstripe/assets' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [ + '5.1.0', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11' + ], + 'repoBranches' => [ + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/assets' => '^2.0' + ] + ], + 'expected' => ['4.13', '4', '5.1', '5'], + ], + 'match on silverstripe/mfa' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [ + '5.1.0', + '5.0.9', + '4.13.11' + ], + 'repoBranches' => [ + '4', + '4.12', + '4.13', + '5', + '5.0', + '5.1' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/mfa' => '^5.0' + ] + ], + 'expected' => ['4.13', '4', '5.1', '5'], + ], + 'Missing `1` branch and match on php version in composer.json' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '2', + 'repoTags' => [ + '2.1.0-beta1', + '2.0.9', + '1.13.11', + '1.12.11', + '1.11.11', + '1.10.11' + ], + 'repoBranches' => [ + '1.10', + '1.11', + '1.12', + '1.13', + '2', + '2.0', + '2.1' + ], + 'composerJson' => [ + 'require' => [ + 'php' => '^8.1' + ] + ], + 'expected' => ['1.13', '2.0', '2.1', '2'], + ], + 'Two minor branches without stable tags in composer.json' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '2', + 'repoTags' => [ + '2.3.0-alpha1', + '2.2.0-beta1', + '2.1.0', + '2.0.9', + '1.13.11' + ], + 'repoBranches' => [ + '2', + '2.0', + '2.1', + '2.2', + '2.3', + '1', + '1.13' + ], + 'composerJson' => [ + 'require' => [ + 'php' => '^8.1' + ] + ], + 'expected' => ['1.13', '1', '2.1', '2.2', '2.3', '2'], + ], + 'Module where default branch has not been changed from CMS 4 and there is a new CMS 6 branch' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', // this repo has a '5' branch for CMS 4 and a '6' branch for CMS 5 + 'repoTags' => [ + '6.0.0', + '5.9.1', + '4.0.1' + ], + 'repoBranches' => [ + '7', + '6', + '6.0', + '5', + '5.9', + '5.8', + '5.7' + ], + 'composerJson' => [ + 'require' => [ + 'php' => '^7.4', + 'silverstripe/framework' => '^4.11' + ] + ], + 'expected' => ['5.9', '5', '6.0', '6', '7'], + ], + 'developer-docs' => [ + 'githubRepository' => 'silverstripe/developer-docs', + 'defaultBranch' => '5', + 'repoTags' => [ + '4.13.0', + '5.0.0' + ], + 'repoBranches' => [ + '5', + '5.0', + '4.13', + '4.12', + '4', + '3' + ], + 'composerJson' => [ + 'no-require' => new stdClass(), + ], + 'expected' => ['4.13', '4', '5.0', '5'], + ], + 'More than 6 branches is fine' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '5', + 'repoTags' => [ + '5.2.0-beta1', + '5.1.0-beta1', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1', + '5.2', + '6' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/framework' => '^5.0' + ] + ], + 'expected' => ['4.13', '4', '5.0', '5.1', '5.2', '5', '6'], + ], + 'cwp-watea-theme' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => '4', + 'repoTags' => [ + '4.0.0', + '5.0.9', + '3.2.0', + '3.1.0', + '3.0.0' + ], + 'repoBranches' => [ + '1', + '1.0', + '2', + '2.0', + '3', + '3.0', + '3.1', + '3.2', + '4', + '4.0' + ], + 'composerJson' => [ + 'require' => [ + 'cwp/starter-theme' => '^4' + ] + ], + 'expected' => ['3.2', '3', '4.0', '4'], + ], + 'gha-ci' => [ + 'githubRepository' => 'silverstripe/gha-ci', + 'defaultBranch' => '1', + 'repoTags' => [ + '1.4.0', + '1.3.0', + '1.2.0', + '1.1.0', + '1.0.0' + ], + 'repoBranches' => [ + '1', + '1.0', + '1.1', + '1.2', + '1.3', + '1.4' + ], + 'composerJson' => null, + 'expected' => ['1.4', '1'], + ], + 'gha-generate-matrix with composerjson' => [ + 'githubRepository' => 'silverstripe/gha-generate-matrix', + 'defaultBranch' => '1', + 'repoTags' => [ + '1.4.0', + '1.3.0', + '1.2.0', + '1.1.0', + '1.0.0' + ], + 'repoBranches' => [ + '1', + '1.0', + '1.1', + '1.2', + '1.3', + '1.4' + ], + 'composerJson' => [ + 'require' => [ + 'something/random' => '^4' + ] + ], + 'expected' => ['1.4', '1'], + ], + 'silverstripe-linkfield beta' => [ + 'githubRepository' => 'silverstripe/silverstripe-linkfield', + 'defaultBranch' => '4', + 'repoTags' => [ + '3.0.0-beta1', + '2.0.0', + '1.0.0' + ], + 'repoBranches' => [ + '1', + '2', + '3', + '4', + '4.0', + '5' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/framework' => '^5' + ] + ], + 'expected' => ['4.0', '4', '5'], + ], + 'silverstripe-linkfield stable' => [ + 'githubRepository' => 'silverstripe/silverstripe-linkfield', + 'defaultBranch' => '4', + 'repoTags' => [ + '4.0.0', + '3.0.0', + '2.0.0', + '1.0.0' + ], + 'repoBranches' => [ + '1', + '2', + '3', + '3.0', + '3.1', + '3.999', + '4', + '4.0', + '5' + ], + 'composerJson' => [ + 'require' => [ + 'silverstripe/framework' => '^5' + ] + ], + 'expected' => ['4.0', '4', '5'], + ], + 'Incorrect default branch for supported module' => [ + 'githubRepository' => 'silverstripe/silverstripe-cms', + 'defaultBranch' => 'main', + 'repoTags' => [ + '5.1.0', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1' + ], + 'composerJson' => null, + 'expected' => ['4.13', '4', '5.1', '5'], + ], + 'Incorrect default branch for supported module' => [ + 'githubRepository' => 'silverstripe/silverstripe-cms', + 'defaultBranch' => 'main', + 'repoTags' => [ + '5.1.0', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1' + ], + 'composerJson' => null, + 'expected' => ['4.13', '4', '5.1', '5'], + ], + 'Fluent, which had weird gaps in its support' => [ + 'githubRepository' => 'tractorcow-farm/silverstripe-fluent', + 'defaultBranch' => '7', + 'repoTags' => [ + '7.0.1', + '7.1.0', + '6.0.5', + '5.0.4', + '5.1.21', + '4.7.4', + '4.8.6' + ], + 'repoBranches' => [ + '8', + '7', + '7.0', + '7.1', + '6', + '6.0', + '5', + '5.0', + '5.1', + '4', + '4.7', + '4.8' + ], + 'composerJson' => null, + 'expected' => ['6.0', '6', '7.1', '7', '8'], + ], + ]; + } + + /** + * @dataProvider provideGetBranchesForMergeUp + */ + public function testGetBranchesForMergeUp( + string $githubRepository, + string $defaultBranch, + array $repoTags, + array $repoBranches, + ?array $composerJson, + array $expected + ): void { + $repoMetaData = MetaData::getMetaDataForRepository($githubRepository); + if (is_array($composerJson)) { + // Convert array json into stdClass + $composerJson = json_decode(json_encode($composerJson)); + } + $branches = BranchLogic::getBranchesForMergeUp( + $githubRepository, + $repoMetaData, + $defaultBranch, + $repoTags, + $repoBranches, + $composerJson + ); + $this->assertSame($expected, $branches); + } + + public function provideGetBranchesForMergeUpExceptions(): array + { + return [ + 'Incorrect default branch for random module' => [ + 'githubRepository' => 'lorem/ipsum', + 'defaultBranch' => 'main', + 'repoTags' => [ + '5.1.0', + '5.0.9', + '4.13.11', + '4.12.11', + '4.11.11', + '4.10.11', + '3.7.4' + ], + 'repoBranches' => [ + '3', + '3.6', + '3.7', + '4', + '4.10', + '4.11', + '4.12', + '4.13', + '5', + '5.0', + '5.1' + ], + // Even though we know what CMS major to use, because the default branch + // is incorrect we can't get a good mapping for the rest of the branches + 'composerJson' => [ + 'require' => [ + 'silverstripe/cms' => '^5.1', + ] + ], + ], + ]; + } + + /** + * @dataProvider provideGetBranchesForMergeUpExceptions + */ + public function testGetBranchesForMergeUpExceptions( + string $githubRepository, + string $defaultBranch, + array $repoTags, + array $repoBranches, + ?array $composerJson = null, + ): void { + $repoMetaData = MetaData::getMetaDataForRepository($githubRepository); + if (is_array($composerJson)) { + // Convert array json into stdClass + $composerJson = json_decode(json_encode($composerJson)); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not work out what default CMS major version for this module"); + + BranchLogic::getBranchesForMergeUp( + $githubRepository, + $repoMetaData, + $defaultBranch, + $repoTags, + $repoBranches, + $composerJson + ); + } +} diff --git a/tests/MetaDataTest.php b/tests/MetaDataTest.php new file mode 100644 index 0000000..889f168 --- /dev/null +++ b/tests/MetaDataTest.php @@ -0,0 +1,231 @@ + [ + 'repoName' => 'org/repo', + 'allowPartialMatch' => true, + 'resultEmpty' => true, + ], + 'packagist ref doesnt match github ref no partial' => [ + 'repoName' => 'silverstripe/framework', + 'allowPartialMatch' => false, + 'resultEmpty' => true, + ], + 'packagist ref doesnt match github ref with partial' => [ + 'repoName' => 'silverstripe/framework', + 'allowPartialMatch' => true, + 'resultEmpty' => true, + ], + 'exact match' => [ + 'repoName' => 'silverstripe/silverstripe-framework', + 'allowPartialMatch' => false, + 'resultEmpty' => false, + ], + 'fork mismatch' => [ + 'repoName' => 'creative-commoners/silverstripe-framework', + 'allowPartialMatch' => false, + 'resultEmpty' => true, + ], + 'fork match' => [ + 'repoName' => 'creative-commoners/silverstripe-framework', + 'allowPartialMatch' => true, + 'resultEmpty' => false, + ], + 'gha match' => [ + 'repoName' => 'silverstripe/gha-generate-matrix', + 'allowPartialMatch' => false, + 'resultEmpty' => false, + ], + ]; + } + + /** + * @dataProvider provideGetMetaDataForRepository + */ + public function testGetMetaDataForRepository(string $repoName, bool $allowPartialMatch, bool $resultEmpty): void + { + $repoData = MetaData::getMetaDataForRepository($repoName, $allowPartialMatch); + $this->assertSame($resultEmpty, empty($repoData)); + } + + public function testGetMetaDataForRepositoryInvalid(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('$gitHubReference must be a valid org/repo reference.'); + MetaData::getMetaDataForRepository(''); + } + + public function provideGetMetaDataByPackagistName(): array + { + return [ + 'missing repo' => [ + 'repoName' => 'org/repo', + 'resultEmpty' => true, + ], + 'packagist ref doesnt match github ref no partial' => [ + 'repoName' => 'silverstripe/silverstripe-framework', + 'resultEmpty' => true, + ], + 'packagist ref doesnt match github ref with partial' => [ + 'repoName' => 'silverstripe/silverstripe-framework', + 'resultEmpty' => true, + ], + 'exact match' => [ + 'repoName' => 'silverstripe/framework', + 'resultEmpty' => false, + ], + ]; + } + + /** + * @dataProvider provideGetMetaDataByPackagistName + */ + public function testGetMetaDataByPackagistName(string $repoName, bool $resultEmpty): void + { + $repoData = MetaData::getMetaDataByPackagistName($repoName); + $this->assertSame($resultEmpty, empty($repoData)); + } + + public function testGetMetaDataByPackagistNameInvalid(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('$packagistName must be a valid org/repo reference.'); + MetaData::getMetaDataByPackagistName(''); + } + + public function provideGetMetaDataForLocksteppedRepos(): array + { + return [ + 'module skeleton not lockstepped' => [ + 'packagistRef' => 'silverstripe-module/skeleton', + 'isLockstepped' => false, + ], + 'config not lockstepped' => [ + 'packagistRef' => 'silverstripe/config', + 'isLockstepped' => false, + ], + 'framework lockstepped' => [ + 'packagistRef' => 'silverstripe/framework', + 'isLockstepped' => true, + ], + 'kitchen sink lockstepped' => [ + 'packagistRef' => 'silverstripe/recipe-kitchen-sink', + 'isLockstepped' => true, + ], + ]; + } + + /** + * @dataProvider provideGetMetaDataForLocksteppedRepos + */ + public function testGetMetaDataForLocksteppedRepos(string $repoName, bool $isLockstepped): void + { + $lockstepped = MetaData::getMetaDataForLocksteppedRepos(); + + if ($isLockstepped) { + $this->assertArrayHasKey($repoName, $lockstepped); + $this->validateVersionMap($lockstepped[$repoName]); + } else { + $this->assertArrayNotHasKey($repoName, $lockstepped); + } + } + + public function testGetAllRepositoryMetaData(): void + { + // Validate data has correct categories + $validKeys = [ + MetaData::CATEGORY_SUPPORTED => [ + 'github', + 'packagist', + 'githubId', + 'isCore', + 'lockstepped', + 'type', + 'majorVersionMapping', + ], + MetaData::CATEGORY_WORKFLOW => [ + 'github', + 'githubId', + 'majorVersionMapping', + ], + MetaData::CATEGORY_TOOLING => [ + 'github', + 'packagist', + 'githubId', + 'majorVersionMapping', + ], + MetaData::CATEGORY_MISC => [ + 'github', + 'packagist', + 'githubId', + 'majorVersionMapping', + ], + ]; + $data = MetaData::getAllRepositoryMetaData(); + $this->assertSame(array_keys($validKeys), array_keys($data)); + + $githubRefs = []; + $packagistRefs = []; + $githubIds = []; + // Validate data schema + foreach ($data as $category => $repos) { + $this->assertIsArray($repos); + foreach ($repos as $repo) { + $this->validateSchema($repo, $validKeys[$category]); + if (isset($repo['github'])) { + $githubRefs[] = $repo['github']; + } + if (isset($repo['packagist'])) { + $packagistRefs[] = $repo['packagist']; + } + if (isset($repo['githubId'])) { + $githubIds[] = $repo['githubId']; + } + } + } + // Validate references are unique (no duplicated repositories) + $this->assertSame(array_unique($githubRefs), $githubRefs, 'GitHub references must be unique'); + $this->assertSame(array_unique($packagistRefs), $packagistRefs, 'Packagist references must be unique'); + $this->assertSame(array_unique($githubIds), $githubIds, 'GitHub IDs must be unique'); + } + + private function validateSchema(array $repo, array $validKeys): void + { + $this->assertSame($validKeys, array_keys($repo)); + in_array('github', $validKeys) && $this->assertStringContainsString('/', $repo['github']); + if (in_array('packagist', $validKeys)) { + if (is_string($repo['packagist'])) { + $this->assertStringContainsString('/', $repo['packagist']); + } else { + $this->assertNull($repo['packagist']); + } + } + in_array('githubId', $validKeys) && $this->assertIsInt($repo['githubId']); + in_array('isCore', $validKeys) && $this->assertIsBool($repo['isCore']); + in_array('lockstepped', $validKeys) && $this->assertIsBool($repo['lockstepped']); + in_array('type', $validKeys) && $this->assertContains($repo['type'], ['module', 'recipe', 'theme', 'other']); + in_array('majorVersionMapping', $validKeys) && $this->validateVersionMap($repo['majorVersionMapping']); + } + + private function validateVersionMap(array $versionMap): void + { + $this->assertNotEmpty($versionMap); + foreach ($versionMap as $cmsMajor => $branches) { + $this->assertIsArray($branches); + if ($cmsMajor !== '*') { + $this->assertTrue(ctype_digit((string)$cmsMajor)); + $this->assertNotEmpty($branches); + } + } + } +}