From 109c424c6a6240ea81c34ad795e9b5f59ce21cbb Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:03:31 +0100 Subject: [PATCH 1/2] feat: add getRepositoryPresignedUrl to download repository archive Adds a way to get a presigned/downloadable URL for a repository archive across all adapters. - GitHub: captures the native 302 redirect to the signed codeload URL - Gitea/Forgejo/Gogs & GitLab: build a directly-downloadable archive URL with the short-lived access token embedded as a query param, so it works without object storage - adds a followRedirects flag to Adapter::call() so the GitHub redirect can be captured instead of followed Co-Authored-By: Claude Opus 4.8 (1M context) --- src/VCS/Adapter.php | 19 +++++++++++++-- src/VCS/Adapter/Git/GitHub.php | 40 ++++++++++++++++++++++++++++++++ src/VCS/Adapter/Git/GitLab.php | 34 +++++++++++++++++++++++++++ src/VCS/Adapter/Git/Gitea.php | 33 ++++++++++++++++++++++++++ tests/VCS/Adapter/GitHubTest.php | 36 ++++++++++++++++++++++++++++ tests/VCS/Adapter/GitLabTest.php | 21 +++++++++++++++++ tests/VCS/Adapter/GiteaTest.php | 16 +++++++++++++ 7 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 1d4ca02b..d6f8b29e 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -369,6 +369,20 @@ abstract public function getCommit(string $owner, string $repositoryName, string */ abstract public function getLatestCommit(string $owner, string $repositoryName, string $branch): array; + /** + * Get a short-lived presigned URL to download the repository archive. + * + * @param string $owner Owner name of the repository + * @param string $repositoryName Name of the repository + * @param string $ref Branch, tag or commit to download (defaults to the default branch) + * @param string $format Archive format, e.g. 'tarball' or 'zipball' + * @return string Presigned download URL + */ + public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string + { + throw new Exception('getRepositoryPresignedUrl() is not implemented for ' . $this->getName()); + } + /** * Call * @@ -379,11 +393,12 @@ abstract public function getLatestCommit(string $owner, string $repositoryName, * @param array $params * @param array $headers * @param bool $decode + * @param bool $followRedirects When false, a redirect response is returned as-is instead of being followed * @return array * * @throws Exception */ - protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true) + protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true, bool $followRedirects = true) { $headers = array_merge($this->headers, $headers); $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); @@ -423,7 +438,7 @@ protected function call(string $method, string $path = '', array $headers = [], curl_setopt($ch, CURLOPT_PATH_AS_IS, 1); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $followRedirects); curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index f0d3b017..e60b1f3f 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -871,6 +871,46 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b ]; } + /** + * Get a short-lived presigned URL to download the repository archive. + * + * GitHub answers its tarball/zipball endpoints with a temporary redirect to a + * signed codeload URL. We capture that URL instead of following the redirect, + * so callers can download the archive directly without proxying the token. + * + * @param string $owner Owner name of the repository + * @param string $repositoryName Name of the repository + * @param string $ref Branch, tag or commit to download (defaults to the default branch) + * @param string $format Archive format: 'tarball' or 'zipball' + * @return string Presigned download URL + */ + public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string + { + if (!\in_array($format, ['tarball', 'zipball'], true)) { + throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."); + } + + $url = "/repos/$owner/$repositoryName/$format"; + if (!empty($ref)) { + $url .= "/$ref"; + } + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [], false, false); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + throw new RepositoryNotFound("Repository or ref not found."); + } + + $presignedUrl = $responseHeaders['location'] ?? ''; + if (empty($presignedUrl)) { + throw new Exception("Failed to get presigned URL: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); + } + + return $presignedUrl; + } + /** * Updates status check of each commit * state can be one of: error, failure, pending, success diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index e5742f30..13c44c9b 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -162,6 +162,40 @@ public function getRepository(string $owner, string $repositoryName): array } + /** + * Get a short-lived presigned URL to download the repository archive. + * + * GitLab only signs archive URLs when backed by object storage, so we build + * a directly downloadable archive URL with the access token embedded as a + * query parameter. Since the adapter's tokens are short-lived, the URL is + * effectively time-limited. Note the URL carries the token; treat it as a + * secret. + * + * @param string $owner Owner name of the repository + * @param string $repositoryName Name of the repository + * @param string $ref Branch, tag or commit to download (defaults to the default branch) + * @param string $format Archive format: 'tarball' or 'zipball' + * @return string Presigned download URL + */ + public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string + { + $extension = match ($format) { + 'tarball' => 'tar.gz', + 'zipball' => 'zip', + default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."), + }; + + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + + $url = "{$this->endpoint}/projects/{$projectPath}/repository/archive.{$extension}?private_token=" . urlencode($this->accessToken); + if (!empty($ref)) { + $url .= "&sha=" . urlencode($ref); + } + + return $url; + } + public function hasAccessToAllRepositories(): bool { return true; diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 5316c790..0ff7a5a6 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -260,6 +260,39 @@ public function getRepository(string $owner, string $repositoryName): array return is_array($result) ? $result : []; } + /** + * Get a short-lived presigned URL to download the repository archive. + * + * Gitea has no signing service for local storage, so we build a directly + * downloadable archive URL with the access token embedded as a query + * parameter. Since the adapter's tokens are short-lived, the URL is + * effectively time-limited. Note the URL carries the token; treat it as a + * secret. + * + * @param string $owner Owner name of the repository + * @param string $repositoryName Name of the repository + * @param string $ref Branch, tag or commit to download (defaults to the default branch) + * @param string $format Archive format: 'tarball' or 'zipball' + * @return string Presigned download URL + */ + public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string + { + $extension = match ($format) { + 'tarball' => 'tar.gz', + 'zipball' => 'zip', + default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."), + }; + + if (empty($ref)) { + $ref = $this->getRepository($owner, $repositoryName)['default_branch'] ?? ''; + if (empty($ref)) { + throw new Exception('Unable to resolve default branch for archive download.'); + } + } + + return "{$this->endpoint}/repos/{$owner}/{$repositoryName}/archive/{$ref}.{$extension}?token=" . urlencode($this->accessToken); + } + public function getRepositoryName(string $repositoryId): string { $url = "/repositories/{$repositoryId}"; diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 857f2a0c..d5bce564 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -516,6 +516,42 @@ public function testGetCommit(): void } } + public function testGetRepositoryPresignedUrl(): void + { + $repositoryName = 'test-presigned-url-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + + $tarballUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch); + $this->assertNotEmpty($tarballUrl); + $this->assertStringStartsWith('https://', $tarballUrl); + + $zipballUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName, static::$defaultBranch, 'zipball'); + $this->assertNotEmpty($zipballUrl); + $this->assertStringStartsWith('https://', $zipballUrl); + + // Defaults to the default branch when no ref is given + $defaultUrl = $adapter->getRepositoryPresignedUrl(static::$owner, $repositoryName); + $this->assertNotEmpty($defaultUrl); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetRepositoryPresignedUrlWithInvalidFormat(): void + { + /** @var GitHub $adapter */ + $adapter = $this->vcsAdapter; + + $this->expectException(\Exception::class); + $adapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); + } + public function testGetCommitWithInvalidHash(): void { $repositoryName = 'test-get-commit-invalid-' . \uniqid(); diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index e2122148..02253db4 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -63,6 +63,27 @@ protected function setupGitLab(): void } + public function testGetRepositoryPresignedUrl(): void + { + /** @var GitLab $adapter */ + $adapter = $this->vcsAdapter; + $owner = static::$owner; + + $url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch); + $this->assertStringContainsString('/repository/archive.tar.gz?private_token=', $url); + $this->assertStringContainsString('&sha=' . static::$defaultBranch, $url); + + $zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball'); + $this->assertStringContainsString('/repository/archive.zip?private_token=', $zip); + + // Without a ref the sha param is omitted so the server uses the default branch + $noRef = $adapter->getRepositoryPresignedUrl($owner, 'some-repo'); + $this->assertStringNotContainsString('sha=', $noRef); + + $this->expectException(\Exception::class); + $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid'); + } + public function testCreateRepository(): void { $repositoryName = 'test-create-repository-' . \uniqid(); diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 07b086c3..a92c1193 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -79,6 +79,22 @@ public function testListBranchesEmptyRepo(): void } } + public function testGetRepositoryPresignedUrl(): void + { + /** @var Gitea $adapter */ + $adapter = $this->vcsAdapter; + $owner = static::$owner; + + $url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch); + $this->assertStringContainsString("/repos/{$owner}/some-repo/archive/" . static::$defaultBranch . '.tar.gz?token=', $url); + + $zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball'); + $this->assertStringContainsString('.zip?token=', $zip); + + $this->expectException(\Exception::class); + $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid'); + } + public function testCreateRepository(): void { $owner = static::$owner; From 854f6b9c27f0e2fe1a621a558099114f6b20ca8a Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:12:37 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20encod?= =?UTF-8?q?e=20refs,=20handle=20GitHub=20auth=20errors,=20test=20no-ref=20?= =?UTF-8?q?path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - encode ref in GitHub/Gitea archive URLs (preserve slashes for nested branches) - map GitHub 401/403 to a clear access-denied error - document the opt-in @throws on the base getRepositoryPresignedUrl - test the Gitea default-branch resolution (no-ref) path Co-Authored-By: Claude Opus 4.8 (1M context) --- src/VCS/Adapter.php | 2 ++ src/VCS/Adapter/Git/GitHub.php | 6 +++++- src/VCS/Adapter/Git/Gitea.php | 5 ++++- tests/VCS/Adapter/GiteaTest.php | 10 ++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index d6f8b29e..4a9d6739 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -377,6 +377,8 @@ abstract public function getLatestCommit(string $owner, string $repositoryName, * @param string $ref Branch, tag or commit to download (defaults to the default branch) * @param string $format Archive format, e.g. 'tarball' or 'zipball' * @return string Presigned download URL + * + * @throws Exception when the adapter does not implement it (opt-in, mirrors createCheckRun()) */ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string { diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index e60b1f3f..278ac102 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -892,7 +892,8 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, $url = "/repos/$owner/$repositoryName/$format"; if (!empty($ref)) { - $url .= "/$ref"; + // Encode the ref but keep slashes so nested branch names (e.g. feature/foo) still resolve + $url .= '/' . \str_replace('%2F', '/', \rawurlencode($ref)); } $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"], [], false, false); @@ -902,6 +903,9 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, if ($responseHeadersStatusCode === 404) { throw new RepositoryNotFound("Repository or ref not found."); } + if ($responseHeadersStatusCode === 401 || $responseHeadersStatusCode === 403) { + throw new Exception("Access denied to repository archive; check the access token and its permissions.", $responseHeadersStatusCode); + } $presignedUrl = $responseHeaders['location'] ?? ''; if (empty($presignedUrl)) { diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 0ff7a5a6..eff8809e 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -290,7 +290,10 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, } } - return "{$this->endpoint}/repos/{$owner}/{$repositoryName}/archive/{$ref}.{$extension}?token=" . urlencode($this->accessToken); + // Encode the ref but keep slashes so nested branch names (e.g. feature/foo) still resolve + $encodedRef = \str_replace('%2F', '/', \rawurlencode($ref)); + + return "{$this->endpoint}/repos/{$owner}/{$repositoryName}/archive/{$encodedRef}.{$extension}?token=" . urlencode($this->accessToken); } public function getRepositoryName(string $repositoryId): string diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index a92c1193..4643891c 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -91,6 +91,16 @@ public function testGetRepositoryPresignedUrl(): void $zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball'); $this->assertStringContainsString('.zip?token=', $zip); + // No ref: the default branch is resolved from the repository + $repositoryName = 'test-presigned-url-' . \uniqid(); + $adapter->createRepository($owner, $repositoryName, false); + try { + $noRef = $adapter->getRepositoryPresignedUrl($owner, $repositoryName); + $this->assertStringContainsString("/archive/" . static::$defaultBranch . '.tar.gz?token=', $noRef); + } finally { + $adapter->deleteRepository($owner, $repositoryName); + } + $this->expectException(\Exception::class); $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'invalid'); }