Skip to content

Commit 5f2299e

Browse files
authored
Merge pull request #116 from utopia-php/feat/tag-resolution
feat: list tags with glob filtering across all adapters
2 parents 5892cfe + 18acca0 commit 5f2299e

9 files changed

Lines changed: 286 additions & 0 deletions

File tree

src/VCS/Adapter.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,16 @@ abstract public function getRepositoryName(string $repositoryId): string;
229229
*/
230230
abstract public function listBranches(string $owner, string $repositoryName): array;
231231

232+
/**
233+
* Lists tags for a given repository, optionally filtered by a glob pattern.
234+
*
235+
* @param string $owner Owner name of the repository
236+
* @param string $repositoryName Name of the repository
237+
* @param string $search Glob pattern (e.g. 'v1.*'); empty returns all tags
238+
* @return array<string> List of tag names as array
239+
*/
240+
abstract public function listTags(string $owner, string $repositoryName, string $search = ''): array;
241+
232242
/**
233243
* Updates status check of each commit
234244
* state can be one of: error, failure, pending, success

src/VCS/Adapter/Git.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,20 @@ abstract public function createTag(string $owner, string $repositoryName, string
105105
* @return array<mixed> List of commit statuses
106106
*/
107107
abstract public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array;
108+
109+
/**
110+
* Filter ref names by a shell glob pattern (e.g. 'v1.*', 'v?.0.0').
111+
* An empty pattern returns every name unchanged.
112+
*
113+
* @param array<string> $names
114+
* @return array<string>
115+
*/
116+
protected function matchGlob(array $names, string $pattern): array
117+
{
118+
if ($pattern === '') {
119+
return \array_values($names);
120+
}
121+
122+
return \array_values(\array_filter($names, fn (string $name) => \fnmatch($pattern, $name)));
123+
}
108124
}

src/VCS/Adapter/Git/GitHub.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,34 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
795795
return array_values(array_map(fn ($branch) => $branch['name'] ?? '', $responseBody));
796796
}
797797

