Add Gitea provider adapter with normalization and tests
This commit is contained in:
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
// If this file is called directly, abort.
|
||||||
|
if ( ! defined( 'WPINC' ) ) {
|
||||||
|
die;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea adapter for the Repository_Provider interface.
|
||||||
|
*
|
||||||
|
* Endpoint base: {base_url}/api/v1
|
||||||
|
* - repo: {base_url}/api/v1/repos/{owner}/{repo}
|
||||||
|
* - release: {base_url}/api/v1/repos/{owner}/{repo}/releases/latest (returns array)
|
||||||
|
* - user: {base_url}/api/v1/users/{login}
|
||||||
|
* - archive: {base_url}/{owner}/{repo}/archive/refs/tags/{tag}.zip
|
||||||
|
*
|
||||||
|
* @since 1.1.0
|
||||||
|
*/
|
||||||
|
class Gitea_Provider implements Repository_Provider {
|
||||||
|
|
||||||
|
private string $repo_url;
|
||||||
|
private string $base_url;
|
||||||
|
private string $api_token;
|
||||||
|
|
||||||
|
public function __construct( string $repo_url, string $base_url, string $api_token = '' ) {
|
||||||
|
$this->repo_url = $repo_url;
|
||||||
|
$this->base_url = rtrim( $base_url, '/' );
|
||||||
|
$this->api_token = $api_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_id(): string {
|
||||||
|
return 'gitea';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_label(): string {
|
||||||
|
return 'Gitea';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_repo_data(): ?array {
|
||||||
|
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path();
|
||||||
|
if ( empty( $api_url ) ) {
|
||||||
|
error_log( 'Projects Portfolio: Gitea repo URL is empty.' );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
|
||||||
|
$cached_data = get_transient( $cache_key );
|
||||||
|
if ( $cached_data ) {
|
||||||
|
return $cached_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = [ 'Accept' => 'application/json' ];
|
||||||
|
if ( ! empty( $this->api_token ) ) {
|
||||||
|
$headers['Authorization'] = 'token ' . $this->api_token;
|
||||||
|
} else {
|
||||||
|
error_log( 'Projects Portfolio: Gitea API token is missing. Using unauthenticated requests.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 !== $code ) {
|
||||||
|
error_log( 'Projects Portfolio: Gitea API error: Received status ' . $code );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
if ( empty( $raw ) || ! is_array( $raw ) ) {
|
||||||
|
error_log( 'Projects Portfolio: Gitea API error: Invalid data received.' );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$owner = $raw['owner'] ?? [];
|
||||||
|
$owner_login = $owner['login'] ?? $this->owner_login_from_path();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'owner' => [
|
||||||
|
'avatar_url' => $owner['avatar_url'] ?? '',
|
||||||
|
'login' => $owner_login,
|
||||||
|
'html_url' => $owner['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
|
||||||
|
],
|
||||||
|
'updated_at' => $raw['updated_at'] ?? '',
|
||||||
|
'language' => $raw['language'] ?? '',
|
||||||
|
'license' => $this->normalize_license( $raw['license'] ?? null ),
|
||||||
|
'stargazers_count' => (int) ( $raw['stars_count'] ?? 0 ),
|
||||||
|
'forks_count' => (int) ( $raw['forks_count'] ?? 0 ),
|
||||||
|
'open_issues_count' => (int) ( $raw['open_issues_count'] ?? 0 ),
|
||||||
|
];
|
||||||
|
|
||||||
|
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_release_url(): ?string {
|
||||||
|
$release = $this->fetch_latest_release();
|
||||||
|
if ( null === $release ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assets = $release['assets'] ?? [];
|
||||||
|
if ( is_array( $assets ) ) {
|
||||||
|
foreach ( $assets as $asset ) {
|
||||||
|
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
|
||||||
|
$url = $asset['browser_download_url'] ?? null;
|
||||||
|
if ( $url ) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = $release['tag_name'] ?? '';
|
||||||
|
if ( '' === $tag ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->base_url . '/' . $this->owner_repo_path() . '/archive/refs/tags/' . $tag . '.zip';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_latest_version(): string {
|
||||||
|
$release = $this->fetch_latest_release();
|
||||||
|
if ( null === $release ) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
return isset( $release['tag_name'] ) ? (string) $release['tag_name'] : 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_repo_browse_url(): string {
|
||||||
|
return $this->base_url . '/' . $this->owner_repo_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_owner_data( string $owner_login ): ?array {
|
||||||
|
if ( empty( $owner_login ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$api_url = $this->base_url . '/api/v1/users/' . rawurlencode( $owner_login );
|
||||||
|
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
if ( ! is_array( $data ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'avatar_url' => $data['avatar_url'] ?? '',
|
||||||
|
'login' => $data['login'] ?? $owner_login,
|
||||||
|
'html_url' => $data['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first release object or null on failure / empty response.
|
||||||
|
* Gitea's /releases/latest returns an array.
|
||||||
|
*/
|
||||||
|
private function fetch_latest_release(): ?array {
|
||||||
|
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path() . '/releases/latest';
|
||||||
|
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
|
||||||
|
|
||||||
|
$cached = get_transient( $cache_key );
|
||||||
|
if ( $cached ) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 !== $code ) {
|
||||||
|
error_log( 'Projects Portfolio: Gitea releases/latest returned status ' . $code );
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
if ( ! is_array( $body ) || empty( $body ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = $body[0];
|
||||||
|
set_transient( $cache_key, $release, HOUR_IN_SECONDS );
|
||||||
|
return $release;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auth_headers(): array {
|
||||||
|
$headers = [ 'Accept' => 'application/json' ];
|
||||||
|
if ( ! empty( $this->api_token ) ) {
|
||||||
|
$headers['Authorization'] = 'token ' . $this->api_token;
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function owner_repo_path(): string {
|
||||||
|
// Strip the base URL prefix if present, return "owner/repo".
|
||||||
|
$path = $this->repo_url;
|
||||||
|
if ( '' !== $this->base_url && 0 === strpos( $path, $this->base_url ) ) {
|
||||||
|
$path = substr( $path, strlen( $this->base_url ) );
|
||||||
|
}
|
||||||
|
$path = ltrim( $path, '/' );
|
||||||
|
// Drop trailing slashes and any "/archive/...", "/releases/...", etc.
|
||||||
|
$path = preg_replace( '#/(archive|releases|tree|blob|issues|pulls|src|commits|wiki)(/.*)?$#', '', $path );
|
||||||
|
return trim( $path, '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function owner_login_from_path(): string {
|
||||||
|
$parts = explode( '/', $this->owner_repo_path() );
|
||||||
|
return $parts[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize_license( $license ): array {
|
||||||
|
if ( is_array( $license ) ) {
|
||||||
|
$name = $license['name'] ?? '';
|
||||||
|
return [ 'name' => '' !== $name ? $name : 'None' ];
|
||||||
|
}
|
||||||
|
if ( is_string( $license ) && '' !== $license ) {
|
||||||
|
return [ 'name' => $license ];
|
||||||
|
}
|
||||||
|
return [ 'name' => 'None' ];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
|
||||||
|
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \Gitea_Provider
|
||||||
|
*/
|
||||||
|
class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
\Brain\Monkey\setUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
\Brain\Monkey\tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function make( string $base = 'https://codeberg.org', string $token = '' ): Gitea_Provider {
|
||||||
|
return new Gitea_Provider( 'https://codeberg.org/owner/repo', $base, $token );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_id_and_label(): void {
|
||||||
|
$p = $this->make();
|
||||||
|
$this->assertSame( 'gitea', $p->get_id() );
|
||||||
|
$this->assertSame( 'Gitea', $p->get_label() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_repo_data_normalizes_gitea_fields(): void {
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->once()
|
||||||
|
->with( 'https://codeberg.org/api/v1/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => json_encode( [
|
||||||
|
'owner' => [
|
||||||
|
'avatar_url' => 'https://codeberg.org/avatars/owner',
|
||||||
|
'login' => 'owner',
|
||||||
|
'html_url' => 'https://codeberg.org/owner',
|
||||||
|
],
|
||||||
|
'updated_at' => '2026-02-03T04:05:06Z',
|
||||||
|
'language' => 'PHP',
|
||||||
|
'license' => [ 'name' => 'MIT' ],
|
||||||
|
'stars_count' => 100,
|
||||||
|
'forks_count' => 10,
|
||||||
|
'open_issues_count' => 5,
|
||||||
|
] ),
|
||||||
|
] );
|
||||||
|
|
||||||
|
$data = $this->make()->get_repo_data();
|
||||||
|
|
||||||
|
$this->assertSame( 100, $data['stargazers_count'] );
|
||||||
|
$this->assertSame( 10, $data['forks_count'] );
|
||||||
|
$this->assertSame( 5, $data['open_issues_count'] );
|
||||||
|
$this->assertSame( 'MIT', $data['license']['name'] );
|
||||||
|
$this->assertSame( 'owner', $data['owner']['login'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_repo_data_handles_string_license(): void {
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->once()
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => json_encode( [
|
||||||
|
'owner' => [],
|
||||||
|
'stars_count' => 0,
|
||||||
|
'forks_count' => 0,
|
||||||
|
'open_issues_count' => 0,
|
||||||
|
'license' => 'Apache-2.0',
|
||||||
|
] ),
|
||||||
|
] );
|
||||||
|
|
||||||
|
$data = $this->make()->get_repo_data();
|
||||||
|
$this->assertSame( 'Apache-2.0', $data['license']['name'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_repo_data_handles_null_license(): void {
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->once()
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => json_encode( [
|
||||||
|
'owner' => [],
|
||||||
|
'stars_count' => 0,
|
||||||
|
'forks_count' => 0,
|
||||||
|
'open_issues_count' => 0,
|
||||||
|
'license' => null,
|
||||||
|
] ),
|
||||||
|
] );
|
||||||
|
|
||||||
|
$data = $this->make()->get_repo_data();
|
||||||
|
$this->assertSame( 'None', $data['license']['name'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_release_url_picks_zip_asset_from_first_release(): void {
|
||||||
|
$body = json_encode( [
|
||||||
|
[
|
||||||
|
'tag_name' => 'v2.0.0',
|
||||||
|
'assets' => [
|
||||||
|
[ 'name' => 'source.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
|
||||||
|
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
] );
|
||||||
|
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->twice()
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => $body,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$this->assertSame( 'https://example/release.zip', $this->make()->get_release_url() );
|
||||||
|
$this->assertSame( 'v2.0.0', $this->make()->get_latest_version() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_release_url_falls_back_to_archive_url(): void {
|
||||||
|
$body = json_encode( [
|
||||||
|
[ 'tag_name' => 'v0.1.0', 'assets' => [] ],
|
||||||
|
] );
|
||||||
|
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->once()
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => $body,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'https://codeberg.org/owner/repo/archive/refs/tags/v0.1.0.zip',
|
||||||
|
$this->make()->get_release_url()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_release_url_empty_array_returns_null(): void {
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->once()
|
||||||
|
->andReturn( [
|
||||||
|
'response' => [ 'code' => 200 ],
|
||||||
|
'headers' => [],
|
||||||
|
'body' => '[]',
|
||||||
|
] );
|
||||||
|
|
||||||
|
$this->assertNull( $this->make()->get_release_url() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_repo_browse_url_is_built_from_base(): void {
|
||||||
|
$this->assertSame(
|
||||||
|
'https://codeberg.org/owner/repo',
|
||||||
|
$this->make()->get_repo_browse_url()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_http_failure_returns_null(): void {
|
||||||
|
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
|
||||||
|
->once()
|
||||||
|
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
|
||||||
|
|
||||||
|
$this->assertNull( $this->make()->get_repo_data() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_self_hosted_base_url_is_used(): void {
|
||||||
|
$provider = new Gitea_Provider(
|
||||||
|
'https://git.example.org/owner/repo',
|
||||||
|
'https://git.example.org',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'https://git.example.org/owner/repo',
|
||||||
|
$provider->get_repo_browse_url()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user