10 our $workdir = './openwrt-changelog-data';
12 unless (defined $range) {
13 printf STDERR
"Usage: $0 range\n";
17 unless (-d
$workdir) {
18 unless (system('mkdir', '-p', $workdir) == 0) {
19 printf STDERR
"Unable to create work directory!\n";
31 my $c = '<color #ccc>%s</color>';
32 my $g = '<color #282>%s</color>';
33 my $r = '<color #f00>%s</color>';
35 if ($commit->added > 1000)
37 $s .= sprintf $g, sprintf '+%.1fK', $commit->added / 1000;
39 elsif ($commit->added > 0)
41 $s .= sprintf $g, sprintf '+%d', $commit->added;
44 if ($commit->deleted > 1000)
46 $s .= $s ?
sprintf($c, ',') : '';
47 $s .= sprintf $r, sprintf '-%.1fK', $commit->deleted / 1000;
49 elsif ($commit->deleted > 0)
51 $s .= $s ?
sprintf($c, ',') : '';
52 $s .= sprintf $r, sprintf '-%d', $commit->deleted;
55 return sprintf($c, '(') . $s . sprintf($c, ')');
58 sub format_subject
($$)
60 my ($subject, $body) = @_;
62 if (length($subject) > 80)
64 $subject = substr($subject, 0, 77) . '...';
67 $subject =~ s!^([^\s:]+):\s*!</nowiki>**<nowiki>$1:</nowiki>** <nowiki>!g;
69 $subject = sprintf '<nowiki>%s</nowiki>', $subject;
70 $subject =~ s!<nowiki></nowiki>!!g;
79 printf "''[[%s|%s]]'' %s //%s//\\\\\n",
80 sprintf($change->repository->commit_link_template, $change->sha1),
81 substr($change->sha1, 0, 7),
82 format_subject
($change->subject, $change->body),
85 my @subhistory = $change->subhistory;
87 if (@subhistory > 0) {
91 foreach my $subchange (@subhistory) {
93 $link_tpl = $subchange->repository->commit_link_template;
97 printf " => ''[[%s|%s]]'' %s //%s//\\\\\n",
98 sprintf($link_tpl, $subchange->sha1),
99 substr($subchange->sha1, 0, 7),
100 format_subject
($subchange->subject, $subchange->body),
101 format_stat
($subchange);
104 printf " => ''%s'' %s //%s//\\\\\n",
105 substr($subchange->sha1, 0, 7),
106 format_subject
($subchange->subject, $subchange->body),
107 format_stat
($subchange);
110 if (++$n > 15 && @subhistory > $n) {
111 printf " => + //%u more...//\\\\\n", @subhistory - $n;
120 unless (-f
"$workdir/cveinfo.csv")
122 system('wget', '-O', "$workdir/cveinfo.csv.gz", 'https://cve.mitre.org/data/downloads/allitems.csv.gz') && return 0;
123 system('gunzip', '-f', "$workdir/cveinfo.csv.gz") && return 0;
131 my $csv = Text
::CSV
->new({ binary
=> 1 });
134 if (fetch_cve_info
() && $csv)
136 if (open CVE
, '<', "$workdir/cveinfo.csv")
138 while (defined(my $row = $csv->getline(*CVE
)))
140 foreach my $cve_id (@_)
142 if ($row->[0] eq $cve_id)
144 $cves{$cve_id} = [$row->[2], $row->[6]];
158 my $repository = Repository
->new('https://git.openwrt.org/openwrt/openwrt.git');
159 my $bugtracker = BugTracker
->new;
161 my @commits = $repository->parse_history($range);
162 my (%bugs, %cves, %sha1s);
164 foreach my $commit (@commits)
166 if ($commit->subject =~ m!\b(?:LEDE|OpenWrt) v\d\d\.\d\d\.\d+(?:-rc\d+)?: (?:adjust config|revert to branch) defaults\b!) {
167 Log
::info
("Skipping maintenance commit %s (%s)", $commit->sha1, $commit->subject);
171 my @topics = $commit->topics;
173 foreach my $topic (@topics)
175 $topics{$topic} ||= [ ];
176 push @
{$topics{$topic}}, $commit;
179 foreach my $bug ($commit->bugs) {
180 if ($bug->status ne 'closed') {
181 Log
::warn("Commit %s closes bug #%d", $commit->sha1, $bug->id);
184 $bugs{ $bug->id } ||= [ ];
185 push @
{$bugs{ $bug->id }}, $commit;
188 foreach my $cve_id ($commit->cve_ids) {
189 $cves{$cve_id} ||= [ ];
190 push @
{$cves{$cve_id}}, $commit;
193 $sha1s{$commit->[1]}++;
196 Log
::info
("Finding commit references in bugs...");
198 foreach my $bug ($bugtracker->bugs)
200 next if exists $bugs{ $bug->id };
202 foreach my $hash ($bug->refs) {
203 my $commit = $repository->find_commit($hash);
204 next unless defined $commit;
206 if ($bug->status ne 'closed') {
207 Log
::warn("Bug #%d closed by commit %s", $bug->id, $commit->sha1);
210 $bugs{ $bug->id } ||= [ ];
211 push @
{$bugs{ $bug->id }}, $commit;
216 my @topics = sort { (($a eq 'Miscellaneous') <=> ($b eq 'Miscellaneous')) || $a cmp $b } keys %topics;
218 foreach my $topic (@topics)
220 my @commits = @
{$topics{$topic}};
222 printf "==== %s (%d change%s) ====\n", $topic, 0 + @commits, @commits > 1 ?
's' : '';
224 foreach my $change (sort { $a->pos <=> $b->pos } @commits)
226 format_change
($change);
232 my @bugs = map { $bugtracker->get($_) } sort { int($a) <=> int($b) } keys %bugs;
235 printf "===== Addressed bugs =====\n";
237 foreach my $bug (@bugs)
240 printf "=== FS#%d (#%d) ===\n", $bug->fsid, $bug->id;
243 printf "=== #%d ===\n", $bug->id;
246 printf "**Description:** <nowiki>%s</nowiki>\\\\\n", $bug->summary;
247 printf "**Link:** [[https://github.com/openwrt/openwrt/issues/%d]]\\\\\n", $bug->id;
248 printf "**Commits:**\\\\\n";
250 foreach my $commit (@
{$bugs{ $bug->id }})
252 format_change
($commit);
263 sort { ($a->[0] <=> $b->[0]) || ($a->[1] cmp $b->[1]) }
264 map { $_ =~ m!^CVE-(\d+)-(\d+)$! ?
[ $1 * 10000000 + $2, $_ ] : [ 0, $_ ] }
267 my $cve_info = parse_cves
(@cves);
271 printf "===== Security fixes ====\n";
273 foreach my $cve (@cves)
275 printf "=== %s ===\n", $cve;
277 if ($cve_info->{$cve} && $cve_info->{$cve}[0])
279 printf "**Description:** <nowiki>%s</nowiki>\n\n", $cve_info->{$cve}[0];
282 printf "**Link:** [[https://cve.mitre.org/cgi-bin/cvename.cgi?name=%s]]\\\\\n", $cve;
283 printf "**Commits:**\\\\\n";
285 foreach my $commit (@
{$cves{$cve}})
287 format_change
($commit);
300 my ($fmt, @args) = @_;
301 printf STDERR
"[I] %s\n", sprintf $fmt, @args;
306 my ($fmt, @args) = @_;
307 printf STDERR
"[W] %s\n", sprintf $fmt, @args;
312 my ($fmt, @args) = @_;
313 printf STDERR
"[E] %s\n", sprintf $fmt, @args;
321 my ($self, $ts) = @_;
322 my @loc = gmtime $ts;
323 return sprintf '%04d-%02d-%02dT%02d:%02d:%02dZ',
324 $loc[5] + 1900, $loc[4] + 1, $loc[3],
325 $loc[2], $loc[1], $loc[0];
329 my ($self, $date) = @_;
330 return 0 unless $date;
332 my ($year, $mon, $mday, $hour, $min, $sec) = $date =~ m!^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$!;
333 return Time
::Local
::timegm_posix
($sec, $min, $hour, $mday, $mon - 1, $year - 1900);
337 my ($self, $path, $records) = @_;
339 if (open my $file, '<:utf8', $path) {
343 push @
$records, @
{ JSON
::decode_json
(readline $file) };
349 return Log
::err
("Unable to read $path: $@");
356 sub _fetch_one_page
{
357 my ($self, $since, $page) = @_;
358 my $url = $self->{'url'};
359 my $sep = ($url =~ m!\?!) ?
'&' : '?';
363 $url .= $sep . 'since=' . $self->_date($since);
368 $url .= $sep . 'per_page=100&page=' . $page;
372 if (open my $wget, '-|', 'wget', '--auth-no-challenge', '-q', '-O', '-', $url) {
376 $res = JSON
::decode_json
(readline $wget);
380 Log
::err
("Failed to parse result from $url: $@");
386 Log
::err
("Failed to fetch $url via wget: $!");
395 my $cache = "$main::workdir/" . $self->{'cachefile'};
396 my @stat = stat $cache;
397 my $since = defined($stat[9]) ?
$stat[9] : $self->{'since'}; $since -= ($since % 86400);
402 Log
::info
("Updating " . $self->{'cachefile'} . " database...");
405 my $res = $self->_fetch_one_page($since, $page);
407 if (ref($res) ne 'ARRAY') {
408 return Log
::err
("Aborting update due to invalid response");
411 push @new_records, @
$res;
413 Log
::info
(" Fetched " . @new_records . " records...");
420 if ($self->_read_cache($cache, \
@old_records)) {
427 foreach my $record (@old_records) {
428 if (ref($record) ne 'HASH' || !exists($record->{ $self->{'idprop'} })) {
432 $index{ $record->{ $self->{'idprop'} } } = $record;
435 foreach my $record (@new_records) {
436 if (ref($record) ne 'HASH' || !exists($record->{ $self->{'idprop'} })) {
440 my $old = $index{ $record->{ $self->{'idprop'} } };
442 if (!$old || $self->_ts($record->{'updated_at'}) != $self->_ts($old->{'updated_at'})) {
443 $index{ $record->{ $self->{'idprop'} } } = $record;
448 if (!defined($stat[9]) || $updated) {
449 Log
::info
(" Found " . $updated . " updated records...");
451 if (open my $file, '>:utf8', $cache) {
452 print $file JSON
::encode_json
([ values %index ]);
456 return Log
::err
("Unable to update $cache: $!");
461 if (!utime($now, $now, $cache)) {
462 Log
::warn("Unable to change $cache modification time: $!");
470 my ($self, $force_update) = @_;
471 my $cache = "$main::workdir/" . $self->{'cachefile'};
478 if (-f
$cache && $self->_read_cache($cache, \
@records)) {
482 return wantarray ?
@records : \
@records;
486 my ($pack, $baseurl, $cachefile, $idprop, $since) = @_;
490 cachefile
=> $cachefile,
504 return 0 if $self->{'bugs'};
506 my $issues = GitHubQuery
->new(
507 "https://api.github.com/repos/openwrt/openwrt/issues?state=all&sort=updated&direction=desc",
513 return 1 unless $issues;
515 $self->{'bugs'} = { };
516 $self->{'fsbugs'} = { };
518 foreach my $issue (@
$issues) {
519 my ($date_opened, $date_closed, $date_modified) = (0, 0, 0);
521 if (exists($issue->{'created_at'})) {
522 $date_opened = GitHubQuery
->_ts($issue->{'created_at'});
525 if (exists($issue->{'updated_at'})) {
526 $date_modified = GitHubQuery
->_ts($issue->{'updated_at'});
529 if (exists($issue->{'closed_at'})) {
530 $date_closed = GitHubQuery
->_ts($issue->{'closed_at'});
542 $self->{'bugs'}{ $bug->id } = $bug;
544 if ($issue->{'title'} =~ /^FS#(\d+) - /) {
545 $self->{'fsbugs'}{$1} = $bug;
556 $inst = bless {}, $pack;
563 my ($self, $id) = @_;
565 return undef if $self->_parse;
566 return $self->{'bugs'}{$id};
570 my ($self, $id) = @_;
572 return undef if $self->_parse;
573 return $self->{'fsbugs'}{$id};
578 return undef if $self->_parse;
580 my @bugs = map { $self->{'bugs'}{$_} } sort { $a <=> $b } keys %{$self->{'bugs'}};
581 return wantarray ?
@bugs : \
@bugs;
601 my ($pack, $id, $summary, $status, $opened, $closed, $modified) = @_;
604 if ($summary =~ s/^FS#(\d+) - //) {
619 sub id
{ shift->[_ID
] }
620 sub fsid
{ shift->[_FSID
] }
621 sub url
{ sprintf 'https://api.github.com/repos/openwrt/openwrt/issues/%d/comments', shift->id }
622 sub file
{ sprintf '%s/issue/%d.json', $main::workdir
, shift->id }
623 sub summary
{ shift->[_SUM
] }
624 sub status
{ shift->[_STAT
] }
629 my @stat = stat $self->file;
632 if (!defined($stat[9]) || ($stat[9] < $self->[_CHANGE
])) {
636 #Log::info("Fetching details for Bug #%d ...", $self->id);
638 if (system('mkdir', '-p', "$main::workdir/issue")) {
639 return Log
::err
("Unable to create directory!");
642 my $comments = GitHubQuery
->new(
644 sprintf('issue/%d.json', $self->id),
650 Log
::err
("Unable to fetch bug details!");
655 return wantarray ? @
$comments : $comments;
658 sub _find_commit_references
()
661 my $comments = $self->_fetch;
663 return undef unless $comments;
665 foreach my $comment (@
$comments) {
666 my $str = $comment->{'body'};
667 my @refs = $str =~ m
!
672 fix \s
+ (?
: in | into
) \s
+ (?
: \w
+ \s
+ )*
674 (?
: <a \s
+ href
=" )? # "
676 https?
://git\
.(?
:openwrt
|lede
-project
)\
.org
/\?p=[\w/]+\
.git\S
*;h
=[a
-fA
-F0
-9]{4,40} |
677 https?
://git\
.(?
:openwrt
|lede
-project
)\
.org
/[a
-fA
-F0
-9]{4,40} |
678 https?
://github\
.com
/[^/]+/commit/[a
-fA
-F0
-9]{4,40} |
683 return @refs if @refs > 0;
690 unless (defined $self->[_REFS
]) {
693 foreach my $ref ($self->_find_commit_references) {
694 if ($ref =~ m!\b([a-fA-F0-9]{4,40})$!) {
699 $self->[_REFS
] = [ sort keys %sha1 ];
702 return wantarray ? @
{$self->[_REFS
]} : $self->[_REFS
];
715 my ($pack, $url) = @_;
718 $id =~ s!\bgit\.lede-project\.org\b!git.openwrt.org!;
719 $id =~ s![^a-z0-9_-]+!-!g;
721 unless (exists $repositories{$id}) {
722 $repositories{$id} = bless {
728 $repositories{$id}->_fetch;
731 return $repositories{$id};
734 sub id
{ shift->{'id'} }
735 sub url
{ shift->{'url'} }
736 sub directory
{ sprintf '%s/repos/%s', $main::workdir
, shift->id }
741 if (-d
$self->directory) {
742 Log
::info
("Updating repository %s ...", $self->url);
744 my $tree = $self->directory;
745 my $git = $tree . '/.git';
747 if (system('git', "--work-tree=$tree", "--git-dir=$git", 'fetch', '--all', '--quiet')) {
748 return Log
::err
("Unable to pull repository!");
754 Log
::info
("Cloning repository %s ...", $self->url);
756 if (system('mkdir', '-p', $self->directory)) {
757 return Log
::err
("Unable to create directory!");
759 elsif (system('git', 'clone', '--quiet', $self->url, $self->directory)) {
760 return Log
::err
("Unable to clone repository!");
767 my ($self, $fh, $default) = @_;
769 my $line = readline $fh;
782 my ($self, $fh) = @_;
787 $self->_readline($fh, undef);
790 my $hash = $self->_readline($fh, '');
791 my $subject = $self->_readline($fh, '');
793 last unless (length($subject) && $hash =~ m!^[a-f0-9]{40}$!);
797 # commit already cached, skip lines and use cached object
798 if (exists $Repository::commits
{$hash}) {
799 for ($line = ''; $line ne '@@'; $line = $self->_readline($fh, '@@')) { next; }
800 for ($line = ''; $line ne '@@'; $line = $self->_readline($fh, '@@')) { next; }
802 push @commits, $Repository::commits
{$hash};
808 my ($add, $del) = (0, 0);
810 while ($line ne '@@') {
811 $body .= length($line) ?
"$line\n" : '';
812 $line = $self->_readline($fh, '@@');
817 my $reading_diff = 0;
818 my ($subhistory, $subhistory_url, $subhistory_start, $subhistory_end);
820 while ($line ne '@@') {
821 if ($line =~ m!^diff --git a/!) {
823 undef $subhistory_url;
824 undef $subhistory_start;
825 undef $subhistory_end;
827 elsif ($reading_diff) {
828 if ($line =~ m!^[ +]PKG_SOURCE_URL\s*:?=\s*(\S+)!) {
829 $subhistory_url = $1;
830 $subhistory_url =~ s!\$\(LEDE_GIT\)!https://git.lede-project.org!g;
831 $subhistory_url =~ s!\$\(OPENWRT_GIT\)!https://git.openwrt.org!g;
832 $subhistory_url =~ s!\$\(PROJECT_GIT\)!https://git.openwrt.org!g;
834 elsif ($line =~ m!^-\S+\s*:?=\s*([a-f0-9]{40})\b!) {
835 $subhistory_start = $1;
837 elsif ($line =~ m!^\+\S+\s*:?=\s*([a-f0-9]{40})\b!) {
838 $subhistory_end = $1;
840 if ($subhistory_url && $subhistory_start && $subhistory_end) {
841 $subhistory = Repository
->new($subhistory_url)->parse_history("$subhistory_start..$subhistory_end");
845 elsif ($line =~ m!^(\d+|-)\s+(\d+|-)\s+(.+)$!) {
846 $add += ($1 eq '-') ?
0 : int($1);
847 $del += ($2 eq '-') ?
0 : int($2);
851 $line = $self->_readline($fh, '@@');
854 my $commit = Commit
->new($self, $num++, $hash, $subject, $body, $add, $del, $subhistory, @files);
856 push @commits, $commit;
857 push @Repository::index, $commit;
859 $Repository::commits
{ $commit->sha1 } = $commit;
862 @Repository::index = sort { $a->sha1 cmp $b->sha1 } @Repository::index;
864 return wantarray ?
@commits : \
@commits;
867 sub parse_history
($$) {
868 my ($self, $range) = @_;
869 my $gitdir = sprintf '%s/.git', $self->directory;
872 if (open my $git, '-|', 'git', "--git-dir=$gitdir", 'log', '-p', '--format=@@%n%H%n%s%n%b%n@@', '--numstat', '--reverse', '--no-merges', $range) {
873 @commits = $self->_parse($git);
877 return wantarray ?
@commits : \
@commits;
880 sub find_commit
($$) {
881 my ($self, $hash) = @_;
883 if (exists $Repository::commits
{$hash}) {
884 return $Repository::commits
{$hash};
887 my ($l, $r) = (0, @Repository::index - 1);
890 my $m = $l + int(($r - $l) / 2);
892 if (index($Repository::index[$m]->sha1, $hash) == 0) {
893 return $Repository::index[$m];
895 elsif ($Repository::index[$m]->sha1 gt $hash) {
908 [ qr
'^[^:]+://(git.lede-project.org/)(.+)$' => 'https://%s?p=%s;a=commitdiff;h=%%s' ],
909 [ qr
'^[^:]+://(git.openwrt.org/)(.+)$' => 'https://%s?p=%s;a=commitdiff;h=%%s' ],
910 [ qr
'^[^:]+://(github.com/.+?)(?:\.git)?$' => 'https://%s/commit/%%s' ],
911 [ qr
'^[^:]+://git.kernel.org/pub/scm/(.+)$' => 'https://git.kernel.org/cgit/%s/commit/?id=%%s' ],
912 [ qr
'^[^:]+://w1.fi/(?:.+/)?(.+)\.git$' => 'https://w1.fi/cgit/%s/commit/?id=%%s' ],
913 [ qr
'^[^:]+://git.netfilter.org/(.+)' => 'https://git.netfilter.org/%s/commit/?id=%%s' ],
914 [ qr
'^[^:]+://git.musl-libc.org/(.+)' => 'https://git.musl-libc.org/cgit/%s/commit/?id=%%s' ],
915 [ qr
'^[^:]+://git.zx2c4.com/(.+)' => 'https://git.zx2c4.com/%s/commit/?id=%%s' ],
916 [ qr
'^[^:]+://sourceware.org/git/(.+)' => 'https://sourceware.org/git/?p=%s;a=commitdiff;h=%%s' ]
919 sub commit_link_template
($) {
922 foreach my $lnk ($self->_weblinks) {
923 my @matches = $self->url =~ $lnk->[0];
925 return sprintf $lnk->[1], @matches;
929 Log
::warn("No web link template available for %s", $self->url);
935 return wantarray ? @
{$self->{'log'}} : $self->{'log'};
954 [ qr
'^package/(kernel)/linux', 'Kernel' ],
955 [ qr
'^(target/linux/generic|include/kernel-version.mk)', 'Kernel' ],
956 [ qr
'^package/kernel/(mac80211)', 'Wireless / Common' ],
957 [ qr
'^package/kernel/(ath10k-ct)', 'Wireless / Ath10k CT' ],
958 [ qr
'^package/kernel/(mt76)', 'Wireless / MT76' ],
959 [ qr
'^package/kernel/(mwlwifi)', 'Wireless / Mwlwifi' ],
960 [ qr
'^package/(base-files)/', 'Packages / OpenWrt base files' ],
961 [ qr
'^package/(boot)/', 'Packages / Boot Loaders' ],
962 [ qr
'^package/firmware/', 'Packages / Firmware' ],
963 [ qr
'^package/.+/(uhttpd|usbmode|jsonfilter|ugps|libubox|procd|mountd|ubus|uci|usign|rpcd|fstools|ubox)/', 'Packages / OpenWrt system userland' ],
964 [ qr
'^package/.+/(iwinfo|umbim|uqmi|relayd|mdns|firewall|netifd|uclient|ustream-ssl|gre|ipip|qos-scripts|swconfig|vti|6in4|6rd|6to4|ds-lite|map|odhcp6c|odhcpd)/', 'Packages / OpenWrt network userland' ],
965 [ qr
'^package/[^/]+/([^/]+)', 'Packages / Common' ],
966 [ qr
'^target/sdk/', 'Build System / SDK' ],
967 [ qr
'^target/imagebuilder/', 'Build System / Image Builder' ],
968 [ qr
'^target/toolchain/', 'Build System / Toolchain' ],
969 [ qr
'^target/linux/([^/]+)', 'Target / $1' ],
970 [ qr
'^(tools)/[^/]+', 'Build System / Host Utilities' ],
971 [ qr
'^(toolchain)/[^/]+', 'Build System / Toolchain' ],
972 [ qr
'^(config/|include/|scripts/|target/[^/]+$|Makefile|rules\.mk)', 'Build System / Buildroot' ],
973 [ qr
'^(feeds)\b', 'Build System / Feeds' ],
976 sub new
($$$$$$$$@
) {
977 my ($pack, $repo, $pos, $hash, $subject, $body, $add, $del, $shist, @files) = @_;
980 $commit[_REPO
] = $repo;
981 $commit[_POS
] = $pos;
982 $commit[_SHA1
] = $hash;
983 $commit[_SUBJ
] = $subject;
984 $commit[_BODY
] = $body;
985 $commit[_NADD
] = $add;
986 $commit[_NDEL
] = $del;
987 $commit[_SHIST
] = $shist;
988 $commit[_FILES
] = \
@files;
990 return bless \
@commit, $pack;
993 sub repository
{ shift->[_REPO
] }
994 sub pos { shift->[_POS
] }
995 sub sha1
{ shift->[_SHA1
] }
996 sub subject
{ shift->[_SUBJ
] }
997 sub body
{ shift->[_BODY
] }
998 sub added
{ shift->[_NADD
] }
999 sub deleted
{ shift->[_NDEL
] }
1000 sub files
{ wantarray ? @
{shift->[_FILES
] || []} : shift->[_FILES
] }
1001 sub subhistory
{ wantarray ? @
{shift->[_SHIST
] || []} : shift->[_SHIST
] }
1008 foreach my $path ($self->files)
1010 if ($path =~ m!^(.+)/\{(.+?) => (.+?)\}$!)
1021 foreach my $path (sort keys %paths)
1023 foreach my $rs ($self->_topic_map)
1025 if ($path =~ $rs->[0])
1038 my @topics = sort keys %topics;
1039 return (@topics > 0 ?
@topics : ('Miscellaneous'));
1045 my $bugtracker = BugTracker
->new;
1046 my $candidates = qr
'\b((?:[Pp]ull [Rr]equest |[Bb]ug |[Ii]ssue |PR |FS |GH |PR|FS|GH)#\d+)\b';
1049 foreach my $match ($self->subject =~ /$candidates/g, $self->body =~ /$candidates/g) {
1052 if ($match =~ /^FS ?#(\d+)$/) {
1053 $bug = $bugtracker->get_fs($1);
1055 elsif ($match =~ /^(GH|PR|[Pp]ull [Rr]equest) ?#(\d+)$/i) {
1056 $bug = $bugtracker->get($1);
1058 elsif ($match =~ /^#(\d+)$/) {
1059 $bug = $bugtracker->get_fs($1) || $bugtracker->get($1);
1063 $bugs{ $bug->id } = $bug;
1067 foreach my $tag (qw(Fixes Closes Supersedes)) {
1068 my ($ids) = $self->body =~ /\b$tag: *((?:GH|PR|FS|)#\d+(?:[, ]+#\d+)*)/;
1070 foreach my $id (split /[, ]+/, ($ids || '')) {
1073 if ($id =~ /^FS#(\d+)$/) {
1074 $bug = $bugtracker->get_fs($1);
1076 elsif ($id =~ /^(GH|PR)#(\d+)$/) {
1077 $bug = $bugtracker->get($1);
1079 elsif ($id =~ /^#(\d+)$/) {
1080 $bug = $bugtracker->get_fs($1) || $bugtracker->get($1);
1084 $bugs{ $bug->id } = $bug;
1089 return map { $bugs{$_} } sort { $a <=> $b } keys %bugs;
1094 my $candidates = qr
'\b(CVE-\d+-\d+|\d+-CVE-\d+)\b';
1097 foreach my $match ($self->subject =~ /$candidates/g, $self->body =~ /$candidates/g) {
1098 # fix misspelled CVE IDs
1099 $match =~ s!^(\d+)-CVE-!CVE-$1-!;
1103 return sort { $a cmp $b } keys %cves;