798+
/**
799+
* Lists tags for a given repository, optionally filtered by a glob pattern.
800+
*
801+
* Uses GET /repos/{owner}/{repo}/git/matching-refs/tags to fetch every tag ref
802+
* in a single call, then filters client-side with the glob pattern.
803+
*
804+
* @param string $owner
805+
* @param string $repositoryName
806+
* @param string $search Glob pattern (e.g. 'v1.*'); empty returns all tags
807+
* @return array<string> List of tag names
808+
*/
809+
public function listTags(string $owner, string $repositoryName, string $search = ''): array
810+
{
811+
$url = "/repos/$owner/$repositoryName/git/matching-refs/tags/";
812+
$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"]);
813+
814+
$statusCode = $response['headers']['status-code'] ?? 0;
815+
$responseBody = $response['body'] ?? [];
816+
817+
if ($statusCode < 200 || $statusCode >= 300 || !is_array($responseBody)) {
818+
return [];
819+
}
820+
821+
$tags = array_map(fn ($ref) => str_replace('refs/tags/', '', $ref['ref'] ?? ''), $responseBody);
822+
823+
return $this->matchGlob($tags, $search);
824+
}
825+
798826
/**
799827
* Get details of a commit using commit hash
800828
*

src/VCS/Adapter/Git/GitLab.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,34 @@ public function listBranches(string $owner, string $repositoryName): array
762762
return $branches;
763763
}
764764

765+
public function listTags(string $owner, string $repositoryName, string $search = ''): array
766+
{
767+
$ownerPath = $this->getOwnerPath($owner);
768+
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
769+
770+
$tags = [];
771+
$page = 1;
772+
do {
773+
$pagedUrl = "/projects/{$projectPath}/repository/tags?per_page=100&page={$page}";
774+
$response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]);
775+
$responseHeaders = $response['headers'] ?? [];
776+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
777+
if ($responseHeadersStatusCode >= 400) {
778+
return [];
779+
}
780+
$responseBody = $response['body'] ?? [];
781+
if (!is_array($responseBody) || empty($responseBody)) {
782+
break;
783+
}
784+
foreach ($responseBody as $tag) {
785+
$tags[] = $tag['name'] ?? '';
786+
}
787+
$page++;
788+
} while (count($responseBody) === 100);
789+
790+
return $this->matchGlob($tags, $search);
791+
}
792+
765793
public function getCommit(string $owner, string $repositoryName, string $commitHash): array
766794
{
767795
$ownerPath = $this->getOwnerPath($owner);

src/VCS/Adapter/Git/Gitea.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,61 @@ public function listBranches(string $owner, string $repositoryName): array
814814
return $allBranches;
815815
}
816816

817+
/**
818+
* Lists tags for a given repository, optionally filtered by a glob pattern.
819+
*
820+
* @param string $owner Owner of the repository
821+
* @param string $repositoryName Name of the repository
822+
* @param string $search Glob pattern (e.g. 'v1.*'); empty returns all tags
823+
* @return array<string> Array of tag names
824+
*/
825+
public function listTags(string $owner, string $repositoryName, string $search = ''): array
826+
{
827+
$allTags = [];
828+
$perPage = 50;
829+
$maxPages = 100;
830+
831+
for ($currentPage = 1; $currentPage <= $maxPages; $currentPage++) {
832+
$url = "/repos/{$owner}/{$repositoryName}/tags?page={$currentPage}&limit={$perPage}";
833+
834+
$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"], decode: false);
835+
836+
$responseHeaders = $response['headers'] ?? [];
837+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
838+
839+
if ($responseHeadersStatusCode === 404) {
840+
return [];
841+
}
842+
843+
if ($responseHeadersStatusCode >= 400) {
844+
if ($currentPage === 1) {
845+
throw new Exception("Failed to list tags: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode);
846+
}
847+
break;
848+
}
849+
850+
$responseBody = \json_decode($response['body'] ?? '', true);
851+
852+
if (!is_array($responseBody)) {
853+
break;
854+
}
855+
856+
$pageCount = 0;
857+
foreach ($responseBody as $tag) {
858+
if (is_array($tag) && array_key_exists('name', $tag)) {
859+
$allTags[] = $tag['name'] ?? '';
860+
$pageCount++;
861+
}
862+
}
863+
864+
if ($pageCount < $perPage) {
865+
break;
866+
}
867+
}
868+
869+
return $this->matchGlob($allTags, $search);
870+
}
871+
817872
/**
818873
* Get details of a commit using commit hash
819874
*

src/VCS/Adapter/Git/Gogs.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,45 @@ public function listBranches(string $owner, string $repositoryName): array
537537

538538
return $branches;
539539
}
540+
541+
/**
542+
* List tags
543+
*
544+
* Gogs supports listing tags but without pagination parameters.
545+
*
546+
* @param string $search Glob pattern (e.g. 'v1.*'); empty returns all tags
547+
* @return array<string>
548+
*/
549+
public function listTags(string $owner, string $repositoryName, string $search = ''): array
550+
{
551+
$url = "/repos/{$owner}/{$repositoryName}/tags";
552+
553+
$response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]);
554+
555+
$responseHeaders = $response['headers'] ?? [];
556+
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
557+
558+
if ($responseHeadersStatusCode === 404) {
559+
return [];
560+
}
561+
562+
if ($responseHeadersStatusCode >= 400) {
563+
throw new Exception("Failed to list tags: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode);
564+
}
565+
566+
$responseBody = $response['body'] ?? [];
567+
568+
if (!is_array($responseBody)) {
569+
return [];
570+
}
571+
572+
$tags = [];
573+
foreach ($responseBody as $tag) {
574+
if (is_array($tag) && array_key_exists('name', $tag)) {
575+
$tags[] = $tag['name'];
576+
}
577+
}
578+
579+
return $this->matchGlob($tags, $search);
580+
}
540581
}

tests/VCS/Adapter/GitHubTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,31 @@ public function testListBranchesNonExistingRepository(): void
622622
$this->assertEmpty($branches);
623623
}
624624

