Files
Projects-Portfolio/specs/2026-08-09-gitea-support-design.md
T
Keith Solomon e924b4833a Add design spec: Gitea support for Projects Portfolio
Adds a provider interface with GitHub and Gitea adapters so each
project can link to either provider. Preserves existing GitHub
behavior bit-for-bit, adds lazy migration from legacy meta, and
ships WP PHPUnit tests.
2026-08-09 22:23:27 -05:00

13 KiB

Gitea Support for Projects Portfolio

  • Date: 2026-08-09
  • Plugin: Projects Portfolio (projects-wp)
  • Status: Approved design, awaiting implementation plan

1. Goals

  • Per-project support for either GitHub OR Gitea (self-hosted, configurable base URL).
  • Feature parity between providers: repo metadata, latest version, release-ZIP download redirect, repo link button.
  • Existing GitHub behavior preserved bit-for-bit — no regressions on current installs.
  • Provider interface designed so adding a third provider later (e.g. GitLab) is additive and does not require template branching.
  • WP PHPUnit tests cover the new adapters, the provider factory, and the metabox/settings surface.

2. Non-goals

  • GitLab, Bitbucket, or any non-GitHub/non-Gitea provider (interface permits it; implementation deferred).
  • Auto-migration on activation (existing posts keep their GitHub association; admin manually switches a project to Gitea when desired).
  • Public-Codeberg-only shortcut — Gitea support always treats the base URL as configurable per project and globally.
  • New CPT or taxonomy fields beyond what's listed in this spec.
  • UI changes outside the project metabox and the plugin settings page.

3. Architecture

The plugin introduces a small Repository_Provider interface with two adapters and a factory. All call sites consume the interface; only the factory knows which adapter to use.

Templates + REST API + Download Redirect
            │
            ▼
projects_portfolio_get_provider( $post_id )   ← factory
            │
   ┌────────┴────────┐
   ▼                 ▼
GitHub_Provider   Gitea_Provider
   │                 │
   └────────┬────────┘
            ▼
   WP HTTP API + transient cache

3.1 Files added

  • includes/providers/interface-repository-provider.phpRepository_Provider interface.
  • includes/providers/class-github-provider.php — current GitHub logic, repackaged.
  • includes/providers/class-gitea-provider.php — new adapter.
  • includes/providers/class-provider-factory.phpprojects_portfolio_get_provider().
  • tests/bootstrap.php, tests/test-*.php — WP PHPUnit suite.
  • composer.json (dev-only) — pulls in phpunit/phpunit and brain/monkey.

3.2 Files modified

  • includes/helper-functions.php — adds provider-aware wrappers; legacy GitHub-named wrappers retained verbatim with deprecation PHPDoc.
  • admin/metabox.php — replaces single GitHub URL field with provider dropdown + repo URL + optional Gitea base URL.
  • admin/admin-settings.php — adds "Gitea Settings" section (default base URL + token).
  • templates/single-projects.php — routes data fetches through the provider; no per-provider branching.
  • admin/rest-api.php — same swap for the REST response builder.
  • projects-portfolio.phphandle_download_redirect() routes through the provider.
  • README.md — documents the new metabox fields, Gitea settings, and a "Connecting to a Gitea repo" subsection.
  • languages/projects-wp.pot (and .po/.l10n.php siblings) — re-generated for new strings.

4. Provider interface

interface Repository_Provider {
    public function get_id(): string;                           // 'github' | 'gitea'
    public function get_label(): string;                        // 'GitHub' | 'Gitea'
    public function get_repo_data(): ?array;                    // normalized shape (see §6)
    public function get_release_url(): ?string;                 // direct asset zip URL or null
    public function get_latest_version(): string;               // tag_name or 'Unknown'
    public function get_repo_browse_url(): string;              // for the 'View Repo' button
    public function get_owner_data( string $owner_login ): ?array; // avatar/login/html_url
}

5. Per-project storage

Post meta keys:

