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.
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.php—Repository_Providerinterface.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 inphpunit/phpunitandbrain/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.phpsiblings) — 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):
- Fetch release at
…/releases/latest. - Iterate
assets[]and return the first entry whose filename ends in.zip(fieldbrowser_download_url). - Fallback: return
zipball_url. nullif neither is present.
Gitea adapter:
- Fetch the latest release endpoint. Gitea returns an array of releases (not a single object), so the adapter takes
releases[0]. - Iterate that release's
assets[]for the first.zipentry. - Fallback: build
{base}/{owner}/{repo}/archive/refs/tags/{tag_name}.zip(Gitea serves this even when no assets are attached). nullif no tag is present.get_latest_version()readstag_namefrom 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:
- Provider —
<select>with optionsgithub(GitHub) andgitea(Gitea). Defaults togithub. - Repository URL —
<input type="url">. Helper text: "Example:https://github.com/owner/repoorhttps://codeberg.org/owner/repo." - 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
-
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."
- Read
-
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.
-
admin/rest-api.phpprojects_portfolio_get_projects_data()usesprojects_portfolio_get_repo_data()andprojects_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
- Implement on a feature branch.
- 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.
- Run the WP PHPUnit suite.
- Update README per §3.2.
- Bump
PROJECTS_PORTFOLIO_VERSIONand plugin header Version1.0.0→1.1.0. - 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.