625+
public function testListTagsEmptyRepository(): void
626+
{
627+
$repositoryName = 'test-list-tags-empty-' . \uniqid();
628+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
629+
630+
try {
631+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
632+
633+
$tags = $this->vcsAdapter->listTags(static::$owner, $repositoryName);
634+
$this->assertSame([], $tags);
635+
636+
// Glob against a repo with no tags stays empty
637+
$this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v*'));
638+
} finally {
639+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
640+
}
641+
}
642+
643+
public function testListTagsNonExistingRepository(): void
644+
{
645+
$tags = $this->vcsAdapter->listTags(static::$owner, 'non-existing-repo-' . \uniqid());
646+
647+
$this->assertSame([], $tags);
648+
}
649+
625650
public function testGetLatestCommit(): void
626651
{
627652
$repositoryName = 'test-get-latest-commit-' . \uniqid();

tests/VCS/Adapter/GitLabTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,41 @@ public function testListBranchesEmptyRepo(): void
10931093
}
10941094
}
10951095

1096+
public function testListTags(): void
1097+
{
1098+
$repositoryName = 'test-list-tags-' . \uniqid();
1099+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
1100+
1101+
try {
1102+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
1103+
$commitHash = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch)['commitHash'];
1104+
1105+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash);
1106+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.1.0', $commitHash);
1107+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v2.0.0', $commitHash);
1108+
1109+
$this->assertEqualsCanonicalizing(['v1.0.0', 'v1.1.0', 'v2.0.0'], $this->vcsAdapter->listTags(static::$owner, $repositoryName));
1110+
$this->assertEqualsCanonicalizing(['v1.0.0', 'v1.1.0'], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v1.*'));
1111+
$this->assertSame(['v2.0.0'], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v2.0.0'));
1112+
$this->assertEmpty($this->vcsAdapter->listTags(static::$owner, $repositoryName, 'nope-*'));
1113+
} finally {
1114+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
1115+
}
1116+
}
1117+
1118+
public function testListTagsEmptyRepo(): void
1119+
{
1120+
$repositoryName = 'test-list-tags-empty-' . \uniqid();
1121+
1122+
try {
1123+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
1124+
1125+
$this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName));
1126+
} finally {
1127+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
1128+
}
1129+
}
1130+
10961131
public function testListRepositoryLanguages(): void
10971132
{
10981133
$repositoryName = 'test-list-repository-languages-' . \uniqid();

tests/VCS/Adapter/GiteaTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,54 @@ public function testCreateTag(): void
15321532
}
15331533
}
15341534

1535+
public function testListTags(): void
1536+
{
1537+
$repositoryName = 'test-list-tags-' . \uniqid();
1538+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
1539+
1540+
try {
1541+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
1542+
$commitHash = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch)['commitHash'];
1543+
1544+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash);
1545+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.1.0', $commitHash);
1546+
$this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v2.0.0', $commitHash);
1547+
1548+
$tags = [];
1549+
for ($attempt = 0; $attempt < 10; $attempt++) {
1550+
$tags = $this->vcsAdapter->listTags(static::$owner, $repositoryName);
1551+
if (count($tags) >= 3) {
1552+
break;
1553+
}
1554+
usleep(500000);
1555+
}
1556+
1557+
$this->assertEqualsCanonicalizing(['v1.0.0', 'v1.1.0', 'v2.0.0'], $tags);
1558+
1559+
// Glob filtering
1560+
$this->assertEqualsCanonicalizing(['v1.0.0', 'v1.1.0'], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v1.*'));
1561+
$this->assertSame(['v2.0.0'], $this->vcsAdapter->listTags(static::$owner, $repositoryName, 'v2.0.0'));
1562+
$this->assertEmpty($this->vcsAdapter->listTags(static::$owner, $repositoryName, 'nope-*'));
1563+
} finally {
1564+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
1565+
}
1566+
}
1567+
1568+
public function testListTagsEmptyAndMissingRepo(): void
1569+
{
1570+
$repositoryName = 'test-list-tags-empty-' . \uniqid();
1571+
$this->vcsAdapter->createRepository(static::$owner, $repositoryName, false);
1572+
1573+
try {
1574+
$this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test');
1575+
$this->assertSame([], $this->vcsAdapter->listTags(static::$owner, $repositoryName));
1576+
} finally {
1577+
$this->vcsAdapter->deleteRepository(static::$owner, $repositoryName);
1578+
}
1579+
1580+
$this->assertSame([], $this->vcsAdapter->listTags(static::$owner, 'non-existing-repo-' . \uniqid()));
1581+
}
1582+
15351583
public function testListRepositoryLanguages(): void
15361584
{
15371585
$repositoryName = 'test-list-repository-languages-' . \uniqid();

0 commit comments

Comments
 (0)