Key Type Purpose
_projects_portfolio_provider 'github' | 'gitea' Provider selection. Defaults to github if missing.
_projects_portfolio_repo_url string (URL) Canonical repo URL. On read, falls back to _projects_portfolio_github_url for backward compatibility.
_projects_portfolio_gitea_base_url string (URL) Optional per-project Gitea base URL override. Empty → uses global default.
_projects_portfolio_github_url string Deprecated, read-only. Retained so legacy posts don't lose data.
_projects_portfolio_download_count int Unchanged.

Lazy migration: in projects_portfolio_save_meta_box(), when a project is saved and _projects_portfolio_provider is unset, _projects_portfolio_repo_url is empty, but the legacy _projects_portfolio_github_url is non-empty, copy the legacy value into _projects_portfolio_repo_url and set provider to github. No batch update is required.

6. Normalized repo data shape

Templates and the REST API read this shape regardless of provider. The GitHub adapter returns it natively; the Gitea adapter maps from Gitea's native response.

[
    '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,
]

6.1 Gitea → normalized mapping

Gitea field Normalized field
stars_count stargazers_count
forks_count forks_count
open_issues_count open_issues_count
updated_at updated_at
language language
license (string|null|object) ['name' => string|'None']
owner.avatar_url owner.avatar_url
owner.login owner.login
owner.html_url (or fallback {base}/{owner}) owner.html_url

7. Endpoints

Purpose GitHub Gitea
Repo metadata https://api.github.com/repos/{owner}/{repo} {base}/api/v1/repos/{owner}/{repo}
Latest release …/{owner}/{repo}/releases/latest {base}/api/v1/repos/{owner}/{repo}/releases/latest
Owner data https://api.github.com/users/{login} {base}/api/v1/users/{login}
Repo browse the project URL itself {base}/{owner}/{repo}

Gitea uses header Authorization: token <PAT> against the projects_portfolio_gitea_api_token option. GitHub uses the same header scheme against the existing projects_portfolio_github_api_token option.

8. Release & download logic

GitHub adapter (preserves current behavior):

  1. Fetch release at …/releases/latest.
  2. Iterate assets[] and return the first entry whose filename ends in .zip (field browser_download_url).
  3. Fallback: return zipball_url.
  4. null if neither is present.

Gitea adapter:

  1. Fetch the latest release endpoint. Gitea returns an array of releases (not a single object), so the adapter takes releases[0].
  2. Iterate that release's assets[] for the first .zip entry.
  3. Fallback: build {base}/{owner}/{repo}/archive/refs/tags/{tag_name}.zip (Gitea serves this even when no assets are attached).
  4. null if no tag is present.
  5. get_latest_version() reads tag_name from the same response.

9. Global settings

Added to the settings page (between General and Templates sections):

Option Default Purpose
projects_portfolio_default_gitea_base_url https://codeberg.org Used when a Gitea project has no per-project base URL override.
projects_portfolio_gitea_api_token '' Bearer token for authenticated Gitea requests.

Existing projects_portfolio_github_api_token is unchanged.

projects_portfolio_settings() returns the new keys so templates can read them.

10. Metabox

The "GitHub URL" metabox becomes a "Repository" metabox with:

  1. Provider<select> with options github (GitHub) and gitea (Gitea). Defaults to github.
  2. Repository URL<input type="url">. Helper text: "Example: https://github.com/owner/repo or https://codeberg.org/owner/repo."
  3. Gitea Base URL (optional override)<input type="url">. Visible only when Provider = Gitea. Helper text: "Leave blank to use the default from Settings."

Save behavior writes _projects_portfolio_provider, _projects_portfolio_repo_url, and _projects_portfolio_gitea_base_url. The legacy _projects_portfolio_github_url is written only during the one-time lazy migration (§5); it is not written on every save.

11. Helper-function surface

New provider-aware wrappers (in includes/helper-functions.php):

projects_portfolio_get_provider( $post_id ): Repository_Provider
projects_portfolio_get_repo_data( $post_id ): ?array
projects_portfolio_get_release_url( $post_id ): ?string
projects_portfolio_get_version( $post_id ): string
projects_portfolio_get_owner( $post_id ): ?array
projects_portfolio_get_repo_browse_url( $post_id ): string

