diff --git a/plans/2026-08-09-gitea-support.md b/plans/2026-08-09-gitea-support.md
new file mode 100644
index 0000000..3cddbff
--- /dev/null
+++ b/plans/2026-08-09-gitea-support.md
@@ -0,0 +1,2193 @@
+# 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
+
+
+
+
+ tests
+
+
+
+```
+
+- [ ] **Step 4: Create `includes/providers/interface-repository-provider.php`**
+
+```php
+ [ '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
+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
+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
+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
+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
+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
+ 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
+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" `
` (before the `...Templates Settings ` heading):
+
+```php
+
+
+```
+
+- [ ] **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
+ 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
+ */
+ 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
+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 );
+ ?>
+
+
+
+ >
+ >
+
+
+
+
+
+
+ >
+
+
+
+
+
+ '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 `` 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 `` to `` and the `href` from `` to ``.
+- In the conditional `` guard, replace with ``.
+- 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"
+```
\ No newline at end of file