mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8452dbd9d | ||
|
|
0bbe82a98b | ||
|
|
a7c1649c67 | ||
|
|
a6b875d3da | ||
|
|
c925ad8ff2 | ||
|
|
15cd55cb06 | ||
|
|
5f66306226 | ||
|
|
d924072b82 | ||
|
|
fc0f19b7a8 | ||
|
|
1f3523b24f | ||
|
|
283efcb3c8 | ||
|
|
3f555e3061 |
@@ -841,6 +841,28 @@ return [
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('providerBranches'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 128,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => [],
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('providerPaths'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 128,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => [],
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
'indexes' => [
|
||||
[
|
||||
@@ -1320,6 +1342,28 @@ return [
|
||||
'array' => false,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('providerBranches'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 128,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => [],
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('providerPaths'),
|
||||
'type' => Database::VAR_STRING,
|
||||
'format' => '',
|
||||
'size' => 128,
|
||||
'signed' => true,
|
||||
'required' => false,
|
||||
'default' => [],
|
||||
'array' => true,
|
||||
'filters' => [],
|
||||
],
|
||||
],
|
||||
'indexes' => [
|
||||
[
|
||||
|
||||
Generated
+7
-7
@@ -5216,16 +5216,16 @@
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/vcs",
|
||||
"version": "3.0.1",
|
||||
"version": "3.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/vcs.git",
|
||||
"reference": "0efe842d695acb4b184f5306a836169c771fbcea"
|
||||
"reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/0efe842d695acb4b184f5306a836169c771fbcea",
|
||||
"reference": "0efe842d695acb4b184f5306a836169c771fbcea",
|
||||
"url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af",
|
||||
"reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5259,9 +5259,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/vcs/issues",
|
||||
"source": "https://github.com/utopia-php/vcs/tree/3.0.1"
|
||||
"source": "https://github.com/utopia-php/vcs/tree/3.1.0"
|
||||
},
|
||||
"time": "2026-03-23T15:58:31+00:00"
|
||||
"time": "2026-03-24T08:49:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/websocket",
|
||||
@@ -8456,5 +8456,5 @@
|
||||
"platform-dev": {
|
||||
"ext-fileinfo": "*"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
+7
-1
@@ -133,7 +133,13 @@ class Update extends Action
|
||||
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
|
||||
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
|
||||
|
||||
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
|
||||
$prFiles = $github->getPullRequestFiles($owner, $providerRepositoryName, $providerPullRequestId);
|
||||
$providerAffectedFiles = [
|
||||
...array_column($prFiles, 'filename'),
|
||||
...array_filter(array_column($prFiles, 'previous_filename')) // Filter out null values
|
||||
];
|
||||
|
||||
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
|
||||
|
||||
$response->noContent();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Appwrite\Event\Event;
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
|
||||
use Appwrite\Vcs\Comment;
|
||||
use Appwrite\Vcs\Validator\BuildTrigger;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Console;
|
||||
use Utopia\Database\Database;
|
||||
@@ -40,6 +41,7 @@ trait Deployment
|
||||
string $providerCommitMessage,
|
||||
string $providerCommitUrl,
|
||||
string $providerPullRequestId,
|
||||
array $providerAffectedFiles,
|
||||
bool $external,
|
||||
Database $dbForPlatform,
|
||||
Authorization $authorization,
|
||||
@@ -90,6 +92,11 @@ trait Deployment
|
||||
$resource = $authorization->skip(fn () => $dbForProject->getDocument($resourceCollection, $resourceId));
|
||||
$resourceInternalId = $resource->getSequence();
|
||||
|
||||
if (!$this->isResourceBuildable($resource, $providerBranch, $providerAffectedFiles, $logBase)) {
|
||||
Span::add("{$logBase}.build.skipped", 'true');
|
||||
continue;
|
||||
}
|
||||
|
||||
$deploymentId = ID::unique();
|
||||
$repositoryId = $repository->getId();
|
||||
$repositoryInternalId = $repository->getSequence();
|
||||
@@ -525,4 +532,31 @@ trait Deployment
|
||||
{
|
||||
return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME);
|
||||
}
|
||||
|
||||
private function isResourceBuildable(Document $resource, string $providerBranch, array $providerAffectedFiles, string $logBase): bool
|
||||
{
|
||||
$branchTrigger = new BuildTrigger($resource->getAttribute('providerBranches', []));
|
||||
if (!$branchTrigger->isValid($providerBranch)) {
|
||||
Span::add("{$logBase}.build.skipped.reason", 'branch');
|
||||
return false;
|
||||
}
|
||||
|
||||
$providerPaths = $resource->getAttribute('providerPaths', []);
|
||||
if (!empty($providerPaths) && !empty($providerAffectedFiles)) {
|
||||
$pathTrigger = new BuildTrigger($providerPaths);
|
||||
$pathMatched = false;
|
||||
foreach ($providerAffectedFiles as $file) {
|
||||
if ($pathTrigger->isValid($file)) {
|
||||
$pathMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$pathMatched) {
|
||||
Span::add("{$logBase}.build.skipped.reason", 'path');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ class Create extends Action
|
||||
|
||||
// Create new deployment only on push (not committed by us) and not when branch is created or deleted
|
||||
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) {
|
||||
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
|
||||
$providerAffectedFiles = $parsedPayload['affectedFiles'] ?? [];
|
||||
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', $providerAffectedFiles, false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,12 +212,18 @@ class Create extends Action
|
||||
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
|
||||
$providerCommitMessage = $commitDetails["commitMessage"] ?? '';
|
||||
|
||||
$prFiles = $github->getPullRequestFiles($providerRepositoryOwner, $providerRepositoryName, $providerPullRequestId);
|
||||
$providerAffectedFiles = [
|
||||
...array_column($prFiles, 'filename'),
|
||||
...array_filter(array_column($prFiles, 'previous_filename')) // Filter out null values
|
||||
];
|
||||
|
||||
$repositories = $authorization->skip(fn () => $dbForPlatform->find('repositories', [
|
||||
Query::equal('providerRepositoryId', [$providerRepositoryId]),
|
||||
Query::orderDesc('$createdAt')
|
||||
]));
|
||||
|
||||
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
|
||||
$this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $providerAffectedFiles, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
|
||||
} elseif ($action == "closed") {
|
||||
// Allowed external contributions cleanup
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Vcs\Validator;
|
||||
|
||||
use Utopia\Validator;
|
||||
|
||||
class BuildTrigger extends Validator
|
||||
{
|
||||
public function __construct(private readonly array $patterns)
|
||||
{
|
||||
}
|
||||
|
||||
public function isValid($value): bool
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->patterns)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$include = array_filter($this->patterns, fn ($p) => !str_starts_with($p, '!'));
|
||||
$exclude = array_filter($this->patterns, fn ($p) => str_starts_with($p, '!'));
|
||||
|
||||
if (empty($include)) {
|
||||
// Only exclusions: pass everything unless excluded.
|
||||
foreach ($exclude as $pattern) {
|
||||
if ($this->matchGlob($value, substr($pattern, 1))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A pattern is "specific" when it contains no wildcard characters.
|
||||
$isSpecific = fn ($pattern) => !str_contains($pattern, '*') && !str_contains($pattern, '?');
|
||||
|
||||
// 1. Specific inclusion always wins — an explicit exact match is never blocked.
|
||||
foreach ($include as $pattern) {
|
||||
if ($isSpecific($pattern) && $this->matchGlob($value, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Any exclusion (specific or wildcard) overrides a wildcard inclusion — refines broad patterns.
|
||||
foreach ($exclude as $pattern) {
|
||||
if ($this->matchGlob($value, substr($pattern, 1))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Wildcard inclusion — no exclusion blocked it.
|
||||
foreach ($include as $pattern) {
|
||||
if (!$isSpecific($pattern) && $this->matchGlob($value, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No inclusion matched.
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Value must match a specific inclusion, or a wildcard inclusion not overridden by any exclusion.';
|
||||
}
|
||||
|
||||
public function isArray(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return self::TYPE_STRING;
|
||||
}
|
||||
|
||||
private function matchGlob(string $subject, string $pattern): bool
|
||||
{
|
||||
$regex = '';
|
||||
$len = strlen($pattern);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
$char = $pattern[$i];
|
||||
|
||||
if ($char === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
|
||||
$prevSlash = $i === 0 || $pattern[$i - 1] === '/';
|
||||
$nextSlash = isset($pattern[$i + 2]) && $pattern[$i + 2] === '/';
|
||||
|
||||
if ($prevSlash && $nextSlash) {
|
||||
// a/**/b → zero or more intermediate dirs (matches a/b, a/x/b, a/x/y/b)
|
||||
// **/foo → zero or more leading dirs (matches foo, a/foo, a/b/foo)
|
||||
$regex .= '(?:.+/)?';
|
||||
$i += 3; // consume ** and the trailing /
|
||||
} else {
|
||||
// foo/** → everything inside (matches foo/a, foo/a/b)
|
||||
$regex .= '.*';
|
||||
$i += 2;
|
||||
}
|
||||
} elseif ($char === '*') {
|
||||
$regex .= '[^/]*'; // anything except a path separator
|
||||
$i++;
|
||||
} elseif ($char === '?') {
|
||||
$regex .= '[^/]'; // any single character except a path separator
|
||||
$i++;
|
||||
} else {
|
||||
$regex .= preg_quote($char, '~');
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
return (bool) preg_match('~^' . $regex . '$~', $subject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Vcs\Validator;
|
||||
|
||||
use Appwrite\Vcs\Validator\BuildTrigger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BuildTriggerTest extends TestCase
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty patterns
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testEmptyPatternsAlwaysPass(): void
|
||||
{
|
||||
$validator = new BuildTrigger([]);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('feature/anything'));
|
||||
$this->assertTrue($validator->isValid('src/deep/nested/file.php'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pure inclusion — OR semantics (any one match is enough)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testSingleExactInclusion(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['main']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertFalse($validator->isValid('develop'));
|
||||
$this->assertFalse($validator->isValid('main-extra'));
|
||||
}
|
||||
|
||||
public function testMultipleExactInclusionsOr(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['main', 'develop', 'staging']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('develop'));
|
||||
$this->assertTrue($validator->isValid('staging'));
|
||||
$this->assertFalse($validator->isValid('feature/foo'));
|
||||
$this->assertFalse($validator->isValid('production'));
|
||||
}
|
||||
|
||||
public function testSingleWildcardInclusion(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['feature/*']);
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
$this->assertTrue($validator->isValid('feature/bar'));
|
||||
$this->assertFalse($validator->isValid('feature/foo/bar')); // * does not cross /
|
||||
$this->assertFalse($validator->isValid('main'));
|
||||
}
|
||||
|
||||
public function testWildcardWithDash(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['feature/test-*']);
|
||||
$this->assertTrue($validator->isValid('feature/test-1'));
|
||||
$this->assertTrue($validator->isValid('feature/test-abc'));
|
||||
$this->assertFalse($validator->isValid('feature/other'));
|
||||
$this->assertFalse($validator->isValid('feature/test'));
|
||||
}
|
||||
|
||||
public function testQuestionMarkWildcard(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['v?.?']);
|
||||
$this->assertTrue($validator->isValid('v1.0'));
|
||||
$this->assertTrue($validator->isValid('v2.5'));
|
||||
$this->assertFalse($validator->isValid('v10.0')); // ? matches exactly one char, not two
|
||||
$this->assertFalse($validator->isValid('v1/0')); // ? does not cross /
|
||||
}
|
||||
|
||||
public function testQuestionMarkDoesNotCrossSlash(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['feature/?']);
|
||||
$this->assertTrue($validator->isValid('feature/a'));
|
||||
$this->assertTrue($validator->isValid('feature/z'));
|
||||
$this->assertFalse($validator->isValid('feature/ab')); // ? matches only one char
|
||||
$this->assertFalse($validator->isValid('feature/a/b')); // ? does not cross /
|
||||
$this->assertFalse($validator->isValid('feature/'));
|
||||
}
|
||||
|
||||
public function testQuestionMarkMixedWithStar(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['fix-?.*']);
|
||||
$this->assertTrue($validator->isValid('fix-1.php'));
|
||||
$this->assertTrue($validator->isValid('fix-a.js'));
|
||||
$this->assertFalse($validator->isValid('fix-12.php')); // ? matches only one char
|
||||
$this->assertFalse($validator->isValid('fix-.php')); // ? requires exactly one char
|
||||
}
|
||||
|
||||
public function testDoubleWildcardAtEnd(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['src/**']);
|
||||
$this->assertTrue($validator->isValid('src/foo.js'));
|
||||
$this->assertTrue($validator->isValid('src/a/b/c.js'));
|
||||
$this->assertTrue($validator->isValid('src/deep/nested/file.php'));
|
||||
$this->assertFalse($validator->isValid('lib/foo.js'));
|
||||
}
|
||||
|
||||
public function testDoubleWildcardInMiddle(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['a/**/b']);
|
||||
$this->assertTrue($validator->isValid('a/b')); // zero intermediate dirs
|
||||
$this->assertTrue($validator->isValid('a/x/b')); // one
|
||||
$this->assertTrue($validator->isValid('a/x/y/b')); // two
|
||||
$this->assertFalse($validator->isValid('a/b/c'));
|
||||
$this->assertFalse($validator->isValid('x/a/b'));
|
||||
}
|
||||
|
||||
public function testDoubleWildcardAtStart(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['**/foo']);
|
||||
$this->assertTrue($validator->isValid('foo')); // zero leading dirs
|
||||
$this->assertTrue($validator->isValid('a/foo')); // one
|
||||
$this->assertTrue($validator->isValid('a/b/foo')); // two
|
||||
$this->assertFalse($validator->isValid('foobar'));
|
||||
$this->assertFalse($validator->isValid('a/foobar'));
|
||||
}
|
||||
|
||||
public function testMixedExactAndWildcardInclusions(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['main', 'feature/*']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
$this->assertFalse($validator->isValid('develop'));
|
||||
$this->assertFalse($validator->isValid('feature/foo/bar'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pure exclusion — AND semantics (must not match any exclusion)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testSingleExactExclusion(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['!main']);
|
||||
$this->assertFalse($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('develop'));
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
}
|
||||
|
||||
public function testMultipleExactExclusionsAnd(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['!main', '!develop']);
|
||||
$this->assertFalse($validator->isValid('main'));
|
||||
$this->assertFalse($validator->isValid('develop'));
|
||||
$this->assertTrue($validator->isValid('staging')); // neither excluded
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
}
|
||||
|
||||
public function testWildcardExclusion(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['!feature/*']);
|
||||
$this->assertFalse($validator->isValid('feature/foo'));
|
||||
$this->assertFalse($validator->isValid('feature/bar'));
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('hotfix/urgent')); // not matched by feature/*
|
||||
}
|
||||
|
||||
public function testDoubleWildcardExclusion(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['!src/**']);
|
||||
$this->assertFalse($validator->isValid('src/foo.js'));
|
||||
$this->assertFalse($validator->isValid('src/a/b/c.js'));
|
||||
$this->assertTrue($validator->isValid('lib/foo.js'));
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mixed inclusion + exclusion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testInclusionTakesPrecedenceWhenBothMatch(): void
|
||||
{
|
||||
// feature/abc matches both '!feature/*' (exclusion) and 'feature/abc' (inclusion)
|
||||
// Inclusion is checked first, so it wins
|
||||
$validator = new BuildTrigger(['!feature/*', 'feature/abc']);
|
||||
$this->assertTrue($validator->isValid('feature/abc')); // inclusion wins
|
||||
$this->assertFalse($validator->isValid('feature/xyz')); // only exclusion matches
|
||||
$this->assertFalse($validator->isValid('main')); // no inclusion matches
|
||||
}
|
||||
|
||||
public function testInclusionWithNoMatchFails(): void
|
||||
{
|
||||
// Inclusions exist but none match — exclusion is irrelevant
|
||||
$validator = new BuildTrigger(['main', '!develop']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertFalse($validator->isValid('develop')); // excluded even if inclusion didn't match
|
||||
$this->assertFalse($validator->isValid('staging')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testExclusionBlocksWhenInclusionDoesNotMatch(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['feature/*', '!hotfix/*']);
|
||||
$this->assertTrue($validator->isValid('feature/foo')); // matches inclusion
|
||||
$this->assertFalse($validator->isValid('hotfix/urgent')); // no inclusion match, also excluded
|
||||
$this->assertFalse($validator->isValid('main')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testMultipleInclusionsWithSingleExclusion(): void
|
||||
{
|
||||
// feature/wip matches wildcard inclusion feature/* but specific exclusion !feature/wip overrides it
|
||||
$validator = new BuildTrigger(['main', 'develop', 'feature/*', '!feature/wip']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('develop'));
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
$this->assertFalse($validator->isValid('feature/wip')); // specific exclusion overrides wildcard inclusion
|
||||
$this->assertFalse($validator->isValid('hotfix/urgent')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testSingleInclusionWithMultipleExclusions(): void
|
||||
{
|
||||
// specific exclusions !feature/wip and !feature/experimental override wildcard inclusion feature/**
|
||||
$validator = new BuildTrigger(['feature/**', '!feature/wip', '!feature/experimental']);
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
$this->assertTrue($validator->isValid('feature/a/b'));
|
||||
$this->assertFalse($validator->isValid('feature/wip')); // specific exclusion wins
|
||||
$this->assertFalse($validator->isValid('feature/experimental')); // specific exclusion wins
|
||||
$this->assertFalse($validator->isValid('main')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testMultipleInclusionsWithMultipleExclusions(): void
|
||||
{
|
||||
// specific exclusions override the wildcard inclusion; specific inclusion 'main' is unaffected
|
||||
$validator = new BuildTrigger(['main', 'feature/**', '!feature/wip', '!feature/experimental']);
|
||||
$this->assertTrue($validator->isValid('main')); // specific inclusion wins regardless
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
$this->assertTrue($validator->isValid('feature/a/b'));
|
||||
$this->assertFalse($validator->isValid('feature/wip')); // specific exclusion wins
|
||||
$this->assertFalse($validator->isValid('feature/experimental')); // specific exclusion wins
|
||||
$this->assertFalse($validator->isValid('develop')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testWildcardExclusionOverridesWildcardInclusion(): void
|
||||
{
|
||||
// src/** is a broad inclusion; !src/generated/** carves out the generated subtree
|
||||
$validator = new BuildTrigger(['src/**', '!src/generated/**']);
|
||||
$this->assertTrue($validator->isValid('src/components/Button.php'));
|
||||
$this->assertTrue($validator->isValid('src/utils/helper.js'));
|
||||
$this->assertFalse($validator->isValid('src/generated/Foo.php')); // wildcard exclusion wins
|
||||
$this->assertFalse($validator->isValid('src/generated/bar/Baz.php')); // wildcard exclusion wins
|
||||
$this->assertFalse($validator->isValid('lib/other.php')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testSpecificInclusionOverridesWildcardExclusion(): void
|
||||
{
|
||||
// Narrow allowlist with a carve-out: exclude all of feature/* except feature/hotfix/critical
|
||||
$validator = new BuildTrigger(['feature/hotfix/critical', '!feature/**']);
|
||||
$this->assertTrue($validator->isValid('feature/hotfix/critical')); // inclusion wins
|
||||
$this->assertFalse($validator->isValid('feature/foo')); // excluded
|
||||
$this->assertFalse($validator->isValid('feature/hotfix/other')); // excluded
|
||||
$this->assertFalse($validator->isValid('main')); // no inclusion match
|
||||
}
|
||||
|
||||
public function testOnlyExclusionsDefaultToTrueUnlessExcluded(): void
|
||||
{
|
||||
$validator = new BuildTrigger(['!main', '!develop']);
|
||||
$this->assertFalse($validator->isValid('main'));
|
||||
$this->assertFalse($validator->isValid('develop'));
|
||||
$this->assertTrue($validator->isValid('staging')); // passes — no inclusions required
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Standalone wildcards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testStarAloneMatchesSingleSegmentOnly(): void
|
||||
{
|
||||
// * → ^[^/]*$ — matches any single segment; the [^/]* guard prevents crossing /
|
||||
$validator = new BuildTrigger(['*']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('develop'));
|
||||
$this->assertFalse($validator->isValid('feature/foo')); // * cannot cross /
|
||||
$this->assertFalse($validator->isValid('a/b/c'));
|
||||
}
|
||||
|
||||
public function testDoubleStarAloneMatchesEverything(): void
|
||||
{
|
||||
// ** alone → ^.*$ — matches any string including paths with separators
|
||||
$validator = new BuildTrigger(['**']);
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
$this->assertTrue($validator->isValid('feature/foo'));
|
||||
$this->assertTrue($validator->isValid('src/a/b/c/d/file.php'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Extension patterns — * scope vs. ** scope
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testStarDotExtMatchesRootLevelOnly(): void
|
||||
{
|
||||
// *.php → ^[^/]*\.php$ — root-level only; * cannot cross /
|
||||
$validator = new BuildTrigger(['*.php']);
|
||||
$this->assertTrue($validator->isValid('Foo.php'));
|
||||
$this->assertTrue($validator->isValid('index.php'));
|
||||
$this->assertFalse($validator->isValid('src/Foo.php')); // * does not cross /
|
||||
$this->assertFalse($validator->isValid('a/b/Foo.php'));
|
||||
$this->assertFalse($validator->isValid('Foo.js'));
|
||||
}
|
||||
|
||||
public function testDoubleStarSlashExtMatchesAnyDepth(): void
|
||||
{
|
||||
// **/*.php → ^(?:.+/)?[^/]*\.php$ — (?:.+/)? is optional so root also matches
|
||||
$validator = new BuildTrigger(['**/*.php']);
|
||||
$this->assertTrue($validator->isValid('Foo.php')); // root — zero leading dirs
|
||||
$this->assertTrue($validator->isValid('src/Foo.php'));
|
||||
$this->assertTrue($validator->isValid('src/components/Foo.php'));
|
||||
$this->assertTrue($validator->isValid('a/b/c/d/Foo.php')); // four levels deep
|
||||
$this->assertFalse($validator->isValid('Foo.js'));
|
||||
$this->assertFalse($validator->isValid('src/Foo.js'));
|
||||
}
|
||||
|
||||
public function testDirPrefixDoubleStarExtPattern(): void
|
||||
{
|
||||
// src/**/*.php → ^src/(?:.+/)?[^/]*\.php$ — scoped to src/ at any depth
|
||||
$validator = new BuildTrigger(['src/**/*.php']);
|
||||
$this->assertTrue($validator->isValid('src/Foo.php'));
|
||||
$this->assertTrue($validator->isValid('src/components/Foo.php'));
|
||||
$this->assertTrue($validator->isValid('src/a/b/c/Foo.php'));
|
||||
$this->assertFalse($validator->isValid('Foo.php')); // outside src/
|
||||
$this->assertFalse($validator->isValid('lib/Foo.php'));
|
||||
$this->assertFalse($validator->isValid('src/Foo.js'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dots as literal characters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testDotsInPatternAreLiteral(): void
|
||||
{
|
||||
// preg_quote escapes . to \. — dots are never regex wildcards
|
||||
$validator = new BuildTrigger(['release-1.0.0']);
|
||||
$this->assertTrue($validator->isValid('release-1.0.0'));
|
||||
$this->assertFalse($validator->isValid('release-1X0Y0')); // X/Y must not satisfy a literal dot
|
||||
$this->assertFalse($validator->isValid('release-1.0.0-hotfix')); // extra suffix rejected by $ anchor
|
||||
}
|
||||
|
||||
public function testVersionWildcardBranchPattern(): void
|
||||
{
|
||||
// v*.*.* → ^v[^/]*\.[^/]*\.[^/]*$ — dots literal; [^/]* is greedy and can consume dots,
|
||||
// so v1.2.3.4 also matches (first [^/]* eats "1.2") — documents current behavior
|
||||
$validator = new BuildTrigger(['v*.*.*']);
|
||||
$this->assertTrue($validator->isValid('v1.2.3'));
|
||||
$this->assertTrue($validator->isValid('v10.20.30'));
|
||||
$this->assertTrue($validator->isValid('v1.2.3.4')); // greedy match — first [^/]* eats "1.2"
|
||||
$this->assertFalse($validator->isValid('v1.2')); // only two dot-segments; third \.[^/]* fails
|
||||
$this->assertFalse($validator->isValid('1.2.3')); // missing leading v
|
||||
$this->assertFalse($validator->isValid('v1/2/3')); // [^/]* cannot cross /
|
||||
}
|
||||
|
||||
public function testDottedFilenamePattern(): void
|
||||
{
|
||||
// *.test.js → ^[^/]*\.test\.js$ — both dots literal; * stays in root segment
|
||||
$validator = new BuildTrigger(['*.test.js']);
|
||||
$this->assertTrue($validator->isValid('Button.test.js'));
|
||||
$this->assertTrue($validator->isValid('App.test.js'));
|
||||
$this->assertFalse($validator->isValid('ButtonXtestYjs')); // dots must be literal
|
||||
$this->assertFalse($validator->isValid('src/Button.test.js')); // * does not cross /
|
||||
$this->assertFalse($validator->isValid('Button.test.ts'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Prefix wildcard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testPrefixWildcardBranchPattern(): void
|
||||
{
|
||||
// main* → ^main[^/]*$ — suffix wildcard; [^/]* cannot cross /
|
||||
$validator = new BuildTrigger(['main*']);
|
||||
$this->assertTrue($validator->isValid('main')); // zero trailing chars
|
||||
$this->assertTrue($validator->isValid('main-extra'));
|
||||
$this->assertTrue($validator->isValid('mainline'));
|
||||
$this->assertFalse($validator->isValid('main/branch')); // [^/]* cannot cross /
|
||||
$this->assertFalse($validator->isValid('develop'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Deep nesting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testDoubleWildcardInMiddleDeepNesting(): void
|
||||
{
|
||||
// a/**/b → ^a/(?:.+/)?b$ — .+ inside (?:.+/)? matches any chars including /,
|
||||
// so it handles arbitrarily many intermediate directories
|
||||
$validator = new BuildTrigger(['a/**/b']);
|
||||
$this->assertTrue($validator->isValid('a/x/y/z/b')); // three intermediate dirs
|
||||
$this->assertTrue($validator->isValid('a/p/q/r/s/b')); // four intermediate dirs
|
||||
$this->assertTrue($validator->isValid('a/1/2/3/4/5/b')); // five intermediate dirs
|
||||
$this->assertFalse($validator->isValid('a/x/y/z/b/extra')); // trailing segment rejected
|
||||
$this->assertFalse($validator->isValid('prefix/a/x/b')); // leading segment rejected
|
||||
}
|
||||
|
||||
public function testDoubleWildcardAtStartDeepNesting(): void
|
||||
{
|
||||
// **/README.md → ^(?:.+/)?README\.md$ — matches at any depth; $ anchor prevents suffixes
|
||||
$validator = new BuildTrigger(['**/README.md']);
|
||||
$this->assertTrue($validator->isValid('README.md')); // zero leading dirs
|
||||
$this->assertTrue($validator->isValid('docs/README.md'));
|
||||
$this->assertTrue($validator->isValid('a/b/c/d/README.md')); // four levels deep
|
||||
$this->assertTrue($validator->isValid('x/y/z/w/v/README.md')); // five levels deep
|
||||
$this->assertFalse($validator->isValid('a/b/c/README.md.bak')); // $ anchor — no suffix
|
||||
$this->assertFalse($validator->isValid('a/b/c/README.md/extra')); // trailing segment rejected
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Real-world path patterns
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testGeneratedFilesAnywhereExclusion(): void
|
||||
{
|
||||
// !**/generated/** → ^(?:.+/)?generated/.*$ — excludes generated/ at any depth
|
||||
$validator = new BuildTrigger(['!**/generated/**']);
|
||||
$this->assertFalse($validator->isValid('generated/Foo.php')); // root generated/
|
||||
$this->assertFalse($validator->isValid('src/generated/Foo.php'));
|
||||
$this->assertFalse($validator->isValid('src/api/generated/Bar.php')); // deep generated/
|
||||
$this->assertFalse($validator->isValid('generated/sub/deep/File.php')); // deep inside root generated/
|
||||
$this->assertTrue($validator->isValid('src/components/Button.php')); // not under generated/
|
||||
$this->assertTrue($validator->isValid('main'));
|
||||
}
|
||||
|
||||
public function testMultipleExtensionInclusions(): void
|
||||
{
|
||||
// OR semantics: any PHP or JS file triggers; other extensions do not
|
||||
$validator = new BuildTrigger(['**/*.php', '**/*.js']);
|
||||
$this->assertTrue($validator->isValid('index.php'));
|
||||
$this->assertTrue($validator->isValid('src/App.php'));
|
||||
$this->assertTrue($validator->isValid('index.js'));
|
||||
$this->assertTrue($validator->isValid('src/components/App.js'));
|
||||
$this->assertFalse($validator->isValid('styles.css'));
|
||||
$this->assertFalse($validator->isValid('src/styles.css'));
|
||||
$this->assertFalse($validator->isValid('README.md'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Named-prefix single-level branch
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testReleaseBranchPattern(): void
|
||||
{
|
||||
// release/* → ^release/[^/]*$ — one level only; * stops at the next /
|
||||
$validator = new BuildTrigger(['release/*']);
|
||||
$this->assertTrue($validator->isValid('release/1.0'));
|
||||
$this->assertTrue($validator->isValid('release/hotfix'));
|
||||
$this->assertTrue($validator->isValid('release/2024-01-15'));
|
||||
$this->assertFalse($validator->isValid('release/1.0/patch')); // * stops at /
|
||||
$this->assertFalse($validator->isValid('release')); // missing the /* segment
|
||||
$this->assertFalse($validator->isValid('main'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Case sensitivity
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testPatternMatchingIsCaseSensitive(): void
|
||||
{
|
||||
// preg_match is used without the i flag — matching is always case-sensitive
|
||||
$branchValidator = new BuildTrigger(['main']);
|
||||
$this->assertTrue($branchValidator->isValid('main'));
|
||||
$this->assertFalse($branchValidator->isValid('Main'));
|
||||
$this->assertFalse($branchValidator->isValid('MAIN'));
|
||||
|
||||
$wildcardValidator = new BuildTrigger(['feature/*']);
|
||||
$this->assertTrue($wildcardValidator->isValid('feature/foo'));
|
||||
$this->assertFalse($wildcardValidator->isValid('Feature/foo'));
|
||||
$this->assertFalse($wildcardValidator->isValid('FEATURE/foo'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user