Add REST API endpoints for repo folder listing and page pushing

- Implemented `GET /repo-folders` to list available sub-folders in the configured repository.
- Added `POST /push` to push a single page to the repository.
- Introduced `POST /push-all` to push all synced pages back to the repository.
- Enhanced `oribi_sync_rest_sync` to push local changes after pulling, except during dry runs.
- Created `oribi_sync_push_page` and `oribi_sync_push_all` functions to handle page pushing logic.
- Updated post meta on successful pushes to track last push time and SHA.
- Added logging for push actions and errors.

Enhance sync engine to support theme file synchronization

- Added functionality to auto-apply changed theme files from the repository's theme directory.
- Created `oribi_sync_apply_theme_files` to handle theme file updates during sync.
- Ensured the existence of a minimal theme structure in the `ots-theme` directory.

Refactor uninstall process to clean up additional post meta

- Updated `uninstall.php` to remove new post meta related to push operations.
- Ensured comprehensive cleanup of options and metadata upon plugin uninstallation.

Introduce push client for handling page pushes to Gitea

- Created `push-client.php` to encapsulate logic for pushing pages back to the Git repository.
- Implemented conflict resolution by creating branches and opening pull requests when necessary.
- Added helper functions for authenticated API requests to Gitea.
This commit is contained in:
Matt Batchelder
2026-02-20 21:03:48 -05:00
parent f528f21573
commit d2228ed0fb
9 changed files with 1144 additions and 115 deletions

530
includes/push-client.php Normal file
View File

