Add GitHub provider adapter with full test coverage

This commit is contained in:
keith
2026-08-09 22:48:58 -05:00
parent 4a9f43c850
commit 28789f6295
4 changed files with 397 additions and 67 deletions
@@ -0,0 +1,195 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* GitHub adapter for the Repository_Provider interface.
*
* Preserves the GitHub URL/header/asset behavior from the original
* helper-functions.php verbatim.
*
* @since 1.1.0
*/
class GitHub_Provider implements Repository_Provider {
private string $repo_url;
private string $api_token;
public function __construct( string $repo_url, string $api_token = '' ) {
$this->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';
}
}
+5 -67
View File
@@ -8,75 +8,13 @@ if ( ! file_exists( $autoload ) ) {
require_once $autoload; require_once $autoload;
// Brain\Monkey sets up minimal function stubs (apply_filters, plugin_dir_path, etc.). // 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(); \Brain\Monkey\setUp();
// Define WP constants the helpers rely on. require_once __DIR__ . '/wp-stubs.php';
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; }
}
}
register_shutdown_function( function () { register_shutdown_function( function () {
\Brain\Monkey\tearDown(); \Brain\Monkey\tearDown();
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
/**
* @covers \GitHub_Provider
*/
class GitHub_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $token = '' ): GitHub_Provider {
return new GitHub_Provider( 'https://github.com/owner/repo', $token );
}
public function test_get_id_and_label(): void {
$p = $this->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() );
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Brain\Monkey\Actions {
if ( ! function_exists( __NAMESPACE__ . '\\anyArgs' ) && function_exists( 'anyArgs' ) ) {
function anyArgs( ...$args ) { return \anyArgs( ...$args ); }
}
}
namespace {
// Shim: ensure Mockery's anyArgs() helper is loaded.
if ( ! function_exists( 'anyArgs' ) ) {
require_once dirname( __DIR__ ) . '/vendor/mockery/mockery/library/helpers.php';
}
// 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; }
}
}
}