Skip to content

Commit 14c63c0

Browse files
authored
fix: Migration to GraphQL API (#185)
1 parent 6ed28c7 commit 14c63c0

File tree

8 files changed

+169
-133
lines changed

8 files changed

+169
-133
lines changed

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
TOKEN=
2-
USERNAME=
1+
# replace ghp_example with your GitHub PAT token
2+
TOKEN=ghp_example

.github/workflows/phpunit-ci-coverage.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,3 @@ jobs:
2323
args: --testdox
2424
env:
2525
TOKEN: ${{ secrets.GITHUB_TOKEN }}
26-
USERNAME: DenverCoder1

CONTRIBUTING.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,12 @@ cd github-readme-streak-stats
5151

5252
To get the GitHub API to run locally you will need to provide a token.
5353

54-
1. Go to https://github.com/settings/tokens.
55-
2. Click **"Generate new token."**
56-
3. Add a note (ex. **"GitHub Readme Streak Stats"**), then scroll to the bottom and click **"Generate token."**
57-
4. **Copy** the token to your clipboard.
58-
5. **Create** a file `config.php` in the `src` directory and replace `ghp_example123` with **your token** and `DenverCoder1` with **your username**:
54+
1. Visit [this link](https://github.com/settings/tokens/new?description=GitHub%20Readme%20Streak%20Stats) to create a new Personal Access Token
55+
2. Scroll to the bottom and click **"Generate token"**
56+
3. **Make a copy** of `.env.example` named `.env` in the root directory and add **your token** after `TOKEN=`.
5957

6058
```php
61-
<?php
62-
putenv("TOKEN=ghp_example123");
63-
putenv("USERNAME=DenverCoder1");
59+
TOKEN=<your-token>
6460
```
6561

6662
### Install dependencies

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,10 @@ To get the GitHub API to run locally you will need to provide a token.
226226

227227
1. Visit [this link](https://github.com/settings/tokens/new?description=GitHub%20Readme%20Streak%20Stats) to create a new Personal Access Token
228228
2. Scroll to the bottom and click **"Generate token"**
229-
3. **Make a copy** of `.env.example` named `.env` in the root directory and add **your token** after `TOKEN=` and **your username** after `USERNAME=`:
229+
3. **Make a copy** of `.env.example` named `.env` in the root directory and add **your token** after `TOKEN=`:
230230

231231
```php
232-
TOKEN=
233-
USERNAME=
232+
TOKEN=<your-token>
234233
```
235234

236235
### Running the app locally

app.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@
99
"TOKEN": {
1010
"description": "GitHub personal access token obtained from https://github.com/settings/tokens/new",
1111
"required": false
12-
},
13-
"USERNAME": {
14-
"description": "GitHub username associated with the token",
15-
"required": false
1612
}
1713
},
1814
"formation": {

src/index.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@
1414
$dotenv->safeLoad();
1515

1616
// if environment variables are not loaded, display error
17-
if (!$_SERVER["TOKEN"] || !$_SERVER["USERNAME"]) {
18-
$message = file_exists(dirname(__DIR__ . '.env', 1))
19-
? "Missing token or username in config. Check Contributing.md for details."
17+
if (!isset($_SERVER["TOKEN"])) {
18+
$message = file_exists(dirname(__DIR__ . '../.env', 1))
19+
? "Missing token in config. Check Contributing.md for details."
2020
: ".env was not found. Check Contributing.md for details.";
21-
2221
renderOutput($message);
2322
}
2423

src/stats.php

Lines changed: 121 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -6,156 +6,175 @@
66
* Get all HTTP request responses for user's contributions
77
*
88
* @param string $user GitHub username to get graphs for
9-
* @return array<string> List of HTML contribution graphs
9+
*
10+
* @return array<stdClass> List of contribution graph response objects
1011
*/
1112
function getContributionGraphs(string $user): array
1213
{
13-
// get the start year based on when the user first contributed
14-
$startYear = getYearJoined($user);
15-
$currentYear = intval(date("Y"));
14+
// Get the years the user has contributed
15+
$contributionYears = getContributionYears($user);
1616
// build a list of individual requests
17-
$urls = array();
18-
for ($year = $currentYear; $year >= $startYear; $year--) {
19-
// create url with year set as end date
20-
$url = "https://github.com/users/${user}/contributions?to=${year}-12-31";
17+
$requests = array();
18+
foreach ($contributionYears as $year) {
19+
// create query for year
20+
$start = "$year-01-01T00:00:00Z";
21+
$end = "$year-12-31T23:59:59Z";
22+
$query = "query {
23+
user(login: \"$user\") {
24+
contributionsCollection(from: \"$start\", to: \"$end\") {
25+
contributionCalendar {
26+
totalContributions
27+
weeks {
28+
contributionDays {
29+
contributionCount
30+
date
31+
}
32+
}
33+
}
34+
}
35+
}
36+
}";
2137
// create curl request
22-
$urls[$year] = curl_init();
23-
// set options for curl
24-
curl_setopt($urls[$year], CURLOPT_AUTOREFERER, true);
25-
curl_setopt($urls[$year], CURLOPT_HEADER, false);
26-
curl_setopt($urls[$year], CURLOPT_RETURNTRANSFER, true);
27-
curl_setopt($urls[$year], CURLOPT_URL, $url);
28-
curl_setopt($urls[$year], CURLOPT_FOLLOWLOCATION, true);
29-
curl_setopt($urls[$year], CURLOPT_VERBOSE, false);
30-
curl_setopt($urls[$year], CURLOPT_SSL_VERIFYPEER, true);
38+
$requests[$year] = getGraphQLCurlHandle($query);
3139
}
3240
// build multi-curl handle
3341
$multi = curl_multi_init();
34-
foreach ($urls as $url) {
35-
curl_multi_add_handle($multi, $url);
42+
foreach ($requests as $request) {
43+
curl_multi_add_handle($multi, $request);
3644
}
3745
// execute queries
3846
$running = null;
3947
do {
4048
curl_multi_exec($multi, $running);
4149
} while ($running);
4250
// close the handles
43-
foreach ($urls as $url) {
44-
curl_multi_remove_handle($multi, $url);
51+
foreach ($requests as $request) {
52+
curl_multi_remove_handle($multi, $request);
4553
}
4654
curl_multi_close($multi);
4755
// collect responses from last to first
4856
$response = array();
49-
foreach ($urls as $url) {
50-
array_unshift($response, curl_multi_getcontent($url));
57+
foreach ($requests as $request) {
58+
array_unshift($response, json_decode(curl_multi_getcontent($request)));
5159
}
5260
return $response;
5361
}
5462

55-
/**
56-
* Get an array of all dates with the number of contributions
57-
*
58-
* @param array<string> $contributionGraphs List of HTML pages with contributions
59-
* @return array<string, int> Y-M-D dates mapped to the number of contributions
63+
/** Create a CurlHandle for a POST request to GitHub's GraphQL API
64+
*
65+
* @param string $query GraphQL query
66+
*
67+
* @return CurlHandle The curl handle for the request
6068
*/
61-
function getContributionDates(array $contributionGraphs): array
69+
function getGraphQLCurlHandle(string $query)
6270
{
63-
// get contributions from HTML
64-
$contributions = array();
65-
$today = date("Y-m-d");
66-
$tomorrow = date("Y-m-d", strtotime("tomorrow"));
67-
foreach ($contributionGraphs as $graph) {
68-
// if HTML contains "Please wait", we are being rate-limited
69-
if (strpos($graph, "Please wait") !== false) {
70-
throw new AssertionError("We are being rate-limited! Check <a href='https://git.io/streak-ratelimit' font-weight='bold'>git.io/streak-ratelimit</a> for details.");
71-
}
72-
// split into lines
73-
$lines = explode("\n", $graph);
74-
// add the dates and contribution counts to the array
75-
foreach ($lines as $line) {
76-
preg_match("/ data-date=\"([0-9\-]{10})\"/", $line, $dateMatch);
77-
preg_match("/ data-count=\"(\d+?)\"/", $line, $countMatch);
78-
if (isset($dateMatch[1]) && isset($countMatch[1])) {
79-
$date = $dateMatch[1];
80-
$count = (int) $countMatch[1];
81-
// count contributions up until today
82-
// also count next day if user contributed already
83-
if ($date <= $today || ($date == $tomorrow && $count > 0)) {
84-
// add contributions to the array
85-
$contributions[$date] = $count;
86-
}
87-
}
88-
}
89-
}
90-
return $contributions;
71+
$token = $_SERVER["TOKEN"];
72+
$headers = array(
73+
"Authorization: bearer $token",
74+
"Content-Type: application/json",
75+
"Accept: application/vnd.github.v4.idl",
76+
"User-Agent: GitHub-Readme-Streak-Stats"
77+
);
78+
$body = array("query" => $query);
79+
// create curl request
80+
$ch = curl_init();
81+
curl_setopt($ch, CURLOPT_URL, "https://api.github.com/graphql");
82+
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
83+
curl_setopt($ch, CURLOPT_POST, true);
84+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
85+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
86+
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
87+
curl_setopt($ch, CURLOPT_VERBOSE, false);
88+
return $ch;
9189
}
9290

9391
/**
94-
* Get the contents of a single URL passing headers for GitHub API
92+
* Create a POST request to GitHub's GraphQL API
93+
*
94+
* @param string $query GraphQL query
9595
*
96-
* @param string $url URL to fetch
97-
* @return string Response from page as a string
96+
* @return stdClass An object from the json response of the request
97+
*
98+
* @throws AssertionError If SSL verification fails
9899
*/
99-
function getGitHubApiResponse(string $url): string
100+
function fetchGraphQL(string $query): stdClass
100101
{
101-
$ch = curl_init();
102-
$token = $_SERVER["TOKEN"];
103-
$username = $_SERVER["USERNAME"];
104-
curl_setopt($ch, CURLOPT_HTTPHEADER, [
105-
"Accept: application/vnd.github.v3+json",
106-
"Authorization: token $token",
107-
"User-Agent: $username",
108-
]);
109-
curl_setopt($ch, CURLOPT_AUTOREFERER, true);
110-
curl_setopt($ch, CURLOPT_HEADER, false);
111-
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
112-
curl_setopt($ch, CURLOPT_URL, $url);
113-
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
114-
curl_setopt($ch, CURLOPT_VERBOSE, false);
115-
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
102+
$ch = getGraphQLCurlHandle($query);
116103
$response = curl_exec($ch);
117104
// handle curl errors
118105
if ($response === false) {
119106
if (str_contains(curl_error($ch), 'unable to get local issuer certificate')) {
120-
throw new InvalidArgumentException("You don't have a valid SSL Certificate installed or XAMPP.");
107+
throw new AssertionError("You don't have a valid SSL Certificate installed or XAMPP.");
121108
}
122-
throw new InvalidArgumentException("An error occurred when getting a response from GitHub.");
109+
throw new AssertionError("An error occurred when getting a response from GitHub.");
123110
}
124-
// close curl handle and return response
125111
curl_close($ch);
126-
return $response;
112+
return json_decode($response);
127113
}
128114

129115
/**
130-
* Get the first year a user contributed
116+
* Get the years the user has contributed
117+
*
118+
* @param string $user GitHub username to get years for
119+
*
120+
* @return array List of years the user has contributed
131121
*
132-
* @param string $user GitHub username to look up
133-
* @return int first contribution year
122+
* @throws InvalidArgumentException If the user doesn't exist or there is an error
134123
*/
135-
function getYearJoined(string $user): int
124+
function getContributionYears(string $user): array
136125
{
137-
// load the user's profile info
138-
$response = getGitHubApiResponse("https://api.github.com/users/${user}");
139-
$json = json_decode($response);
140-
// find the year the user was created
141-
if ($json && isset($json->type) && $json->type == "User" && isset($json->created_at)) {
142-
return intval(substr($json->created_at, 0, 4));
143-
}
144-
// Account is not a user (eg. Organization account)
145-
if (isset($json->type)) {
146-
throw new InvalidArgumentException("The username given is not a user.");
126+
$query = "query {
127+
user(login: \"$user\") {
128+
contributionsCollection {
129+
contributionYears
130+
}
131+
}
132+
}";
133+
$response = fetchGraphQL($query);
134+
// User not found
135+
if (!empty($response->errors) && $response->errors[0]->type === "NOT_FOUND") {
136+
throw new InvalidArgumentException("Could not find a user with that name.");
147137
}
148138
// API Error
149-
if ($json && isset($json->message)) {
150-
// User not found
151-
if ($json->message == "Not Found") {
152-
throw new InvalidArgumentException("User could not be found.");
153-
}
139+
if (!empty($response->errors)) {
154140
// Other errors that contain a message field
155-
throw new InvalidArgumentException($json->message);
141+
throw new InvalidArgumentException($response->data->errors[0]->message);
156142
}
157-
// Response doesn't contain a message field
158-
throw new InvalidArgumentException("An unknown error occurred.");
143+
return $response->data->user->contributionsCollection->contributionYears;
144+
}
145+
146+
/**
147+
* Get an array of all dates with the number of contributions
148+
*
149+
* @param array<string> $contributionCalendars List of GraphQL response objects
150+
*
151+
* @return array<string, int> Y-M-D dates mapped to the number of contributions
152+
*/
153+
function getContributionDates(array $contributionGraphs): array
154+
{
155+
// get contributions from HTML
156+
$contributions = array();
157+
$today = date("Y-m-d");
158+
$tomorrow = date("Y-m-d", strtotime("tomorrow"));
159+
foreach ($contributionGraphs as $graph) {
160+
if (!empty($graph->errors)) {
161+
throw new AssertionError($graph->data->errors[0]->message);
162+
}
163+
$weeks = $graph->data->user->contributionsCollection->contributionCalendar->weeks;
164+
foreach ($weeks as $week) {
165+
foreach ($week->contributionDays as $day) {
166+
$date = $day->date;
167+
$count = $day->contributionCount;
168+
// count contributions up until today
169+
// also count next day if user contributed already
170+
if ($date <= $today || ($date == $tomorrow && $count > 0)) {
171+
// add contributions to the array
172+
$contributions[$date] = $count;
173+
}
174+
}
175+
}
176+
}
177+
return $contributions;
159178
}
160179

161180
/**

0 commit comments

Comments
 (0)