Issues (2963)

LibreNMS/Util/GitHub.php (1 issue)

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
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
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