Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
29.03% covered (danger)
29.03%
36 / 124
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitHubRepository
29.03% covered (danger)
29.03%
36 / 124
40.00% covered (danger)
40.00%
2 / 5
351.68
0.00% covered (danger)
0.00%
0 / 1
 request
64.29% covered (warning)
64.29%
27 / 42
0.00% covered (danger)
0.00%
0 / 1
14.56
 getRepositoryReleases
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRepositoryArtifacts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 downloadFile
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
110
 downloadPublicFile
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace App\Http\Repositories;
4
5use Exception;
6use Illuminate\Support\Facades\Http;
7use Illuminate\Support\Facades\Log;
8
9/**
10 * Repository for interacting with the GitHub API
11 *
12 * Provides methods for fetching repository releases and artifacts.
13 */
14class GitHubRepository
15{
16    /**
17     * Make an authenticated request to the GitHub API
18     *
19     * @param  string  $endpoint  The API endpoint (without base URL)
20     * @param  string  $method  HTTP method (GET, POST, etc.)
21     * @param  array<string, mixed>  $params  Query parameters or request body
22     * @return array<string, mixed> Decoded JSON response
23     *
24     * @throws Exception When the API request fails
25     */
26    protected function request(string $endpoint, string $method = 'GET', array $params = []): array
27    {
28        $token = config('github.api_token');
29        $baseUrl = config('github.api_url');
30        $timeout = config('github.timeout');
31
32        if (empty($token)) {
33            throw new Exception('GitHub API token is not configured. Please set GITHUB_API_TOKEN in .env');
34        }
35
36        try {
37            $response = Http::timeout($timeout)
38                ->withHeaders([
39                    'Authorization' => "Bearer {$token}",
40                    'Accept' => 'application/vnd.github.v3+json',
41                    'User-Agent' => 'FlyMSG-Backend',
42                ])
43                ->$method("{$baseUrl}/{$endpoint}", $params);
44
45            if ($response->failed()) {
46                $statusCode = $response->status();
47                $body = $response->body();
48
49                Log::error('GitHub API request failed', [
50                    'endpoint' => $endpoint,
51                    'status_code' => $statusCode,
52                    'response_body' => $body,
53                ]);
54
55                // Check for rate limiting
56                if ($statusCode === 403 && str_contains($body, 'rate limit')) {
57                    throw new Exception('GitHub API rate limit exceeded. Please try again later.');
58                }
59
60                // Check for authentication errors
61                if ($statusCode === 401) {
62                    throw new Exception('GitHub authentication failed. Please check your API token.');
63                }
64
65                throw new Exception("GitHub API request failed with status {$statusCode}");
66            }
67
68            return $response->json();
69        } catch (Exception $e) {
70            // Check for timeout
71            if (str_contains($e->getMessage(), 'timeout') || str_contains($e->getMessage(), 'timed out')) {
72                Log::error('GitHub API timeout', [
73                    'endpoint' => $endpoint,
74                    'timeout' => $timeout,
75                    'error' => $e->getMessage(),
76                ]);
77
78                throw new Exception('GitHub API timeout. Please try again.');
79            }
80
81            // Re-throw the exception if it's already a user-friendly message
82            if (str_contains($e->getMessage(), 'GitHub')) {
83                throw $e;
84            }
85
86            // Log and throw a generic error for unexpected exceptions
87            Log::error('Unexpected error during GitHub API request', [
88                'endpoint' => $endpoint,
89                'error' => $e->getMessage(),
90                'trace' => $e->getTraceAsString(),
91            ]);
92
93            throw new Exception('Failed to communicate with GitHub API. Please try again later.');
94        }
95    }
96
97    /**
98     * Get repository releases
99     *
100     * @param  string  $owner  Repository owner
101     * @param  string  $repo  Repository name
102     * @param  int  $perPage  Number of results per page
103     * @return array<int, array<string, mixed>> Array of release objects
104     *
105     * @throws Exception When the API request fails
106     */
107    public function getRepositoryReleases(string $owner, string $repo, int $perPage = 30): array
108    {
109        $endpoint = "repos/{$owner}/{$repo}/releases";
110
111        return $this->request($endpoint, 'GET', [
112            'per_page' => $perPage,
113        ]);
114    }
115
116    /**
117     * Get repository artifacts
118     *
119     * @param  string  $owner  Repository owner
120     * @param  string  $repo  Repository name
121     * @param  int  $perPage  Number of results per page
122     * @return array<int, array<string, mixed>> Array of artifact objects
123     *
124     * @throws Exception When the API request fails
125     */
126    public function getRepositoryArtifacts(string $owner, string $repo, int $perPage = 30): array
127    {
128        $endpoint = "repos/{$owner}/{$repo}/actions/artifacts";
129
130        $response = $this->request($endpoint, 'GET', [
131            'per_page' => $perPage,
132        ]);
133
134        // GitHub wraps artifacts in an 'artifacts' key, extract it
135        return $response['artifacts'] ?? [];
136    }
137
138    /**
139     * Download a file from GitHub with authentication
140     *
141     * Used for downloading artifacts which require authentication.
142     * Release assets with public URLs should be downloaded directly without this method.
143     *
144     * @param  string  $downloadUrl  The GitHub download URL
145     * @param  int|null  $timeout  Optional custom timeout in seconds
146     * @return string Binary file content
147     *
148     * @throws Exception When the download fails
149     */
150    public function downloadFile(string $downloadUrl, ?int $timeout = null): string
151    {
152        $token = config('github.api_token');
153        $defaultTimeout = config('github.extension_cache.download_timeout', 120);
154
155        if (empty($token)) {
156            throw new Exception('GitHub API token is not configured. Please set GITHUB_API_TOKEN in .env');
157        }
158
159        try {
160            $response = Http::timeout($timeout ?? $defaultTimeout)
161                ->withHeaders([
162                    'Authorization' => "Bearer {$token}",
163                    'Accept' => 'application/vnd.github.v3+json',
164                    'User-Agent' => 'FlyMSG-Backend',
165                ])
166                ->get($downloadUrl);
167
168            if ($response->failed()) {
169                $statusCode = $response->status();
170
171                Log::error('GitHub file download failed', [
172                    'url' => $downloadUrl,
173                    'status_code' => $statusCode,
174                ]);
175
176                if ($statusCode === 403) {
177                    throw new Exception('GitHub API rate limit exceeded or access denied.');
178                }
179
180                if ($statusCode === 401) {
181                    throw new Exception('GitHub authentication failed. Please check your API token.');
182                }
183
184                if ($statusCode === 404) {
185                    throw new Exception('File not found on GitHub. It may have expired or been deleted.');
186                }
187
188                throw new Exception("GitHub file download failed with status {$statusCode}");
189            }
190
191            return $response->body();
192        } catch (Exception $e) {
193            if (str_contains($e->getMessage(), 'timeout') || str_contains($e->getMessage(), 'timed out')) {
194                Log::error('GitHub file download timeout', [
195                    'url' => $downloadUrl,
196                    'timeout' => $timeout ?? $defaultTimeout,
197                    'error' => $e->getMessage(),
198                ]);
199
200                throw new Exception('GitHub file download timed out. The file may be too large.');
201            }
202
203            if (str_contains($e->getMessage(), 'GitHub')) {
204                throw $e;
205            }
206
207            Log::error('Unexpected error during GitHub file download', [
208                'url' => $downloadUrl,
209                'error' => $e->getMessage(),
210                'trace' => $e->getTraceAsString(),
211            ]);
212
213            throw new Exception('Failed to download file from GitHub. Please try again later.');
214        }
215    }
216
217    /**
218     * Download a public file without authentication
219     *
220     * Used for downloading release assets with public URLs.
221     *
222     * @param  string  $downloadUrl  The public download URL
223     * @param  int|null  $timeout  Optional custom timeout in seconds
224     * @return string Binary file content
225     *
226     * @throws Exception When the download fails
227     */
228    public function downloadPublicFile(string $downloadUrl, ?int $timeout = null): string
229    {
230        $defaultTimeout = config('github.extension_cache.download_timeout', 120);
231
232        try {
233            $response = Http::timeout($timeout ?? $defaultTimeout)
234                ->withHeaders([
235                    'User-Agent' => 'FlyMSG-Backend',
236                ])
237                ->get($downloadUrl);
238
239            if ($response->failed()) {
240                $statusCode = $response->status();
241
242                Log::error('Public file download failed', [
243                    'url' => $downloadUrl,
244                    'status_code' => $statusCode,
245                ]);
246
247                if ($statusCode === 404) {
248                    throw new Exception('File not found. It may have been deleted.');
249                }
250
251                throw new Exception("File download failed with status {$statusCode}");
252            }
253
254            return $response->body();
255        } catch (Exception $e) {
256            if (str_contains($e->getMessage(), 'timeout') || str_contains($e->getMessage(), 'timed out')) {
257                Log::error('Public file download timeout', [
258                    'url' => $downloadUrl,
259                    'timeout' => $timeout ?? $defaultTimeout,
260                    'error' => $e->getMessage(),
261                ]);
262
263                throw new Exception('File download timed out. The file may be too large.');
264            }
265
266            if (str_contains($e->getMessage(), 'download failed') || str_contains($e->getMessage(), 'not found')) {
267                throw $e;
268            }
269
270            Log::error('Unexpected error during public file download', [
271                'url' => $downloadUrl,
272                'error' => $e->getMessage(),
273                'trace' => $e->getTraceAsString(),
274            ]);
275
276            throw new Exception('Failed to download file. Please try again later.');
277        }
278    }
279}