1 | <?php |
||
2 | |||
3 | /** |
||
4 | * GitHub.php |
||
5 | * |
||
6 | * An interface to GitHubs api |
||
7 | * |
||
8 | * This program is free software: you can redistribute it and/or modify |
||
9 | * it under the terms of the GNU General Public License as published by |
||
10 | * the Free Software Foundation, either version 3 of the License, or |
||
11 | * (at your option) any later version. |
||
12 | * |
||
13 | * This program is distributed in the hope that it will be useful, |
||
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the |
||
16 | * GNU General Public License for more details. |
||
17 | * |
||
18 | * You should have received a copy of the GNU General Public License |
||
19 | * along with this program. If not, see <https://www.gnu.org/licenses/>. |
||
20 | * |
||
21 | * @link https://www.librenms.org |
||
22 | * |
||
23 | * @copyright 2018 Neil Lathwood |
||
24 | * @author Neil Lathwood <[email protected]> |
||
25 | */ |
||
26 | |||
27 | namespace LibreNMS\Util; |
||
28 | |||
29 | use Exception; |
||
30 | use Requests; |
||
31 | use Requests_Response; |
||
32 | |||
33 | class GitHub |
||
34 | { |
||
35 | protected $tag; |
||
36 | protected $from; |
||
37 | protected $token; |
||
38 | protected $file; |
||
39 | protected $pr; |
||
40 | protected $stop = false; |
||
41 | protected $pull_requests = []; |
||
42 | protected $changelog = [ |
||
43 | 'feature' => [], |
||
44 | 'enhancement' => [], |
||
45 | 'breaking change' => [], |
||
46 | 'security' => [], |
||
47 | 'device' => [], |
||
48 | 'webui' => [], |
||
49 | 'authentication' => [], |
||
50 | 'graphs' => [], |
||
51 | 'snmp traps' => [], |
||
52 | 'applications' => [], |
||
53 | 'api' => [], |
||
54 | 'alerting' => [], |
||
55 | 'billing' => [], |
||
56 | 'discovery' => [], |
||
57 | 'polling' => [], |
||
58 | 'rancid' => [], |
||
59 | 'oxidized' => [], |
||
60 | 'bug' => [], |
||
61 | 'refactor' => [], |
||
62 | 'cleanup' => [], |
||
63 | 'documentation' => [], |
||
64 | 'translation' => [], |
||
65 | 'tests' => [], |
||
66 | 'misc' => [], |
||
67 | 'mibs' => [], |
||
68 | 'dependencies' => [], |
||
69 | ]; |
||
70 | protected $changelog_users = []; |
||
71 | protected $changelog_mergers = []; |
||
72 | protected $profile_links = []; |
||
73 | |||
74 | protected $markdown; |
||
75 | protected $github = 'https://api.github.com/repos/librenms/librenms'; |
||
76 | protected $graphql = 'https://api.github.com/graphql'; |
||
77 | |||
78 | public function __construct($tag, $from, $file, $token = null, $pr = null) |
||
79 | { |
||
80 | $this->tag = $tag; |
||
81 | $this->from = $from; |
||
82 | $this->file = $file; |
||
83 | $this->pr = $pr; |
||
84 | if (! is_null($token) || getenv('GH_TOKEN')) { |
||
85 | $this->token = $token ?: getenv('GH_TOKEN'); |
||
86 | } |
||
87 | } |
||
88 | |||
89 | /** |
||
90 | * Return the GitHub Authorization header for the API call |
||
91 | * |
||
92 | * @return array |
||
93 | */ |
||
94 | public function getHeaders() |
||
95 | { |
||
96 | $headers = [ |
||
97 | 'Content-Type' => 'application/json', |
||
98 | ]; |
||
99 | |||
100 | if (! is_null($this->token)) { |
||
101 | $headers['Authorization'] = "token {$this->token}"; |
||
102 | } |
||
103 | |||
104 | return $headers; |
||
105 | } |
||
106 | |||
107 | /** |
||
108 | * Get the release information for a specific tag |
||
109 | * |
||
110 | * @param string $tag |
||
111 | * @return mixed |
||
112 | */ |
||
113 | public function getRelease($tag) |
||
114 | { |
||
115 | $release = Requests::get($this->github . "/releases/tags/$tag", $this->getHeaders()); |
||
116 | |||
117 | return json_decode($release->body, true); |
||
118 | } |
||
119 | |||
120 | /** |
||
121 | * Get a single pull request information |
||
122 | */ |
||
123 | public function getPullRequest() |
||
124 | { |
||
125 | $pull_request = Requests::get($this->github . "/pulls/{$this->pr}", $this->getHeaders()); |
||
126 | $this->pr = json_decode($pull_request->body, true); |
||
127 | } |
||
128 | |||
129 | /** |
||
130 | * Get all closed pull requests up to a certain date |
||
131 | * |
||
132 | * @param string $date |
||
133 | * @param string $after |
||
134 | */ |
||
135 | public function getPullRequests($date, $after = null) |
||
136 | { |
||
137 | if ($after) { |
||
138 | $after = ", after: \"$after\""; |
||
139 | } |
||
140 | |||
141 | $query = <<<GRAPHQL |
||
142 | { |
||
143 | search(query: "repo:librenms/librenms is:pr is:merged merged:>=$date", type: ISSUE, first: 100$after) { |
||
144 | edges { |
||
145 | node { |
||
146 | ... on PullRequest { |
||
147 | number |
||
148 | title |
||
149 | url |
||
150 | mergedAt |
||
151 | author { |
||
152 | login |
||
153 | url |
||
154 | } |
||
155 | mergedBy { |
||
156 | login |
||
157 | url |
||
158 | } |
||
159 | labels(first: 20) { |
||
160 | nodes { |
||
161 | name |
||
162 | } |
||
163 | } |
||
164 | reviews(first: 100) { |
||
165 | nodes { |
||
166 | author { |
||
167 | login |
||
168 | url |
||
169 | } |
||
170 | } |
||
171 | } |
||
172 | } |
||
173 | } |
||
174 | } |
||
175 | pageInfo { |
||
176 | endCursor |
||
177 | hasNextPage |
||
178 | } |
||
179 | } |
||
180 | } |
||
181 | GRAPHQL; |
||
182 | |||
183 | $data = json_encode(['query' => $query]); |
||
184 | $prs = Requests::post($this->graphql, $this->getHeaders(), $data); |
||
185 | $prs = json_decode($prs->body, true); |
||
186 | if (! isset($prs['data'])) { |
||
187 | var_dump($prs); |
||
188 | } |
||
189 | |||
190 | foreach ($prs['data']['search']['edges'] as $edge) { |
||
191 | $pr = $edge['node']; |
||
192 | $pr['labels'] = $this->parseLabels($pr['labels']['nodes']); |
||
193 | $this->pull_requests[] = $pr; |
||
194 | } |
||
195 | |||
196 | // recurse through the pages |
||
197 | if ($prs['data']['search']['pageInfo']['hasNextPage']) { |
||
198 | $this->getPullRequests($date, $prs['data']['search']['pageInfo']['endCursor']); |
||
199 | } |
||
200 | } |
||
201 | |||
202 | /** |
||
203 | * Parse labels response into standardized names and remove emoji |
||
204 | * |
||
205 | * @param array $labels |
||
206 | * @return array |
||
207 | */ |
||
208 | private function parseLabels($labels) |
||
209 | { |
||
210 | return array_map(function ($label) { |
||
211 | $name = preg_replace('/ :[\S]+:/', '', strtolower($label['name'])); |
||
212 | |||
213 | return str_replace('-', ' ', $name); |
||
214 | }, $labels); |
||
215 | } |
||
216 | |||
217 | /** |
||
218 | * Build the data for the change log. |
||
219 | */ |
||
220 | public function buildChangeLog() |
||
221 | { |
||
222 | $valid_labels = array_keys($this->changelog); |
||
223 | |||
224 | foreach ($this->pull_requests as $k => $pr) { |
||
225 | // check valid labels in order |
||
226 | $category = 'misc'; |
||
227 | foreach ($valid_labels as $valid_label) { |
||
228 | if (in_array($valid_label, $pr['labels'])) { |
||
229 | $category = $valid_label; |
||
230 | break; // only put in the first found label |
||
231 | } |
||
232 | } |
||
233 | |||
234 | // If the Gihub profile doesnt exist anymore, the author is null |
||
235 | if (empty($pr['author'])) { |
||
236 | $pr['author'] = ['login' => 'ghost', 'url' => 'https://github.com/ghost']; |
||
237 | } |
||
238 | |||
239 | // only add the changelog if it isn't set to ignore |
||
240 | if (! in_array('ignore changelog', $pr['labels'])) { |
||
241 | $title = addcslashes(ucfirst(trim(preg_replace('/^[\S]+: /', '', $pr['title']))), '<>'); |
||
242 | $this->changelog[$category][] = "$title ([#{$pr['number']}]({$pr['url']})) - [{$pr['author']['login']}]({$pr['author']['url']})" . PHP_EOL; |
||
243 | } |
||
244 | |||
245 | $this->recordUserInfo($pr['author']); |
||
246 | // Let's not count self-merges |
||
247 | if ($pr['author']['login'] != $pr['mergedBy']['login']) { |
||
248 | $this->recordUserInfo($pr['mergedBy'], 'changelog_mergers'); |
||
249 | } |
||
250 | |||
251 | $ignore = [$pr['author']['login'], $pr['mergedBy']['login']]; |
||
252 | foreach (array_unique($pr['reviews']['nodes'], SORT_REGULAR) as $reviewer) { |
||
253 | if (! in_array($reviewer['author']['login'], $ignore)) { |
||
254 | $this->recordUserInfo($reviewer['author'], 'changelog_mergers'); |
||
255 | } |
||
256 | } |
||
257 | } |
||
258 | } |
||
259 | |||
260 | /** |
||
261 | * Record user info and count into the specified array (default changelog_users) |
||
262 | * Record profile links too. |
||
263 | * |
||
264 | * @param array $user |
||
265 | * @param string $type |
||
266 | */ |
||
267 | private function recordUserInfo($user, $type = 'changelog_users') |
||
268 | { |
||
269 | $user_count = &$this->$type; |
||
270 | |||
271 | $user_count[$user['login']] = isset($user_count[$user['login']]) |
||
272 | ? $user_count[$user['login']] + 1 |
||
273 | : 1; |
||
274 | |||
275 | if (! isset($this->profile_links[$user['login']])) { |
||
276 | $this->profile_links[$user['login']] = $user['url']; |
||
277 | } |
||
278 | } |
||
279 | |||
280 | /** |
||
281 | * Format the change log into Markdown. |
||
282 | */ |
||
283 | public function formatChangeLog() |
||
284 | { |
||
285 | $tmp_markdown = "## $this->tag" . PHP_EOL; |
||
286 | $tmp_markdown .= '*(' . date('Y-m-d') . ')*' . PHP_EOL . PHP_EOL; |
||
287 | |||
288 | if (! empty($this->changelog_users)) { |
||
289 | $tmp_markdown .= 'A big thank you to the following ' . count($this->changelog_users) . ' contributors this last month:' . PHP_EOL . PHP_EOL; |
||
290 | $tmp_markdown .= $this->formatUserList($this->changelog_users); |
||
291 | } |
||
292 | |||
293 | $tmp_markdown .= PHP_EOL; |
||
294 | |||
295 | if (! empty($this->changelog_mergers)) { |
||
296 | $tmp_markdown .= 'Thanks to maintainers and others that helped with pull requests this month:' . PHP_EOL . PHP_EOL; |
||
297 | $tmp_markdown .= $this->formatUserList($this->changelog_mergers) . PHP_EOL; |
||
298 | } |
||
299 | |||
300 | foreach ($this->changelog as $section => $items) { |
||
301 | if (! empty($items)) { |
||
302 | $tmp_markdown .= '#### ' . ucwords($section) . PHP_EOL; |
||
303 | $tmp_markdown .= '* ' . implode('* ', $items) . PHP_EOL; |
||
304 | } |
||
305 | } |
||
306 | |||
307 | $this->markdown = $tmp_markdown; |
||
308 | } |
||
309 | |||
310 | /** |
||
311 | * Create a markdown list of users and link their github profile |
||
312 | * |
||
313 | * @param array $users |
||
314 | * @return string |
||
315 | */ |
||
316 | private function formatUserList($users) |
||
317 | { |
||
318 | $output = ''; |
||
319 | arsort($users); |
||
320 | foreach ($users as $user => $count) { |
||
321 | $output .= " - [$user]({$this->profile_links[$user]}) ($count)" . PHP_EOL; |
||
322 | } |
||
323 | |||
324 | return $output; |
||
325 | } |
||
326 | |||
327 | /** |
||
328 | * Update the specified file with the new Change log info. |
||
329 | */ |
||
330 | public function writeChangeLog() |
||
331 | { |
||
332 | if (file_exists($this->file)) { |
||
333 | $existing = file_get_contents($this->file); |
||
334 | $content = $this->getMarkdown() . PHP_EOL . $existing; |
||
335 | if (is_writable($this->file)) { |
||
336 | file_put_contents($this->file, $content); |
||
337 | } |
||
338 | } else { |
||
339 | echo "Couldn't write to file {$this->file}" . PHP_EOL; |
||
340 | exit; |
||
0 ignored issues
–
show
|
|||
341 | } |
||
342 | } |
||
343 | |||
344 | /** |
||
345 | * Return the generated markdown. |
||
346 | * |
||
347 | * @return mixed |
||
348 | */ |
||
349 | public function getMarkdown() |
||
350 | { |
||
351 | return $this->markdown; |
||
352 | } |
||
353 | |||
354 | /** |
||
355 | * @return bool |
||
356 | * |
||
357 | * @throws Exception |
||
358 | */ |
||
359 | public function createRelease() |
||
360 | { |
||
361 | // push the changelog and version bump |
||
362 | $this->pushFileContents($this->file, file_get_contents($this->file), "Changelog for $this->tag"); |
||
363 | $updated_sha = $this->pushVersionBump(); |
||
364 | |||
365 | // make sure the markdown is built |
||
366 | if (empty($this->markdown)) { |
||
367 | $this->createChangelog(false); |
||
368 | } |
||
369 | |||
370 | $release = Requests::post($this->github . '/releases', $this->getHeaders(), json_encode([ |
||
371 | 'tag_name' => $this->tag, |
||
372 | 'target_commitish' => $updated_sha, |
||
373 | 'body' => $this->markdown, |
||
374 | 'draft' => false, |
||
375 | ])); |
||
376 | |||
377 | return $release->status_code == 201; |
||
378 | } |
||
379 | |||
380 | /** |
||
381 | * Function to control the creation of creating a change log. |
||
382 | * |
||
383 | * @param bool $write |
||
384 | * |
||
385 | * @throws Exception |
||
386 | */ |
||
387 | public function createChangelog($write = true) |
||
388 | { |
||
389 | $previous_release = $this->getRelease($this->from); |
||
390 | if (! is_null($this->pr)) { |
||
391 | $this->getPullRequest(); |
||
392 | } |
||
393 | |||
394 | if (! isset($previous_release['published_at'])) { |
||
395 | throw new Exception( |
||
396 | $previous_release['message'] ?? |
||
397 | "Could not find previous release tag. ($this->from)" |
||
398 | ); |
||
399 | } |
||
400 | |||
401 | $this->getPullRequests($previous_release['published_at']); |
||
402 | $this->buildChangeLog(); |
||
403 | $this->formatChangeLog(); |
||
404 | |||
405 | if ($write) { |
||
406 | $this->writeChangeLog(); |
||
407 | } |
||
408 | } |
||
409 | |||
410 | private function pushVersionBump() |
||
411 | { |
||
412 | $version_file = 'LibreNMS/Util/Version.php'; |
||
413 | $contents = file_get_contents(base_path($version_file)); |
||
414 | $updated_contents = preg_replace("/const VERSION = '[^']+';/", "const VERSION = '$this->tag';", $contents); |
||
415 | |||
416 | return $this->pushFileContents($version_file, $updated_contents, "Bump version to $this->tag"); |
||
417 | } |
||
418 | |||
419 | /** |
||
420 | * @param string $file Path in git repo |
||
421 | * @param string $contents new file contents |
||
422 | * @param string $message The commit message |
||
423 | * @return Requests_Response |
||
424 | */ |
||
425 | private function pushFileContents($file, $contents, $message) |
||
426 | { |
||
427 | $existing = Requests::get($this->github . '/contents/' . $file, $this->getHeaders()); |
||
428 | $existing_sha = json_decode($existing->body)->sha; |
||
429 | |||
430 | $updated = Requests::put($this->github . '/contents/' . $file, $this->getHeaders(), json_encode([ |
||
431 | 'message' => $message, |
||
432 | 'content' => base64_encode($contents), |
||
433 | 'sha' => $existing_sha, |
||
434 | ])); |
||
435 | |||
436 | return json_decode($updated->body)->commit->sha; |
||
437 | } |
||
438 | } |
||
439 |
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.