# 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.php` — `Repository_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.php` — `projects_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.php` — `handle_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 ```php 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. ```php [ '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 ` 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** — ``. Helper text: "Example: `https://github.com/owner/repo` or `https://codeberg.org/owner/repo`." 3. **Gitea Base URL (optional override)** — ``. 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`): ```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: ```php 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.php` → `projects_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.0` → `1.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.