From 28789f6295c9106bd097eb21579bd434622106a8 Mon Sep 17 00:00:00 2001 From: keith Date: Sun, 9 Aug 2026 22:48:58 -0500 Subject: [PATCH] Add GitHub provider adapter with full test coverage --- includes/providers/class-github-provider.php | 195 +++++++++++++++++++ tests/bootstrap.php | 72 +------ tests/test-github-provider.php | 114 +++++++++++ tests/wp-stubs.php | 83 ++++++++ 4 files changed, 397 insertions(+), 67 deletions(-) create mode 100644 includes/providers/class-github-provider.php create mode 100644 tests/test-github-provider.php create mode 100644 tests/wp-stubs.php diff --git a/includes/providers/class-github-provider.php b/includes/providers/class-github-provider.php new file mode 100644 index 0000000..182de4c --- /dev/null +++ b/includes/providers/class-github-provider.php @@ -0,0 +1,195 @@ +repo_url = rtrim( $repo_url, '/' ); + $this->api_token = $api_token; + } + + public function get_id(): string { + return 'github'; + } + + public function get_label(): string { + return 'GitHub'; + } + + public function get_repo_data(): ?array { + if ( empty( $this->repo_url ) ) { + error_log( 'Projects Portfolio: GitHub URL is empty.' ); + return null; + } + + $api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url ); + + $cache_key = 'projects_portfolio_provider_data_' . md5( $api_url ); + $cached_data = get_transient( $cache_key ); + if ( $cached_data ) { + return $cached_data; + } + + $headers = [ 'Accept' => 'application/vnd.github.v3+json' ]; + if ( ! empty( $this->api_token ) ) { + $headers['Authorization'] = 'token ' . $this->api_token; + } else { + error_log( 'Projects Portfolio: GitHub API token is missing. Using unauthenticated requests.' ); + } + + $response = wp_remote_get( $api_url, [ 'headers' => $headers ] ); + + if ( is_wp_error( $response ) ) { + error_log( 'Projects Portfolio: GitHub API error: ' . $response->get_error_message() ); + return null; + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( 403 === $code ) { + $remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' ); + $reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' ); + error_log( 'Projects Portfolio: GitHub API 403: Rate limit exceeded. Remaining: ' . $remaining . ' Reset at: ' . date( 'Y-m-d H:i:s', (int) $reset ) ); + return null; + } + if ( 200 !== $code ) { + error_log( 'Projects Portfolio: GitHub API error: Received status ' . $code ); + return null; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( empty( $data ) || ! is_array( $data ) ) { + error_log( 'Projects Portfolio: GitHub API error: Invalid data received.' ); + return null; + } + + set_transient( $cache_key, $data, HOUR_IN_SECONDS ); + return $data; + } + + public function get_release_url(): ?string { + $api_url = $this->release_api_url(); + if ( null === $api_url ) { + return null; + } + + $cache_key = 'projects_portfolio_provider_release_' . md5( $api_url ); + $cached = get_transient( $cache_key ); + if ( $cached ) { + return $cached; + } + + $headers = [ 'Accept' => 'application/vnd.github.v3+json' ]; + if ( ! empty( $this->api_token ) ) { + $headers['Authorization'] = 'token ' . $this->api_token; + } + + $response = wp_remote_get( $api_url, [ 'headers' => $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; + } + + if ( ! empty( $data['assets'] ) && is_array( $data['assets'] ) ) { + foreach ( $data['assets'] as $asset ) { + if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) { + $url = $asset['browser_download_url'] ?? null; + if ( $url ) { + set_transient( $cache_key, $url, HOUR_IN_SECONDS ); + return $url; + } + } + } + } + + $zipball = $data['zipball_url'] ?? null; + if ( $zipball ) { + set_transient( $cache_key, $zipball, HOUR_IN_SECONDS ); + } + return $zipball; + } + + public function get_latest_version(): string { + $api_url = $this->release_api_url(); + if ( null === $api_url ) { + return 'Unknown'; + } + + $cache_key = 'projects_portfolio_provider_release_version_' . md5( $api_url ); + $cached = get_transient( $cache_key ); + if ( $cached ) { + return $cached; + } + + $response = wp_remote_get( $api_url, [] ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return 'Unknown'; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + $tag = is_array( $data ) && isset( $data['tag_name'] ) ? (string) $data['tag_name'] : 'Unknown'; + + set_transient( $cache_key, $tag, HOUR_IN_SECONDS ); + return $tag; + } + + public function get_repo_browse_url(): string { + return $this->repo_url; + } + + public function get_owner_data( string $owner_login ): ?array { + if ( empty( $owner_login ) ) { + return null; + } + + $api_url = 'https://api.github.com/users/' . $owner_login; + $ctx = stream_context_create( [ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: WORDPRESS\r\n", + ], + ] ); + $body = @file_get_contents( $api_url, false, $ctx ); + + if ( false === $body ) { + error_log( 'Projects Portfolio: Error fetching data from GitHub API.' ); + return null; + } + + $data = json_decode( $body, true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { + error_log( 'Projects Portfolio: Error decoding JSON data: ' . json_last_error_msg() ); + return null; + } + + return [ + 'avatar_url' => $data['avatar_url'] ?? '', + 'login' => $data['login'] ?? $owner_login, + 'html_url' => $data['html_url'] ?? ( 'https://github.com/' . $owner_login ), + ]; + } + + private function release_api_url(): ?string { + if ( empty( $this->repo_url ) ) { + return null; + } + return str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url ) . '/releases/latest'; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 092261d..ed5b977 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,75 +8,13 @@ if ( ! file_exists( $autoload ) ) { require_once $autoload; // Brain\Monkey sets up minimal function stubs (apply_filters, plugin_dir_path, etc.). +// Must run BEFORE the WP function stubs in wp-stubs.php are loaded, because Patchwork +// (used by Brain\Monkey) cannot redefine functions defined in the same file/import +// chain that loaded Patchwork itself. Loading the stubs in a separate file imported +// after Brain\Monkey\setUp() lets Patchwork track them properly. \Brain\Monkey\setUp(); -// Define WP constants the helpers rely on. -if ( ! defined( 'HOUR_IN_SECONDS' ) ) { - define( 'HOUR_IN_SECONDS', 3600 ); -} -if ( ! defined( 'WPINC' ) ) { - define( 'WPINC', 'wp-includes' ); -} -if ( ! defined( 'ABSPATH' ) ) { - define( 'ABSPATH', __DIR__ . '/' ); -} - -// Provide minimal WordPress function stubs used by the providers under test. -if ( ! function_exists( 'get_option' ) ) { - function get_option( $key, $default = false ) { return $default; } -} -if ( ! function_exists( 'get_transient' ) ) { - function get_transient( $key ) { return false; } -} -if ( ! function_exists( 'set_transient' ) ) { - function set_transient( $key, $value, $expiration ) { return true; } -} -if ( ! function_exists( 'delete_transient' ) ) { - function delete_transient( $key ) { return true; } -} -if ( ! function_exists( 'wp_remote_get' ) ) { - function wp_remote_get( $url, $args = [] ) { return new \WP_Error( 'no_http', 'no http' ); } -} -if ( ! function_exists( 'is_wp_error' ) ) { - function is_wp_error( $thing ) { return $thing instanceof \WP_Error; } -} -if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { - function wp_remote_retrieve_response_code( $response ) { - if ( $response instanceof \WP_Error ) { return 0; } - return $response['response']['code'] ?? 0; - } -} -if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { - function wp_remote_retrieve_body( $response ) { - if ( $response instanceof \WP_Error ) { return ''; } - return $response['body'] ?? ''; - } -} -if ( ! function_exists( 'wp_remote_retrieve_header' ) ) { - function wp_remote_retrieve_header( $response, $header ) { - if ( $response instanceof \WP_Error ) { return ''; } - $headers = $response['headers'] ?? []; - $key = strtolower( $header ); - return $headers[ $key ] ?? ''; - } -} -if ( ! function_exists( 'error_log' ) ) { - function error_log( $message ) { /* swallow during tests */ } -} - -// Minimal WP_Error stub if Brain\Monkey doesn't supply one. -if ( ! class_exists( 'WP_Error' ) ) { - class WP_Error { - private $code; - private $message; - public function __construct( $code = '', $message = '' ) { - $this->code = $code; - $this->message = $message; - } - public function get_error_code() { return $this->code; } - public function get_error_message() { return $this->message; } - } -} +require_once __DIR__ . '/wp-stubs.php'; register_shutdown_function( function () { \Brain\Monkey\tearDown(); diff --git a/tests/test-github-provider.php b/tests/test-github-provider.php new file mode 100644 index 0000000..d626000 --- /dev/null +++ b/tests/test-github-provider.php @@ -0,0 +1,114 @@ +make(); + $this->assertSame( 'github', $p->get_id() ); + $this->assertSame( 'GitHub', $p->get_label() ); + } + + public function test_get_repo_data_normalizes_response(): void { + \Brain\Monkey\Functions\expect( 'wp_remote_get' ) + ->once() + ->with( 'https://api.github.com/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() ) + ->andReturn( [ + 'response' => [ 'code' => 200 ], + 'headers' => [], + 'body' => json_encode( [ + 'owner' => [ + 'avatar_url' => 'https://avatars.example/owner.png', + 'login' => 'owner', + 'html_url' => 'https://github.com/owner', + ], + 'updated_at' => '2026-01-02T03:04:05Z', + 'language' => 'PHP', + 'license' => [ 'name' => 'MIT' ], + 'stargazers_count' => 42, + 'forks_count' => 7, + 'open_issues_count' => 3, + ] ), + ] ); + + $data = $this->make()->get_repo_data(); + + $this->assertSame( 'owner', $data['owner']['login'] ); + $this->assertSame( 42, $data['stargazers_count'] ); + $this->assertSame( 'MIT', $data['license']['name'] ); + } + + public function test_release_url_prefers_zip_asset(): void { + $release_body = json_encode( [ + 'tag_name' => 'v1.2.3', + 'assets' => [ + [ 'name' => 'project.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ], + [ 'name' => 'project.zip', 'browser_download_url' => 'https://example/x.zip' ], + ], + ] ); + + \Brain\Monkey\Functions\expect( 'wp_remote_get' ) + ->twice() + ->with( 'https://api.github.com/repos/owner/repo/releases/latest', \Brain\Monkey\Actions\anyArgs() ) + ->andReturn( [ + 'response' => [ 'code' => 200 ], + 'headers' => [], + 'body' => $release_body, + ] ); + + $this->assertSame( 'https://example/x.zip', $this->make()->get_release_url() ); + $this->assertSame( 'v1.2.3', $this->make()->get_latest_version() ); + } + + public function test_release_url_falls_back_to_zipball(): void { + $release_body = json_encode( [ + 'tag_name' => 'v0.0.1', + 'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v0.0.1', + 'assets' => [], + ] ); + + \Brain\Monkey\Functions\expect( 'wp_remote_get' ) + ->once() + ->andReturn( [ + 'response' => [ 'code' => 200 ], + 'headers' => [], + 'body' => $release_body, + ] ); + + $this->assertSame( + 'https://api.github.com/repos/owner/repo/zipball/v0.0.1', + $this->make()->get_release_url() + ); + } + + public function test_repo_browse_url_returns_input(): void { + $this->assertSame( + 'https://github.com/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() ); + } +} diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php new file mode 100644 index 0000000..41f8ae9 --- /dev/null +++ b/tests/wp-stubs.php @@ -0,0 +1,83 @@ +code = $code; + $this->message = $message; + } + public function get_error_code() { return $this->code; } + public function get_error_message() { return $this->message; } + } +} + +}