Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1595327a80 | ||
|
|
b4b6158f2d | ||
|
|
0e56de3c56 | ||
|
|
f9b8611519 | ||
|
|
84e5119c41 | ||
|
|
67a28e0037 | ||
|
|
fb6c9d4d61 | ||
|
|
0480abe292 | ||
|
|
981c29ab39 | ||
|
|
5913c8799f | ||
|
|
d250f6b89f | ||
|
|
62a049b2c2 | ||
|
|
e25431a25d | ||
|
|
9803b497ab | ||
|
|
d8ce719433 | ||
|
|
e60a9eafb3 | ||
|
|
925ef184b9 | ||
|
|
40793cd4bb | ||
|
|
e3b27e8974 | ||
|
|
62a20ed514 | ||
|
|
28789f6295 | ||
|
|
4a9f43c850 | ||
|
|
730893c1f0 | ||
|
|
e924b4833a |
@@ -0,0 +1,58 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & publish plugin zip
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build plugin zip
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
STAGE="dist/staging"
|
||||
OUT="dist/projects-portfolio-v${VERSION}.zip"
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p "$STAGE/projects-portfolio"
|
||||
|
||||
cp projects-portfolio.php "$STAGE/projects-portfolio/"
|
||||
cp README.md "$STAGE/projects-portfolio/"
|
||||
cp LICENSE "$STAGE/projects-portfolio/"
|
||||
cp -r admin/ "$STAGE/projects-portfolio/"
|
||||
cp -r assets/ "$STAGE/projects-portfolio/"
|
||||
cp -r includes/ "$STAGE/projects-portfolio/"
|
||||
cp -r languages/ "$STAGE/projects-portfolio/"
|
||||
cp -r templates/ "$STAGE/projects-portfolio/"
|
||||
|
||||
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
|
||||
|
||||
echo "Built $OUT"
|
||||
unzip -l "$OUT"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: projects-portfolio-${{ github.ref_name }}
|
||||
path: dist/projects-portfolio-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: dist/projects-portfolio-*.zip
|
||||
@@ -2,3 +2,6 @@
|
||||
.vscode/
|
||||
docs/
|
||||
notes/
|
||||
vendor/
|
||||
phpunit.xml
|
||||
.phpunit.result.cache
|
||||
|
||||
@@ -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,18 +105,42 @@ 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**.
|
||||
|
||||
## Building a Release
|
||||
|
||||
Releases are automated via GitHub Actions. To cut a new release:
|
||||
|
||||
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
|
||||
2. Commit and push to `main`.
|
||||
3. Tag the release commit: `git tag -a v1.1.1 -m "v1.1.1 — short summary"`.
|
||||
4. Push the tag: `git push origin v1.1.1`.
|
||||
|
||||
The `.github/workflows/release.yml` workflow runs and:
|
||||
|
||||
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
|
||||
- Uploads the zip as a workflow artifact.
|
||||
- Creates (or updates) the matching GitHub Release with the zip attached.
|
||||
|
||||
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
|
||||
|
||||
## How to Zip the Plugin for GitHub Releases
|
||||
|
||||
When attaching the plugin to a GitHub release:
|
||||
|
||||
1. Zip **only the contents of the plugin folder**, not the parent folder.
|
||||
2. Ensure `projects-portfolio.php` and `/includes`, `/admin`, `/templates`, `/assets` are at the root level of the ZIP.
|
||||
3. Name the ZIP clearly (e.g. `projects-portfolio-1.0.0.zip`).
|
||||
4. Go to your GitHub repo → **Releases** → **New Release**.
|
||||
5. Tag the release with the version number (e.g., `1.0.0`).
|
||||
6. Upload your correctly structured ZIP as a release asset.
|
||||
|
||||
Your users will be redirected to this file when they use the `/download/` endpoint.
|
||||
This is now automated. See [Building a Release](#building-a-release) above — pushing a `v*` tag triggers `.github/workflows/release.yml` which builds and publishes the zip.
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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','« Previous'=>'« Anterior','Next »'=>'Siguiente »','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','« Previous'=>'« Anterior','Next »'=>'Siguiente »','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']];
|
||||
|
||||
@@ -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,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 d’informations 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','« Previous'=>'« Précédent','Next »'=>'Suivant »','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 d’informations 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','« Previous'=>'« Précédent','Next »'=>'Suivant »','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']];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -0,0 +1,293 @@
|
||||
# Release Workflow Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a GitHub Actions workflow that, on every `v*` tag push, builds `projects-portfolio-v<version>.zip` from an allowlist of runtime files and publishes it both as a workflow artifact and as an attachment to a matching GitHub release.
|
||||
|
||||
**Architecture:** Single workflow file at `.github/workflows/release.yml`. One job (`release`) on `ubuntu-latest` performs: checkout → bash build step (allowlisted `cp -r` into a staging dir, then `zip -r`) → `actions/upload-artifact@v4` → `softprops/action-gh-release@v2`. Uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission.
|
||||
|
||||
**Tech Stack:** GitHub Actions YAML, bash, `zip` CLI (preinstalled on `ubuntu-latest`), `actions/checkout@v4`, `actions/upload-artifact@v4`, `softprops/action-gh-release@v2`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Workflow file lives at `.github/workflows/release.yml`.
|
||||
- Trigger: `on.push.tags: ['v*']`. No `workflow_dispatch`.
|
||||
- Permission: `contents: write`.
|
||||
- Zip filename: `projects-portfolio-v<version>.zip` where `<version>` is `GITHUB_REF_NAME` with leading `v` stripped.
|
||||
- Zip internal layout: `projects-portfolio/` directory at zip root, with `projects-portfolio.php` inside it (WordPress install-from-zip convention).
|
||||
- Allowlisted source paths copied into staging: `projects-portfolio.php`, `README.md`, `LICENSE`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`.
|
||||
- Excluded paths (no `cp` of these): `composer.json`, `composer.lock`, `vendor/`, `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`.
|
||||
- Build step uses `set -euo pipefail` and an `unzip -l "$OUT"` debug print at the end so the action log self-documents the contents.
|
||||
- Artifact upload uses `if-no-files-found: error` so a missing zip fails the job.
|
||||
- `softprops/action-gh-release@v2` is given the tag name, release name = tag name, `generate_release_notes: true`, and `files:` pointing at the zip.
|
||||
- README gets a "Building a Release" section after "Connect Your Gitea Repo".
|
||||
- First release is `v1.1.1`: implementation includes a version bump from `1.1.0` → `1.1.1` (header `Version:` + `PROJECTS_PORTFOLIO_VERSION` constant).
|
||||
- No PHP files outside of the version bump are touched. The PHPUnit suite is unaffected.
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `.github/workflows/release.yml` — the workflow.
|
||||
|
||||
**Modified:**
|
||||
- `README.md` — adds a "Building a Release" section after the existing "Connect Your Gitea Repo" section.
|
||||
- `projects-portfolio.php` — bumps header `Version:` and `PROJECTS_PORTFOLIO_VERSION` from `1.1.0` to `1.1.1` as part of the first-release rollout.
|
||||
|
||||
No other files touched. No tests added.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add the release workflow file
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/release.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a workflow that triggers on `v*` tag push and produces a release zip + GitHub release.
|
||||
|
||||
- [ ] **Step 1: Create `.github/workflows/release.yml`**
|
||||
|
||||
Create the directory if missing, then create the file with this exact content:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & publish plugin zip
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build plugin zip
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
STAGE="dist/staging"
|
||||
OUT="dist/projects-portfolio-v${VERSION}.zip"
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p "$STAGE/projects-portfolio"
|
||||
|
||||
cp projects-portfolio.php "$STAGE/projects-portfolio/"
|
||||
cp README.md "$STAGE/projects-portfolio/"
|
||||
cp LICENSE "$STAGE/projects-portfolio/"
|
||||
cp -r admin/ "$STAGE/projects-portfolio/"
|
||||
cp -r assets/ "$STAGE/projects-portfolio/"
|
||||
cp -r includes/ "$STAGE/projects-portfolio/"
|
||||
cp -r languages/ "$STAGE/projects-portfolio/"
|
||||
cp -r templates/ "$STAGE/projects-portfolio/"
|
||||
|
||||
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
|
||||
|
||||
echo "Built $OUT"
|
||||
unzip -l "$OUT"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: projects-portfolio-v${{ github.ref_name }}
|
||||
path: dist/projects-portfolio-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: dist/projects-portfolio-*.zip
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate YAML syntax locally**
|
||||
|
||||
Run from the project root:
|
||||
```bash
|
||||
php -r "require 'vendor/autoload.php';" 2>/dev/null || true
|
||||
# PHP doesn't have a built-in YAML parser; use Python's if available
|
||||
python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml').read()); print('OK')"
|
||||
```
|
||||
|
||||
If `python` is not on PATH, use any other YAML validator you have. If none is available, skip this step — GitHub's own workflow validation will catch syntax errors on the next push.
|
||||
|
||||
Expected (if `python` is available): `OK` on stdout, exit 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/release.yml
|
||||
git commit -m "Add GitHub Actions release workflow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add "Building a Release" section to README
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a "Building a Release" section placed after the "Connect Your Gitea Repo" section (which was added in the previous feature).
|
||||
|
||||
- [ ] **Step 1: Insert the new section**
|
||||
|
||||
Find the existing `## Connect Your Gitea Repo` section in `README.md`. Locate the heading `## Building a Release` (it does not yet exist; that's the insertion target).
|
||||
|
||||
Insert the following block directly after the closing line of the Gitea repo subsection (`Paste it into the plugin settings screen under **Gitea API Token**.`) and before any subsequent `## ` heading:
|
||||
|
||||
```markdown
|
||||
|
||||
## Building a Release
|
||||
|
||||
Releases are automated via GitHub Actions. To cut a new release:
|
||||
|
||||
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
|
||||
2. Commit and push to `main`.
|
||||
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
|
||||
4. Push the tag: `git push origin v1.1.0`.
|
||||
|
||||
The `.github/workflows/release.yml` workflow runs and:
|
||||
|
||||
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
|
||||
- Uploads the zip as a workflow artifact.
|
||||
- Creates (or updates) the matching GitHub Release with the zip attached.
|
||||
|
||||
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify with grep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "Building a Release" README.md
|
||||
```
|
||||
Expected: one matching line, located after the "Connect Your Gitea Repo" section.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "Document automated release process in README"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Bump version to 1.1.1 for first automated release
|
||||
|
||||
**Files:**
|
||||
- Modify: `projects-portfolio.php` — header `Version:` and `PROJECTS_PORTFOLIO_VERSION` constant.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: in-tree version `1.1.1`, header comment line `* Version: 1.1.1`, constant `define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );`.
|
||||
|
||||
- [ ] **Step 1: Update the plugin header**
|
||||
|
||||
In `projects-portfolio.php`, in the file header comment block near the top, change:
|
||||
```
|
||||
* Version: 1.1.0
|
||||
```
|
||||
to:
|
||||
```
|
||||
* Version: 1.1.1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the version constant**
|
||||
|
||||
In `projects-portfolio.php`, find the line:
|
||||
```php
|
||||
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
|
||||
```
|
||||
Replace with:
|
||||
```php
|
||||
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test suite to confirm no regression**
|
||||
|
||||
Run: `php vendor/bin/phpunit`
|
||||
Expected: `OK (24 tests, 45 assertions)` (same as before — no PHP behavior change).
|
||||
|
||||
- [ ] **Step 4: Verify with grep**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "Version:" projects-portfolio.php | head -1
|
||||
grep -n "PROJECTS_PORTFOLIO_VERSION" projects-portfolio.php
|
||||
```
|
||||
Expected output:
|
||||
- First grep: a line containing `* Version: 1.1.1`.
|
||||
- Second grep: two lines — the `define(...)` and (possibly) usage sites. The `define` line must show `'1.1.1'`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add projects-portfolio.php
|
||||
git commit -m "Bump version to 1.1.1 for first automated release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Confirm all three commits landed**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
Expected (most recent at top): three commits from this plan are visible:
|
||||
- "Bump version to 1.1.1 for first automated release"
|
||||
- "Document automated release process in README"
|
||||
- "Add GitHub Actions release workflow"
|
||||
|
||||
- [ ] **Step 2: Confirm working tree is clean**
|
||||
|
||||
Run: `git status`
|
||||
Expected: `nothing to commit, working tree clean`.
|
||||
|
||||
- [ ] **Step 3: Run full PHPUnit suite**
|
||||
|
||||
Run: `php vendor/bin/phpunit`
|
||||
Expected: `OK (24 tests, 45 assertions)`.
|
||||
|
||||
- [ ] **Step 4: Confirm workflow file content**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
head -30 .github/workflows/release.yml
|
||||
```
|
||||
Expected: matches the YAML produced in Task 1, with `name: Release`, `on.push.tags: ['v*']`, `permissions.contents: write`, and the `release` job.
|
||||
|
||||
- [ ] **Step 5: Confirm README has the new section**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -c "Building a Release" README.md
|
||||
```
|
||||
Expected: `1`.
|
||||
|
||||
- [ ] **Step 6: Note (do NOT execute): the first automated release**
|
||||
|
||||
The user will tag `v1.1.1` and push the tag themselves, which triggers the workflow. The workflow file is in place; the in-tree version is bumped; the first release will produce a zip with this code. The implementer does NOT push the tag or the branch — that's the user's call (their existing git history is ahead of `origin/main`).
|
||||
|
||||
- [ ] **Step 7: Final commit if anything changed**
|
||||
|
||||
```bash
|
||||
git status
|
||||
# If clean, skip. Otherwise:
|
||||
git add -A
|
||||
git commit -m "Final verification fixes"
|
||||
```
|
||||
@@ -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.1
|
||||
* 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.1' );
|
||||
|
||||
// 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 )
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Release Workflow Design
|
||||
|
||||
- **Date:** 2026-08-11
|
||||
- **Plugin:** Projects Portfolio (`projects-wp`)
|
||||
- **Status:** Approved design, awaiting implementation plan
|
||||
|
||||
## 1. Goals
|
||||
|
||||
- A single tag push (`v*`) automatically produces a release-ready zip named `projects-portfolio-v<version>.zip`.
|
||||
- The zip contains exactly the files WordPress needs to install and use the plugin: `projects-portfolio.php` at the root of `projects-portfolio/`, with `admin/`, `assets/`, `includes/`, `languages/`, `templates/` subdirectories plus `README.md` and `LICENSE`.
|
||||
- The same zip is uploaded as a workflow artifact AND attached to a GitHub release matching the tag.
|
||||
- README gets a short "Building a release" section explaining how the workflow runs and what it produces.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- Publishing to WordPress.org — out of scope (different credentials, different approval workflow).
|
||||
- Cross-repo publishing or PAT-based auth.
|
||||
- Signing, cosign, or checksums for the zip.
|
||||
- Auto-bumping `PROJECTS_PORTFOLIO_VERSION` or the plugin header before a tag is cut. The workflow assumes the tag is created *after* the version has been bumped in-tree.
|
||||
- Backporting tags. The workflow only acts on the tag push, not on retags of older SHAs.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
A single GitHub Actions workflow file at `.github/workflows/release.yml` runs on every `v*` tag push. It performs four sequential steps inside a single job:
|
||||
|
||||
1. **Checkout** — `actions/checkout@v4` at the tag's commit.
|
||||
2. **Build zip** — bash script copies allowlisted runtime files into a staging directory `dist/staging/projects-portfolio/`, then runs `zip -r` from the staging root to produce `dist/projects-portfolio-v<version>.zip`.
|
||||
3. **Upload artifact** — `actions/upload-artifact@v4` with name `projects-portfolio-v<tag>` and path `dist/projects-portfolio-*.zip`. Fails if no file matches.
|
||||
4. **Create release** — `softprops/action-gh-release@v2` with the tag, generated release notes, and the zip file. Replaces the asset if a release with the same tag already exists (default `action-gh-release@v2` behavior).
|
||||
|
||||
The workflow uses the runner-provided `GITHUB_TOKEN` with `contents: write` permission. No additional secrets are needed.
|
||||
|
||||
### 3.1 Why a staging directory
|
||||
|
||||
WordPress's "Upload Plugin" zip-install expects `<plugin-slug>/<plugin-file>.php` at the zip root. A direct `zip -r` of the working tree would put everything at the zip root — no enclosing folder — which would fail to install. The staging directory ensures the zip's internal layout has `projects-portfolio/` at the top with `projects-portfolio.php` inside it.
|
||||
|
||||
### 3.2 Files added
|
||||
|
||||
- `.github/workflows/release.yml` — the workflow.
|
||||
- `README.md` — modified, with a new "Building a release" section after the existing "Connect Your Gitea Repo" section.
|
||||
|
||||
No PHP files are touched. The PHPUnit suite is unaffected (no test bootstrap or production code changes).
|
||||
|
||||
## 4. Trigger & permissions
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
```
|
||||
|
||||
`workflow_dispatch` is intentionally omitted. To re-run for an existing tag, use the GitHub UI's "Re-run all jobs" button.
|
||||
|
||||
## 5. Zip filename
|
||||
|
||||
`projects-portfolio-v<version>.zip`, where `<version>` is the tag name with the leading `v` stripped. Examples:
|
||||
|
||||
- Tag `v1.1.0` → `projects-portfolio-v1.1.0.zip`
|
||||
- Tag `v2.0.0-rc1` → `projects-portfolio-v2.0.0-rc1.zip`
|
||||
|
||||
## 6. Allowlisted zip contents
|
||||
|
||||
The build step copies these paths from the working tree into `dist/staging/projects-portfolio/`:
|
||||
|
||||
```
|
||||
projects-portfolio.php
|
||||
README.md
|
||||
LICENSE
|
||||
admin/
|
||||
assets/
|
||||
includes/
|
||||
languages/
|
||||
templates/
|
||||
```
|
||||
|
||||
The bundled `includes/plugin-update-checker/` library IS included — it's a runtime dependency used by `projects-portfolio.php`.
|
||||
|
||||
## 7. Excluded paths
|
||||
|
||||
The build step does **not** copy any of these. Most are already not tracked in git; the explicit list prevents accidental inclusion if any are added later:
|
||||
|
||||
- `composer.json`, `composer.lock` — dev-only.
|
||||
- `vendor/` — Composer dev dependencies.
|
||||
- `tests/`, `phpunit.xml`, `phpunit.xml.dist`, `.phpunit.result.cache` — test infrastructure.
|
||||
- `plans/`, `specs/` — design artifacts (not tracked, but listed for safety).
|
||||
- `.github/` — the workflow file itself (would otherwise be included by the catch-all `cp -r`).
|
||||
- `.gitignore`, `.vscode/`, `.claude/` — editor / project config (not tracked except `.gitignore`).
|
||||
|
||||
If the build script can't find a tracked source path it expects (e.g. `projects-portfolio.php` renamed), `set -e` fails the step.
|
||||
|
||||
## 8. Build step (verbatim)
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
STAGE="dist/staging"
|
||||
OUT="dist/projects-portfolio-v${VERSION}.zip"
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p "$STAGE/projects-portfolio"
|
||||
|
||||
cp projects-portfolio.php "$STAGE/projects-portfolio/"
|
||||
cp README.md "$STAGE/projects-portfolio/"
|
||||
cp LICENSE "$STAGE/projects-portfolio/"
|
||||
cp -r admin/ "$STAGE/projects-portfolio/"
|
||||
cp -r assets/ "$STAGE/projects-portfolio/"
|
||||
cp -r includes/ "$STAGE/projects-portfolio/"
|
||||
cp -r languages/ "$STAGE/projects-portfolio/"
|
||||
cp -r templates/ "$STAGE/projects-portfolio/"
|
||||
|
||||
( cd "$STAGE" && zip -r "../../$OUT" projects-portfolio )
|
||||
|
||||
echo "Built $OUT"
|
||||
unzip -l "$OUT"
|
||||
```
|
||||
|
||||
`unzip -l` prints the table of contents to the action log so the run is self-documenting.
|
||||
|
||||
## 9. README addition
|
||||
|
||||
Add a "Building a Release" section after the existing "Connect Your Gitea Repo" section:
|
||||
|
||||
```markdown
|
||||
## Building a Release
|
||||
|
||||
Releases are automated via GitHub Actions. To cut a new release:
|
||||
|
||||
1. Bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header `Version:` in `projects-portfolio.php`.
|
||||
2. Commit and push to `main`.
|
||||
3. Tag the release commit: `git tag -a v1.1.0 -m "v1.1.0 — short summary"`.
|
||||
4. Push the tag: `git push origin v1.1.0`.
|
||||
|
||||
The `.github/workflows/release.yml` workflow runs and:
|
||||
|
||||
- Builds `projects-portfolio-v<version>.zip` containing only the runtime files (`projects-portfolio.php`, `admin/`, `assets/`, `includes/`, `languages/`, `templates/`, `README.md`, `LICENSE`).
|
||||
- Uploads the zip as a workflow artifact.
|
||||
- Creates (or updates) the matching GitHub Release with the zip attached.
|
||||
|
||||
Dev-only paths (`composer.json`, `composer.lock`, `vendor/`, `tests/`, `plans/`, `specs/`, `.github/`, `.gitignore`, `.vscode/`, `.claude/`) are intentionally excluded.
|
||||
```
|
||||
|
||||
## 10. Error handling
|
||||
|
||||
| Failure | Behavior |
|
||||
|---|---|
|
||||
| Tag doesn't match `v*` | Workflow doesn't run. |
|
||||
| `cp -r` finds a missing source path | `set -e` fails the step; no zip, no release. |
|
||||
| `zip` command fails | `set -e` fails the step; no artifact upload, no release. |
|
||||
| Artifact upload finds no file | `if-no-files-found: error` fails the step. |
|
||||
| Release already exists for tag | `softprops/action-gh-release@v2` replaces the asset and updates notes (default behavior). |
|
||||
| Missing `contents: write` permission | Job fails at checkout or release step with a clear permissions error. |
|
||||
|
||||
## 11. Rollout
|
||||
|
||||
1. Implement on a feature branch.
|
||||
2. Merge into `main` (the workflow file is tracked and applies to all future tags).
|
||||
3. To produce the first automated release: bump `PROJECTS_PORTFOLIO_VERSION` and the plugin header from `1.1.0` to `1.1.1`, commit, tag `v1.1.1`, push the tag.
|
||||
4. Verify on the GitHub Actions run page that: artifact uploads, zip contents look right (via the `unzip -l` log line), GitHub release is created with the zip attached.
|
||||
|
||||
## 12. New translatable strings
|
||||
|
||||
None. The workflow file and README change introduce no user-facing strings.
|
||||
@@ -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%;" />
|
||||
|
||||
@@ -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();
|
||||
} );
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user