Add a way to filter GitHub and Gitlab releases.

The filter is applied when trying to get the latest release from a VCS repository. Inspired by #506.

Example of filtering releases by the version number: 
```php
//Allow only beta versions (e.g. for testing).
$updateChecker->getVcsApi()->setReleaseVersionFilter(
    '/beta/i', //Regex for the version number.
    Api::RELEASE_FILTER_ALL, //Disables the default filter(s).
    30 //Max number of recent releases to scan for matches.
);
```

Alternatively, you can use a callback to implement custom filtering rules. 
```php
//Set an arbitrary custom filter.
$updateChecker->getVcsApi()->setReleaseFilter(
    function($versionNumber, $releaseObject) {
        /* 
        Put your custom logic here. The $releaseObject variable contains
        the release data returned by the GitHub/GitLab API. The format
        will vary depending on which service you're using.
        */
        return true;
    },
    Api::RELEASE_FILTER_ALL
);
```

Setting a new filter will override any previous filters, so you can't add a regex-based version filter and a custom callback at the same time.
This commit is contained in:
Yahnis Elsts 2022-11-17 18:26:23 +02:00
parent affb44665f
commit c4bf64eca4
4 changed files with 238 additions and 55 deletions

View File

@ -13,6 +13,21 @@ if ( !class_exists(Api::class, false) ):
const STRATEGY_STABLE_TAG = 'stable_tag';
const STRATEGY_BRANCH = 'branch';
/**
* Consider all releases regardless of their version number or prerelease/upcoming
* release status.
*/
const RELEASE_FILTER_ALL = 3;
/**
* Exclude releases that have the "prerelease" or "upcoming release" flag.
*
* This does *not* look for prerelease keywords like "beta" in the version number.
* It only uses the data provided by the API. For example, on GitHub, you can
* manually mark a release as a prerelease.
*/
const RELEASE_FILTER_SKIP_PRERELEASE = 1;
/**
* If there are no release assets or none of them match the configured filter,
* fall back to the automatically generated source code archive.

View File

@ -1,4 +1,5 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p0\Vcs;
use Parsedown;
@ -7,6 +8,7 @@ if ( !class_exists(GitHubApi::class, false) ):
class GitHubApi extends Api {
use ReleaseAssetSupport;
use ReleaseFilteringFeature;
/**
* @var string GitHub username.
@ -50,58 +52,103 @@ if ( !class_exists(GitHubApi::class, false) ):
* @return Reference|null
*/
public function getLatestRelease() {
$release = $this->api('/repos/:user/:repo/releases/latest');
if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) {
return null;
}
$reference = new Reference(array(
'name' => $release->tag_name,
'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3".
'downloadUrl' => $release->zipball_url,
'updated' => $release->created_at,
'apiResponse' => $release,
));
if ( isset($release->assets[0]) ) {
$reference->downloadCount = $release->assets[0]->download_count;
}
if ( $this->releaseAssetsEnabled ) {
//Use the first release asset that matches the specified regular expression.
if ( isset($release->assets, $release->assets[0]) ) {
$matchingAssets = array_filter($release->assets, array($this, 'matchesAssetFilter'));
} else {
$matchingAssets = array();
//The "latest release" endpoint returns one release and always skips pre-releases,
//so we can only use it if that's compatible with the current filter settings.
if (
$this->shouldSkipPreReleases()
&& (
($this->releaseFilterMaxReleases === 1) || !$this->hasCustomReleaseFilter()
)
) {
//Just get the latest release.
$release = $this->api('/repos/:user/:repo/releases/latest');
if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) {
return null;
}
if ( !empty($matchingAssets) ) {
if ( $this->isAuthenticationEnabled() ) {
/**
* Keep in mind that we'll need to add an "Accept" header to download this asset.
*
* @see setUpdateDownloadHeaders()
*/
$reference->downloadUrl = $matchingAssets[0]->url;
} else {
//It seems that browser_download_url only works for public repositories.
//Using an access_token doesn't help. Maybe OAuth would work?
$reference->downloadUrl = $matchingAssets[0]->browser_download_url;
}
$reference->downloadCount = $matchingAssets[0]->download_count;
} else if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) {
//None of the assets match the filter, and we're not allowed
//to fall back to the auto-generated source ZIP.
$foundReleases = array($release);
} else {
//Get a list of the most recent releases.
$foundReleases = $this->api(
'/repos/:user/:repo/releases',
array('per_page' => $this->releaseFilterMaxReleases)
);
if ( is_wp_error($foundReleases) || !is_array($foundReleases) ) {
return null;
}
}
if ( !empty($release->body) ) {
$reference->changelog = Parsedown::instance()->text($release->body);
foreach ($foundReleases as $release) {
//Always skip drafts.
if ( isset($release->draft) && !empty($release->draft) ) {
continue;
}
//Skip pre-releases unless specifically included.
if (
$this->shouldSkipPreReleases()
&& isset($release->prerelease)
&& !empty($release->prerelease)
) {
continue;
}
$versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
//Custom release filtering.
if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) {
continue;
}
$reference = new Reference(array(
'name' => $release->tag_name,
'version' => $versionNumber,
'downloadUrl' => $release->zipball_url,
'updated' => $release->created_at,
'apiResponse' => $release,
));
if ( isset($release->assets[0]) ) {
$reference->downloadCount = $release->assets[0]->download_count;
}
if ( $this->releaseAssetsEnabled ) {
//Use the first release asset that matches the specified regular expression.
if ( isset($release->assets, $release->assets[0]) ) {
$matchingAssets = array_filter($release->assets, array($this, 'matchesAssetFilter'));
} else {
$matchingAssets = array();
}
if ( !empty($matchingAssets) ) {
if ( $this->isAuthenticationEnabled() ) {
/**
* Keep in mind that we'll need to add an "Accept" header to download this asset.
*
* @see setUpdateDownloadHeaders()
*/
$reference->downloadUrl = $matchingAssets[0]->url;
} else {
//It seems that browser_download_url only works for public repositories.
//Using an access_token doesn't help. Maybe OAuth would work?
$reference->downloadUrl = $matchingAssets[0]->browser_download_url;
}
$reference->downloadCount = $matchingAssets[0]->download_count;
} else if ( $this->releaseAssetPreference === Api::REQUIRE_RELEASE_ASSETS ) {
//None of the assets match the filter, and we're not allowed
//to fall back to the auto-generated source ZIP.
return null;
}
}
if ( !empty($release->body) ) {
$reference->changelog = Parsedown::instance()->text($release->body);
}
return $reference;
}
return $reference;
return null;
}
/**
@ -319,7 +366,7 @@ if ( !class_exists(GitHubApi::class, false) ):
}
//Alternatively, just use the branch itself.
$strategies[self::STRATEGY_BRANCH] = function() use ($configBranch) {
$strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) {
return $this->getBranch($configBranch);
};
@ -347,9 +394,9 @@ if ( !class_exists(GitHubApi::class, false) ):
}
/**
* @internal
* @param bool $result
* @return bool
* @internal
*/
public function addHttpRequestFilter($result) {
if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) {
@ -365,6 +412,7 @@ if ( !class_exists(GitHubApi::class, false) ):
* Set the HTTP headers that are necessary to download updates from private repositories.
*
* See GitHub docs:
*
* @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
* @link https://developer.github.com/v3/auth/#basic-authentication
*
@ -391,9 +439,9 @@ if ( !class_exists(GitHubApi::class, false) ):
* the authorization header to other hosts. We don't want that because it breaks
* AWS downloads and can leak authorization information.
*
* @internal
* @param string $location
* @param array $headers
* @internal
*/
public function removeAuthHeaderFromRedirects(&$location, &$headers) {
$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());

View File

@ -1,10 +1,12 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p0\Vcs;
if ( !class_exists(GitLabApi::class, false) ):
class GitLabApi extends Api {
use ReleaseAssetSupport;
use ReleaseFilteringFeature;
/**
* @var string GitLab username.
@ -103,7 +105,7 @@ if ( !class_exists(GitLabApi::class, false) ):
* @return Reference|null
*/
public function getLatestRelease() {
$releases = $this->api('/:id/releases');
$releases = $this->api('/:id/releases', array('per_page' => $this->releaseFilterMaxReleases));
if ( is_wp_error($releases) || empty($releases) || !is_array($releases) ) {
return null;
}
@ -114,11 +116,21 @@ if ( !class_exists(GitLabApi::class, false) ):
!is_object($release)
|| !isset($release->tag_name)
//Skip upcoming releases.
|| !empty($release->upcoming_release)
|| (
!empty($release->upcoming_release)
&& $this->shouldSkipPreReleases()
)
) {
continue;
}
$versionNumber = ltrim($release->tag_name, 'v'); //Remove the "v" prefix from "v1.2.3".
//Apply custom filters.
if ( !$this->matchesCustomReleaseFilter($versionNumber, $release) ) {
continue;
}
$downloadUrl = $this->findReleaseDownloadUrl($release);
if ( empty($downloadUrl) ) {
//The latest release doesn't have valid download URL.
@ -131,7 +143,7 @@ if ( !class_exists(GitLabApi::class, false) ):
return new Reference(array(
'name' => $release->tag_name,
'version' => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3".
'version' => $versionNumber,
'downloadUrl' => $downloadUrl,
'updated' => $release->released_at,
'apiResponse' => $release,
@ -362,7 +374,7 @@ if ( !class_exists(GitLabApi::class, false) ):
$strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag');
}
$strategies[self::STRATEGY_BRANCH] = function() use ($configBranch) {
$strategies[self::STRATEGY_BRANCH] = function () use ($configBranch) {
return $this->getBranch($configBranch);
};
@ -380,13 +392,13 @@ if ( !class_exists(GitLabApi::class, false) ):
*
* This is included for backwards compatibility with older versions of PUC.
*
* @deprecated Use enableReleaseAssets() instead.
* @noinspection PhpUnused -- Public API
* @return void
* @deprecated Use enableReleaseAssets() instead.
* @noinspection PhpUnused -- Public API
*/
public function enableReleasePackages() {
$this->enableReleaseAssets(
/** @lang RegExp */ '/\.zip($|[?&#])/i',
/** @lang RegExp */ '/\.zip($|[?&#])/i',
Api::REQUIRE_RELEASE_ASSETS
);
}

View File

@ -0,0 +1,108 @@
<?php
namespace YahnisElsts\PluginUpdateChecker\v5p0\Vcs;
if ( !trait_exists(ReleaseFilteringFeature::class, false) ) :
trait ReleaseFilteringFeature {
/**
* @var callable|null
*/
protected $releaseFilterCallback = null;
/**
* @var int
*/
protected $releaseFilterMaxReleases = 1;
/**
* @var string One of the Api::RELEASE_FILTER_* constants.
*/
protected $releaseFilterByType = Api::RELEASE_FILTER_SKIP_PRERELEASE;
/**
* Set a custom release filter.
*
* Setting a new filter will override the old filter, if any.
*
* @param callable $callback A callback that accepts a version number and a release
* object, and returns a boolean.
* @param int $releaseTypes One of the Api::RELEASE_FILTER_* constants.
* @param int $maxReleases Optional. The maximum number of recent releases to examine
* when trying to find a release that matches the filter. 1 to 100.
* @return $this
*/
public function setReleaseFilter(
$callback,
$releaseTypes = Api::RELEASE_FILTER_SKIP_PRERELEASE,
$maxReleases = 20
) {
if ( $maxReleases > 100 ) {
throw new \InvalidArgumentException(sprintf(
'The max number of releases is too high (%d). It must be 100 or less.',
$maxReleases
));
} else if ( $maxReleases < 1 ) {
throw new \InvalidArgumentException(sprintf(
'The max number of releases is too low (%d). It must be at least 1.',
$maxReleases
));
}
$this->releaseFilterCallback = $callback;
$this->releaseFilterByType = $releaseTypes;
$this->releaseFilterMaxReleases = $maxReleases;
return $this;
}
/**
* Filter releases by their version number.
*
* @param string $regex A regular expression. The release version number must match this regex.
* @param int $releaseTypes
* @param int $maxReleasesToExamine
* @return $this
* @noinspection PhpUnused -- Public API
*/
public function setReleaseVersionFilter(
$regex,
$releaseTypes = Api::RELEASE_FILTER_SKIP_PRERELEASE,
$maxReleasesToExamine = 20
) {
return $this->setReleaseFilter(
function ($versionNumber) use ($regex) {
return (preg_match($regex, $versionNumber) === 1);
},
$releaseTypes,
$maxReleasesToExamine
);
}
/**
* @param string $versionNumber The detected release version number.
* @param object $releaseObject Varies depending on the host/API.
* @return bool
*/
protected function matchesCustomReleaseFilter($versionNumber, $releaseObject) {
if ( !is_callable($this->releaseFilterCallback) ) {
return true; //No custom filter.
}
return call_user_func($this->releaseFilterCallback, $versionNumber, $releaseObject);
}
/**
* @return bool
*/
protected function shouldSkipPreReleases() {
//Maybe this could be a bitfield in the future, if we need to support
//more release types.
return ($this->releaseFilterByType !== Api::RELEASE_FILTER_ALL);
}
/**
* @return bool
*/
protected function hasCustomReleaseFilter() {
return isset($this->releaseFilterCallback) && is_callable($this->releaseFilterCallback);
}
}
endif;