'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': $url = $api_base . '/raw/' . $encoded_path . '?ref=' . rawurlencode( $branch ); $accept = 'text/plain'; break; case 'github': default: $url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch ); $accept = 'application/vnd.github.raw+json'; 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 ) ); } return wp_remote_retrieve_body( $response ); } // ─── Tree filtering ─────────────────────────────────────────────────────────── /** * Filter tree entries to only those under a given directory prefix. * * @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, '/' ) . '/'; $out = []; foreach ( $tree as $entry ) { if ( $entry['type'] !== 'blob' ) continue; if ( strpos( $entry['path'], $prefix ) !== 0 ) continue; $relative = substr( $entry['path'], strlen( $prefix ) ); // Skip sub-directory files unless recursive is enabled if ( ! $recursive && strpos( $relative, '/' ) !== false ) continue; $out[] = $entry; } return $out; }