Legacy GitHub-named wrappers are retained verbatim with a _deprecated_function() PHPDoc note pointing to the new wrappers:

projects_portfolio_get_github_data( $github_url ): ?array
projects_portfolio_get_github_release_url( $github_url ): ?string
projects_portfolio_get_version_from_github( $github_url ): string
projects_portfolio_github_owner( $owner_name ): ?array

These wrappers still take a GitHub URL and never route through the provider interface, preserving any external code that called them directly.

12. Call-site swaps

  1. projects-portfolio.phpprojects_portfolio_handle_download_redirect()

    • Read _projects_portfolio_repo_url (with legacy fallback) and _projects_portfolio_provider.
    • Call projects_portfolio_get_release_url( $post_id ).
    • Error message: "Invalid download URL. Please check the repository URL."
  2. templates/single-projects.php

    • $repo_data = projects_portfolio_get_repo_data( $post_id );
    • $version = projects_portfolio_get_version( $post_id );
    • $owner = projects_portfolio_get_owner( $post_id );
    • "View on GitHub" → "View Repo", href = projects_portfolio_get_repo_browse_url( $post_id ).
    • Field reads ($repo_data['stargazers_count'] etc.) unchanged because adapters normalize.
  3. admin/rest-api.php

    • projects_portfolio_get_projects_data() uses projects_portfolio_get_repo_data() and projects_portfolio_get_version(). Response shape is unchanged.

13. Error handling

Scenario Behavior
Provider meta missing Default to github; fall back to legacy _projects_portfolio_github_url.
Repo URL invalid/empty Provider returns null; templates omit provider-only rows; download redirect shows wp_die.
API 401/403 Logged, treated as null.
API 429 Logged with reset time, treated as null.
Gitea release array empty get_release_url() returns null.
Gitea release with no zip asset Falls back to archive URL pattern.
Gitea license = string Adapter wraps to ['name' => $string].
Gitea license = null Adapter returns ['name' => 'None'].
Cache transient corruption Treated as a miss; re-fetched.
Owner fetch failure Returns null; owner avatar block is omitted.

All errors are logged via error_log() with a Projects Portfolio: prefix. No die() calls in adapter code — the legacy wrappers retain their existing die() behavior for backward compatibility.

14. Testing

WP PHPUnit suite under tests/:

  • test-github-provider.php — correct URL construction, header behavior, asset/zipball fallback, multi-asset selection.
  • test-gitea-provider.php — correct URL construction, Gitea→normalized mapping, releases-as-array handling, archive-URL fallback, license normalization.
  • test-provider-factory.php — provider selection from meta; legacy URL fallback; token wiring per provider.
  • test-metabox.php — save persists new meta keys; legacy key preserved; lazy migration from legacy → new repo URL.
  • test-settings.php — new options register, save, and sanitize correctly.

Local run: composer install && vendor/bin/phpunit.

15. Rollout

  1. Implement on a feature branch.
  2. Manual smoke tests:
    • GitHub project on fresh install — output matches current plugin exactly.
    • Gitea project on codeberg.org.
    • Self-hosted Gitea with custom base URL.
    • Project with empty repo URL — download endpoint shows wp_die gracefully.
  3. Run the WP PHPUnit suite.
  4. Update README per §3.2.
  5. Bump PROJECTS_PORTFOLIO_VERSION and plugin header Version 1.0.01.1.0.
  6. Release as a backward-compatible minor version.

16. New translatable strings

  • "Repository" (metabox title)
  • "Provider"
  • "GitHub" (option label)
  • "Gitea" (option label)
  • "Repository URL"
  • "Gitea Base URL (optional override)"
  • "Leave blank to use the default from Settings."
  • "View Repo"
  • "Invalid download URL. Please check the repository URL."
  • "Gitea Settings" (settings section heading)
  • "Default Gitea Base URL"
  • "Gitea API Token"

All strings use the existing projects-wp text domain. POT regeneration picks them up.