Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/VCS/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,22 @@ 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
*
* @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
{
throw new Exception('getRepositoryPresignedUrl() is not implemented for ' . $this->getName());
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
/**
* Call
*
Expand All @@ -379,11 +395,12 @@ abstract public function getLatestCommit(string $owner, string $repositoryName,
* @param array<mixed> $params
* @param array<string, string> $headers
* @param bool $decode
* @param bool $followRedirects When false, a redirect response is returned as-is instead of being followed
* @return array<mixed>
*
* @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) : ''));
Expand Down Expand Up @@ -423,7 +440,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);
Expand Down
44 changes: 44 additions & 0 deletions src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,50 @@ 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)) {
// 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);

$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
if ($responseHeadersStatusCode === 404) {
throw new RepositoryNotFound("Repository or ref not found.");
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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)) {
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
Expand Down
34 changes: 34 additions & 0 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,42 @@ 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.');
}
}

// 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
{
$url = "/repositories/{$repositoryId}";
Expand Down
36 changes: 36 additions & 0 deletions tests/VCS/Adapter/GitHubTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions tests/VCS/Adapter/GitLabTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions tests/VCS/Adapter/GiteaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,32 @@ 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);

// 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');
}

public function testCreateRepository(): void
{
$owner = static::$owner;
Expand Down
Loading