diff --git a/specs/2026-08-09-gitea-support-design.md b/specs/2026-08-09-gitea-support-design.md new file mode 100644 index 0000000..eadc27a --- /dev/null +++ b/specs/2026-08-09-gitea-support-design.md @@ -0,0 +1,271 @@ +# 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. \ No newline at end of file