From 62a20ed5149c3fd352ca8f9ce0ead0d6fa63b5a4 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 9 Aug 2026 22:56:16 -0500 Subject: [PATCH] Add Gitea provider adapter with normalization and tests --- includes/providers/class-gitea-provider.php | 229 ++++++++++++++++++++ tests/test-gitea-provider.php | 177 +++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 includes/providers/class-gitea-provider.php create mode 100644 tests/test-gitea-provider.php diff --git a/includes/providers/class-gitea-provider.php b/includes/providers/class-gitea-provider.php new file mode 100644 index 0000000..ad2e4f7 --- /dev/null +++ b/includes/providers/class-gitea-provider.php @@ -0,0 +1,229 @@ +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' ]; + } +} diff --git a/tests/test-gitea-provider.php b/tests/test-gitea-provider.php new file mode 100644 index 0000000..e7c406c --- /dev/null +++ b/tests/test-gitea-provider.php @@ -0,0 +1,177 @@ +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() + ); + } +}