17 Commits
Author SHA1 Message Date
Keith Solomon 0480abe292 fix: clear legacy _projects_portfolio_github_url on provider switch / empty repo URL 2026-08-09 23:49:06 -05:00
Keith Solomon 981c29ab39 Regenerate translation template for Gitea strings 2026-08-09 23:35:45 -05:00
Keith Solomon 5913c8799f Document Gitea support in README 2026-08-09 23:30:53 -05:00
Keith Solomon d250f6b89f Wire provider includes and bump version to 1.1.0 2026-08-09 23:28:47 -05:00
Keith Solomon 62a049b2c2 Route download redirect through provider interface 2026-08-09 23:26:02 -05:00
Keith Solomon e25431a25d Route REST API responses through provider interface 2026-08-09 23:23:57 -05:00
Keith Solomon 9803b497ab Route single-projects template through provider interface 2026-08-09 23:21:06 -05:00
Keith Solomon d8ce719433 Add provider dropdown and per-project Gitea base URL to metabox 2026-08-09 23:17:51 -05:00
Keith Solomon e60a9eafb3 Surface Gitea settings via projects_portfolio_settings() and test 2026-08-09 23:12:37 -05:00
Keith Solomon 925ef184b9 Add Gitea base URL + token fields to settings page 2026-08-09 23:09:50 -05:00
Keith Solomon 40793cd4bb Add provider-aware helpers; mark legacy wrappers deprecated 2026-08-09 23:06:24 -05:00
Keith Solomon e3b27e8974 Add provider factory with meta + option wiring 2026-08-09 23:02:20 -05:00
Keith Solomon 62a20ed514 Add Gitea provider adapter with normalization and tests 2026-08-09 22:56:16 -05:00
keith 28789f6295 Add GitHub provider adapter with full test coverage 2026-08-09 22:48:58 -05:00
Keith Solomon 4a9f43c850 Scaffold provider interface and test bootstrap 2026-08-09 22:35:23 -05:00
Keith Solomon 730893c1f0 Add Gitea support implementation plan
15-task plan with TDD-style steps, WP PHPUnit tests, and a
provider-interface architecture. Tasks cover scaffolding, both
adapters, factory, settings, metabox, template, REST, download
redirect, version bump, README, translations, and final smoke
testing.
2026-08-09 22:29:13 -05:00
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
29 changed files with 6161 additions and 122 deletions
+3
View File
@@ -2,3 +2,6 @@
.vscode/
docs/
notes/
vendor/
phpunit.xml
.phpunit.result.cache
+22 -3
View File
@@ -45,8 +45,9 @@ Create a beautifully structured, developer-first showcase directory for your Wor
1. In the WordPress® admin, go to **Projects → Add New**.
2. Add your project title, description, and featured image.
3. Select a **Project Type**: Plugin, Theme, or Pattern.
4. In the sidebar, paste the GitHub Repository URL (e.g., `https://github.com/username/repo`).
5. Publish.
4. In the sidebar, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/username/repo`).
5. For Gitea projects, optionally set a **Gitea Base URL** to override the global default.
6. Publish.
### Enable Download Link
@@ -55,7 +56,7 @@ Each project automatically gets a unique download endpoint:
Example:
`https://example.com/download/123`
This URL fetches the latest `.zip` release from GitHub and increments the download count.
This URL fetches the latest `.zip` release from the project's host (GitHub or Gitea) and increments the download count.
## Settings Overview
@@ -64,6 +65,8 @@ Navigate to **Projects → Settings** to configure:
### General Settings
- **GitHub API Token** Recommended for authenticated requests (avoids GitHub rate limits).
- **Default Gitea Base URL** Used when a Gitea project doesn't specify one. Defaults to `https://codeberg.org`.
- **Gitea API Token** Recommended for authenticated Gitea requests.
- **Telemetry** Toggle to help improve the plugin.
### Templates Settings
@@ -102,6 +105,22 @@ To avoid hitting rate limits or improve reliability:
2. Generate a token (no scopes required).
3. Paste it into the settings screen under **GitHub API Token**.
## Connect Your Gitea Repo
To connect a project to a Gitea instance (self-hosted or public):
1. Set **Provider** to **Gitea** in the project editor.
2. Paste the full repository URL into **Repository URL** (e.g., `https://codeberg.org/username/repo`).
3. Optionally override the Gitea base URL for this project (leave blank to use the global default).
4. Make sure the repo has a release with a `.zip` asset, or any tag — the plugin will fall back to the source archive URL.
### Optional: Add a Gitea API Token
To avoid rate limits or improve reliability:
1. In your Gitea instance, go to **Settings → Applications** and generate a token.
2. Paste it into the plugin settings screen under **Gitea API Token**.
## How to Zip the Plugin for GitHub Releases
When attaching the plugin to a GitHub release:
+25
View File
@@ -42,6 +42,8 @@ function projects_portfolio_render_settings_page() {
// Retrieve saved settings.
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
$gitea_api_token = get_option( 'projects_portfolio_gitea_api_token', '' );
$default_gitea_base_url = get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );
// Templates Settings.
@@ -83,6 +85,27 @@ function projects_portfolio_render_settings_page() {
</tr>
</table>
<h2><?php esc_html_e( 'Gitea Settings', 'projects-wp' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row">
<label for="projects_portfolio_default_gitea_base_url"><?php esc_html_e( 'Default Gitea Base URL', 'projects-wp' ); ?></label>
</th>
<td>
<input type="url" id="projects_portfolio_default_gitea_base_url" name="projects_portfolio_default_gitea_base_url" value="<?php echo esc_attr( $default_gitea_base_url ); ?>" class="regular-text" placeholder="https://codeberg.org" />
<p class="description"><?php esc_html_e( 'Used when a Gitea project has no per-project base URL override.', 'projects-wp' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="projects_portfolio_gitea_api_token"><?php esc_html_e( 'Gitea API Token', 'projects-wp' ); ?></label>
</th>
<td>
<input type="password" id="projects_portfolio_gitea_api_token" name="projects_portfolio_gitea_api_token" value="<?php echo esc_attr( $gitea_api_token ); ?>" class="regular-text" />
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Templates Settings', 'projects-wp' ); ?></h2>
<table class="form-table">
<?php foreach ( $templates_settings as $key => $label ) :
@@ -134,6 +157,8 @@ function projects_portfolio_save_settings() {
}
update_option( 'projects_portfolio_github_api_token', sanitize_text_field( $_POST['projects_portfolio_github_api_token'] ?? '' ) );
update_option( 'projects_portfolio_gitea_api_token', sanitize_text_field( $_POST['projects_portfolio_gitea_api_token'] ?? '' ) );
update_option( 'projects_portfolio_default_gitea_base_url', esc_url_raw( $_POST['projects_portfolio_default_gitea_base_url'] ?? 'https://codeberg.org' ) );
update_option( 'projects_portfolio_share_telemetry', isset( $_POST['projects_portfolio_share_telemetry'] ) ? '1' : '0' );
// Templates Settings Keys.
+87 -15
View File
@@ -6,15 +6,15 @@ if ( ! defined( 'WPINC' ) ) {
}
/**
* Add GitHub Repository URL meta box.
* Add Repository meta box (provider-aware).
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_add_meta_boxes() {
add_meta_box(
'projects_portfolio_github_url',
esc_html__( 'GitHub URL', 'projects-wp' ),
'projects_portfolio_repo',
esc_html__( 'Repository', 'projects-wp' ),
'projects_portfolio_render_meta_box',
'projects',
'side'
@@ -23,27 +23,64 @@ function projects_portfolio_add_meta_boxes() {
add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
/**
* Renders the GitHub Repository URL meta box for the Projects WP plugin.
* Render the Repository meta box.
*
* Outputs a label and input field for setting the GitHub repository URL,
* as well as a nonce for security.
*
* @param WP_Post $post The post object currently being edited.
* @param WP_Post $post
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_render_meta_box( $post ) {
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' );
$github_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
echo '<label for="projects_portfolio_github_url">' . __( 'GitHub Repository URL:', 'projects-wp' ) . '</label>';
echo '<input type="url" id="projects_portfolio_github_url" name="projects_portfolio_github_url" value="' . esc_attr( $github_url ) . '" style="width: 100%;" />';
$provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
if ( '' === $provider ) {
$provider = 'github';
}
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
}
$gitea_base_url = get_post_meta( $post->ID, '_projects_portfolio_gitea_base_url', true );
?>
<p>
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label>
<select id="projects_portfolio_provider" name="projects_portfolio_provider" style="width: 100%;">
<option value="github" <?php selected( $provider, 'github' ); ?>><?php esc_html_e( 'GitHub', 'projects-wp' ); ?></option>
<option value="gitea" <?php selected( $provider, 'gitea' ); ?>><?php esc_html_e( 'Gitea', 'projects-wp' ); ?></option>
</select>
</p>
<p>
<label for="projects_portfolio_repo_url"><?php esc_html_e( 'Repository URL', 'projects-wp' ); ?></label>
<input type="url" id="projects_portfolio_repo_url" name="projects_portfolio_repo_url" value="<?php echo esc_attr( $repo_url ); ?>" style="width: 100%;" placeholder="https://github.com/owner/repo" />
</p>
<p class="projects-portfolio-gitea-only" <?php echo 'gitea' !== $provider ? 'style="display:none;"' : ''; ?>>
<label for="projects_portfolio_gitea_base_url"><?php esc_html_e( 'Gitea Base URL (optional override)', 'projects-wp' ); ?></label>
<input type="url" id="projects_portfolio_gitea_base_url" name="projects_portfolio_gitea_base_url" value="<?php echo esc_attr( $gitea_base_url ); ?>" style="width: 100%;" placeholder="https://codeberg.org" />
<span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
</p>
<script>
(function(){
var sel = document.getElementById('projects_portfolio_provider');
if (!sel) return;
var gitea = document.querySelector('.projects-portfolio-gitea-only');
function toggle(){
if (!gitea) return;
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
}
sel.addEventListener('change', toggle);
toggle();
})();
</script>
<?php
}
/**
* Saves the GitHub repository URL meta box data for the Projects WP plugin.
* Save the Repository meta box.
*
* @param int $post_id The ID of the post currently being saved.
* Performs a lazy migration from legacy `_projects_portfolio_github_url`.
*
* @param int $post_id
*
* @since 1.0.0
* @return void
@@ -55,8 +92,43 @@ function projects_portfolio_save_meta_box( $post_id ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( isset( $_POST['projects_portfolio_github_url'] ) ) {
update_post_meta( $post_id, '_projects_portfolio_github_url', esc_url_raw( $_POST['projects_portfolio_github_url'] ) );
$existing_provider = get_post_meta( $post_id, '_projects_portfolio_provider', true );
$existing_repo_url = get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
$legacy_github = get_post_meta( $post_id, '_projects_portfolio_github_url', true );
// Lazy migration: legacy URL present, no explicit choice.
if ( '' === $existing_provider && '' === $existing_repo_url && '' !== $legacy_github ) {
update_post_meta( $post_id, '_projects_portfolio_provider', 'github' );
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
}
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
}
if ( isset( $_POST['projects_portfolio_repo_url'] ) ) {
$repo_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_repo_url'] ) );
update_post_meta( $post_id, '_projects_portfolio_repo_url', $repo_url );
// Maintain the legacy key for backward-compat reads (only when this is a GitHub project).
$provider_now = get_post_meta( $post_id, '_projects_portfolio_provider', true );
if ( 'github' === $provider_now ) {
if ( '' === $repo_url ) {
// Clearing the field on a GitHub project should clear the legacy key too.
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
} else {
update_post_meta( $post_id, '_projects_portfolio_github_url', $repo_url );
}
} else {
// Provider is gitea (or any non-github): ensure no stale GitHub URL can
// resurrect via the legacy fallback chain.
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
}
}
if ( isset( $_POST['projects_portfolio_gitea_base_url'] ) ) {
update_post_meta( $post_id, '_projects_portfolio_gitea_base_url', esc_url_raw( wp_unslash( $_POST['projects_portfolio_gitea_base_url'] ) ) );
}
}
add_action( 'save_post', 'projects_portfolio_save_meta_box' );
+19 -15
View File
@@ -28,8 +28,11 @@ function projects_portfolio_get_projects_data( $request ) {
$query->the_post();
$project_id = get_the_ID();
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$github_data = projects_portfolio_get_github_data( $github_url );
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
}
$repo_data = projects_portfolio_get_repo_data( $project_id );
// Fetch project-type taxonomy terms (names).
$project_types = wp_get_post_terms( $project_id, 'project-type', [
@@ -44,23 +47,24 @@ function projects_portfolio_get_projects_data( $request ) {
'thumbnail' => get_the_post_thumbnail_url( $project_id, 'large' ),
'download_count' => (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true ),
'download_url' => esc_url( site_url( '/download/' . $project_id ) ),
'github_url' => $github_url,
'github_url' => $repo_url,
'provider' => get_post_meta( $project_id, '_projects_portfolio_provider', true ) ?: 'github',
'github_data' => [
'owner' => [
'avatar_url' => $github_data['owner']['avatar_url'] ?? '',
'name' => $github_data['owner']['login'] ?? '',
'profile' => $github_data['owner']['html_url'] ?? '',
'avatar_url' => $repo_data['owner']['avatar_url'] ?? '',
'name' => $repo_data['owner']['login'] ?? '',
'profile' => $repo_data['owner']['html_url'] ?? '',
],
'last_updated' => ! empty( $github_data['updated_at'] )
? date_i18n( get_option( 'date_format' ), strtotime( $github_data['updated_at'] ) )
'last_updated' => ! empty( $repo_data['updated_at'] )
? date_i18n( get_option( 'date_format' ), strtotime( $repo_data['updated_at'] ) )
: '',
'language' => $github_data['language'] ?? '',
'license' => $github_data['license']['name'] ?? 'None',
'stars' => $github_data['stargazers_count'] ?? 0,
'forks' => $github_data['forks_count'] ?? 0,
'issues' => $github_data['open_issues_count'] ?? 0,
'language' => $repo_data['language'] ?? '',
'license' => $repo_data['license']['name'] ?? 'None',
'stars' => $repo_data['stargazers_count'] ?? 0,
'forks' => $repo_data['forks_count'] ?? 0,
'issues' => $repo_data['open_issues_count'] ?? 0,
],
'version' => projects_portfolio_get_version_from_github( $github_url ),
'version' => projects_portfolio_get_version( $project_id ),
'project_types' => $project_types,
'content' => get_the_content(),
];
@@ -121,7 +125,7 @@ function projects_portfolio_get_popular_projects( $data ) {
'title' => get_the_title(),
'download_count' => (int) get_post_meta( get_the_ID(), '_projects_portfolio_download_count', true ),
'download_url' => esc_url( site_url( '/download/' . get_the_ID() ) ),
'github_url' => get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ),
'github_url' => ( $url = get_post_meta( get_the_ID(), '_projects_portfolio_repo_url', true ) ?: get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ) ),
'permalink' => get_permalink(),
];
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "keithsolomon/projects-portfolio",
"description": "WordPress plugin — dev-only test dependencies.",
"type": "wordpress-plugin",
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"brain/monkey": "^2.6",
"yoast/phpunit-polyfills": "^2.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": false
}
}
}
Generated
+2116
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -17,6 +17,8 @@ function projects_portfolio_settings() {
if ( $settings === null ) {
$settings = [
'github_api_token' => get_option( 'projects_portfolio_github_api_token', '' ),
'gitea_api_token' => get_option( 'projects_portfolio_gitea_api_token', '' ),
'default_gitea_base_url' => get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' ),
'share_telemetry' => get_option( 'projects_portfolio_share_telemetry', '0' ),
'templates' => [
'version' => get_option( 'projects_portfolio_templates_version', '0' ),
@@ -41,6 +43,67 @@ function projects_portfolio_settings() {
return $settings;
}
/**
* Fetch normalized repository data for a project.
*
* @since 1.1.0
* @param int $post_id
* @return array|null
*/
function projects_portfolio_get_repo_data( int $post_id ): ?array {
return projects_portfolio_get_provider( $post_id )->get_repo_data();
}
/**
* Resolve the latest release ZIP download URL for a project.
*
* @since 1.1.0
* @param int $post_id
* @return string|null
*/
function projects_portfolio_get_release_url( int $post_id ): ?string {
return projects_portfolio_get_provider( $post_id )->get_release_url();
}
/**
* Fetch the latest tag name for a project.
*
* @since 1.1.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_version( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_latest_version();
}
/**
* Fetch the repo owner profile data for a project.
*
* @since 1.1.0
* @param int $post_id
* @return array|null
*/
function projects_portfolio_get_owner( int $post_id ): ?array {
$provider = projects_portfolio_get_provider( $post_id );
$repo = $provider->get_repo_data();
$login = $repo['owner']['login'] ?? '';
if ( '' === $login ) {
return null;
}
return $provider->get_owner_data( $login );
}
/**
* Public browse URL for the project's repo.
*
* @since 1.1.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_repo_browse_url( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
}
/**
* GitHub API call for owner data
*
@@ -50,6 +113,8 @@ function projects_portfolio_settings() {
* @return mixed
*/
function projects_portfolio_github_owner( $owner_name = NULL ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_owner( $post_id )' );
if ( ! $owner_name ) { return; }
$owner = 'https://api.github.com/users/' . $owner_name;
@@ -87,6 +152,8 @@ function projects_portfolio_github_owner( $owner_name = NULL ) {
* @return mixed
*/
function projects_portfolio_get_github_release_url( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_release_url( $post_id )' );
$api_token = get_option( 'projects_portfolio_github_api_token', '' );
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
@@ -126,6 +193,8 @@ function projects_portfolio_get_github_release_url( $github_url ) {
* @return array|null Associative array of GitHub repo data on success, or null on failure.
*/
function projects_portfolio_get_github_data( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_repo_data( $post_id )' );
if ( empty( $github_url ) ) {
error_log( 'GitHub URL is empty.' );
return null;
@@ -186,6 +255,8 @@ function projects_portfolio_get_github_data( $github_url ) {
* @return string The version number or 'Unknown'.
*/
function projects_portfolio_get_version_from_github( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_version( $post_id )' );
if ( empty( $github_url ) ) {
return 'Unknown';
}
+229
View File
@@ -0,0 +1,229 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Gitea adapter for the Repository_Provider interface.
*
* Endpoint base: {base_url}/api/v1
* - repo: {base_url}/api/v1/repos/{owner}/{repo}
* - release: {base_url}/api/v1/repos/{owner}/{repo}/releases/latest (returns array)
* - user: {base_url}/api/v1/users/{login}
* - archive: {base_url}/{owner}/{repo}/archive/refs/tags/{tag}.zip
*
* @since 1.1.0
*/
class Gitea_Provider implements Repository_Provider {
private string $repo_url;
private string $base_url;
private string $api_token;
public function __construct( string $repo_url, string $base_url, string $api_token = '' ) {
$this->repo_url = $repo_url;
$this->base_url = rtrim( $base_url, '/' );
$this->api_token = $api_token;
}
public function get_id(): string {
return 'gitea';
}
public function get_label(): string {
return 'Gitea';
}
public function get_repo_data(): ?array {
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path();
if ( empty( $api_url ) ) {
error_log( 'Projects Portfolio: Gitea repo URL is empty.' );
return null;
}
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
$cached_data = get_transient( $cache_key );
if ( $cached_data ) {
return $cached_data;
}
$headers = [ 'Accept' => 'application/json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
} else {
error_log( 'Projects Portfolio: Gitea API token is missing. Using unauthenticated requests.' );
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: Gitea API error: Received status ' . $code );
return null;
}
$raw = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $raw ) || ! is_array( $raw ) ) {
error_log( 'Projects Portfolio: Gitea API error: Invalid data received.' );
return null;
}
$owner = $raw['owner'] ?? [];
$owner_login = $owner['login'] ?? $this->owner_login_from_path();
$data = [
'owner' => [
'avatar_url' => $owner['avatar_url'] ?? '',
'login' => $owner_login,
'html_url' => $owner['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
],
'updated_at' => $raw['updated_at'] ?? '',
'language' => $raw['language'] ?? '',
'license' => $this->normalize_license( $raw['license'] ?? null ),
'stargazers_count' => (int) ( $raw['stars_count'] ?? 0 ),
'forks_count' => (int) ( $raw['forks_count'] ?? 0 ),
'open_issues_count' => (int) ( $raw['open_issues_count'] ?? 0 ),
];
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
public function get_release_url(): ?string {
$release = $this->fetch_latest_release();
if ( null === $release ) {
return null;
}
$assets = $release['assets'] ?? [];
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
$url = $asset['browser_download_url'] ?? null;
if ( $url ) {
return $url;
}
}
}
}
$tag = $release['tag_name'] ?? '';
if ( '' === $tag ) {
return null;
}
return $this->base_url . '/' . $this->owner_repo_path() . '/archive/refs/tags/' . $tag . '.zip';
}
public function get_latest_version(): string {
$release = $this->fetch_latest_release();
if ( null === $release ) {
return 'Unknown';
}
return isset( $release['tag_name'] ) ? (string) $release['tag_name'] : 'Unknown';
}
public function get_repo_browse_url(): string {
return $this->base_url . '/' . $this->owner_repo_path();
}
public function get_owner_data( string $owner_login ): ?array {
if ( empty( $owner_login ) ) {
return null;
}
$api_url = $this->base_url . '/api/v1/users/' . rawurlencode( $owner_login );
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return null;
}
return [
'avatar_url' => $data['avatar_url'] ?? '',
'login' => $data['login'] ?? $owner_login,
'html_url' => $data['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
];
}
/**
* Returns the first release object or null on failure / empty response.
* Gitea's /releases/latest returns an array.
*/
private function fetch_latest_release(): ?array {
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path() . '/releases/latest';
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: Gitea releases/latest returned status ' . $code );
return null;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) || empty( $body ) ) {
return null;
}
$release = $body[0];
set_transient( $cache_key, $release, HOUR_IN_SECONDS );
return $release;
}
private function auth_headers(): array {
$headers = [ 'Accept' => 'application/json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
}
return $headers;
}
private function owner_repo_path(): string {
// Strip the base URL prefix if present, return "owner/repo".
$path = $this->repo_url;
if ( '' !== $this->base_url && 0 === strpos( $path, $this->base_url ) ) {
$path = substr( $path, strlen( $this->base_url ) );
}
$path = ltrim( $path, '/' );
// Drop trailing slashes and any "/archive/...", "/releases/...", etc.
$path = preg_replace( '#/(archive|releases|tree|blob|issues|pulls|src|commits|wiki)(/.*)?$#', '', $path );
return trim( $path, '/' );
}
private function owner_login_from_path(): string {
$parts = explode( '/', $this->owner_repo_path() );
return $parts[0] ?? '';
}
private function normalize_license( $license ): array {
if ( is_array( $license ) ) {
$name = $license['name'] ?? '';
return [ 'name' => '' !== $name ? $name : 'None' ];
}
if ( is_string( $license ) && '' !== $license ) {
return [ 'name' => $license ];
}
return [ 'name' => 'None' ];
}
}
@@ -0,0 +1,195 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* GitHub adapter for the Repository_Provider interface.
*
* Preserves the GitHub URL/header/asset behavior from the original
* helper-functions.php verbatim.
*
* @since 1.1.0
*/
class GitHub_Provider implements Repository_Provider {
private string $repo_url;
private string $api_token;
public function __construct( string $repo_url, string $api_token = '' ) {
$this->repo_url = rtrim( $repo_url, '/' );
$this->api_token = $api_token;
}
public function get_id(): string {
return 'github';
}
public function get_label(): string {
return 'GitHub';
}
public function get_repo_data(): ?array {
if ( empty( $this->repo_url ) ) {
error_log( 'Projects Portfolio: GitHub URL is empty.' );
return null;
}
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url );
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
$cached_data = get_transient( $cache_key );
if ( $cached_data ) {
return $cached_data;
}
$headers = [ 'Accept' => 'application/vnd.github.v3+json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
} else {
error_log( 'Projects Portfolio: GitHub API token is missing. Using unauthenticated requests.' );
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: GitHub API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 403 === $code ) {
$remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
$reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
error_log( 'Projects Portfolio: GitHub API 403: Rate limit exceeded. Remaining: ' . $remaining . ' Reset at: ' . date( 'Y-m-d H:i:s', (int) $reset ) );
return null;
}
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: GitHub API error: Received status ' . $code );
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $data ) || ! is_array( $data ) ) {
error_log( 'Projects Portfolio: GitHub API error: Invalid data received.' );
return null;
}
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
public function get_release_url(): ?string {
$api_url = $this->release_api_url();
if ( null === $api_url ) {
return null;
}
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$headers = [ 'Accept' => 'application/vnd.github.v3+json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return null;
}
if ( ! empty( $data['assets'] ) && is_array( $data['assets'] ) ) {
foreach ( $data['assets'] as $asset ) {
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
$url = $asset['browser_download_url'] ?? null;
if ( $url ) {
set_transient( $cache_key, $url, HOUR_IN_SECONDS );
return $url;
}
}
}
}
$zipball = $data['zipball_url'] ?? null;
if ( $zipball ) {
set_transient( $cache_key, $zipball, HOUR_IN_SECONDS );
}
return $zipball;
}
public function get_latest_version(): string {
$api_url = $this->release_api_url();
if ( null === $api_url ) {
return 'Unknown';
}
$cache_key = 'projects_portfolio_provider_release_version_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$response = wp_remote_get( $api_url, [] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return 'Unknown';
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$tag = is_array( $data ) && isset( $data['tag_name'] ) ? (string) $data['tag_name'] : 'Unknown';
set_transient( $cache_key, $tag, HOUR_IN_SECONDS );
return $tag;
}
public function get_repo_browse_url(): string {
return $this->repo_url;
}
public function get_owner_data( string $owner_login ): ?array {
if ( empty( $owner_login ) ) {
return null;
}
$api_url = 'https://api.github.com/users/' . $owner_login;
$ctx = stream_context_create( [
'http' => [
'method' => 'GET',
'header' => "User-Agent: WORDPRESS\r\n",
],
] );
$body = @file_get_contents( $api_url, false, $ctx );
if ( false === $body ) {
error_log( 'Projects Portfolio: Error fetching data from GitHub API.' );
return null;
}
$data = json_decode( $body, true );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
error_log( 'Projects Portfolio: Error decoding JSON data: ' . json_last_error_msg() );
return null;
}
return [
'avatar_url' => $data['avatar_url'] ?? '',
'login' => $data['login'] ?? $owner_login,
'html_url' => $data['html_url'] ?? ( 'https://github.com/' . $owner_login ),
];
}
private function release_api_url(): ?string {
if ( empty( $this->repo_url ) ) {
return null;
}
return str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url ) . '/releases/latest';
}
}
@@ -0,0 +1,36 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Provider factory: selects the right adapter for a project post.
*
* @since 1.1.0
*
* @param int $post_id
* @return Repository_Provider
*/
function projects_portfolio_get_provider( int $post_id ): Repository_Provider {
$provider_meta = (string) get_post_meta( $post_id, '_projects_portfolio_provider', true );
$provider_id = '' !== $provider_meta ? $provider_meta : 'github';
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
// Lazy fallback to legacy key.
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_github_url', true );
}
if ( 'gitea' === $provider_id ) {
$base_url = (string) get_post_meta( $post_id, '_projects_portfolio_gitea_base_url', true );
if ( '' === $base_url ) {
$base_url = (string) get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
}
$token = (string) get_option( 'projects_portfolio_gitea_api_token', '' );
return new Gitea_Provider( $repo_url, $base_url, $token );
}
$token = (string) get_option( 'projects_portfolio_github_api_token', '' );
return new GitHub_Provider( $repo_url, $token );
}
@@ -0,0 +1,47 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Contract for a repository host adapter (GitHub, Gitea, ...).
*
* @since 1.1.0
*/
interface Repository_Provider {
public function get_id(): string;
public function get_label(): string;
/**
* Normalized repo metadata. Returns null on any failure.
*
* Shape:
* [
* '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,
* ]
*/
public function get_repo_data(): ?array;
/** Direct URL to the latest release zip asset, or null. */
public function get_release_url(): ?string;
/** Tag name of the latest release, or 'Unknown'. */
public function get_latest_version(): string;
/** Public URL of the repo, for the 'View Repo' button. */
public function get_repo_browse_url(): string;
/**
* Owner profile data: [ 'avatar_url', 'login', 'html_url' ].
* Returns null on failure.
*/
public function get_owner_data( string $owner_login ): ?array;
}
@@ -1,3 +1,3 @@
<?php
// generated by Poedit from projects-portfolio-es_MX.po, do not edit directly
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'es_MX','pot-creation-date'=>'2025-04-04 10:12-0400','po-revision-date'=>'2025-04-04 10:13-0400','translation-revision-date'=>'2025-04-04 10:13-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Ajustes','Version'=>'Versión','Last Updated'=>'Última Actualización','License'=>'Licencia','Language'=>'Idioma','Downloads'=>'Descargas','Forks'=>'Horquillas','Stars'=>'Estrellas','Issues'=>'Problemas','GitHub Owner'=>'Propietario de GitHub','Archive Title'=>'Título del archivo','Project Title'=>'Título del Proyecto','Project Excerpt'=>'Estracto de proyecto','Project Buttons'=>'Botones de proyecto','Projects for WordPress Settings'=>'Proyectos para la configuración de WordPress','General Settings'=>'Ajustes Generales','GitHub API Token'=>'Token de la API de GitHub','Templates Settings'=>'Configuración de plantillas','Archives Settings'=>'Ajustes de los archivos','Save Settings'=>'Guardar Ajustes','Settings saved successfully.'=>'Ajustes guardados exitosamente.','Projects'=>'Proyectos','Project'=>'Proyecto','Add New Project'=>'Agregar Proyecto','Project Types'=>'Proyectos','Project Type'=>'Tipo de Proyecto','Plugin'=>'Plugin','Theme'=>'Tema','Pattern'=>'Patron','GitHub URL'=>'GitHub URL','GitHub Repository URL:'=>'URL del repositorio GitHub:','View details'=>'Ver Detalles','More information about %s'=>'Más información sobre %s','Check for updates'=>'Checar por Actualizaciones','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'El plugin %s está actualizado.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Una nueva versión del plugin %s está disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'No se pudo determinar si las actualizaciones están disponibles para %s.','Unknown update checker status "%s"'=>'Status de actualización desconocido "%s"','There is no changelog available.'=>'No está disponible la lista de cambios.','Invalid download URL. Please check the GitHub repository.'=>'URL de descarga no válida. Compruebe el repositorio de GitHub.','Download Error'=>'Error','Download Count'=>'Descargar el recuento','0'=>'0','Download'=>'Descargar','View Project'=>'Ver Proyecto','&laquo; Previous'=>'&laquo; Anterior','Next &raquo;'=>'Siguiente &raquo;','No projects found.'=>'No se han encontrado proyectos.','Project Banner'=>'Proyecto Banner','Download Now'=>'Descargar Ahora','View on GitHub'=>'Ver en GitHub','GitHub Repository:'=>'Repositorio GitHub:','No repository linked.'=>'Ningún repositorio vinculado.','Version:'=>'Versión:','Updated:'=>'Actualizado:','License:'=>'Licencia:','Language:'=>'Idioma:','Downloads:'=>'Descargas:','Stars:'=>'Estrellas:','Forks:'=>'Horquillas:','Issues:'=>'Problemas:','Follow'=>'Seguir','Projects for WordPress®'=>'Proyectos para WordPress','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Crear un directorio de escaparate para proyectos (plugins, temas, patrones) con tipos de post personalizados, taxonomías y funcionalidad de descarga.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'es_MX','pot-creation-date'=>'2025-04-04 10:12-0400','po-revision-date'=>'2025-04-04 10:13-0400','translation-revision-date'=>'2025-04-04 10:13-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Ajustes','Version'=>'Versión','Last Updated'=>'Última Actualización','License'=>'Licencia','Language'=>'Idioma','Downloads'=>'Descargas','Forks'=>'Horquillas','Stars'=>'Estrellas','Issues'=>'Problemas','GitHub Owner'=>'Propietario de GitHub','Archive Title'=>'Título del archivo','Project Title'=>'Título del Proyecto','Project Excerpt'=>'Estracto de proyecto','Project Buttons'=>'Botones de proyecto','Projects for WordPress Settings'=>'Proyectos para la configuración de WordPress','General Settings'=>'Ajustes Generales','GitHub API Token'=>'Token de la API de GitHub','Templates Settings'=>'Configuración de plantillas','Archives Settings'=>'Ajustes de los archivos','Save Settings'=>'Guardar Ajustes','Settings saved successfully.'=>'Ajustes guardados exitosamente.','Projects'=>'Proyectos','Project'=>'Proyecto','Add New Project'=>'Agregar Proyecto','Project Types'=>'Proyectos','Project Type'=>'Tipo de Proyecto','Plugin'=>'Plugin','Theme'=>'Tema','Pattern'=>'Patron','GitHub URL'=>'GitHub URL','GitHub Repository URL:'=>'URL del repositorio GitHub:','View details'=>'Ver Detalles','More information about %s'=>'Más información sobre %s','Check for updates'=>'Checar por Actualizaciones','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'El plugin %s está actualizado.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Una nueva versión del plugin %s está disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'No se pudo determinar si las actualizaciones están disponibles para %s.','Unknown update checker status "%s"'=>'Status de actualización desconocido "%s"','There is no changelog available.'=>'No está disponible la lista de cambios.','Invalid download URL. Please check the GitHub repository.'=>'URL de descarga no válida. Compruebe el repositorio de GitHub.','Invalid download URL. Please check the repository URL.'=>'URL de descarga no válida. Compruebe el repositorio.','Repository'=>'','Provider'=>'','Gitea Base URL (optional override)'=>'','Leave blank to use the default from Settings.'=>'','View Repo'=>'','Gitea Settings'=>'','Default Gitea Base URL'=>'','Used when a Gitea project has no per-project base URL override.'=>'','Gitea API Token'=>'','Download Error'=>'Error','Download Count'=>'Descargar el recuento','0'=>'0','Download'=>'Descargar','View Project'=>'Ver Proyecto','&laquo; Previous'=>'&laquo; Anterior','Next &raquo;'=>'Siguiente &raquo;','No projects found.'=>'No se han encontrado proyectos.','Project Banner'=>'Proyecto Banner','Download Now'=>'Descargar Ahora','View on GitHub'=>'Ver en GitHub','GitHub Repository:'=>'Repositorio GitHub:','No repository linked.'=>'Ningún repositorio vinculado.','Version:'=>'Versión:','Updated:'=>'Actualizado:','License:'=>'Licencia:','Language:'=>'Idioma:','Downloads:'=>'Descargas:','Stars:'=>'Estrellas:','Forks:'=>'Horquillas:','Issues:'=>'Problemas:','Follow'=>'Seguir','Projects for WordPress®'=>'Proyectos para WordPress','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Crear un directorio de escaparate para proyectos (plugins, temas, patrones) con tipos de post personalizados, taxonomías y funcionalidad de descarga.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
+40
View File
@@ -88,6 +88,42 @@ msgstr "Ajustes Generales"
msgid "GitHub API Token"
msgstr "Token de la API de GitHub"
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:86
msgid "Templates Settings"
msgstr "Configuración de plantillas"
@@ -189,6 +225,10 @@ msgstr "No está disponible la lista de cambios."
msgid "Invalid download URL. Please check the GitHub repository."
msgstr "URL de descarga no válida. Compruebe el repositorio de GitHub."
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr "URL de descarga no válida. Compruebe el repositorio."
#: projects-portfolio.php:118
msgid "Download Error"
msgstr "Error"
+1 -1
View File
@@ -1,3 +1,3 @@
<?php
// generated by Poedit from projects-wp-fr_FR.po, do not edit directly
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'fr_FR','pot-creation-date'=>'2025-04-04 10:13-0400','po-revision-date'=>'2025-04-04 10:14-0400','translation-revision-date'=>'2025-04-04 10:14-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Paramètres','Version'=>'Version','Last Updated'=>'Dernière mise à jour','License'=>'Licence','Language'=>'Langue','Downloads'=>'Téléchargements','Forks'=>'Fourchettes','Stars'=>'Étoiles','Issues'=>'Problèmes','GitHub Owner'=>'Propriétaire de GitHub','Archive Title'=>'Archive Titre','Project Title'=>'Titre du projet','Project Excerpt'=>'Extrait du projet','Project Buttons'=>'Boutons de projet','Projects for WordPress Settings'=>'Projets pour les réglages de WordPress','General Settings'=>'Réglages généraux','GitHub API Token'=>'Token API GitHub','Templates Settings'=>'¨Paramètres des modèles','Archives Settings'=>'Paramètres des archives','Save Settings'=>'Enregistrer les paramètres','Settings saved successfully.'=>'Réglages bien enregistrés.','Projects'=>'Projets','Project'=>'Projet','Add New Project'=>'Ajouter un nouveau projet','Project Types'=>'Types de projets','Project Type'=>'Type de projet','Plugin'=>'Extension','Theme'=>'Thème','Pattern'=>'Motif','GitHub URL'=>'URL GitHub','GitHub Repository URL:'=>'URL du dépôt GitHub :','View details'=>'Voir les détails','More information about %s'=>'Plus dinformations sur %s','Check for updates'=>'Vérifier les mises à jour','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'Le plugin %s est à jour.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Une nouvelle version de l\'extension %s est disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'Impossible de déterminer si des mises à jour sont disponibles pour %s.','Unknown update checker status "%s"'=>'Mise à jour inconnue statut "%s"','There is no changelog available.'=>'Aucun registre de modifications disponible.','Invalid download URL. Please check the GitHub repository.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub.','Download Error'=>'Télécharger Erreur','Download Count'=>'Nombre de Téléchargements','0'=>'0','Download'=>'Télécharger','View Project'=>'Voir le projet','&laquo; Previous'=>'&laquo; Précédent','Next &raquo;'=>'Suivant &raquo;','No projects found.'=>'Aucun projet trouvé.','Project Banner'=>'Bannière du projet','Download Now'=>'Télécharger maintenant','View on GitHub'=>'Voir sur GitHub','GitHub Repository:'=>'Dépôt GitHub:','No repository linked.'=>'Aucun dépôt n\'est lié.','Version:'=>'Version:','Updated:'=>'Mis à jour :','License:'=>'Licence:','Language:'=>'Langue:','Downloads:'=>'Téléchargements:','Stars:'=>'Étoiles:','Forks:'=>'Forks:','Issues:'=>'Problèmes:','Follow'=>'Suivre','Projects for WordPress®'=>'Projets pour WordPress®','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Créer un répertoire de présentation pour les projets (plugins, thèmes, modèles) avec des types d\'articles personnalisés, des taxonomies et des fonctionnalités de téléchargement.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'fr_FR','pot-creation-date'=>'2025-04-04 10:13-0400','po-revision-date'=>'2025-04-04 10:14-0400','translation-revision-date'=>'2025-04-04 10:14-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Paramètres','Version'=>'Version','Last Updated'=>'Dernière mise à jour','License'=>'Licence','Language'=>'Langue','Downloads'=>'Téléchargements','Forks'=>'Fourchettes','Stars'=>'Étoiles','Issues'=>'Problèmes','GitHub Owner'=>'Propriétaire de GitHub','Archive Title'=>'Archive Titre','Project Title'=>'Titre du projet','Project Excerpt'=>'Extrait du projet','Project Buttons'=>'Boutons de projet','Projects for WordPress Settings'=>'Projets pour les réglages de WordPress','General Settings'=>'Réglages généraux','GitHub API Token'=>'Token API GitHub','Templates Settings'=>'¨Paramètres des modèles','Archives Settings'=>'Paramètres des archives','Save Settings'=>'Enregistrer les paramètres','Settings saved successfully.'=>'Réglages bien enregistrés.','Projects'=>'Projets','Project'=>'Projet','Add New Project'=>'Ajouter un nouveau projet','Project Types'=>'Types de projets','Project Type'=>'Type de projet','Plugin'=>'Extension','Theme'=>'Thème','Pattern'=>'Motif','GitHub URL'=>'URL GitHub','GitHub Repository URL:'=>'URL du dépôt GitHub :','View details'=>'Voir les détails','More information about %s'=>'Plus dinformations sur %s','Check for updates'=>'Vérifier les mises à jour','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'Le plugin %s est à jour.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Une nouvelle version de l\'extension %s est disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'Impossible de déterminer si des mises à jour sont disponibles pour %s.','Unknown update checker status "%s"'=>'Mise à jour inconnue statut "%s"','There is no changelog available.'=>'Aucun registre de modifications disponible.','Invalid download URL. Please check the GitHub repository.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub.','Invalid download URL. Please check the repository URL.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt.','Repository'=>'','Provider'=>'','Gitea Base URL (optional override)'=>'','Leave blank to use the default from Settings.'=>'','View Repo'=>'','Gitea Settings'=>'','Default Gitea Base URL'=>'','Used when a Gitea project has no per-project base URL override.'=>'','Gitea API Token'=>'','Download Error'=>'Télécharger Erreur','Download Count'=>'Nombre de Téléchargements','0'=>'0','Download'=>'Télécharger','View Project'=>'Voir le projet','&laquo; Previous'=>'&laquo; Précédent','Next &raquo;'=>'Suivant &raquo;','No projects found.'=>'Aucun projet trouvé.','Project Banner'=>'Bannière du projet','Download Now'=>'Télécharger maintenant','View on GitHub'=>'Voir sur GitHub','GitHub Repository:'=>'Dépôt GitHub:','No repository linked.'=>'Aucun dépôt n\'est lié.','Version:'=>'Version:','Updated:'=>'Mis à jour :','License:'=>'Licence:','Language:'=>'Langue:','Downloads:'=>'Téléchargements:','Stars:'=>'Étoiles:','Forks:'=>'Forks:','Issues:'=>'Problèmes:','Follow'=>'Suivre','Projects for WordPress®'=>'Projets pour WordPress®','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Créer un répertoire de présentation pour les projets (plugins, thèmes, modèles) avec des types d\'articles personnalisés, des taxonomies et des fonctionnalités de téléchargement.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
+40
View File
@@ -88,6 +88,42 @@ msgstr "Réglages généraux"
msgid "GitHub API Token"
msgstr "Token API GitHub"
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:86
msgid "Templates Settings"
msgstr "¨Paramètres des modèles"
@@ -189,6 +225,10 @@ msgstr "Aucun registre de modifications disponible."
msgid "Invalid download URL. Please check the GitHub repository."
msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub."
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt."
#: projects-portfolio.php:118
msgid "Download Error"
msgstr "Télécharger Erreur"
+40
View File
@@ -189,10 +189,50 @@ msgstr ""
msgid "Invalid download URL. Please check the GitHub repository."
msgstr ""
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr ""
#: projects-portfolio.php:118
msgid "Download Error"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: projects-portfolio.php:133
msgid "Download Count"
msgstr ""
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true">
<testsuites>
<testsuite name="projects-portfolio">
<directory suffix=".php">tests</directory>
</testsuite>
</testsuites>
</phpunit>
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -11,7 +11,7 @@
* Plugin Name: Projects Portfolio
* Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.
* Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
* Version: 1.0.0
* Version: 1.1.0
* Author: Keith Solomon
* Author URI: https://keithsolomon.net
* License: GPL-2.0+
@@ -39,13 +39,17 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
$myUpdateChecker->setBranch( 'main' );
// Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', time() );
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
// Add the required files.
require 'admin/admin-settings.php';
require 'admin/cpt-taxonomy.php';
require 'admin/metabox.php';
require 'includes/helper-functions.php';
require 'includes/providers/interface-repository-provider.php';
require 'includes/providers/class-github-provider.php';
require 'includes/providers/class-gitea-provider.php';
require 'includes/providers/class-provider-factory.php';
/**
* Flush rewrite rules on activation.
@@ -99,8 +103,7 @@ function projects_portfolio_handle_download_redirect() {
$project_id = get_query_var( 'project_download_id' );
if ( $project_id ) {
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$download_url = projects_portfolio_get_github_release_url( $github_url );
$download_url = projects_portfolio_get_release_url( (int) $project_id );
if ( $download_url ) {
// Increment download count.
@@ -108,12 +111,12 @@ function projects_portfolio_handle_download_redirect() {
++$download_count;
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
// Redirect to the GitHub ZIP file.
// Redirect to the release ZIP on the project's host.
wp_safe_redirect( esc_url_raw( $download_url ) );
exit;
} else {
wp_die(
esc_html__( 'Invalid download URL. Please check the GitHub repository.', 'projects-wp' ),
esc_html__( 'Invalid download URL. Please check the repository URL.', 'projects-wp' ),
esc_html__( 'Download Error', 'projects-wp' ),
array( 'response' => 404 )
);
+271
View File
@@ -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.
+27 -76
View File
@@ -12,73 +12,24 @@ if ( $is_fse_theme ) {
$settings = projects_portfolio_settings();
$project_id = get_the_ID();
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$version = 'Unknown';
$github_data = projects_portfolio_get_github_data( $github_url );
// Extract specific GitHub data.
$owner_avatar = $github_data['owner']['avatar_url'] ?? '';
$owner_name = $github_data['owner']['login'] ?? '';
$owner_url = $github_data['owner']['html_url'] ?? '';
$last_updated = $github_data['updated_at'] ?? '';
$language = $github_data['language'] ?? '';
$license = $github_data['license']['name'] ?? 'None';
$owner = projects_portfolio_github_owner( $owner_name );
// Fetch the version number from the GitHub API if the URL is set.
if ( $github_url ) {
// Retrieve the GitHub API token from the plugin settings.
$github_token = get_option( 'projects_portfolio_github_api_token', '' );
// Prepare the API URL.
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
// Check if the response is cached.
$transient_key = 'github_api_response_' . md5( $api_url );
$response = get_transient( $transient_key );
if ( false === $response ) {
// Prepare the request arguments.
$args = [];
if ( ! empty( $github_token ) ) {
$args['headers'] = [
'Authorization' => 'token ' . $github_token,
];
}
// Make the API request.
$response = wp_remote_get( $api_url, $args );
// Cache the response for 1 hour if the request is successful.
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
set_transient( $transient_key, $response, HOUR_IN_SECONDS );
}
}
// Handle the response.
if ( is_wp_error( $response ) ) {
error_log( 'GitHub API error: ' . $response->get_error_message() );
} else {
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
error_log( 'GitHub API response code: ' . $response_code );
error_log( 'GitHub API response body: ' . $response_body );
if ( $response_code === 200 ) {
$data = json_decode( $response_body, true );
// Check if tag_name exists in the response.
if ( isset( $data['tag_name'] ) ) {
$version = esc_html( $data['tag_name'] );
}
} else {
error_log( "GitHub API error: HTTP $response_code for URL $api_url" );
}
}
$repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
}
// Format last updated date.
$repo_data = projects_portfolio_get_repo_data( $project_id );
$version = projects_portfolio_get_version( $project_id );
$owner = projects_portfolio_get_owner( $project_id );
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
$owner_name = $repo_data['owner']['login'] ?? '';
$owner_url = $repo_data['owner']['html_url'] ?? '';
$last_updated = $repo_data['updated_at'] ?? '';
$language = $repo_data['language'] ?? '';
$license = $repo_data['license']['name'] ?? 'None';
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
if ( $last_updated ) {
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
}
@@ -117,12 +68,12 @@ if ( $last_updated ) {
<a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button">
<?php esc_html_e( 'Download Now', 'projects-wp' ); ?>
</a>
<?php if ( $github_url ) : ?>
<a href="<?php echo esc_url( $github_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View on GitHub', 'projects-wp' ); ?>
<?php if ( $repo_url ) : ?>
<a href="<?php echo esc_url( $browse_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
</a>
<?php else : ?>
<p><?php esc_html_e( 'GitHub Repository:', 'projects-wp' ); ?> <?php esc_html_e( 'No repository linked.', 'projects-wp' ); ?></p>
<p><?php esc_html_e( 'Repository:', 'projects-wp' ); ?> <?php esc_html_e( 'No repository linked.', 'projects-wp' ); ?></p>
<?php endif; ?>
</div>
@@ -163,23 +114,23 @@ if ( $last_updated ) {
</tr>
<?php endif; ?>
<?php
if ( ! empty( $github_data ) ) :
if ( isset( $github_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?>
if ( ! empty( $repo_data ) ) :
if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?>
<tr>
<th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['stargazers_count'] ); ?></td>
<td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td>
</tr>
<?php endif;
if ( isset( $github_data['forks_count'] ) && $settings['templates']['forks'] ) : ?>
if ( isset( $repo_data['forks_count'] ) && $settings['templates']['forks'] ) : ?>
<tr>
<th><?php esc_html_e( 'Forks:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['forks_count'] ); ?></td>
<td><?php esc_html_e( $repo_data['forks_count'] ); ?></td>
</tr>
<?php endif;
if ( isset( $github_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?>
if ( isset( $repo_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?>
<tr>
<th><?php esc_html_e( 'Issues:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['open_issues_count'] ); ?></td>
<td><?php esc_html_e( $repo_data['open_issues_count'] ); ?></td>
</tr>
<?php endif;
endif;
@@ -189,7 +140,7 @@ if ( $last_updated ) {
</div>
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
<a href="<?php esc_html_e( $github_url ); ?>">
<a href="<?php echo esc_url( $browse_url ); ?>">
<div class="project-owner">
<span>
<img src="<?php echo esc_url( $owner_avatar ); ?>" alt="<?php esc_attr_e( $owner_name ); ?>" class="owner-avatar" style="width: 50px; height: 50px; border-radius: 50%;" />
+21
View File
@@ -0,0 +1,21 @@
<?php
// Load Composer dev autoload (PHPUnit + Brain\Monkey + polyfills).
$autoload = dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! file_exists( $autoload ) ) {
fwrite( STDERR, "Run `composer install` before running tests.\n" );
exit( 1 );
}
require_once $autoload;
// Brain\Monkey sets up minimal function stubs (apply_filters, plugin_dir_path, etc.).
// Must run BEFORE the WP function stubs in wp-stubs.php are loaded, because Patchwork
// (used by Brain\Monkey) cannot redefine functions defined in the same file/import
// chain that loaded Patchwork itself. Loading the stubs in a separate file imported
// after Brain\Monkey\setUp() lets Patchwork track them properly.
\Brain\Monkey\setUp();
require_once __DIR__ . '/wp-stubs.php';
register_shutdown_function( function () {
\Brain\Monkey\tearDown();
} );
+177
View File
@@ -0,0 +1,177 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
/**
* @covers \Gitea_Provider
*/
class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $base = 'https://codeberg.org', string $token = '' ): Gitea_Provider {
return new Gitea_Provider( 'https://codeberg.org/owner/repo', $base, $token );
}
public function test_get_id_and_label(): void {
$p = $this->make();
$this->assertSame( 'gitea', $p->get_id() );
$this->assertSame( 'Gitea', $p->get_label() );
}
public function test_repo_data_normalizes_gitea_fields(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://codeberg.org/api/v1/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [
'avatar_url' => 'https://codeberg.org/avatars/owner',
'login' => 'owner',
'html_url' => 'https://codeberg.org/owner',
],
'updated_at' => '2026-02-03T04:05:06Z',
'language' => 'PHP',
'license' => [ 'name' => 'MIT' ],
'stars_count' => 100,
'forks_count' => 10,
'open_issues_count' => 5,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 100, $data['stargazers_count'] );
$this->assertSame( 10, $data['forks_count'] );
$this->assertSame( 5, $data['open_issues_count'] );
$this->assertSame( 'MIT', $data['license']['name'] );
$this->assertSame( 'owner', $data['owner']['login'] );
}
public function test_repo_data_handles_string_license(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [],
'stars_count' => 0,
'forks_count' => 0,
'open_issues_count' => 0,
'license' => 'Apache-2.0',
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'Apache-2.0', $data['license']['name'] );
}
public function test_repo_data_handles_null_license(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [],
'stars_count' => 0,
'forks_count' => 0,
'open_issues_count' => 0,
'license' => null,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'None', $data['license']['name'] );
}
public function test_release_url_picks_zip_asset_from_first_release(): void {
$body = json_encode( [
[
'tag_name' => 'v2.0.0',
'assets' => [
[ 'name' => 'source.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$this->assertSame( 'https://example/release.zip', $this->make()->get_release_url() );
$this->assertSame( 'v2.0.0', $this->make()->get_latest_version() );
}
public function test_release_url_falls_back_to_archive_url(): void {
$body = json_encode( [
[ 'tag_name' => 'v0.1.0', 'assets' => [] ],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$this->assertSame(
'https://codeberg.org/owner/repo/archive/refs/tags/v0.1.0.zip',
$this->make()->get_release_url()
);
}
public function test_release_url_empty_array_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => '[]',
] );
$this->assertNull( $this->make()->get_release_url() );
}
public function test_repo_browse_url_is_built_from_base(): void {
$this->assertSame(
'https://codeberg.org/owner/repo',
$this->make()->get_repo_browse_url()
);
}
public function test_http_failure_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
$this->assertNull( $this->make()->get_repo_data() );
}
public function test_self_hosted_base_url_is_used(): void {
$provider = new Gitea_Provider(
'https://git.example.org/owner/repo',
'https://git.example.org',
''
);
$this->assertSame(
'https://git.example.org/owner/repo',
$provider->get_repo_browse_url()
);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
/**
* @covers \GitHub_Provider
*/
class GitHub_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $token = '' ): GitHub_Provider {
return new GitHub_Provider( 'https://github.com/owner/repo', $token );
}
public function test_get_id_and_label(): void {
$p = $this->make();
$this->assertSame( 'github', $p->get_id() );
$this->assertSame( 'GitHub', $p->get_label() );
}
public function test_get_repo_data_normalizes_response(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://api.github.com/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [
'avatar_url' => 'https://avatars.example/owner.png',
'login' => 'owner',
'html_url' => 'https://github.com/owner',
],
'updated_at' => '2026-01-02T03:04:05Z',
'language' => 'PHP',
'license' => [ 'name' => 'MIT' ],
'stargazers_count' => 42,
'forks_count' => 7,
'open_issues_count' => 3,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'owner', $data['owner']['login'] );
$this->assertSame( 42, $data['stargazers_count'] );
$this->assertSame( 'MIT', $data['license']['name'] );
}
public function test_release_url_prefers_zip_asset(): void {
$release_body = json_encode( [
'tag_name' => 'v1.2.3',
'assets' => [
[ 'name' => 'project.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
[ 'name' => 'project.zip', 'browser_download_url' => 'https://example/x.zip' ],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice()
->with( 'https://api.github.com/repos/owner/repo/releases/latest', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $release_body,
] );
$this->assertSame( 'https://example/x.zip', $this->make()->get_release_url() );
$this->assertSame( 'v1.2.3', $this->make()->get_latest_version() );
}
public function test_release_url_falls_back_to_zipball(): void {
$release_body = json_encode( [
'tag_name' => 'v0.0.1',
'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v0.0.1',
'assets' => [],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $release_body,
] );
$this->assertSame(
'https://api.github.com/repos/owner/repo/zipball/v0.0.1',
$this->make()->get_release_url()
);
}
public function test_repo_browse_url_returns_input(): void {
$this->assertSame(
'https://github.com/owner/repo',
$this->make()->get_repo_browse_url()
);
}
public function test_http_failure_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
$this->assertNull( $this->make()->get_repo_data() );
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
require_once __DIR__ . '/../admin/metabox.php';
/**
* @covers \projects_portfolio_save_meta_box
*/
class Metabox_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
$_POST = [];
\Brain\Monkey\tearDown();
}
/** @var array<string,string> */
private array $saved = [];
protected function setUpSaveStubs(): void {
$this->saved = [];
\Brain\Monkey\Functions\stubs( [
'update_post_meta' => function ( $post_id, $key, $value ) {
$this->saved[ $key ] = $value;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
}
public function test_save_persists_provider_and_repo_url(): void {
$this->setUpSaveStubs();
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'gitea';
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
$_POST['projects_portfolio_gitea_base_url'] = 'https://codeberg.org';
projects_portfolio_save_meta_box( 42 );
$this->assertSame( 'gitea', $this->saved['_projects_portfolio_provider'] );
$this->assertSame( 'https://codeberg.org/owner/repo', $this->saved['_projects_portfolio_repo_url'] );
$this->assertSame( 'https://codeberg.org', $this->saved['_projects_portfolio_gitea_base_url'] );
}
public function test_lazy_migration_from_legacy_url(): void {
$existing = [
'_projects_portfolio_provider' => '',
'_projects_portfolio_repo_url' => '',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
// No $_POST provider/repo_url. Lazy migration should fire.
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
projects_portfolio_save_meta_box( 1 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
}
public function test_switching_to_gitea_deletes_legacy_github_url(): void {
$existing = [
'_projects_portfolio_provider' => 'github',
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
$deleted = [];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
unset( $existing[ $key ] );
$deleted[] = $key;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'gitea';
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
projects_portfolio_save_meta_box( 7 );
$this->assertSame( 'gitea', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://codeberg.org/owner/repo', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
public function test_github_with_empty_repo_url_deletes_legacy_github_url(): void {
$existing = [
'_projects_portfolio_provider' => 'github',
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
$deleted = [];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
unset( $existing[ $key ] );
$deleted[] = $key;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'github';
$_POST['projects_portfolio_repo_url'] = '';
projects_portfolio_save_meta_box( 9 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( '', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
require_once __DIR__ . '/../includes/providers/class-provider-factory.php';
/**
* @covers \projects_portfolio_get_provider
*/
class Provider_Factory_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function stub_meta( array $provider_meta, array $options = [] ): void {
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( $provider_meta ) {
return $provider_meta[ $key ] ?? '';
},
'get_option' => function ( $key, $default = '' ) use ( $options ) {
return $options[ $key ] ?? $default;
},
] );
}
public function test_defaults_to_github_when_provider_meta_missing(): void {
$this->stub_meta( [
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
] );
$provider = projects_portfolio_get_provider( 42 );
$this->assertSame( 'github', $provider->get_id() );
$this->assertSame( 'https://github.com/owner/repo', $provider->get_repo_browse_url() );
}
public function test_uses_repo_url_when_provider_explicit(): void {
$this->stub_meta( [
'_projects_portfolio_provider' => 'gitea',
'_projects_portfolio_repo_url' => 'https://codeberg.org/owner/repo',
], [
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
'projects_portfolio_gitea_api_token' => 'gitea-token',
] );
$provider = projects_portfolio_get_provider( 7 );
$this->assertSame( 'gitea', $provider->get_id() );
}
public function test_per_project_gitea_base_overrides_global(): void {
$this->stub_meta( [
'_projects_portfolio_provider' => 'gitea',
'_projects_portfolio_repo_url' => 'https://git.example.org/owner/repo',
'_projects_portfolio_gitea_base_url' => 'https://git.example.org',
], [
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
] );
$provider = projects_portfolio_get_provider( 7 );
$this->assertSame( 'https://git.example.org/owner/repo', $provider->get_repo_browse_url() );
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
require_once __DIR__ . '/../includes/helper-functions.php';
/**
* @covers \projects_portfolio_settings
*/
class Settings_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
public function test_settings_aggregator_includes_gitea_keys(): void {
\Brain\Monkey\Functions\stubs( [
'get_option' => function ( $key, $default = '' ) {
$values = [
'projects_portfolio_default_gitea_base_url' => 'https://git.example.org',
'projects_portfolio_gitea_api_token' => 'secret',
];
return $values[ $key ] ?? $default;
},
] );
$settings = projects_portfolio_settings();
$this->assertSame( 'https://git.example.org', $settings['default_gitea_base_url'] );
$this->assertSame( 'secret', $settings['gitea_api_token'] );
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Brain\Monkey\Actions {
if ( ! function_exists( __NAMESPACE__ . '\\anyArgs' ) && function_exists( 'anyArgs' ) ) {
function anyArgs( ...$args ) { return \anyArgs( ...$args ); }
}
}
namespace {
// Shim: ensure Mockery's anyArgs() helper is loaded.
if ( ! function_exists( 'anyArgs' ) ) {
require_once dirname( __DIR__ ) . '/vendor/mockery/mockery/library/helpers.php';
}
// Define WP constants the helpers rely on.
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 3600 );
}
if ( ! defined( 'WPINC' ) ) {
define( 'WPINC', 'wp-includes' );
}
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
// Provide minimal WordPress function stubs used by the providers under test.
if ( ! function_exists( 'get_option' ) ) {
function get_option( $key, $default = false ) { return $default; }
}
if ( ! function_exists( 'get_transient' ) ) {
function get_transient( $key ) { return false; }
}
if ( ! function_exists( 'set_transient' ) ) {
function set_transient( $key, $value, $expiration ) { return true; }
}
if ( ! function_exists( 'delete_transient' ) ) {
function delete_transient( $key ) { return true; }
}
if ( ! function_exists( 'wp_remote_get' ) ) {
function wp_remote_get( $url, $args = [] ) { return new \WP_Error( 'no_http', 'no http' ); }
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ) { return $thing instanceof \WP_Error; }
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( $response ) {
if ( $response instanceof \WP_Error ) { return 0; }
return $response['response']['code'] ?? 0;
}
}
if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
function wp_remote_retrieve_body( $response ) {
if ( $response instanceof \WP_Error ) { return ''; }
return $response['body'] ?? '';
}
}
if ( ! function_exists( 'wp_remote_retrieve_header' ) ) {
function wp_remote_retrieve_header( $response, $header ) {
if ( $response instanceof \WP_Error ) { return ''; }
$headers = $response['headers'] ?? [];
$key = strtolower( $header );
return $headers[ $key ] ?? '';
}
}
if ( ! function_exists( 'error_log' ) ) {
function error_log( $message ) { /* swallow during tests */ }
}
if ( ! function_exists( 'get_post_meta' ) ) {
function get_post_meta( $post_id, $key = '', $single = false ) { return ''; }
}
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( $post_id, $key, $value ) { return true; }
}
if ( ! function_exists( 'delete_post_meta' ) ) {
function delete_post_meta( $post_id, $key ) { return true; }
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $value ) { return $value; }
}
if ( ! function_exists( 'esc_url_raw' ) ) {
function esc_url_raw( $url ) { return $url; }
}
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private $code;
private $message;
public function __construct( $code = '', $message = '' ) {
$this->code = $code;
$this->message = $message;
}
public function get_error_code() { return $this->code; }
public function get_error_message() { return $this->message; }
}
}
}