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
3 changes: 3 additions & 0 deletions .horde.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dependencies:
php: ^8.3
composer:
horde/http: '*'
psr/http-client: ^1.0
psr/http-factory: ^1.0
psr/http-message: ^2.0
dev:
composer:
phpunit/phpunit: ^12
Expand Down
10 changes: 7 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
"repositories": [],
"require": {
"php": "^8.3",
"horde/http": "* || dev-FRAMEWORK_6_0"
"horde/http": "* || dev-FRAMEWORK_6_0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^12",
Expand Down Expand Up @@ -44,5 +47,6 @@
"branch-alias": {
"dev-FRAMEWORK_6_0": "0.x-dev"
}
}
}
},
"minimum-stability": "dev"
}
65 changes: 65 additions & 0 deletions src/CreateReleaseParams.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Horde\GithubApiClient;

/**
* Data transfer object for creating a GitHub release
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package GithubApiClient
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/
class CreateReleaseParams
{
/**
* @param string $tagName The name of the tag (required)
* @param string $name The name of the release (optional)
* @param string $body Text describing the contents of the release (optional)
* @param bool $draft True to create a draft (unpublished) release (default: false)
* @param bool $prerelease True to identify the release as a prerelease (default: false)
* @param string $targetCommitish Specifies the commitish value for tag creation (optional)
*/
public function __construct(
public readonly string $tagName,
public readonly string $name = '',
public readonly string $body = '',
public readonly bool $draft = false,
public readonly bool $prerelease = false,
public readonly string $targetCommitish = '',
) {}

/**
* Convert to array for API request
*
* @return array<string, string|bool>
*/
public function toArray(): array
{
$data = [
'tag_name' => $this->tagName,
'draft' => $this->draft,
'prerelease' => $this->prerelease,
];

if ($this->name !== '') {
$data['name'] = $this->name;
}

if ($this->body !== '') {
$data['body'] = $this->body;
}

if ($this->targetCommitish !== '') {
$data['target_commitish'] = $this->targetCommitish;
}

return $data;
}
}
57 changes: 57 additions & 0 deletions src/CreateReleaseRequestFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Horde\GithubApiClient;

use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamFactoryInterface;

/**
* Factory for creating requests to create a GitHub release
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package GithubApiClient
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/
class CreateReleaseRequestFactory
{
public function __construct(
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
private readonly GithubApiConfig $config,
private readonly GithubRepository $repo,
private readonly CreateReleaseParams $params,
) {}

/**
* Create HTTP request to create a release
*
* @return RequestInterface
*/
public function create(): RequestInterface
{
$url = sprintf(
'https://api.github.com/repos/%s/%s/releases',
$this->repo->owner,
$this->repo->name,
);

$jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR);
$stream = $this->streamFactory->createStream($jsonBody);

$request = $this->requestFactory->createRequest('POST', $url);
$request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken);
$request = $request->withHeader('Accept', 'application/vnd.github.v3+json');
$request = $request->withHeader('Content-Type', 'application/json');
$request = $request->withBody($stream);

return $request;
}
}
51 changes: 51 additions & 0 deletions src/GetReleaseByTagRequestFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Horde\GithubApiClient;

use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;

