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.
This commit is contained in:
@@ -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 <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`):
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user