Files
Projects-Portfolio/plans/2026-08-09-gitea-support.md
Keith Solomon 730893c1f0 Add Gitea support implementation plan
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.
2026-08-09 22:29:13 -05:00

78 KiB
Raw Permalink Blame History

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.01.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.phpRepository_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.phpprojects_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.phphandle_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
{
    "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 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
// 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
// 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
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
/**
 * @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
// 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
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
/**
 * @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
// 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
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
/**
 * @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
// 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
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.

/**
 * 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 {:

_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_repo_data( $post_id )' );

(Use the matching replacement name for get_release_urlprojects_portfolio_get_release_url( $post_id ), get_versionprojects_portfolio_get_version( $post_id ), get_ownerprojects_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
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):

<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():

$api_token       = get_option( 'projects_portfolio_github_api_token', '' );
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );

With:

$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:

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
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
/**
 * @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'):

$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
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
/**
 * @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

// 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
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.):

$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:

$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
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
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.phpprojects_portfolio_handle_download_redirect() uses the provider.

  • Step 1: Replace the redirect logic

Find and replace:

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:

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
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.phprequire 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:

// Add the required files.
require 'admin/admin-settings.php';
require 'admin/cpt-taxonomy.php';
require 'admin/metabox.php';
require 'includes/helper-functions.php';

With:

// 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:

define( 'PROJECTS_PORTFOLIO_VERSION', time() );

With:

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
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
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
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

git status
# If clean, skip. Otherwise:
git add -A
git commit -m "Final smoke-test fixes"
  • Step 4: Tag the release
git tag -a v1.1.0 -m "v1.1.0 — Gitea support"