/**
* Factory for creating requests to get a GitHub release by tag
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @package GithubApiClient
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
*/
class GetReleaseByTagRequestFactory
{
public function __construct(
private readonly RequestFactoryInterface $requestFactory,
private readonly GithubApiConfig $config,
private readonly GithubRepository $repo,
private readonly string $tag,
) {}

/**
* Create HTTP request to get a release by tag
*
* @return RequestInterface
*/
public function create(): RequestInterface
{
$url = sprintf(
'https://api.github.com/repos/%s/%s/releases/tags/%s',
$this->repo->owner,
$this->repo->name,
rawurlencode($this->tag),
);

$request = $this->requestFactory->createRequest('GET', $url);
$request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken);
$request = $request->withHeader('Accept', 'application/vnd.github.v3+json');

return $request;
}
}
130 changes: 130 additions & 0 deletions src/GithubApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
if ($response->getReasonPhrase() == 'OK') {
$pullRequestData = json_decode((string) $response->getBody());
$prFactory = new GithubPullRequestFactory();
foreach ($pullRequestData as $pr) {

Check failure on line 56 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Argument of an invalid type mixed supplied for foreach, only iterables are supported.
$pullRequests[] = $prFactory->createFromApiResponse($pr);

Check failure on line 57 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $apiResponse of method Horde\GithubApiClient\GithubPullRequestFactory::createFromApiResponse() expects stdClass, mixed given.
}
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
Expand All @@ -76,7 +76,7 @@

if ($response->getReasonPhrase() == 'OK') {
$data = json_decode((string) $response->getBody());
return RateLimit::fromApiResponse($data);

Check failure on line 79 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $response of static method Horde\GithubApiClient\RateLimit::fromApiResponse() expects object, mixed given.
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
Expand Down Expand Up @@ -126,7 +126,7 @@
if ($response->getStatusCode() === 200) {
$data = json_decode((string) $response->getBody());
$prFactory = new GithubPullRequestFactory();
return $prFactory->createFromApiResponse($data);

Check failure on line 129 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $apiResponse of method Horde\GithubApiClient\GithubPullRequestFactory::createFromApiResponse() expects stdClass, mixed given.
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
Expand Down Expand Up @@ -161,7 +161,7 @@
if ($response->getStatusCode() === 200) {
$data = json_decode((string) $response->getBody());
$prFactory = new GithubPullRequestFactory();
return $prFactory->createFromApiResponse($data);

Check failure on line 164 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $apiResponse of method Horde\GithubApiClient\GithubPullRequestFactory::createFromApiResponse() expects stdClass, mixed given.
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
Expand Down Expand Up @@ -190,8 +190,8 @@
$data = json_decode((string) $response->getBody());
$commentFactory = new GithubCommentFactory();
$comments = [];
foreach ($data as $commentData) {

Check failure on line 193 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Argument of an invalid type mixed supplied for foreach, only iterables are supported.
$comments[] = $commentFactory->createFromApiResponse($commentData);

Check failure on line 194 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $data of method Horde\GithubApiClient\GithubCommentFactory::createFromApiResponse() expects object, mixed given.
}
return new GithubCommentList($comments);
} else {
Expand Down Expand Up @@ -228,7 +228,7 @@
if ($response->getStatusCode() === 201) {
$data = json_decode((string) $response->getBody());
$commentFactory = new GithubCommentFactory();
return $commentFactory->createFromApiResponse($data);

Check failure on line 231 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $data of method Horde\GithubApiClient\GithubCommentFactory::createFromApiResponse() expects object, mixed given.
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
Expand Down Expand Up @@ -263,7 +263,7 @@
if ($response->getStatusCode() === 200) {
$data = json_decode((string) $response->getBody());
$commentFactory = new GithubCommentFactory();
return $commentFactory->createFromApiResponse($data);

Check failure on line 266 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $data of method Horde\GithubApiClient\GithubCommentFactory::createFromApiResponse() expects object, mixed given.
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
Expand Down Expand Up @@ -316,7 +316,7 @@
$data = json_decode((string) $response->getBody());
$reviewFactory = new GithubReviewFactory();
$reviews = [];
foreach ($data as $reviewData) {

Check failure on line 319 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Argument of an invalid type mixed supplied for foreach, only iterables are supported.
$reviews[] = $reviewFactory->createFromApiResponse($reviewData);
}
return new GithubReviewList($reviews);
Expand Down Expand Up @@ -653,6 +653,136 @@
}
}

/**
* Create a new release for a tag
*
* @param GithubRepository $repo The repository
* @param CreateReleaseParams $params The release parameters
* @return GithubRelease The created release
* @throws Exception
*/
public function createRelease(GithubRepository $repo, CreateReleaseParams $params): GithubRelease
{
if ($this->streamFactory === null) {
throw new Exception('StreamFactory is required for createRelease. Please provide it in the constructor.');
}

$requestFactory = new CreateReleaseRequestFactory(
$this->requestFactory,
$this->streamFactory,
$this->config,
$repo,
$params
);
$request = $requestFactory->create();
$response = $this->httpClient->sendRequest($request);

if ($response->getStatusCode() === 201) {
$data = json_decode((string) $response->getBody());
return GithubRelease::fromApiResponse($data);
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
}

/**
* Get a release by tag name
*
* @param GithubRepository $repo The repository
* @param string $tag The tag name
* @return GithubRelease The release
* @throws Exception
*/
public function getReleaseByTag(GithubRepository $repo, string $tag): GithubRelease
{
$requestFactory = new GetReleaseByTagRequestFactory(
$this->requestFactory,
$this->config,
$repo,
$tag
);
$request = $requestFactory->create();
$response = $this->httpClient->sendRequest($request);

if ($response->getStatusCode() === 200) {
$data = json_decode((string) $response->getBody());
return GithubRelease::fromApiResponse($data);
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
}

/**
* Update an existing release
*
* @param GithubRepository $repo The repository
* @param int $releaseId The release ID
* @param UpdateReleaseParams $params The update parameters
* @return GithubRelease The updated release
* @throws Exception
*/
public function updateRelease(GithubRepository $repo, int $releaseId, UpdateReleaseParams $params): GithubRelease
{
if ($this->streamFactory === null) {
throw new Exception('StreamFactory is required for updateRelease. Please provide it in the constructor.');
}

$requestFactory = new UpdateReleaseRequestFactory(
$this->requestFactory,
$this->streamFactory,
$this->config,
$repo,
$releaseId,
$params
);
$request = $requestFactory->create();
$response = $this->httpClient->sendRequest($request);

if ($response->getStatusCode() === 200) {
$data = json_decode((string) $response->getBody());
return GithubRelease::fromApiResponse($data);
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
}

/**
* Upload an asset to a release
*
* @param string $uploadUrl The upload URL from the release object
* @param string $filename The filename for the asset
* @param string $fileContent The file content
* @param string $contentType The MIME type (default: application/octet-stream)
* @return GithubReleaseAsset The uploaded asset
* @throws Exception
*/
public function uploadReleaseAsset(string $uploadUrl, string $filename, string $fileContent, string $contentType = 'application/octet-stream'): GithubReleaseAsset
{
if ($this->streamFactory === null) {
throw new Exception('StreamFactory is required for uploadReleaseAsset. Please provide it in the constructor.');
}

$stream = $this->streamFactory->createStream($fileContent);

$requestFactory = new UploadReleaseAssetRequestFactory(
$this->requestFactory,
$this->config,
$uploadUrl,
$filename,
$stream,
$contentType
);
$request = $requestFactory->create();
$response = $this->httpClient->sendRequest($request);

if ($response->getStatusCode() === 201) {
$data = json_decode((string) $response->getBody());
return GithubReleaseAsset::fromApiResponse($data);
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
}
}

/**
* @param array<mixed> $repos
* @param string $json
Expand Down
Loading