435 lines
18 KiB
PHP
435 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Oribi Sync — Git provider API client.
|
|
*
|
|
* Supports GitHub, GitLab, Bitbucket Cloud, Gitea / Forgejo,
|
|
* and Azure DevOps REST APIs.
|
|
* Fetches repository tree and file contents using PAT authentication.
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
|
|
|
// ─── Supported providers ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return the list of supported providers for the admin dropdown.
|
|
*/
|
|
function oribi_sync_providers(): array {
|
|
return [
|
|
'github' => 'GitHub',
|
|
'gitlab' => 'GitLab',
|
|
'bitbucket' => 'Bitbucket Cloud',
|
|
'gitea' => 'Gitea / Forgejo',
|
|
'azure' => 'Azure DevOps',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the configured provider (stored in options).
|
|
*/
|
|
function oribi_sync_get_provider(): string {
|
|
$provider = get_option( 'oribi_sync_provider', '' );
|
|
if ( ! empty( $provider ) && array_key_exists( $provider, oribi_sync_providers() ) ) {
|
|
return $provider;
|
|
}
|
|
// Auto-detect fallback for existing installs without the option
|
|
$repo = get_option( 'oribi_sync_repo', '' );
|
|
return oribi_sync_detect_provider( $repo );
|
|
}
|
|
|
|
/**
|
|
* Auto-detect the Git provider from the repo URL.
|
|
* Used as fallback when no explicit provider is set.
|
|
*
|
|
* @return string Provider key.
|
|
*/
|
|
function oribi_sync_detect_provider( string $repo_url ): string {
|
|
if ( stripos( $repo_url, 'github.com' ) !== false ) return 'github';
|
|
if ( stripos( $repo_url, 'gitlab.com' ) !== false ) return 'gitlab';
|
|
if ( stripos( $repo_url, 'gitlab' ) !== false ) return 'gitlab';
|
|
if ( stripos( $repo_url, 'bitbucket.org' ) !== false ) return 'bitbucket';
|
|
if ( stripos( $repo_url, 'dev.azure.com' ) !== false ) return 'azure';
|
|
if ( stripos( $repo_url, 'visualstudio.com' ) !== false ) return 'azure';
|
|
// Assume Gitea for unknown self-hosted (most compatible generic API)
|
|
return 'gitea';
|
|
}
|
|
|
|
// ─── URL parsing ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse owner/repo (and optionally project) from a Git URL.
|
|
*
|
|
* Accepts HTTPS and SSH styles for all providers.
|
|
*
|
|
* @return array{owner: string, repo: string, host?: string, project?: string}|WP_Error
|
|
*/
|
|
function oribi_sync_parse_repo_url( string $url ) {
|
|
// Azure DevOps: https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
if ( preg_match( '#https?://dev\.azure\.com/([^/]+)/([^/]+)/_git/([^/\s]+?)(?:\.git)?$#i', $url, $m ) ) {
|
|
return [ 'owner' => $m[1], 'project' => $m[2], 'repo' => $m[3], 'host' => 'dev.azure.com' ];
|
|
}
|
|
// Azure DevOps legacy: https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
if ( preg_match( '#https?://([^.]+)\.visualstudio\.com/([^/]+)/_git/([^/\s]+?)(?:\.git)?$#i', $url, $m ) ) {
|
|
return [ 'owner' => $m[1], 'project' => $m[2], 'repo' => $m[3], 'host' => $m[1] . '.visualstudio.com' ];
|
|
}
|
|
// Generic HTTPS: https://host/owner/repo
|
|
if ( preg_match( '#https?://([^/]+)/([^/]+)/([^/\s.]+?)(?:\.git)?(?:/)?$#i', $url, $m ) ) {
|
|
return [ 'owner' => $m[2], 'repo' => $m[3], 'host' => $m[1] ];
|
|
}
|
|
// SSH: git@host:owner/repo.git
|
|
if ( preg_match( '#[^@]+@([^:]+):([^/]+)/([^/\s.]+?)(?:\.git)?$#i', $url, $m ) ) {
|
|
return [ 'owner' => $m[2], 'repo' => $m[3], 'host' => $m[1] ];
|
|
}
|
|
return new WP_Error( 'oribi_sync_bad_url', 'Could not parse owner/repo from the repository URL.' );
|
|
}
|
|
|
|
// ─── API base URLs ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build the base API URL for the configured provider.
|
|
*/
|
|
function oribi_sync_api_base( string $provider, array $parsed ): string {
|
|
$owner = $parsed['owner'];
|
|
$repo = $parsed['repo'];
|
|
$host = $parsed['host'] ?? '';
|
|
|
|
switch ( $provider ) {
|
|
case 'gitlab':
|
|
$api_host = ( $host && $host !== 'gitlab.com' ) ? $host : 'gitlab.com';
|
|
$encoded = rawurlencode( $owner . '/' . $repo );
|
|
return "https://{$api_host}/api/v4/projects/{$encoded}";
|
|
|
|
case 'bitbucket':
|
|
return "https://api.bitbucket.org/2.0/repositories/{$owner}/{$repo}";
|
|
|
|
case 'azure':
|
|
$project = $parsed['project'] ?? $repo;
|
|
$org = $owner;
|
|
return "https://dev.azure.com/{$org}/{$project}/_apis/git/repositories/{$repo}";
|
|
|
|
case 'gitea':
|
|
// Works for Gitea, Forgejo, and most Gitea-compatible hosts
|
|
$api_host = $host ?: 'localhost:3000';
|
|
return "https://{$api_host}/api/v1/repos/{$owner}/{$repo}";
|
|
|
|
case 'github':
|
|
default:
|
|
$api_host = ( $host && $host !== 'github.com' ) ? $host . '/api/v3' : 'api.github.com';
|
|
return "https://{$api_host}/repos/{$owner}/{$repo}";
|
|
}
|
|
}
|
|
|
|
// ─── Auth headers ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build authorization headers for the provider.
|
|
*
|
|
* @return array Headers array to merge into request.
|
|
*/
|
|
function oribi_sync_auth_headers( string $provider, string $pat ): array {
|
|
switch ( $provider ) {
|
|
case 'bitbucket':
|
|
// Bitbucket Cloud app passwords use Basic auth (username:app_password)
|
|
// If PAT contains ':', treat as username:password; otherwise Bearer
|
|
if ( strpos( $pat, ':' ) !== false ) {
|
|
return [ 'Authorization' => 'Basic ' . base64_encode( $pat ) ];
|
|
}
|
|
return [ 'Authorization' => 'Bearer ' . $pat ];
|
|
|
|
case 'azure':
|
|
// Azure DevOps PATs use Basic auth with empty username
|
|
return [ 'Authorization' => 'Basic ' . base64_encode( ':' . $pat ) ];
|
|
|
|
case 'gitlab':
|
|
return [ 'PRIVATE-TOKEN' => $pat ];
|
|
|
|
case 'gitea':
|
|
return [ 'Authorization' => 'token ' . $pat ];
|
|
|
|
case 'github':
|
|
default:
|
|
return [ 'Authorization' => 'Bearer ' . $pat ];
|
|
}
|
|
}
|
|
|
|
// ─── API request ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Perform an authenticated GET request to the Git API.
|
|
*
|
|
* @return array|WP_Error Decoded JSON body or WP_Error.
|
|
*/
|
|
function oribi_sync_api_get( string $url, string $provider, string $pat ) {
|
|
$headers = array_merge(
|
|
oribi_sync_auth_headers( $provider, $pat ),
|
|
[
|
|
'Accept' => 'application/json',
|
|
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
|
|
]
|
|
);
|
|
|
|
// Provider-specific Accept overrides
|
|
if ( $provider === 'github' ) {
|
|
$headers['Accept'] = 'application/vnd.github+json';
|
|
} elseif ( $provider === 'azure' ) {
|
|
$headers['Accept'] = 'application/json';
|
|
}
|
|
|
|
$response = wp_remote_get( $url, [
|
|
'timeout' => 30,
|
|
'headers' => $headers,
|
|
] );
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
return $response;
|
|
}
|
|
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
$body = wp_remote_retrieve_body( $response );
|
|
|
|
if ( $code < 200 || $code >= 300 ) {
|
|
return new WP_Error(
|
|
'oribi_sync_api_error',
|
|
sprintf( 'Git API returned HTTP %d: %s', $code, wp_trim_words( $body, 30, '…' ) )
|
|
);
|
|
}
|
|
|
|
$decoded = json_decode( $body, true );
|
|
if ( json_last_error() !== JSON_ERROR_NONE ) {
|
|
return new WP_Error( 'oribi_sync_json_error', 'Failed to parse API JSON response.' );
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
// ─── Tree fetching ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch the repository tree (recursive) for the given branch.
|
|
*
|
|
* Returns a flat list of file entries with 'path' and 'type'.
|
|
*
|
|
* @return array|WP_Error
|
|
*/
|
|
function oribi_sync_fetch_tree( string $api_base, string $branch, string $provider, string $pat ) {
|
|
switch ( $provider ) {
|
|
|
|
// ── GitLab ──────────────────────────────────────────────────────
|
|
case 'gitlab':
|
|
$entries = [];
|
|
$page = 1;
|
|
do {
|
|
$url = $api_base . '/repository/tree?' . http_build_query( [
|
|
'ref' => $branch,
|
|
'recursive' => 'true',
|
|
'per_page' => 100,
|
|
'page' => $page,
|
|
] );
|
|
$result = oribi_sync_api_get( $url, $provider, $pat );
|
|
if ( is_wp_error( $result ) ) return $result;
|
|
if ( empty( $result ) ) break;
|
|
|
|
foreach ( $result as $item ) {
|
|
$entries[] = [
|
|
'path' => $item['path'],
|
|
'type' => $item['type'] === 'blob' ? 'blob' : $item['type'],
|
|
'sha' => $item['id'] ?? '', // GitLab uses 'id' for blob SHA
|
|
];
|
|
}
|
|
$page++;
|
|
} while ( count( $result ) === 100 );
|
|
return $entries;
|
|
|
|
// ── Bitbucket Cloud ─────────────────────────────────────────────
|
|
case 'bitbucket':
|
|
$entries = [];
|
|
$url = $api_base . '/src/' . rawurlencode( $branch ) . '/?pagelen=100&max_depth=10';
|
|
// Bitbucket returns directory listings; we recurse via 'next' links
|
|
while ( $url ) {
|
|
$result = oribi_sync_api_get( $url, $provider, $pat );
|
|
if ( is_wp_error( $result ) ) return $result;
|
|
|
|
foreach ( $result['values'] ?? [] as $item ) {
|
|
if ( ( $item['type'] ?? '' ) === 'commit_file' ) {
|
|
$entries[] = [ 'path' => $item['path'], 'type' => 'blob' ];
|
|
}
|
|
}
|
|
$url = $result['next'] ?? null;
|
|
}
|
|
return $entries;
|
|
|
|
// ── Azure DevOps ───────────────────────────────────────────────
|
|
case 'azure':
|
|
$url = $api_base . '/items?' . http_build_query( [
|
|
'recursionLevel' => 'full',
|
|
'versionDescriptor.version' => $branch,
|
|
'versionDescriptor.versionType' => 'branch',
|
|
'api-version' => '7.0',
|
|
] );
|
|
$result = oribi_sync_api_get( $url, $provider, $pat );
|
|
if ( is_wp_error( $result ) ) return $result;
|
|
|
|
$entries = [];
|
|
foreach ( $result['value'] ?? [] as $item ) {
|
|
if ( ! $item['isFolder'] ) {
|
|
// Azure paths start with '/' — strip leading slash
|
|
$path = ltrim( $item['path'], '/' );
|
|
$entries[] = [ 'path' => $path, 'type' => 'blob' ];
|
|
}
|
|
}
|
|
return $entries;
|
|
|
|
// ── Gitea / Forgejo ─────────────────────────────────────────────
|
|
case 'gitea':
|
|
$url = $api_base . '/git/trees/' . rawurlencode( $branch ) . '?recursive=true';
|
|
$result = oribi_sync_api_get( $url, $provider, $pat );
|
|
if ( is_wp_error( $result ) ) return $result;
|
|
|
|
if ( ! isset( $result['tree'] ) ) {
|
|
return new WP_Error( 'oribi_sync_tree_error', 'Unexpected tree response from Gitea.' );
|
|
}
|
|
return array_map( function ( $item ) {
|
|
return [ 'path' => $item['path'], 'type' => $item['type'], 'sha' => $item['sha'] ?? '' ];
|
|
}, $result['tree'] );
|
|
|
|
// ── GitHub (default) ───────────────────────────────────────────
|
|
case 'github':
|
|
default:
|
|
$url = $api_base . '/git/trees/' . rawurlencode( $branch ) . '?recursive=1';
|
|
$result = oribi_sync_api_get( $url, $provider, $pat );
|
|
if ( is_wp_error( $result ) ) return $result;
|
|
|
|
if ( ! isset( $result['tree'] ) ) {
|
|
return new WP_Error( 'oribi_sync_tree_error', 'Unexpected tree response structure.' );
|
|
}
|
|
return array_map( function ( $item ) {
|
|
return [ 'path' => $item['path'], 'type' => $item['type'], 'sha' => $item['sha'] ?? '' ];
|
|
}, $result['tree'] );
|
|
}
|
|
}
|
|
|
|
// ─── File fetching ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Fetch raw file content from the repository.
|
|
*
|
|
* @return string|WP_Error Raw file content.
|
|
*/
|
|
function oribi_sync_fetch_file( string $api_base, string $branch, string $file_path, string $provider, string $pat ) {
|
|
$encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $file_path ) ) );
|
|
|
|
switch ( $provider ) {
|
|
case 'gitlab':
|
|
$url = $api_base . '/repository/files/' . rawurlencode( $file_path ) . '/raw?' . http_build_query( [ 'ref' => $branch ] );
|
|
$accept = 'text/plain';
|
|
break;
|
|
|
|
case 'bitbucket':
|
|
$url = $api_base . '/src/' . rawurlencode( $branch ) . '/' . $encoded_path;
|
|
$accept = 'text/plain';
|
|
break;
|
|
|
|
case 'azure':
|
|
$url = $api_base . '/items?' . http_build_query( [
|
|
'path' => '/' . $file_path,
|
|
'versionDescriptor.version' => $branch,
|
|
'versionDescriptor.versionType' => 'branch',
|
|
'api-version' => '7.0',
|
|
'\$format' => 'octetStream',
|
|
] );
|
|
$accept = 'application/octet-stream';
|
|
break;
|
|
|
|
case 'gitea':
|
|
// Use /contents/ endpoint which returns base64-encoded content (more reliable)
|
|
$url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
|
|
$accept = 'application/json';
|
|
break;
|
|
|
|
case 'github':
|
|
default:
|
|
$url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
|
|
$accept = 'application/vnd.github.raw';
|
|
break;
|
|
}
|
|
|
|
$headers = array_merge(
|
|
oribi_sync_auth_headers( $provider, $pat ),
|
|
[
|
|
'Accept' => $accept,
|
|
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
|
|
]
|
|
);
|
|
|
|
$response = wp_remote_get( $url, [
|
|
'timeout' => 30,
|
|
'headers' => $headers,
|
|
] );
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
return $response;
|
|
}
|
|
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
if ( $code < 200 || $code >= 300 ) {
|
|
return new WP_Error(
|
|
'oribi_sync_file_error',
|
|
sprintf( 'Failed to fetch %s (HTTP %d)', $file_path, $code )
|
|
);
|
|
}
|
|
|
|
$body = wp_remote_retrieve_body( $response );
|
|
|
|
// For Gitea, the /contents/ endpoint returns base64-encoded content in JSON.
|
|
// Gitea (like GitHub) inserts \n every 60 chars in the base64 — strip them before decoding.
|
|
if ( $provider === 'gitea' ) {
|
|
$decoded = json_decode( $body, true );
|
|
if ( isset( $decoded['content'] ) && is_string( $decoded['content'] ) ) {
|
|
$clean = str_replace( [ "\r", "\n", " " ], '', $decoded['content'] );
|
|
$body = base64_decode( $clean, true );
|
|
if ( $body === false ) {
|
|
return new WP_Error( 'oribi_sync_decode_error', 'Failed to decode base64 content from Gitea.' );
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate and fix encoding if necessary (handles non-UTF-8 sources)
|
|
if ( ! empty( $body ) ) {
|
|
if ( ! mb_check_encoding( $body, 'UTF-8' ) ) {
|
|
// Try to convert from common encodings to UTF-8
|
|
$body = mb_convert_encoding( $body, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252' );
|
|
}
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
// ─── Tree filtering ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Filter tree entries to only those under a given directory prefix.
|
|
* Matching is case-insensitive so Pages/, pages/, PAGES/ etc. all work.
|
|
*
|
|
* @param array $tree Tree from oribi_sync_fetch_tree().
|
|
* @param string $prefix Directory prefix (e.g. 'Pages').
|
|
* @param bool $recursive Whether to include files in subdirectories (default: false).
|
|
*
|
|
* @return array Filtered entries (blobs only).
|
|
*/
|
|
function oribi_sync_filter_tree( array $tree, string $prefix, bool $recursive = false ): array {
|
|
$prefix = rtrim( $prefix, '/' ) . '/';
|
|
$plen = strlen( $prefix );
|
|
$out = [];
|
|
|
|
foreach ( $tree as $entry ) {
|
|
if ( $entry['type'] !== 'blob' ) continue;
|
|
if ( strncasecmp( $entry['path'], $prefix, $plen ) !== 0 ) continue;
|
|
$relative = substr( $entry['path'], $plen );
|
|
// Skip sub-directory files unless recursive is enabled
|
|
if ( ! $recursive && strpos( $relative, '/' ) !== false ) continue;
|
|
$out[] = $entry;
|
|
}
|
|
|
|
return $out;
|
|
}
|