15-task plan with TDD-style steps, WP PHPUnit tests, and a provider-interface architecture. Tasks cover scaffolding, both adapters, factory, settings, metabox, template, REST, download redirect, version bump, README, translations, and final smoke testing.
2193 lines
78 KiB
Markdown
2193 lines
78 KiB
Markdown
# Gitea Support Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add per-project support for Gitea repositories (in addition to GitHub) to the Projects Portfolio WordPress plugin, behind a `Repository_Provider` interface that preserves all existing GitHub behavior bit-for-bit.
|
||
|
||
**Architecture:** Introduce a `Repository_Provider` interface with two adapters (`GitHub_Provider`, `Gitea_Provider`) and a `projects_portfolio_get_provider( $post_id )` factory. Adapters normalize to a GitHub-shaped data structure so templates and the REST API don't branch on provider. Existing GitHub code is repackaged into the GitHub adapter; new Gitea logic parallels it. Legacy `_projects_portfolio_github_url` meta is read with a lazy migration path so existing installs don't lose data.
|
||
|
||
**Tech Stack:** PHP 7.4+ (WordPress), WordPress HTTP API (`wp_remote_get`), WordPress transients for caching, WP test suite + PHPUnit, Brain\Monkey for HTTP mocking.
|
||
|
||
## Global Constraints
|
||
|
||
- Text domain: `projects-wp`. All user-facing strings run through `esc_html__` / `esc_attr__` / `__` etc.
|
||
- New post meta keys: `_projects_portfolio_provider` (`'github'`|`'gitea'`), `_projects_portfolio_repo_url`, `_projects_portfolio_gitea_base_url`. Legacy `_projects_portfolio_github_url` is read-only after this change.
|
||
- New options: `projects_portfolio_default_gitea_base_url` (default `https://codeberg.org`), `projects_portfolio_gitea_api_token` (default `''`).
|
||
- All `error_log()` calls in new code must use the prefix `Projects Portfolio:` so debug-log entries are grep-able.
|
||
- Adapter code must **never** call `die()`. Return `null` on failure. (Legacy wrappers retain existing `die()` behavior.)
|
||
- Repo data must conform to the normalized shape: `['owner' => ['avatar_url','login','html_url'], 'updated_at','language','license' => ['name'], 'stargazers_count','forks_count','open_issues_count']`.
|
||
- Cache key pattern: `projects_portfolio_provider_data_{md5( $api_url )}` and `projects_portfolio_provider_release_{md5( $api_url )}`, lifetime `HOUR_IN_SECONDS`.
|
||
- GitHub adapter preserves current behavior bit-for-bit (same URL rewrites, same headers, same fallbacks).
|
||
- Gitea release endpoint returns an **array**; adapter takes element `[0]`.
|
||
- Lazy migration: on save, if provider is unset, repo_url empty, and legacy `_projects_portfolio_github_url` is non-empty, copy legacy → `_projects_portfolio_repo_url` and set provider to `github`.
|
||
- Version bump: plugin header `Version: 1.0.0` → `1.1.0` and `define( 'PROJECTS_PORTFOLIO_VERSION', time() );` → `define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );`.
|
||
- Provider interface lives in `includes/providers/`. Adapter files use `class-*.php` and are loaded via `require_once`.
|
||
|
||
## File Structure
|
||
|
||
**Created:**
|
||
- `includes/providers/interface-repository-provider.php` — `Repository_Provider` interface.
|
||
- `includes/providers/class-github-provider.php` — GitHub adapter (repackaged from `helper-functions.php`).
|
||
- `includes/providers/class-gitea-provider.php` — Gitea adapter.
|
||
- `includes/providers/class-provider-factory.php` — `projects_portfolio_get_provider()`.
|
||
- `tests/bootstrap.php` — loads WP test suite and the plugin.
|
||
- `tests/test-github-provider.php`, `tests/test-gitea-provider.php`, `tests/test-provider-factory.php`, `tests/test-metabox.php`, `tests/test-settings.php`.
|
||
- `composer.json` — dev-only PHPUnit + Brain\Monkey.
|
||
- `phpunit.xml.dist` — PHPUnit config.
|
||
- `.gitignore` test entries (no — keep one project .gitignore, dev-only paths covered there if needed).
|
||
|
||
**Modified:**
|
||
- `includes/helper-functions.php` — adds new provider-aware wrappers; existing GitHub wrappers retained with deprecation PHPDoc.
|
||
- `admin/metabox.php` — replaces single-field "GitHub URL" with provider dropdown + repo URL + optional Gitea base URL.
|
||
- `admin/admin-settings.php` — adds "Gitea Settings" section.
|
||
- `templates/single-projects.php` — routes data fetches through the provider.
|
||
- `admin/rest-api.php` — uses provider-aware wrappers in REST response.
|
||
- `projects-portfolio.php` — `handle_download_redirect` uses provider; version bumped to 1.1.0.
|
||
- `README.md` — documents new metabox fields, Gitea settings, "Connecting to a Gitea repo" subsection.
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold providers directory, interface, and tests directory
|
||
|
||
**Files:**
|
||
- Create: `includes/providers/interface-repository-provider.php`
|
||
- Create: `tests/bootstrap.php`
|
||
- Create: `composer.json`
|
||
- Create: `phpunit.xml.dist`
|
||
- Modify: `.gitignore` (add `vendor/`, `phpunit.xml`)
|
||
|
||
**Interfaces:**
|
||
- Produces: `interface Repository_Provider` with methods `get_id(): string`, `get_label(): string`, `get_repo_data(): ?array`, `get_release_url(): ?string`, `get_latest_version(): string`, `get_repo_browse_url(): string`, `get_owner_data( string $owner_login ): ?array`. No consuming tasks yet.
|
||
|
||
- [ ] **Step 1: Add dev paths to `.gitignore`**
|
||
|
||
Append to `.gitignore` (after the existing lines):
|
||
```
|
||
vendor/
|
||
phpunit.xml
|
||
.phpunit.result.cache
|
||
```
|
||
|
||
- [ ] **Step 2: Create `composer.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "keithsolomon/projects-portfolio",
|
||
"description": "WordPress plugin — dev-only test dependencies.",
|
||
"type": "wordpress-plugin",
|
||
"require": {
|
||
"php": ">=7.4"
|
||
},
|
||
"require-dev": {
|
||
"phpunit/phpunit": "^9.6",
|
||
"brain/monkey": "^2.6",
|
||
"yoast/phpunit-polyfills": "^2.0"
|
||
},
|
||
"config": {
|
||
"allow-plugins": {
|
||
"php-http/discovery": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `phpunit.xml.dist`**
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<phpunit
|
||
bootstrap="tests/bootstrap.php"
|
||
colors="true"
|
||
convertErrorsToExceptions="true"
|
||
convertNoticesToExceptions="true"
|
||
convertWarningsToExceptions="true">
|
||
<testsuites>
|
||
<testsuite name="projects-portfolio">
|
||
<directory suffix=".php">tests</directory>
|
||
</testsuite>
|
||
</testsuites>
|
||
</phpunit>
|
||
```
|
||
|
||
- [ ] **Step 4: Create `includes/providers/interface-repository-provider.php`**
|
||
|
||
```php
|
||
<?php
|
||
// If this file is called directly, abort.
|
||
if ( ! defined( 'WPINC' ) ) {
|
||
die;
|
||
}
|
||
|
||
/**
|
||
* Contract for a repository host adapter (GitHub, Gitea, ...).
|
||
*
|
||
* @since 1.1.0
|
||
*/
|
||
interface Repository_Provider {
|
||
public function get_id(): string;
|
||
|
||
public function get_label(): string;
|
||
|
||
/**
|
||
* Normalized repo metadata. Returns null on any failure.
|
||
*
|
||
* Shape:
|
||
* [
|
||
* 'owner' => [ 'avatar_url' => string, 'login' => string, 'html_url' => string ],
|
||
* 'updated_at' => string (ISO 8601),
|
||
* 'language' => string,
|
||
* 'license' => [ 'name' => string ],
|
||
* 'stargazers_count' => int,
|
||
* 'forks_count' => int,
|
||
* 'open_issues_count' => int,
|
||
* ]
|
||
*/
|
||
public function get_repo_data(): ?array;
|
||
|
||
/** Direct URL to the latest release zip asset, or null. */
|
||
public function get_release_url(): ?string;
|
||
|
||
/** Tag name of the latest release, or 'Unknown'. */
|
||
public function get_latest_version(): string;
|
||
|
||
/** Public URL of the repo, for the 'View Repo' button. */
|
||
public function get_repo_browse_url(): string;
|
||
|
||
/**
|
||
* Owner profile data: [ 'avatar_url', 'login', 'html_url' ].
|
||
* Returns null on failure.
|
||
*/
|
||
public function get_owner_data( string $owner_login ): ?array;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `tests/bootstrap.php`**
|
||
|
||
```php
|
||
<?php
|
||
// Load Composer dev autoload (PHPUnit + Brain\Monkey + polyfills).
|
||
$autoload = dirname( __DIR__ ) . '/vendor/autoload.php';
|
||
if ( ! file_exists( $autoload ) ) {
|
||
fwrite( STDERR, "Run `composer install` before running tests.\n" );
|
||
exit( 1 );
|
||
}
|
||
require_once $autoload;
|
||
|
||
// Brain\Monkey sets up minimal function stubs (apply_filters, plugin_dir_path, etc.).
|
||
\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; }
|
||
}
|
||
}
|
||
|
||
register_shutdown_function( function () {
|
||
\Brain\Monkey\tearDown();
|
||
} );
|
||
```
|
||
|
||
- [ ] **Step 6: Run `composer install` to confirm autoload works**
|
||
|
||
Run: `composer install --no-interaction --no-progress`
|
||
Expected: installs `phpunit/phpunit`, `brain/monkey`, `yoast/phpunit-polyfills`. No errors.
|
||
|
||
- [ ] **Step 7: Run the test suite with no tests**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: PASS with `OK (0 tests, 0 assertions)`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add .gitignore composer.json phpunit.xml.dist includes/providers/interface-repository-provider.php tests/bootstrap.php
|
||
git commit -m "Scaffold provider interface and test bootstrap"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Build the GitHub provider adapter (repackaged from current code)
|
||
|
||
**Files:**
|
||
- Create: `includes/providers/class-github-provider.php`
|
||
- Create: `tests/test-github-provider.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Repository_Provider` interface from Task 1.
|
||
- Produces: `class GitHub_Provider implements Repository_Provider` with constructor `( string $repo_url, string $api_token )`. Used by factory in Task 4.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/test-github-provider.php`:
|
||
|
||
```php
|
||
<?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' )
|
||
->once()
|
||
->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() );
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
Run: `vendor/bin/phpunit --filter GitHub_Provider_Test`
|
||
Expected: FAIL — `class GitHub_Provider not found`.
|
||
|
||
- [ ] **Step 3: Implement `includes/providers/class-github-provider.php`**
|
||
|
||
```php
|
||
<?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';
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and confirm they pass**
|
||
|
||
Run: `vendor/bin/phpunit --filter GitHub_Provider_Test`
|
||
Expected: PASS — all six tests green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add includes/providers/class-github-provider.php tests/test-github-provider.php
|
||
git commit -m "Add GitHub provider adapter with full test coverage"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Build the Gitea provider adapter
|
||
|
||
**Files:**
|
||
- Create: `includes/providers/class-gitea-provider.php`
|
||
- Create: `tests/test-gitea-provider.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Repository_Provider` interface from Task 1, Gitea response shape (different field names — see tests).
|
||
- Produces: `class Gitea_Provider implements Repository_Provider` with constructor `( string $repo_url, string $base_url, string $api_token )`. Used by factory in Task 4.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/test-gitea-provider.php`:
|
||
|
||
```php
|
||
<?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' )
|
||
->once()
|
||
->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()
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
Run: `vendor/bin/phpunit --filter Gitea_Provider_Test`
|
||
Expected: FAIL — `class Gitea_Provider not found`.
|
||
|
||
- [ ] **Step 3: Implement `includes/providers/class-gitea-provider.php`**
|
||
|
||
```php
|
||
<?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' ];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and confirm they pass**
|
||
|
||
Run: `vendor/bin/phpunit --filter Gitea_Provider_Test`
|
||
Expected: PASS — all ten tests green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add includes/providers/class-gitea-provider.php tests/test-gitea-provider.php
|
||
git commit -m "Add Gitea provider adapter with normalization and tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Build the provider factory
|
||
|
||
**Files:**
|
||
- Create: `includes/providers/class-provider-factory.php`
|
||
- Create: `tests/test-provider-factory.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GitHub_Provider` from Task 2, `Gitea_Provider` from Task 3, post meta keys (see below).
|
||
- Produces: function `projects_portfolio_get_provider( int $post_id ): Repository_Provider`. Used by helper-functions.php (Task 5) and direct calls in single-projects.php / rest-api.php / handle_download_redirect.
|
||
|
||
Post meta contract:
|
||
- `_projects_portfolio_provider` — `'github'` | `'gitea'`. Defaults to `'github'` when missing.
|
||
- `_projects_portfolio_repo_url` — canonical. Falls back to legacy `_projects_portfolio_github_url`.
|
||
- `_projects_portfolio_gitea_base_url` — optional per-project override.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/test-provider-factory.php`:
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* @covers \projects_portfolio_get_provider
|
||
*/
|
||
class Provider_Factory_Test extends \PHPUnit\Framework\TestCase {
|
||
|
||
protected function setUp(): void {
|
||
\Brain\Monkey\setUp();
|
||
}
|
||
|
||
protected function tearDown(): void {
|
||
\Brain\Monkey\tearDown();
|
||
}
|
||
|
||
private function stub_meta( array $provider_meta, array $options = [] ): void {
|
||
\Brain\Monkey\Functions\stubs( [
|
||
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( $provider_meta ) {
|
||
return $provider_meta[ $key ] ?? '';
|
||
},
|
||
'get_option' => function ( $key, $default = '' ) use ( $options ) {
|
||
return $options[ $key ] ?? $default;
|
||
},
|
||
] );
|
||
}
|
||
|
||
public function test_defaults_to_github_when_provider_meta_missing(): void {
|
||
$this->stub_meta( [
|
||
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
|
||
] );
|
||
|
||
$provider = projects_portfolio_get_provider( 42 );
|
||
$this->assertSame( 'github', $provider->get_id() );
|
||
$this->assertSame( 'https://github.com/owner/repo', $provider->get_repo_browse_url() );
|
||
}
|
||
|
||
public function test_uses_repo_url_when_provider_explicit(): void {
|
||
$this->stub_meta( [
|
||
'_projects_portfolio_provider' => 'gitea',
|
||
'_projects_portfolio_repo_url' => 'https://codeberg.org/owner/repo',
|
||
], [
|
||
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
|
||
'projects_portfolio_gitea_api_token' => 'gitea-token',
|
||
] );
|
||
|
||
$provider = projects_portfolio_get_provider( 7 );
|
||
$this->assertSame( 'gitea', $provider->get_id() );
|
||
}
|
||
|
||
public function test_per_project_gitea_base_overrides_global(): void {
|
||
$this->stub_meta( [
|
||
'_projects_portfolio_provider' => 'gitea',
|
||
'_projects_portfolio_repo_url' => 'https://git.example.org/owner/repo',
|
||
'_projects_portfolio_gitea_base_url' => 'https://git.example.org',
|
||
], [
|
||
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
|
||
] );
|
||
|
||
$provider = projects_portfolio_get_provider( 7 );
|
||
$this->assertSame( 'https://git.example.org/owner/repo', $provider->get_repo_browse_url() );
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
Run: `vendor/bin/phpunit --filter Provider_Factory_Test`
|
||
Expected: FAIL — function `projects_portfolio_get_provider` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `includes/providers/class-provider-factory.php`**
|
||
|
||
```php
|
||
<?php
|
||
// If this file is called directly, abort.
|
||
if ( ! defined( 'WPINC' ) ) {
|
||
die;
|
||
}
|
||
|
||
/**
|
||
* Provider factory: selects the right adapter for a project post.
|
||
*
|
||
* @since 1.1.0
|
||
*
|
||
* @param int $post_id
|
||
* @return Repository_Provider
|
||
*/
|
||
function projects_portfolio_get_provider( int $post_id ): Repository_Provider {
|
||
$provider_meta = (string) get_post_meta( $post_id, '_projects_portfolio_provider', true );
|
||
$provider_id = '' !== $provider_meta ? $provider_meta : 'github';
|
||
|
||
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
|
||
if ( '' === $repo_url ) {
|
||
// Lazy fallback to legacy key.
|
||
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_github_url', true );
|
||
}
|
||
|
||
if ( 'gitea' === $provider_id ) {
|
||
$base_url = (string) get_post_meta( $post_id, '_projects_portfolio_gitea_base_url', true );
|
||
if ( '' === $base_url ) {
|
||
$base_url = (string) get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
|
||
}
|
||
$token = (string) get_option( 'projects_portfolio_gitea_api_token', '' );
|
||
return new Gitea_Provider( $repo_url, $base_url, $token );
|
||
}
|
||
|
||
$token = (string) get_option( 'projects_portfolio_github_api_token', '' );
|
||
return new GitHub_Provider( $repo_url, $token );
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and confirm they pass**
|
||
|
||
Run: `vendor/bin/phpunit --filter Provider_Factory_Test`
|
||
Expected: PASS — three tests green.
|
||
|
||
- [ ] **Step 5: Run full test suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All earlier tests + new factory tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add includes/providers/class-provider-factory.php tests/test-provider-factory.php
|
||
git commit -m "Add provider factory with meta + option wiring"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Add provider-aware wrappers to helper-functions.php
|
||
|
||
**Files:**
|
||
- Modify: `includes/helper-functions.php` — add new wrappers at the top of the file (after the existing `projects_portfolio_settings()`); mark legacy wrappers with `_deprecated_function()`.
|
||
|
||
**Interfaces:**
|
||
- Consumes: `projects_portfolio_get_provider()` from Task 4.
|
||
- Produces: public functions used by `single-projects.php` (Task 9), `rest-api.php` (Task 10), and `projects-portfolio.php` (Task 8).
|
||
|
||
- [ ] **Step 1: Add new wrappers at the top of `helper-functions.php`**
|
||
|
||
Insert immediately after the existing `projects_portfolio_settings()` function (i.e. before the `projects_portfolio_github_owner()` function), keeping the existing `if ( ! defined( 'WPINC' ) ) { die; }` guard at the top.
|
||
|
||
```php
|
||
/**
|
||
* Fetch normalized repository data for a project.
|
||
*
|
||
* @since 1.1.0
|
||
* @param int $post_id
|
||
* @return array|null
|
||
*/
|
||
function projects_portfolio_get_repo_data( int $post_id ): ?array {
|
||
return projects_portfolio_get_provider( $post_id )->get_repo_data();
|
||
}
|
||
|
||
/**
|
||
* Resolve the latest release ZIP download URL for a project.
|
||
*
|
||
* @since 1.1.0
|
||
* @param int $post_id
|
||
* @return string|null
|
||
*/
|
||
function projects_portfolio_get_release_url( int $post_id ): ?string {
|
||
return projects_portfolio_get_provider( $post_id )->get_release_url();
|
||
}
|
||
|
||
/**
|
||
* Fetch the latest tag name for a project.
|
||
*
|
||
* @since 1.1.0
|
||
* @param int $post_id
|
||
* @return string
|
||
*/
|
||
function projects_portfolio_get_version( int $post_id ): string {
|
||
return projects_portfolio_get_provider( $post_id )->get_latest_version();
|
||
}
|
||
|
||
/**
|
||
* Fetch the repo owner profile data for a project.
|
||
*
|
||
* @since 1.1.0
|
||
* @param int $post_id
|
||
* @return array|null
|
||
*/
|
||
function projects_portfolio_get_owner( int $post_id ): ?array {
|
||
$provider = projects_portfolio_get_provider( $post_id );
|
||
$repo = $provider->get_repo_data();
|
||
$login = $repo['owner']['login'] ?? '';
|
||
if ( '' === $login ) {
|
||
return null;
|
||
}
|
||
return $provider->get_owner_data( $login );
|
||
}
|
||
|
||
/**
|
||
* Public browse URL for the project's repo.
|
||
*
|
||
* @since 1.1.0
|
||
* @param int $post_id
|
||
* @return string
|
||
*/
|
||
function projects_portfolio_get_repo_browse_url( int $post_id ): string {
|
||
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add deprecation notices to legacy wrappers**
|
||
|
||
Wrap each of the four legacy function bodies (`projects_portfolio_get_github_data`, `projects_portfolio_get_github_release_url`, `projects_portfolio_get_version_from_github`, `projects_portfolio_github_owner`) so the first executable statement calls `_deprecated_function()`.
|
||
|
||
For each function, add this line immediately after the opening `{`:
|
||
|
||
```php
|
||
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_repo_data( $post_id )' );
|
||
```
|
||
|
||
(Use the matching replacement name for `get_release_url` → `projects_portfolio_get_release_url( $post_id )`, `get_version` → `projects_portfolio_get_version( $post_id )`, `get_owner` → `projects_portfolio_get_owner( $post_id )`.)
|
||
|
||
- [ ] **Step 3: Run the full test suite to confirm no regressions**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add includes/helper-functions.php
|
||
git commit -m "Add provider-aware helpers; mark legacy wrappers deprecated"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Update project settings to expose default Gitea base URL + token
|
||
|
||
**Files:**
|
||
- Modify: `admin/admin-settings.php` — add a new "Gitea Settings" section between General and Templates.
|
||
|
||
**Interfaces:**
|
||
- Produces: option keys `projects_portfolio_default_gitea_base_url`, `projects_portfolio_gitea_api_token`. Read by the factory (Task 4) and the `projects_portfolio_settings()` aggregator.
|
||
|
||
- [ ] **Step 1: Add the new section to `projects_portfolio_render_settings_page()`**
|
||
|
||
Insert directly after the existing "General Settings" `<table>` (before the `<h2>...Templates Settings</h2>` heading):
|
||
|
||
```php
|
||
<h2><?php esc_html_e( 'Gitea Settings', 'projects-wp' ); ?></h2>
|
||
<table class="form-table">
|
||
<tr>
|
||
<th scope="row">
|
||
<label for="projects_portfolio_default_gitea_base_url"><?php esc_html_e( 'Default Gitea Base URL', 'projects-wp' ); ?></label>
|
||
</th>
|
||
<td>
|
||
<input type="url" id="projects_portfolio_default_gitea_base_url" name="projects_portfolio_default_gitea_base_url" value="<?php echo esc_attr( $default_gitea_base_url ); ?>" class="regular-text" placeholder="https://codeberg.org" />
|
||
<p class="description"><?php esc_html_e( 'Used when a Gitea project has no per-project base URL override.', 'projects-wp' ); ?></p>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<th scope="row">
|
||
<label for="projects_portfolio_gitea_api_token"><?php esc_html_e( 'Gitea API Token', 'projects-wp' ); ?></label>
|
||
</th>
|
||
<td>
|
||
<input type="password" id="projects_portfolio_gitea_api_token" name="projects_portfolio_gitea_api_token" value="<?php echo esc_attr( $gitea_api_token ); ?>" class="regular-text" />
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
```
|
||
|
||
- [ ] **Step 2: Load the option values at the top of the render function**
|
||
|
||
Replace the existing local variable block in `projects_portfolio_render_settings_page()`:
|
||
|
||
```php
|
||
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
|
||
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );
|
||
```
|
||
|
||
With:
|
||
|
||
```php
|
||
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
|
||
$gitea_api_token = get_option( 'projects_portfolio_gitea_api_token', '' );
|
||
$default_gitea_base_url = get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
|
||
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );
|
||
```
|
||
|
||
- [ ] **Step 3: Persist the new options in `projects_portfolio_save_settings()`**
|
||
|
||
Add immediately after the existing `update_option( 'projects_portfolio_github_api_token', … )` line:
|
||
|
||
```php
|
||
update_option( 'projects_portfolio_gitea_api_token', sanitize_text_field( $_POST['projects_portfolio_gitea_api_token'] ?? '' ) );
|
||
update_option( 'projects_portfolio_default_gitea_base_url', esc_url_raw( $_POST['projects_portfolio_default_gitea_base_url'] ?? 'https://codeberg.org' ) );
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add admin/admin-settings.php
|
||
git commit -m "Add Gitea base URL + token fields to settings page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Extend settings aggregator + write settings tests
|
||
|
||
**Files:**
|
||
- Modify: `includes/helper-functions.php` — add the new option keys to `projects_portfolio_settings()`.
|
||
- Create: `tests/test-settings.php` — covers new option reads.
|
||
|
||
**Interfaces:**
|
||
- Produces: `projects_portfolio_settings()` array now contains `'default_gitea_base_url'` and `'gitea_api_token'` keys.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/test-settings.php`:
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* @covers \projects_portfolio_settings
|
||
*/
|
||
class Settings_Test extends \PHPUnit\Framework\TestCase {
|
||
|
||
protected function setUp(): void {
|
||
\Brain\Monkey\setUp();
|
||
}
|
||
|
||
protected function tearDown(): void {
|
||
\Brain\Monkey\tearDown();
|
||
}
|
||
|
||
public function test_settings_aggregator_includes_gitea_keys(): void {
|
||
\Brain\Monkey\Functions\stubs( [
|
||
'get_option' => function ( $key, $default = '' ) {
|
||
$values = [
|
||
'projects_portfolio_default_gitea_base_url' => 'https://git.example.org',
|
||
'projects_portfolio_gitea_api_token' => 'secret',
|
||
];
|
||
return $values[ $key ] ?? $default;
|
||
},
|
||
] );
|
||
|
||
$settings = projects_portfolio_settings();
|
||
$this->assertSame( 'https://git.example.org', $settings['default_gitea_base_url'] );
|
||
$this->assertSame( 'secret', $settings['gitea_api_token'] );
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests and confirm failure**
|
||
|
||
Run: `vendor/bin/phpunit --filter Settings_Test`
|
||
Expected: FAIL — settings array missing the new keys.
|
||
|
||
- [ ] **Step 3: Update `projects_portfolio_settings()` in `helper-functions.php`**
|
||
|
||
Replace the existing `$settings = [ ... ]` block with the version that adds the two keys at the top level (sibling to `'github_api_token'`):
|
||
|
||
```php
|
||
$settings = [
|
||
'github_api_token' => get_option( 'projects_portfolio_github_api_token', '' ),
|
||
'gitea_api_token' => get_option( 'projects_portfolio_gitea_api_token', '' ),
|
||
'default_gitea_base_url' => get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' ),
|
||
'share_telemetry' => get_option( 'projects_portfolio_share_telemetry', '0' ),
|
||
'templates' => [
|
||
'version' => get_option( 'projects_portfolio_templates_version', '0' ),
|
||
'last_updated' => get_option( 'projects_portfolio_templates_last_updated', '0' ),
|
||
'license' => get_option( 'projects_portfolio_templates_license', '0' ),
|
||
'language' => get_option( 'projects_portfolio_templates_language', '0' ),
|
||
'downloads' => get_option( 'projects_portfolio_templates_downloads', '0' ),
|
||
'forks' => get_option( 'projects_portfolio_templates_forks', '0' ),
|
||
'stargazers_count' => get_option( 'projects_portfolio_templates_stargazers_count', '0' ),
|
||
'open_issues_count' => get_option( 'projects_portfolio_templates_open_issues_count', '0' ),
|
||
'github_owner' => get_option( 'projects_portfolio_templates_github_owner', '0' ),
|
||
],
|
||
'archives' => [
|
||
'archive_title' => get_option( 'projects_portfolio_archives_archive_title', '0' ),
|
||
'project_title' => get_option( 'projects_portfolio_archives_project_title', '0' ),
|
||
'project_excerpt' => get_option( 'projects_portfolio_archives_project_excerpt', '0' ),
|
||
'project_buttons' => get_option( 'projects_portfolio_archives_project_buttons', '0' ),
|
||
],
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and confirm pass**
|
||
|
||
Run: `vendor/bin/phpunit --filter Settings_Test`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Run the full suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add includes/helper-functions.php tests/test-settings.php
|
||
git commit -m "Surface Gitea settings via projects_portfolio_settings() and test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Update the metabox (provider dropdown + repo URL + Gitea base URL)
|
||
|
||
**Files:**
|
||
- Modify: `admin/metabox.php` — replace single-field GitHub URL metabox with a "Repository" metabox containing provider dropdown, repo URL, optional Gitea base URL.
|
||
- Create: `tests/test-metabox.php`
|
||
|
||
**Interfaces:**
|
||
- Produces: post meta keys `_projects_portfolio_provider`, `_projects_portfolio_repo_url`, `_projects_portfolio_gitea_base_url` on save. Lazy migration from `_projects_portfolio_github_url`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/test-metabox.php`:
|
||
|
||
```php
|
||
<?php
|
||
/**
|
||
* @covers \projects_portfolio_save_meta_box
|
||
*/
|
||
class Metabox_Test extends \PHPUnit\Framework\TestCase {
|
||
|
||
protected function setUp(): void {
|
||
\Brain\Monkey\setUp();
|
||
}
|
||
|
||
protected function tearDown(): void {
|
||
\Brain\Monkey\tearDown();
|
||
}
|
||
|
||
/** @var array<string,string> */
|
||
private array $saved = [];
|
||
|
||
protected function setUpSaveStubs(): void {
|
||
$this->saved = [];
|
||
\Brain\Monkey\Functions\stubs( [
|
||
'update_post_meta' => function ( $post_id, $key, $value ) {
|
||
$this->saved[ $key ] = $value;
|
||
return true;
|
||
},
|
||
'wp_verify_nonce' => function () { return true; },
|
||
'wp_nonce_field' => function () { /* noop */ },
|
||
] );
|
||
}
|
||
|
||
public function test_save_persists_provider_and_repo_url(): void {
|
||
$this->setUpSaveStubs();
|
||
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
|
||
$_POST['projects_portfolio_provider'] = 'gitea';
|
||
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
|
||
$_POST['projects_portfolio_gitea_base_url'] = 'https://codeberg.org';
|
||
|
||
projects_portfolio_save_meta_box( 42 );
|
||
|
||
$this->assertSame( 'gitea', $this->saved['_projects_portfolio_provider'] );
|
||
$this->assertSame( 'https://codeberg.org/owner/repo', $this->saved['_projects_portfolio_repo_url'] );
|
||
$this->assertSame( 'https://codeberg.org', $this->saved['_projects_portfolio_gitea_base_url'] );
|
||
}
|
||
|
||
public function test_lazy_migration_from_legacy_url(): void {
|
||
$existing = [
|
||
'_projects_portfolio_provider' => '',
|
||
'_projects_portfolio_repo_url' => '',
|
||
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
|
||
];
|
||
\Brain\Monkey\Functions\stubs( [
|
||
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
|
||
return $existing[ $key ] ?? '';
|
||
},
|
||
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
|
||
$existing[ $key ] = $value;
|
||
return true;
|
||
},
|
||
'wp_verify_nonce' => function () { return true; },
|
||
'wp_nonce_field' => function () { /* noop */ },
|
||
] );
|
||
|
||
// No $_POST provider/repo_url. Lazy migration should fire.
|
||
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
|
||
|
||
projects_portfolio_save_meta_box( 1 );
|
||
|
||
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
|
||
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests and confirm failure**
|
||
|
||
Run: `vendor/bin/phpunit --filter Metabox_Test`
|
||
Expected: FAIL — tests fail because the legacy implementation doesn't persist new keys.
|
||
|
||
- [ ] **Step 3: Replace `admin/metabox.php` contents**
|
||
|
||
Replace the entire file with:
|
||
|
||
```php
|
||
<?php
|
||
|
||
// If this file is called directly, abort.
|
||
if ( ! defined( 'WPINC' ) ) {
|
||
die;
|
||
}
|
||
|
||
/**
|
||
* Add Repository meta box (provider-aware).
|
||
*
|
||
* @since 1.0.0
|
||
* @return void
|
||
*/
|
||
function projects_portfolio_add_meta_boxes() {
|
||
add_meta_box(
|
||
'projects_portfolio_repo',
|
||
esc_html__( 'Repository', 'projects-wp' ),
|
||
'projects_portfolio_render_meta_box',
|
||
'projects',
|
||
'side'
|
||
);
|
||
}
|
||
add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
|
||
|
||
/**
|
||
* Render the Repository meta box.
|
||
*
|
||
* @param WP_Post $post
|
||
*
|
||
* @since 1.0.0
|
||
* @return void
|
||
*/
|
||
function projects_portfolio_render_meta_box( $post ) {
|
||
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' );
|
||
|
||
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
|
||
if ( '' === $provider ) {
|
||
$provider = 'github';
|
||
}
|
||
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_repo_url', true );
|
||
if ( '' === $repo_url ) {
|
||
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
|
||
}
|
||
$gitea_base_url = get_post_meta( $post->ID, '_projects_portfolio_gitea_base_url', true );
|
||
?>
|
||
<p>
|
||
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label>
|
||
<select id="projects_portfolio_provider" name="projects_portfolio_provider" style="width: 100%;">
|
||
<option value="github" <?php selected( $provider, 'github' ); ?>><?php esc_html_e( 'GitHub', 'projects-wp' ); ?></option>
|
||
<option value="gitea" <?php selected( $provider, 'gitea' ); ?>><?php esc_html_e( 'Gitea', 'projects-wp' ); ?></option>
|
||
</select>
|
||
</p>
|
||
<p>
|
||
<label for="projects_portfolio_repo_url"><?php esc_html_e( 'Repository URL', 'projects-wp' ); ?></label>
|
||
<input type="url" id="projects_portfolio_repo_url" name="projects_portfolio_repo_url" value="<?php echo esc_attr( $repo_url ); ?>" style="width: 100%;" placeholder="https://github.com/owner/repo" />
|
||
</p>
|
||
<p class="projects-portfolio-gitea-only" <?php echo 'gitea' !== $provider ? 'style="display:none;"' : ''; ?>>
|
||
<label for="projects_portfolio_gitea_base_url"><?php esc_html_e( 'Gitea Base URL (optional override)', 'projects-wp' ); ?></label>
|
||
<input type="url" id="projects_portfolio_gitea_base_url" name="projects_portfolio_gitea_base_url" value="<?php echo esc_attr( $gitea_base_url ); ?>" style="width: 100%;" placeholder="https://codeberg.org" />
|
||
<span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
|
||
</p>
|
||
<script>
|
||
(function(){
|
||
var sel = document.getElementById('projects_portfolio_provider');
|
||
if (!sel) return;
|
||
var gitea = document.querySelector('.projects-portfolio-gitea-only');
|
||
function toggle(){
|
||
if (!gitea) return;
|
||
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
|
||
}
|
||
sel.addEventListener('change', toggle);
|
||
toggle();
|
||
})();
|
||
</script>
|
||
<?php
|
||
}
|
||
|
||
/**
|
||
* Save the Repository meta box.
|
||
*
|
||
* Performs a lazy migration from legacy `_projects_portfolio_github_url`.
|
||
*
|
||
* @param int $post_id
|
||
*
|
||
* @since 1.0.0
|
||
* @return void
|
||
*/
|
||
function projects_portfolio_save_meta_box( $post_id ) {
|
||
if ( ! isset( $_POST['projects_portfolio_meta_box_nonce'] ) || ! wp_verify_nonce( $_POST['projects_portfolio_meta_box_nonce'], 'projects_portfolio_save_meta_box' ) ) {
|
||
return;
|
||
}
|
||
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
|
||
return;
|
||
}
|
||
|
||
$existing_provider = get_post_meta( $post_id, '_projects_portfolio_provider', true );
|
||
$existing_repo_url = get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
|
||
$legacy_github = get_post_meta( $post_id, '_projects_portfolio_github_url', true );
|
||
|
||
// Lazy migration: legacy URL present, no explicit choice.
|
||
if ( '' === $existing_provider && '' === $existing_repo_url && '' !== $legacy_github ) {
|
||
update_post_meta( $post_id, '_projects_portfolio_provider', 'github' );
|
||
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
|
||
}
|
||
|
||
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
|
||
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
|
||
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
|
||
}
|
||
|
||
if ( isset( $_POST['projects_portfolio_repo_url'] ) ) {
|
||
$repo_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_repo_url'] ) );
|
||
update_post_meta( $post_id, '_projects_portfolio_repo_url', $repo_url );
|
||
// Maintain the legacy key for backward-compat reads (only when this is a GitHub project).
|
||
$provider_now = get_post_meta( $post_id, '_projects_portfolio_provider', true );
|
||
if ( 'github' === $provider_now ) {
|
||
update_post_meta( $post_id, '_projects_portfolio_github_url', $repo_url );
|
||
}
|
||
}
|
||
|
||
if ( isset( $_POST['projects_portfolio_gitea_base_url'] ) ) {
|
||
update_post_meta( $post_id, '_projects_portfolio_gitea_base_url', esc_url_raw( wp_unslash( $_POST['projects_portfolio_gitea_base_url'] ) ) );
|
||
}
|
||
}
|
||
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
|
||
```
|
||
|
||
- [ ] **Step 4: Run the metabox tests**
|
||
|
||
Run: `vendor/bin/phpunit --filter Metabox_Test`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Run the full suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add admin/metabox.php tests/test-metabox.php
|
||
git commit -m "Add provider dropdown and per-project Gitea base URL to metabox"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Update single-projects.php template to use provider
|
||
|
||
**Files:**
|
||
- Modify: `templates/single-projects.php` — replace GitHub-specific fetches with provider-aware wrappers; rename button to "View Repo"; use new helper names.
|
||
|
||
**Interfaces:**
|
||
- Consumes: `projects_portfolio_get_repo_data()`, `projects_portfolio_get_version()`, `projects_portfolio_get_owner()`, `projects_portfolio_get_repo_browse_url()` from Task 5.
|
||
|
||
- [ ] **Step 1: Replace the data-fetch block at the top of `templates/single-projects.php`**
|
||
|
||
Replace this entire block (the lines from `$settings = …` through `// Format last updated date.`):
|
||
|
||
```php
|
||
$settings = projects_portfolio_settings();
|
||
$project_id = get_the_ID();
|
||
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
|
||
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
|
||
$version = 'Unknown';
|
||
$github_data = projects_portfolio_get_github_data( $github_url );
|
||
|
||
// Extract specific GitHub data.
|
||
$owner_avatar = $github_data['owner']['avatar_url'] ?? '';
|
||
$owner_name = $github_data['owner']['login'] ?? '';
|
||
$owner_url = $github_data['owner']['html_url'] ?? '';
|
||
$last_updated = $github_data['updated_at'] ?? '';
|
||
$language = $github_data['language'] ?? '';
|
||
$license = $github_data['license']['name'] ?? 'None';
|
||
|
||
$owner = projects_portfolio_github_owner( $owner_name );
|
||
|
||
// Fetch the version number from the GitHub API if the URL is set.
|
||
if ( $github_url ) {
|
||
// Retrieve the GitHub API token from the plugin settings.
|
||
$github_token = get_option( 'projects_portfolio_github_api_token', '' );
|
||
|
||
// Prepare the API URL.
|
||
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
|
||
|
||
// Check if the response is cached.
|
||
$transient_key = 'github_api_response_' . md5( $api_url );
|
||
$response = get_transient( $transient_key );
|
||
|
||
if ( false === $response ) {
|
||
// Prepare the request arguments.
|
||
$args = [];
|
||
if ( ! empty( $github_token ) ) {
|
||
$args['headers'] = [
|
||
'Authorization' => 'token ' . $github_token,
|
||
];
|
||
}
|
||
|
||
// Make the API request.
|
||
$response = wp_remote_get( $api_url, $args );
|
||
|
||
// Cache the response for 1 hour if the request is successful.
|
||
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
|
||
set_transient( $transient_key, $response, HOUR_IN_SECONDS );
|
||
}
|
||
}
|
||
|
||
// Handle the response.
|
||
if ( is_wp_error( $response ) ) {
|
||
error_log( 'GitHub API error: ' . $response->get_error_message() );
|
||
} else {
|
||
$response_code = wp_remote_retrieve_response_code( $response );
|
||
$response_body = wp_remote_retrieve_body( $response );
|
||
error_log( 'GitHub API response code: ' . $response_code );
|
||
error_log( 'GitHub API response body: ' . $response_body );
|
||
|
||
if ( $response_code === 200 ) {
|
||
$data = json_decode( $response_body, true );
|
||
|
||
// Check if tag_name exists in the response.
|
||
if ( isset( $data['tag_name'] ) ) {
|
||
$version = esc_html( $data['tag_name'] );
|
||
}
|
||
} else {
|
||
error_log( "GitHub API error: HTTP $response_code for URL $api_url" );
|
||
}
|
||
}
|
||
}
|
||
|
||
// Format last updated date.
|
||
if ( $last_updated ) {
|
||
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
|
||
}
|
||
```
|
||
|
||
With:
|
||
|
||
```php
|
||
$settings = projects_portfolio_settings();
|
||
$project_id = get_the_ID();
|
||
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
|
||
|
||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
|
||
if ( '' === $repo_url ) {
|
||
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
|
||
}
|
||
|
||
$repo_data = projects_portfolio_get_repo_data( $project_id );
|
||
$version = projects_portfolio_get_version( $project_id );
|
||
$owner = projects_portfolio_get_owner( $project_id );
|
||
|
||
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
|
||
$owner_name = $repo_data['owner']['login'] ?? '';
|
||
$owner_url = $repo_data['owner']['html_url'] ?? '';
|
||
$last_updated = $repo_data['updated_at'] ?? '';
|
||
$language = $repo_data['language'] ?? '';
|
||
$license = $repo_data['license']['name'] ?? 'None';
|
||
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
|
||
|
||
if ( $last_updated ) {
|
||
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Replace references to `$github_data` and `$github_url` in the template body**
|
||
|
||
- In the meta `<table>` block, replace every `$github_data[…]` with `$repo_data[…]` (e.g. `$github_data['stargazers_count']` → `$repo_data['stargazers_count']`).
|
||
- In the "View on GitHub" anchor, change the label from `<?php esc_html_e( 'View on GitHub', 'projects-wp' ); ?>` to `<?php esc_html_e( 'View Repo', 'projects-wp' ); ?>` and the `href` from `<?php echo esc_url( $github_url ); ?>` to `<?php echo esc_url( $browse_url ); ?>`.
|
||
- In the conditional `<?php if ( $github_url ) : ?>` guard, replace with `<?php if ( $repo_url ) : ?>`.
|
||
- In the "No repository linked" fallback message, change `esc_html__( 'GitHub Repository:', 'projects-wp' )` to `esc_html__( 'Repository:', 'projects-wp' )`.
|
||
|
||
- [ ] **Step 3: Run the full test suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass. (The template isn't unit-tested; this step confirms no helper regressions.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add templates/single-projects.php
|
||
git commit -m "Route single-projects template through provider interface"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Update REST API response to use provider
|
||
|
||
**Files:**
|
||
- Modify: `admin/rest-api.php` — replace GitHub-specific calls with provider-aware helpers.
|
||
|
||
- [ ] **Step 1: Replace the GitHub-specific fetches in `projects_portfolio_get_projects_data()`**
|
||
|
||
Find and replace:
|
||
|
||
- `$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );` →
|
||
`$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true ); if ( '' === $repo_url ) { $repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true ); }`
|
||
- `$github_data = projects_portfolio_get_github_data( $github_url );` →
|
||
`$repo_data = projects_portfolio_get_repo_data( $project_id );`
|
||
- All occurrences of `$github_data[…]` → `$repo_data[…]` in the response array.
|
||
- `'github_url' => $github_url,` → keep the existing field name `'github_url'` but assign `$repo_url` to it.
|
||
- `'version' => projects_portfolio_get_version_from_github( $github_url ),` → `'version' => projects_portfolio_get_version( $project_id ),`.
|
||
|
||
Keep the response field name `'github_url'` for backward compatibility, but populate it with the canonical `$repo_url` value (which may point at any provider). Add a sibling `'provider' => get_post_meta( $post_id, '_projects_portfolio_provider', true ) ?: 'github'` so consumers can detect the provider without parsing the URL.
|
||
|
||
This preserves the existing REST response shape (spec §12: "Response shape is unchanged.") while exposing the minimum needed info for consumers to detect Gitea.
|
||
|
||
- [ ] **Step 2: Update `projects_portfolio_get_popular_projects()`**
|
||
|
||
Find and replace:
|
||
|
||
- `'github_url' => get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ),` →
|
||
`'github_url' => ( $url = get_post_meta( get_the_ID(), '_projects_portfolio_repo_url', true ) ?: get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ) ),`
|
||
|
||
- [ ] **Step 3: Run the full suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add admin/rest-api.php
|
||
git commit -m "Route REST API responses through provider interface"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Update download redirect to use provider
|
||
|
||
**Files:**
|
||
- Modify: `projects-portfolio.php` — `projects_portfolio_handle_download_redirect()` uses the provider.
|
||
|
||
- [ ] **Step 1: Replace the redirect logic**
|
||
|
||
Find and replace:
|
||
|
||
```php
|
||
function projects_portfolio_handle_download_redirect() {
|
||
$project_id = get_query_var( 'project_download_id' );
|
||
|
||
if ( $project_id ) {
|
||
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
|
||
$download_url = projects_portfolio_get_github_release_url( $github_url );
|
||
|
||
if ( $download_url ) {
|
||
// Increment download count.
|
||
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
|
||
++$download_count;
|
||
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
|
||
|
||
// Redirect to the GitHub ZIP file.
|
||
wp_safe_redirect( esc_url_raw( $download_url ) );
|
||
exit;
|
||
} else {
|
||
wp_die(
|
||
esc_html__( 'Invalid download URL. Please check the GitHub repository.', 'projects-wp' ),
|
||
esc_html__( 'Download Error', 'projects-wp' ),
|
||
array( 'response' => 404 )
|
||
);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
With:
|
||
|
||
```php
|
||
function projects_portfolio_handle_download_redirect() {
|
||
$project_id = get_query_var( 'project_download_id' );
|
||
|
||
if ( $project_id ) {
|
||
$download_url = projects_portfolio_get_release_url( (int) $project_id );
|
||
|
||
if ( $download_url ) {
|
||
// Increment download count.
|
||
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
|
||
++$download_count;
|
||
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
|
||
|
||
// Redirect to the release ZIP on the project's host.
|
||
wp_safe_redirect( esc_url_raw( $download_url ) );
|
||
exit;
|
||
} else {
|
||
wp_die(
|
||
esc_html__( 'Invalid download URL. Please check the repository URL.', 'projects-wp' ),
|
||
esc_html__( 'Download Error', 'projects-wp' ),
|
||
array( 'response' => 404 )
|
||
);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add projects-portfolio.php
|
||
git commit -m "Route download redirect through provider interface"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Wire provider includes from the main plugin file + bump version
|
||
|
||
**Files:**
|
||
- Modify: `projects-portfolio.php` — `require` the new provider files at the top of the "Add the required files" block; bump version constant and plugin header.
|
||
|
||
- [ ] **Step 1: Add the provider requires**
|
||
|
||
In `projects-portfolio.php`, replace the existing `require` block:
|
||
|
||
```php
|
||
// Add the required files.
|
||
require 'admin/admin-settings.php';
|
||
require 'admin/cpt-taxonomy.php';
|
||
require 'admin/metabox.php';
|
||
require 'includes/helper-functions.php';
|
||
```
|
||
|
||
With:
|
||
|
||
```php
|
||
// Add the required files.
|
||
require 'admin/admin-settings.php';
|
||
require 'admin/cpt-taxonomy.php';
|
||
require 'admin/metabox.php';
|
||
require 'includes/helper-functions.php';
|
||
require 'includes/providers/interface-repository-provider.php';
|
||
require 'includes/providers/class-github-provider.php';
|
||
require 'includes/providers/class-gitea-provider.php';
|
||
require 'includes/providers/class-provider-factory.php';
|
||
```
|
||
|
||
- [ ] **Step 2: Bump the version constant**
|
||
|
||
Replace:
|
||
```php
|
||
define( 'PROJECTS_PORTFOLIO_VERSION', time() );
|
||
```
|
||
With:
|
||
```php
|
||
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
|
||
```
|
||
|
||
- [ ] **Step 3: Bump the plugin header**
|
||
|
||
In the file header comment, change:
|
||
```
|
||
* Version: 1.0.0
|
||
```
|
||
to:
|
||
```
|
||
* Version: 1.1.0
|
||
```
|
||
|
||
- [ ] **Step 4: Run the full test suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 5: Smoke-test the bootstrap manually**
|
||
|
||
Run: `php -r "define('WPINC','wp-includes'); require 'includes/providers/interface-repository-provider.php'; require 'includes/providers/class-github-provider.php'; require 'includes/providers/class-gitea-provider.php'; require 'includes/providers/class-provider-factory.php'; echo 'OK'; echo PHP_EOL;"`
|
||
|
||
Expected output ends with `OK`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add projects-portfolio.php
|
||
git commit -m "Wire provider includes and bump version to 1.1.0"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Update README
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Update the "Add a New Project" usage section**
|
||
|
||
Find the existing "Add a New Project" subsection in `README.md`. Replace the line:
|
||
```
|
||
4. In the sidebar, paste the GitHub Repository URL (e.g., `https://github.com/username/repo`).
|
||
```
|
||
With:
|
||
```
|
||
4. In the sidebar, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/username/repo`).
|
||
5. For Gitea projects, optionally set a **Gitea Base URL** to override the global default.
|
||
```
|
||
|
||
- [ ] **Step 2: Update the "Settings Overview" — General Settings**
|
||
|
||
Replace:
|
||
```
|
||
- **GitHub API Token** – Recommended for authenticated requests (avoids GitHub rate limits).
|
||
- **Telemetry** – Toggle to help improve the plugin.
|
||
```
|
||
With:
|
||
```
|
||
- **GitHub API Token** – Recommended for authenticated requests (avoids GitHub rate limits).
|
||
- **Default Gitea Base URL** – Used when a Gitea project doesn't specify one. Defaults to `https://codeberg.org`.
|
||
- **Gitea API Token** – Recommended for authenticated Gitea requests.
|
||
- **Telemetry** – Toggle to help improve the plugin.
|
||
```
|
||
|
||
- [ ] **Step 3: Add a "Connecting to a Gitea repo" subsection**
|
||
|
||
Insert directly after the existing "Connect Your GitHub Repo" section:
|
||
|
||
```
|
||
## Connect Your Gitea Repo
|
||
|
||
To connect a project to a Gitea instance (self-hosted or public):
|
||
|
||
1. Set **Provider** to **Gitea** in the project editor.
|
||
2. Paste the full repository URL into **Repository URL** (e.g., `https://codeberg.org/username/repo`).
|
||
3. Optionally override the Gitea base URL for this project (leave blank to use the global default).
|
||
4. Make sure the repo has a release with a `.zip` asset, or any tag — the plugin will fall back to the source archive URL.
|
||
|
||
### Optional: Add a Gitea API Token
|
||
|
||
To avoid rate limits or improve reliability:
|
||
|
||
1. In your Gitea instance, go to **Settings → Applications** and generate a token.
|
||
2. Paste it into the plugin settings screen under **Gitea API Token**.
|
||
```
|
||
|
||
- [ ] **Step 4: Update the "Enable Download Link" section**
|
||
|
||
Replace:
|
||
```
|
||
This URL fetches the latest `.zip` release from GitHub and increments the download count.
|
||
```
|
||
With:
|
||
```
|
||
This URL fetches the latest `.zip` release from the project's host (GitHub or Gitea) and increments the download count.
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "Document Gitea support in README"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Regenerate the translation template
|
||
|
||
**Files:**
|
||
- Modify: `languages/projects-wp.pot`, `languages/projects-for-wordpress-es_MX.po`, `languages/projects-wp-fr_FR.po` (and any `.l10n.php` siblings).
|
||
|
||
- [ ] **Step 1: Check whether the plugin uses WP-CLI for POT generation**
|
||
|
||
Run: `grep -r "wp i18n" . --include="*.json" --include="*.sh" --include="*.md" 2>/dev/null || true`
|
||
|
||
If no WP-CLI scaffold exists, the `.pot` is hand-maintained. In that case, regenerate it manually using POEdit, `xgettext`, or by hand-adding the new strings listed in spec §16. Then update the `.po` and `.l10n.php` files to mirror the new template.
|
||
|
||
- [ ] **Step 2: Verify the new strings are present in `projects-wp.pot`**
|
||
|
||
Run: `grep -E 'msgid "(Repository|Provider|Gitea Base URL \(optional override\)|View Repo|Invalid download URL\. Please check the repository URL\.|Default Gitea Base URL|Gitea API Token|Gitea Settings)"' languages/projects-wp.pot`
|
||
|
||
Expected: every string from spec §16 appears at least once as a `msgid` in the POT.
|
||
|
||
- [ ] **Step 3: Commit any changes**
|
||
|
||
```bash
|
||
git add languages/
|
||
git commit -m "Regenerate translation template for Gitea strings"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Final smoke test and full test run
|
||
|
||
**Files:** none (verification only).
|
||
|
||
- [ ] **Step 1: Run the full PHPUnit suite**
|
||
|
||
Run: `vendor/bin/phpunit`
|
||
Expected: All tests pass with no failures.
|
||
|
||
- [ ] **Step 2: Manual smoke test checklist** (verify in a real WordPress install)
|
||
|
||
- [ ] Fresh install with a GitHub project — output matches the previous plugin exactly (same fields, same labels).
|
||
- [ ] A Gitea project on `codeberg.org` — version, stars, license, owner avatar all render.
|
||
- [ ] A self-hosted Gitea project with a custom base URL — data renders correctly.
|
||
- [ ] A project with no repo URL — download endpoint shows the wp_die gracefully; templates omit provider-only rows.
|
||
- [ ] Existing project with only the legacy `_projects_portfolio_github_url` meta — first save migrates to the new keys and continues to work.
|
||
|
||
- [ ] **Step 3: Final commit if anything changed**
|
||
|
||
```bash
|
||
git status
|
||
# If clean, skip. Otherwise:
|
||
git add -A
|
||
git commit -m "Final smoke-test fixes"
|
||
```
|
||
|
||
- [ ] **Step 4: Tag the release**
|
||
|
||
```bash
|
||
git tag -a v1.1.0 -m "v1.1.0 — Gitea support"
|
||
``` |