3
3
declare (strict_types=1 );
4
4
5
5
/**
6
- * Build a query for a contribution graph
6
+ * Build a GraphQL query for a contribution graph
7
7
*
8
8
* @param string $user GitHub username to get graphs for
9
9
* @param int $year Year to get graph for
@@ -17,8 +17,8 @@ function buildContributionGraphQuery(string $user, int $year)
17
17
return "query {
18
18
user(login: \"$ user \") {
19
19
contributionsCollection(from: \"$ start \", to: \"$ end \") {
20
+ contributionYears
20
21
contributionCalendar {
21
- totalContributions
22
22
weeks {
23
23
contributionDays {
24
24
contributionCount
@@ -32,49 +32,60 @@ function buildContributionGraphQuery(string $user, int $year)
32
32
}
33
33
34
34
/**
35
- * Get all HTTP request responses for user's contributions
35
+ * Execute multiple requests with cURL and handle GitHub API rate limits and errors
36
36
*
37
37
* @param string $user GitHub username to get graphs for
38
+ * @param array<int> $years Years to get graphs for
38
39
*
39
- * @return array<stdClass> List of contribution graph response objects
40
+ * @return array<stdClass> List of GraphQL response objects
40
41
*/
41
- function getContributionGraphs (string $ user ): array
42
+ function executeContributionGraphRequests (string $ user, array $ years ): array
42
43
{
43
- // Get the years the user has contributed
44
- $ contributionYears = getContributionYears ($ user );
45
- // build a list of individual requests
46
44
$ tokens = [];
47
45
$ requests = [];
48
- foreach ($ contributionYears as $ year ) {
49
- // create query for year
50
- $ query = buildContributionGraphQuery ($ user , $ year );
51
- // create curl request
46
+ // build handles for each year
47
+ foreach ($ years as $ year ) {
52
48
$ tokens [$ year ] = getGitHubToken ();
49
+ $ query = buildContributionGraphQuery ($ user , $ year );
53
50
$ requests [$ year ] = getGraphQLCurlHandle ($ query , $ tokens [$ year ]);
54
51
}
55
52
// build multi-curl handle
56
53
$ multi = curl_multi_init ();
57
- foreach ($ requests as $ request ) {
58
- curl_multi_add_handle ($ multi , $ request );
54
+ foreach ($ requests as $ handle ) {
55
+ curl_multi_add_handle ($ multi , $ handle );
59
56
}
60
57
// execute queries
61
58
$ running = null ;
62
59
do {
63
60
curl_multi_exec ($ multi , $ running );
64
61
} while ($ running );
65
- // collect responses from last to first
66
- $ response = [];
67
- foreach ($ requests as $ year => $ request ) {
68
- $ contents = curl_multi_getcontent ($ request );
62
+ // collect responses
63
+ $ responses = [];
64
+ foreach ($ requests as $ year => $ handle ) {
65
+ $ contents = curl_multi_getcontent ($ handle );
69
66
$ decoded = is_string ($ contents ) ? json_decode ($ contents ) : null ;
70
- // if response is empty or invalid, retry request one time
71
- if (empty ($ decoded ) || empty ($ decoded ->data )) {
72
- // if rate limit is exceeded, don't retry with same token
67
+ // if response is empty or invalid, retry request one time or throw an error
68
+ if (empty ($ decoded ) || empty ($ decoded ->data ) || !empty ($ decoded ->errors )) {
73
69
$ message = $ decoded ->errors [0 ]->message ?? ($ decoded ->message ?? "An API error occurred. " );
70
+ $ error_type = $ decoded ->errors [0 ]->type ?? "" ;
71
+ // Missing SSL certificate
72
+ if (curl_errno ($ handle ) === 60 ) {
73
+ throw new AssertionError ("You don't have a valid SSL Certificate installed or XAMPP. " , 500 );
74
+ }
75
+ // Other cURL error
76
+ elseif (curl_errno ($ handle )) {
77
+ throw new AssertionError ("cURL error: " . curl_error ($ handle ), 500 );
78
+ }
79
+ // GitHub API error - Not Found
80
+ elseif ($ error_type === "NOT_FOUND " ) {
81
+ throw new InvalidArgumentException ("Could not find a user with that name. " , 404 );
82
+ }
83
+ // if rate limit is exceeded, don't retry with same token
74
84
if (str_contains ($ message , "rate limit exceeded " )) {
75
85
removeGitHubToken ($ tokens [$ year ]);
76
86
}
77
87
error_log ("First attempt to decode response for $ user's $ year contributions failed. $ message " );
88
+ // retry request
78
89
$ query = buildContributionGraphQuery ($ user , $ year );
79
90
$ token = getGitHubToken ();
80
91
$ request = getGraphQLCurlHandle ($ query , $ token );
@@ -90,14 +101,36 @@ function getContributionGraphs(string $user): array
90
101
continue ;
91
102
}
92
103
}
93
- array_unshift ( $ response , $ decoded) ;
104
+ $ responses [ $ year ] = $ decoded ;
94
105
}
95
106
// close the handles
96
107
foreach ($ requests as $ request ) {
97
- curl_multi_remove_handle ($ multi , $ request );
108
+ curl_multi_remove_handle ($ multi , $ handle );
98
109
}
99
110
curl_multi_close ($ multi );
100
- return $ response ;
111
+ return $ responses ;
112
+ }
113
+
114
+ /**
115
+ * Get all HTTP request responses for user's contributions
116
+ *
117
+ * @param string $user GitHub username to get graphs for
118
+ *
119
+ * @return array<stdClass> List of contribution graph response objects
120
+ */
121
+ function getContributionGraphs (string $ user ): array
122
+ {
123
+ // get the list of years the user has contributed and the current year's contribution graph
124
+ $ currentYear = intval (date ("Y " ));
125
+ $ responses = executeContributionGraphRequests ($ user , [$ currentYear ]);
126
+ $ contributionYears = $ responses [$ currentYear ]->data ->user ->contributionsCollection ->contributionYears ;
127
+ // remove the current year from the list since it's already been fetched
128
+ $ contributionYears = array_filter ($ contributionYears , function ($ year ) use ($ currentYear ) {
129
+ return $ year !== $ currentYear ;
130
+ });
131
+ // get the contribution graphs for the previous years
132
+ $ responses += executeContributionGraphRequests ($ user , $ contributionYears );
133
+ return $ responses ;
101
134
}
102
135
103
136
/**
@@ -127,7 +160,7 @@ function getGitHubTokens()
127
160
/**
128
161
* Get a token from the token pool
129
162
*
130
- * @throws AssertionError if no tokens are available
163
+ * @return string GitHub token
131
164
*/
132
165
function getGitHubToken ()
133
166
{
@@ -139,6 +172,8 @@ function getGitHubToken()
139
172
* Remove a token from the token pool
140
173
*
141
174
* @param string $token Token to remove
175
+ *
176
+ * @throws AssertionError if no tokens are available after removing the token
142
177
*/
143
178
function removeGitHubToken (string $ token )
144
179
{
@@ -183,94 +218,10 @@ function getGraphQLCurlHandle(string $query, string $token)
183
218
return $ ch ;
184
219
}
185
220
186
- /**
187
- * Create a POST request to GitHub's GraphQL API
188
- *
189
- * @param string $query GraphQL query
190
- * @param string $token GitHub token to use for the request
191
- *
192
- * @return stdClass An object from the json response of the request
193
- *
194
- * @throws AssertionError If SSL verification fails
195
- */
196
- function fetchGraphQL (string $ query , string $ token ): stdClass
197
- {
198
- $ ch = getGraphQLCurlHandle ($ query , $ token );
199
- $ response = curl_exec ($ ch );
200
- curl_close ($ ch );
201
- $ decoded = is_string ($ response ) ? json_decode ($ response ) : null ;
202
- // handle curl errors
203
- if ($ response === false || $ decoded === null || curl_getinfo ($ ch , CURLINFO_HTTP_CODE ) >= 400 ) {
204
- $ message = $ decoded ->errors [0 ]->message ?? ($ decoded ->message ?? "" );
205
- if (str_contains ($ message , "rate limit exceeded " )) {
206
- removeGitHubToken ($ token );
207
- }
208
- // set response code to curl error code
209
- http_response_code (curl_getinfo ($ ch , CURLINFO_HTTP_CODE ));
210
- // Missing SSL certificate
211
- if (str_contains (curl_error ($ ch ), "unable to get local issuer certificate " )) {
212
- throw new AssertionError ("You don't have a valid SSL Certificate installed or XAMPP. " , 400 );
213
- }
214
- // Handle errors such as "Bad credentials"
215
- if ($ message ) {
216
- throw new AssertionError ("Error: $ message \n<!-- $ response --> " , 401 );
217
- }
218
- // Handle curl errors
219
- if (curl_errno ($ ch )) {
220
- throw new AssertionError ("cURL error: " . curl_error ($ ch ) . "\n<!-- $ response --> " , 500 );
221
- }
222
- throw new AssertionError ("An error occurred when getting a response from GitHub. \n<!-- $ response --> " , 502 );
223
- }
224
- return $ decoded ;
225
- }
226
-
227
- /**
228
- * Get the years the user has contributed
229
- *
230
- * @param string $user GitHub username to get years for
231
- *
232
- * @return array List of years the user has contributed
233
- *
234
- * @throws InvalidArgumentException If the user doesn't exist or there is an error
235
- */
236
- function getContributionYears (string $ user ): array
237
- {
238
- $ query = "query {
239
- user(login: \"$ user \") {
240
- contributionsCollection {
241
- contributionYears
242
- }
243
- }
244
- } " ;
245
- try {
246
- $ response = fetchGraphQL ($ query , getGitHubToken ());
247
- } catch (AssertionError $ e ) {
248
- // retry once if an error occurred
249
- error_log ("An error occurred getting contribution years for $ user: " . $ e ->getMessage ());
250
- $ response = fetchGraphQL ($ query , getGitHubToken ());
251
- }
252
- // User not found
253
- if (!empty ($ response ->errors )) {
254
- $ type = $ response ->errors [0 ]->type ?? "" ;
255
- if ($ type === "NOT_FOUND " ) {
256
- throw new InvalidArgumentException ("Could not find a user with that name. " , 404 );
257
- }
258
- $ message = $ response ->errors [0 ]->message ?? "An API error occurred. " ;
259
- // Other errors that contain a message field
260
- throw new InvalidArgumentException ($ message , 502 );
261
- }
262
- // API did not return data
263
- if (!isset ($ response ->data ) && isset ($ response ->message )) {
264
- // Other errors that contain a message field
265
- throw new InvalidArgumentException ($ response ->message , 204 );
266
- }
267
- return $ response ->data ->user ->contributionsCollection ->contributionYears ;
268
- }
269
-
270
221
/**
271
222
* Get an array of all dates with the number of contributions
272
223
*
273
- * @param array<string > $contributionCalendars List of GraphQL response objects
224
+ * @param array<stdClass > $contributionCalendars List of GraphQL response objects
274
225
*
275
226
* @return array<string, int> Y-M-D dates mapped to the number of contributions
276
227
*/
0 commit comments