@@ -0,0 +1,530 @@
<?php
/**
* Oribi Sync — Push client.
*
* Writes page content back to the Git repository.
* On SHA conflict (remote file changed since last sync), creates a branch
* named oribi-sync/{slug}-{timestamp} and opens a pull request.
*
* Currently supports Gitea / Forgejo.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Auto-push on page save ──────────────────────────────────────────────────
add_action( 'save_post_page', 'oribi_sync_maybe_push_on_save', 20, 3 );
/**
* Push a synced page to the repo whenever it is saved.
*
* Only fires for pages that were previously pulled (have _oribi_sync_checksum
* meta), skips autosaves, revisions, and non-publish statuses.
* Uses a static guard to prevent re-entry when we update post meta after push.
*/
function oribi_sync_maybe_push_on_save( int $post_id, WP_Post $post, bool $update ): void {
// Guard: only on genuine content saves
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
if ( wp_is_post_revision( $post_id ) ) return;
if ( $post->post_status !== 'publish' ) return;
// Guard: only pages that came from a sync (have checksum meta)
$checksum = get_post_meta( $post_id, '_oribi_sync_checksum', true );
if ( empty( $checksum ) ) return;
// Guard: prevent re-entry when push updates meta on the same post
static $pushing = [];
if ( isset( $pushing[ $post_id ] ) ) return;
$pushing[ $post_id ] = true;
oribi_sync_push_page( $post_id );
unset( $pushing[ $post_id ] );
}
// ─── Generic authenticated request helpers ────────────────────────────────────
/**
* Perform an authenticated POST request to the Git API.
*
* @param string $url Full API URL.
* @param array $body Body payload (will be JSON-encoded).
* @param string $provider Provider key.
* @param string $pat Personal access token.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_post( string $url, array $body, string $provider, string $pat ) {
return oribi_sync_api_request( 'POST', $url, $body, $provider, $pat );
}
/**
* Perform an authenticated PUT request to the Git API.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_put( string $url, array $body, string $provider, string $pat ) {
return oribi_sync_api_request( 'PUT', $url, $body, $provider, $pat );
}
/**
* Perform an authenticated DELETE request to the Git API.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_delete( string $url, array $body, string $provider, string $pat ) {
return oribi_sync_api_request( 'DELETE', $url, $body, $provider, $pat );
}
/**
* Internal: send an authenticated JSON request.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_request( string $method, string $url, array $body, string $provider, string $pat ) {
$headers = array_merge(
oribi_sync_auth_headers( $provider, $pat ),
[
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
]
);
$args = [
'method' => $method,
'timeout' => 30,
'headers' => $headers,
'body' => wp_json_encode( $body ),
];
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$raw_body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw_body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
$decoded = $raw_body;
}
return [ 'code' => $code, 'body' => $decoded ];
}
// ─── Gitea file metadata ──────────────────────────────────────────────────────
/**
* Get file metadata (including current SHA) from the Gitea API.
*
* @param string $api_base Gitea API base (e.g. https://host/api/v1/repos/owner/repo).
* @param string $branch Branch name.
* @param string $filepath Repo-relative file path.
* @param string $pat Personal access token.
*
* @return array{sha: string, content: string}|null|WP_Error
* null if file does not exist (404), WP_Error on failure.
*/
function oribi_sync_gitea_get_file_meta( string $api_base, string $branch, string $filepath, string $pat ) {
$encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) );
$url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
$result = oribi_sync_api_get( $url, 'gitea', $pat );
if ( is_wp_error( $result ) ) {
$msg = $result->get_error_message();
// 404 means file doesn't exist yet — not an error
if ( strpos( $msg, 'HTTP 404' ) !== false ) {
return null;
}
return $result;
}
return [
'sha' => $result['sha'] ?? '',
'content' => isset( $result['content'] ) ? base64_decode( $result['content'] ) : '',
];
}
// ─── Gitea file create / update ───────────────────────────────────────────────
/**
* Create or update a file in a Gitea repository.
*
* @param string $api_base API base URL.
* @param string $branch Target branch.
* @param string $filepath Repo-relative path.
* @param string $content Raw file content (will be base64-encoded).
* @param string $pat Personal access token.
* @param string|null $sha Current file SHA (required for updates; null for creates).
* @param string $message Commit message.
*
* @return array{code: int, body: array}|WP_Error
*/
function oribi_sync_gitea_put_file(
string $api_base,
string $branch,
string $filepath,
string $content,
string $pat,
?string $sha = null,
string $message = ''
) {
$encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) );
$url = $api_base . '/contents/' . $encoded_path;
$body = [
'content' => base64_encode( $content ),
'branch' => $branch,
'message' => $message ?: 'Update ' . basename( $filepath ),
];
if ( $sha !== null ) {
$body['sha'] = $sha;
return oribi_sync_api_put( $url, $body, 'gitea', $pat );
}
return oribi_sync_api_post( $url, $body, 'gitea', $pat );
}
// ─── Gitea branch creation ───────────────────────────────────────────────────
/**
* Create a new branch in a Gitea repository.
*
* @return array{code: int, body: array}|WP_Error
*/
function oribi_sync_gitea_create_branch( string $api_base, string $new_branch, string $base_branch, string $pat ) {
$url = $api_base . '/branches';
return oribi_sync_api_post( $url, [
'new_branch_name' => $new_branch,
'old_branch_name' => $base_branch,
], 'gitea', $pat );
}
// ─── Gitea pull request ──────────────────────────────────────────────────────
/**
* Open a pull request in a Gitea repository.
*
* @return array{code: int, body: array}|WP_Error
*/
function oribi_sync_gitea_create_pr( string $api_base, string $head, string $base, string $title, string $body_text, string $pat ) {
$url = $api_base . '/pulls';
return oribi_sync_api_post( $url, [
'title' => $title,
'body' => $body_text,
'head' => $head,
'base' => $base,
], 'gitea', $pat );
}
// ─── PHP page-data wrapper generation ─────────────────────────────────────────
/**
* Generate a PHP page-data wrapper suitable for the repo's page-data convention.
*
* The wrapper uses a nowdoc return so the file can be `include`d by
* oribi_sync_execute_php() and produce the Gutenberg block HTML.
*
* @param string $content Gutenberg block HTML (from post_content).
* @param string $slug Page slug.
* @param string $title Page title.
*
* @return string PHP source code.
*/
function oribi_sync_generate_php_wrapper( string $content, string $slug, string $title = '' ): string {
if ( empty( $title ) ) {
$title = oribi_sync_slug_to_title( $slug );
}
$date = current_time( 'Y-m-d H:i:s' );
// Use a nowdoc so the content is treated as a literal string (no interpolation).
// Escape the content if it accidentally contains the heredoc delimiter on its own line.
$delimiter = 'ORIBI_SYNC_CONTENT';
$safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $content );
$php = "<?php\n";
$php .= "/**\n";
$php .= " * Page: {$title}\n";
$php .= " * Slug: {$slug}\n";
$php .= " * Pushed by Oribi Tech Sync on {$date}.\n";
$php .= " */\n\n";
$php .= "return <<<'{$delimiter}'\n";
$php .= $safe_content . "\n";
$php .= $delimiter . ";\n";
return $php;
}
// ─── Push orchestrator ───────────────────────────────────────────────────────
/**
* Push a single WordPress page back to the Git repository.
*
* Flow:
* 1. Derive repo path from post meta (_oribi_sync_source) or settings.
* 2. Generate PHP wrapper from page content.
* 3. Fetch remote file SHA from the Gitea contents API.
* 4. If remote SHA matches stored SHA → direct update (no conflict).
* 5. If remote SHA differs (file changed externally) → create branch + PR.
* 6. If file doesn't exist → create it on the target branch.
* 7. Update post meta on success.
*
* @param int $post_id WP post ID.
* @param array $opts Optional overrides: 'message' (commit message).
*
* @return array{ok: bool, action: string, message: string, pr_url?: string}
*/
function oribi_sync_push_page( int $post_id, array $opts = [] ): array {
$post = get_post( $post_id );
if ( ! $post || $post->post_type !== 'page' ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Post not found or not a page.' ];
}
// ── Settings ──────────────────────────────────────────────────────────
$repo_url = get_option( 'oribi_sync_repo', '' );
$branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main';
$pat = oribi_sync_get_pat();
$provider = oribi_sync_get_provider();
if ( empty( $repo_url ) || empty( $pat ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Repository URL or PAT not configured.' ];
}
if ( $provider !== 'gitea' ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Push is currently supported for Gitea / Forgejo only.' ];
}
$parsed = oribi_sync_parse_repo_url( $repo_url );
if ( is_wp_error( $parsed ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => $parsed->get_error_message() ];
}
$api_base = oribi_sync_api_base( $provider, $parsed );
// ── Determine repo path ───────────────────────────────────────────────
$source_meta = get_post_meta( $post_id, '_oribi_sync_source', true );
$repo_path = '';
if ( ! empty( $source_meta ) ) {
// Format: {repo_url}@{branch}:{path}
$colon_pos = strpos( $source_meta, ':' );
if ( $colon_pos !== false ) {
// Find the last occurrence of the pattern @branch:
$at_pos = strrpos( substr( $source_meta, 0, $colon_pos ), '@' );
if ( $at_pos !== false ) {
$repo_path = substr( $source_meta, $colon_pos + 1 );
}
}
}
if ( empty( $repo_path ) ) {
// Derive from settings + slug
$pages_folder = get_option( 'oribi_sync_pages_folder', '' );
if ( empty( $pages_folder ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Cannot determine repo path — no pages folder configured and no source meta on this page.' ];
}
$repo_path = 'Pages/' . $pages_folder . '/' . $post->post_name . '.php';
}
// ── Generate content ──────────────────────────────────────────────────
$slug = $post->post_name;
$title = $post->post_title;
$wp_content = $post->post_content;
$php_source = oribi_sync_generate_php_wrapper( $wp_content, $slug, $title );
$new_checksum = hash( 'sha256', $php_source );
$commit_msg = $opts['message'] ?? "Sync: update {$slug} from WordPress";
// ── Fetch remote file metadata ────────────────────────────────────────
$remote = oribi_sync_gitea_get_file_meta( $api_base, $branch, $repo_path, $pat );
if ( is_wp_error( $remote ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Failed to check remote file: ' . $remote->get_error_message() ];
}
$stored_sha = get_post_meta( $post_id, '_oribi_sync_git_sha', true );
// ── Decide strategy ───────────────────────────────────────────────────
if ( $remote === null ) {
// File doesn't exist in repo → create it directly
$result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, null, $commit_msg );
if ( is_wp_error( $result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Create failed: ' . $result->get_error_message() ];
}
if ( $result['code'] < 200 || $result['code'] >= 300 ) {
$err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Create failed (HTTP {$result['code']}): {$err}" ];
}
// Update post meta
$new_sha = $result['body']['content']['sha'] ?? '';
oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path );
oribi_sync_log_push( $slug, 'created', $branch );
return [ 'ok' => true, 'action' => 'created', 'message' => "Created {$repo_path} on branch {$branch}." ];
}
$remote_sha = $remote['sha'];
// Check for conflict: remote SHA differs from what we last synced
$has_conflict = ! empty( $stored_sha ) && $remote_sha !== $stored_sha;
if ( $has_conflict ) {
// ── Conflict path: branch + PR ────────────────────────────────────
$timestamp = gmdate( 'Ymd-His' );
$new_branch = 'oribi-sync/' . $slug . '-' . $timestamp;
// Create branch
$branch_result = oribi_sync_gitea_create_branch( $api_base, $new_branch, $branch, $pat );
if ( is_wp_error( $branch_result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Branch creation failed: ' . $branch_result->get_error_message() ];
}
if ( $branch_result['code'] < 200 || $branch_result['code'] >= 300 ) {
$err = is_array( $branch_result['body'] ) ? ( $branch_result['body']['message'] ?? '' ) : $branch_result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Branch creation failed (HTTP {$branch_result['code']}): {$err}" ];
}
// Fetch file meta on the new branch (same SHA as base branch initially)
$branch_remote = oribi_sync_gitea_get_file_meta( $api_base, $new_branch, $repo_path, $pat );
$branch_sha = null;
if ( ! is_wp_error( $branch_remote ) && $branch_remote !== null ) {
$branch_sha = $branch_remote['sha'];
}
// Commit to the new branch
$put_result = oribi_sync_gitea_put_file( $api_base, $new_branch, $repo_path, $php_source, $pat, $branch_sha, $commit_msg );
if ( is_wp_error( $put_result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Commit to branch failed: ' . $put_result->get_error_message() ];
}
if ( $put_result['code'] < 200 || $put_result['code'] >= 300 ) {
$err = is_array( $put_result['body'] ) ? ( $put_result['body']['message'] ?? '' ) : $put_result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Commit to branch failed (HTTP {$put_result['code']}): {$err}" ];
}
// Open PR
$pr_title = "Sync: {$slug}";
$pr_body = "Automatic push from WordPress (Oribi Tech Sync).\n\n";
$pr_body .= "**Page:** {$title} (`{$slug}`)\n";
$pr_body .= "**Commit:** {$commit_msg}\n\n";
$pr_body .= "The target branch `{$branch}` has been modified since the last sync, ";
$pr_body .= "so this change was pushed to `{$new_branch}` for review.\n\n";
$pr_body .= "Stored SHA: `{$stored_sha}`\n";
$pr_body .= "Remote SHA: `{$remote_sha}`\n";
$pr_result = oribi_sync_gitea_create_pr( $api_base, $new_branch, $branch, $pr_title, $pr_body, $pat );
if ( is_wp_error( $pr_result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'PR creation failed: ' . $pr_result->get_error_message() ];
}
$pr_url = '';
if ( $pr_result['code'] >= 200 && $pr_result['code'] < 300 && is_array( $pr_result['body'] ) ) {
$pr_url = $pr_result['body']['html_url'] ?? '';
}
// Save PR URL on the post
if ( ! empty( $pr_url ) ) {
update_post_meta( $post_id, '_oribi_sync_pr_url', $pr_url );
}
oribi_sync_log_push( $slug, 'pr_created', $new_branch, $pr_url );
return [
'ok' => true,
'action' => 'pr_created',
'message' => "Conflict detected — created PR on branch {$new_branch}.",
'pr_url' => $pr_url,
];
}
// ── No conflict: direct update ────────────────────────────────────────
$result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, $remote_sha, $commit_msg );
if ( is_wp_error( $result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Update failed: ' . $result->get_error_message() ];
}
if ( $result['code'] < 200 || $result['code'] >= 300 ) {
$err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Update failed (HTTP {$result['code']}): {$err}" ];
}
// Update post meta
$new_sha = $result['body']['content']['sha'] ?? '';
oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path );
oribi_sync_log_push( $slug, 'updated', $branch );
return [ 'ok' => true, 'action' => 'updated', 'message' => "Updated {$repo_path} on branch {$branch}." ];
}
/**
* Bulk-push all synced pages.
*
* @return array{ok: bool, results: array}
*/
function oribi_sync_push_all(): array {
$query = new WP_Query( [
'post_type' => 'page',
'post_status' => 'publish',
'meta_key' => '_oribi_sync_checksum',
'posts_per_page' => -1,
'fields' => 'ids',
] );
$results = [];
foreach ( $query->posts as $post_id ) {
$page = get_post( $post_id );
$slug = $page ? $page->post_name : "#{$post_id}";
$result = oribi_sync_push_page( (int) $post_id );
$results[] = array_merge( $result, [ 'slug' => $slug, 'post_id' => $post_id ] );
}
$all_ok = ! empty( $results ) && count( array_filter( $results, fn( $r ) => ! $r['ok'] ) ) === 0;
return [ 'ok' => $all_ok, 'results' => $results ];
}
// ─── Post meta helpers ────────────────────────────────────────────────────────
/**
* Update post meta after a successful push.
*/
function oribi_sync_update_push_meta( int $post_id, string $sha, string $checksum, string $repo_url, string $branch, string $path ): void {
if ( ! empty( $sha ) ) {
update_post_meta( $post_id, '_oribi_sync_git_sha', $sha );
}
update_post_meta( $post_id, '_oribi_sync_checksum', $checksum );
update_post_meta( $post_id, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $path );
update_post_meta( $post_id, '_oribi_sync_last_push', current_time( 'mysql' ) );
// Clear any stale PR URL on successful direct push
delete_post_meta( $post_id, '_oribi_sync_pr_url' );
}
// ─── Push log ─────────────────────────────────────────────────────────────────
/**
* Append an entry to the push log.
*/
function oribi_sync_log_push( string $slug, string $action, string $branch, string $pr_url = '' ): void {
$log = get_option( 'oribi_sync_push_log', [] );
if ( ! is_array( $log ) ) $log = [];
array_unshift( $log, [
'time' => current_time( 'mysql' ),
'slug' => $slug,
'action' => $action,
'branch' => $branch,
'pr_url' => $pr_url,
] );
// Keep last 50 entries
$log = array_slice( $log, 0, 50 );
update_option( 'oribi_sync_push_log', $log, 'no' );
}