9 our $workdir = './openwrt-changelog-data';
11 unless (defined $range) {
12 printf STDERR
"Usage: $0 range\n";
16 unless (-d
$workdir) {
17 unless (system('mkdir', '-p', $workdir) == 0) {
18 printf STDERR
"Unable to create work directory!\n";
30 my $c = '<color #ccc>%s</color>';
31 my $g = '<color #282>%s</color>';
32 my $r = '<color #f00>%s</color>';
34 if ($commit->added > 1000)
36 $s .= sprintf $g, sprintf '+%.1fK', $commit->added / 1000;
38 elsif ($commit->added > 0)
40 $s .= sprintf $g, sprintf '+%d', $commit->added;
43 if ($commit->deleted > 1000)
45 $s .= $s ?
sprintf($c, ',') : '';
46 $s .= sprintf $r, sprintf '-%.1fK', $commit->deleted / 1000;
48 elsif ($commit->deleted > 0)
50 $s .= $s ?
sprintf($c, ',') : '';
51 $s .= sprintf $r, sprintf '-%d', $commit->deleted;
54 return sprintf($c, '(') . $s . sprintf($c, ')');
57 sub format_subject
($$)
59 my ($subject, $body) = @_;
61 if (length($subject) > 80)
63 $subject = substr($subject, 0, 77) . '...';
66 $subject =~ s!^([^\s:]+):\s*!</nowiki>**<nowiki>$1:</nowiki>** <nowiki>!g;
68 $subject = sprintf '<nowiki>%s</nowiki>', $subject;
69 $subject =~ s!<nowiki></nowiki>!!g;
78 printf "''[[%s|%s]]'' %s //%s//\\\\\n",
79 sprintf($change->repository->commit_link_template, $change->sha1),
80 substr($change->sha1, 0, 7),
81 format_subject
($change->subject, $change->body),
84 my @subhistory = $change->subhistory;
86 if (@subhistory > 0) {
90 foreach my $subchange (@subhistory) {
92 $link_tpl = $subchange->repository->commit_link_template;
96 printf " => ''[[%s|%s]]'' %s //%s//\\\\\n",
97 sprintf($link_tpl, $subchange->sha1),
98 substr($subchange->sha1, 0, 7),
99 format_subject
($subchange->subject, $subchange->body),
100 format_stat
($subchange);
103 printf " => ''%s'' %s //%s//\\\\\n",
104 substr($subchange->sha1, 0, 7),
105 format_subject
($subchange->subject, $subchange->body),
106 format_stat
($subchange);
109 if (++$n > 15 && @subhistory > $n) {
110 printf " => + //%u more...//\\\\\n", @subhistory - $n;
119 unless (-f
"$workdir/cveinfo.csv")
121 system('wget', '-O', "$workdir/cveinfo.csv.gz", 'https://cve.mitre.org/data/downloads/allitems.csv.gz') && return 0;
122 system('gunzip', '-f', "$workdir/cveinfo.csv.gz") && return 0;
130 my $csv = Text
::CSV
->new({ binary
=> 1 });
133 if (fetch_cve_info
() && $csv)
135 if (open CVE
, '<', "$workdir/cveinfo.csv")
137 while (defined(my $row = $csv->getline(*CVE
)))
139 foreach my $cve_id (@_)
141 if ($row->[0] eq $cve_id)
143 $cves{$cve_id} = [$row->[2], $row->[6]];
157 my $repository = Repository
->new('https://git.openwrt.org/openwrt/openwrt.git');
158 my $bugtracker = BugTracker
->new;
160 my @commits = $repository->parse_history($range);
161 my (%bugs, %cves, %sha1s);
163 foreach my $commit (@commits)
165 if ($commit->subject =~ m!\b(?:LEDE|OpenWrt) v\d\d\.\d\d\.\d+(?:-rc\d+)?: (?:adjust config|revert to branch) defaults\b!) {
166 Log
::info
("Skipping maintenance commit %s (%s)", $commit->sha1, $commit->subject);
170 my @topics = $commit->topics;
172 foreach my $topic (@topics)
174 $topics{$topic} ||= [ ];
175 push @
{$topics{$topic}}, $commit;
178 foreach my $bug ($commit->bugs) {
179 if ($bug->status ne 'closed') {
180 Log
::warn("Commit %s closes bug #%d", $commit->sha1, $bug->id);
183 $bugs{ $bug->id } ||= [ ];
184 push @
{$bugs{ $bug->id }}, $commit;
187 foreach my $cve_id ($commit->cve_ids) {
188 $cves{$cve_id} ||= [ ];
189 push @
{$cves{$cve_id}}, $commit;
192 $sha1s{$commit->[1]}++;
195 Log
::info
("Finding commit references in bugs...");
197 foreach my $bug ($bugtracker->bugs)
199 next if exists $bugs{ $bug->id };
201 foreach my $hash ($bug->refs) {
202 my $commit = $repository->find_commit($hash);
203 next unless defined $commit;
205 if ($bug->status ne 'closed') {
206 Log
::warn("Bug #%d closed by commit %s", $bug->id, $commit->sha1);
209 $bugs{ $bug->id } ||= [ ];
210 push @
{$bugs{ $bug->id }}, $commit;
215 my @topics = sort { (($a eq 'Miscellaneous') <=> ($b eq 'Miscellaneous')) || $a cmp $b } keys %topics;
217 foreach my $topic (@topics)
219 my @commits = @
{$topics{$topic}};
221 printf "==== %s (%d change%s) ====\n", $topic, 0 + @commits, @commits > 1 ?
's' : '';
223 foreach my $change (sort { $a->pos <=> $b->pos } @commits)
225 format_change
($change);
231 my @bugs = map { $bugtracker->get($_) } sort { int($a) <=> int($b) } keys %bugs;
234 printf "===== Addressed bugs =====\n";
236 foreach my $bug (@bugs)
238 printf "=== #%d ===\n", $bug->id;
239 printf "**Description:** <nowiki>%s</nowiki>\\\\\n", $bug->summary;
240 printf "**Link:** [[https://bugs.openwrt.org/index.php?do=details&task_id=%d]]\\\\\n", $bug->id;
241 printf "**Commits:**\\\\\n";
243 foreach my $commit (@
{$bugs{ $bug->id }})
245 format_change
($commit);
256 sort { ($a->[0] <=> $b->[0]) || ($a->[1] cmp $b->[1]) }
257 map { $_ =~ m!^CVE-(\d+)-(\d+)$! ?
[ $1 * 10000000 + $2, $_ ] : [ 0, $_ ] }
260 my $cve_info = parse_cves
(@cves);
264 printf "===== Security fixes ====\n";
266 foreach my $cve (@cves)
268 printf "=== %s ===\n", $cve;
270 if ($cve_info->{$cve} && $cve_info->{$cve}[0])
272 printf "**Description:** <nowiki>%s</nowiki>\n\n", $cve_info->{$cve}[0];
275 printf "**Link:** [[https://cve.mitre.org/cgi-bin/cvename.cgi?name=%s]]\\\\\n", $cve;
276 printf "**Commits:**\\\\\n";
278 foreach my $commit (@
{$cves{$cve}})
280 format_change
($commit);
293 my ($fmt, @args) = @_;
294 printf STDERR
"[I] %s\n", sprintf $fmt, @args;
299 my ($fmt, @args) = @_;
300 printf STDERR
"[W] %s\n", sprintf $fmt, @args;
305 my ($fmt, @args) = @_;
306 printf STDERR
"[E] %s\n", sprintf $fmt, @args;
316 my ($self, $ts) = @_;
317 my @loc = gmtime $ts;
318 return sprintf '%04d-%02d-%02d', $loc[5] + 1900, $loc[4] + 1, $loc[3];
324 return 0 if $self->{'fetched'};
326 my @stat = stat "$main::workdir/buginfo.csv";
327 my $since = defined($stat[9]) ?
$stat[9] : 86400; $since -= ($since % 86400);
328 my $sdate = $self->_date($since - 86400);
330 Log
::info
("Updating bug database...");
332 if (system('wget', '-qO', "$main::workdir/buginfo-delta.csv",
333 "https://bugs.openwrt.org/index.php?string=&project=2&do=index&export_list=Export+Tasklist&advancedsearch=on&type%5B%5D=&sev%5B%5D=&pri%5B%5D=&due%5B%5D=&reported%5B%5D=&cat%5B%5D=&status%5B%5D=&percent%5B%5D=&opened=&dev=&closed=&duedatefrom=&duedateto=&changedfrom=$sdate&changedto=&openedfrom=&openedto=&closedfrom=&closedto=")) {
334 return Log
::err
('Unable to fetch database changes!');
337 $self->_update($since);
339 $self->{'fetched'}++;
348 my $csv = Text
::CSV
->new({
350 'allow_loose_quotes' => 1
353 if (open my $file, '<', "$main::workdir/buginfo.csv") {
354 while (defined(my $row = $csv->getline($file))) {
355 next if $row->[0] eq 'ID';
356 $row->[13] = 0 unless defined $row->[13];
357 $records{$row->[0]} = $row;
363 if (open my $file, '<', "$main::workdir/buginfo-delta.csv") {
367 while (defined(my $row = $csv->getline($file))) {
368 next if $row->[0] eq 'ID';
371 $records{$row->[0]} = $row;
377 if (open $file, '>:utf8', "$main::workdir/buginfo.csv") {
378 foreach my $id (sort { $a <=> $b } keys %records) {
379 $csv->print($file, $records{$id});
385 if (!utime($now, $now, "$main::workdir/buginfo.csv")) {
386 Log
::warn("Unable to change modification time: $!");
389 Log
::info
("Found %d updated bugs", $changed);
397 return 0 if $self->{'bugs'};
398 return 1 if $self->_fetch;
400 $self->{'bugs'} = { };
402 my $csv = Text
::CSV
->new({
404 'allow_loose_quotes' => 1
407 if (open my $file, '<', "$main::workdir/buginfo.csv") {
408 while (defined(my $row = $csv->getline($file))) {
409 next if $row->[0] eq 'ID';
411 my ($date_opened, $date_closed, $date_modified) = (0, 0, 0);
413 if (defined($row->[7]) && $row->[7] =~ m!^(\d+)$!) {
414 $date_opened = int($1);
417 if (defined($row->[8]) && $row->[8] =~ m!^(\d+)$!) {
418 $date_closed = int($1);
421 if (defined($row->[13]) && $row->[13] =~ m!^(\d+)$!) {
422 $date_modified = int($1);
428 lc(($date_closed > $date_opened) ?
'Closed' : $row->[5]),
434 $self->{'bugs'}{ $bug->id } = $bug;
447 $inst = bless {}, $pack;
454 my ($self, $id) = @_;
456 return undef if $self->_parse;
457 return $self->{'bugs'}{$id};
462 return undef if $self->_parse;
464 my @bugs = map { $self->{'bugs'}{$_} } sort { $a <=> $b } keys %{$self->{'bugs'}};
465 return wantarray ?
@bugs : \
@bugs;
484 my ($pack, $id, $summary, $status, $opened, $closed, $modified) = @_;
495 sub id
{ shift->[_ID
] }
496 sub url
{ sprintf 'https://bugs.openwrt.org/index.php?do=details&task_id=%d', shift->id }
497 sub file
{ sprintf '%s/ticket/%d.html', $main::workdir
, shift->id }
498 sub summary
{ shift->[_SUM
] }
499 sub status
{ shift->[_STAT
] }
504 my @stat = stat $self->file;
506 if (defined($stat[9]) && ($stat[9] >= $self->[_CHANGE
])) {
510 Log
::info
("Fetching details for Bug #%d ...", $self->id);
512 if (system('mkdir', '-p', File
::Basename
::dirname
($self->file))) {
513 return Log
::err
("Unable to create directory!");
515 elsif (system('wget', '-q', '-O', $self->file, $self->url)) {
516 return Log
::err
("Unable to fetch bug details!");
518 elsif (!utime($self->[_CHANGE
], $self->[_CHANGE
], $self->file)) {
519 return LOG
::warn("Unable to change modification time: $!");
525 sub _find_commit_references
()
529 return undef if $self->_fetch;
532 my $tree = HTML
::TreeBuilder
->new_from_file($self->file);
534 my $closed = $tree->look_down('id' => 'taskclosed');
536 my $str = $closed->as_HTML;
537 if ($str =~ m!<strong>Reason for closing:</strong>[^\n]+\bFixed\b!) {
541 my @refs = $str =~ m
!\b (
542 https?
://git\
.(?
:openwrt
|lede
-project
)\
.org
/\?p=[\w/]+\
.git\S
*;h
=[a
-fA
-F0
-9]{4,40} |
543 https?
://git\
.(?
:openwrt
|lede
-project
)\
.org
/[a
-fA
-F0
-9]{4,40} |
544 https?
://github\
.com
/[^/]+/commit/[a
-fA
-F0
-9]{4,40} |
548 return @refs if @refs > 0;
552 foreach my $comment (reverse $tree->look_down('class' => 'commenttext')) {
553 my $str = $comment->as_HTML;
554 my @refs = $str =~ m
!
558 fix \s
+ (?
: in | into
) \s
+ (?
: \w
+ \s
+ )*
560 (?
: <a \s
+ href
=" )? # "
562 https?
://git\
.(?
:openwrt
|lede
-project
)\
.org
/\?p=[\w/]+\
.git\S
*;h
=[a
-fA
-F0
-9]{4,40} |
563 https?
://git\
.(?
:openwrt
|lede
-project
)\
.org
/[a
-fA
-F0
-9]{4,40} |
564 https?
://github\
.com
/[^/]+/commit/[a
-fA
-F0
-9]{4,40} |
569 return @refs if @refs > 0;
577 unless (defined $self->[_REFS
]) {
580 foreach my $ref ($self->_find_commit_references) {
581 if ($ref =~ m!\b([a-fA-F0-9]{4,40})$!) {
586 $self->[_REFS
] = [ sort keys %sha1 ];
589 return wantarray ? @
{$self->[_REFS
]} : $self->[_REFS
];
602 my ($pack, $url) = @_;
605 $id =~ s!\bgit\.lede-project\.org\b!git.openwrt.org!;
606 $id =~ s![^a-z0-9_-]+!-!g;
608 unless (exists $repositories{$id}) {
609 $repositories{$id} = bless {
615 $repositories{$id}->_fetch;
618 return $repositories{$id};
621 sub id
{ shift->{'id'} }
622 sub url
{ shift->{'url'} }
623 sub directory
{ sprintf '%s/repos/%s', $main::workdir
, shift->id }
628 if (-d
$self->directory) {
629 Log
::info
("Updating repository %s ...", $self->url);
631 my $tree = $self->directory;
632 my $git = $tree . '/.git';
634 if (system('git', "--work-tree=$tree", "--git-dir=$git", 'fetch', '--all', '--quiet')) {
635 return Log
::err
("Unable to pull repository!");
641 Log
::info
("Cloning repository %s ...", $self->url);
643 if (system('mkdir', '-p', $self->directory)) {
644 return Log
::err
("Unable to create directory!");
646 elsif (system('git', 'clone', '--quiet', $self->url, $self->directory)) {
647 return Log
::err
("Unable to clone repository!");
654 my ($self, $fh, $default) = @_;
656 my $line = readline $fh;
669 my ($self, $fh) = @_;
674 $self->_readline($fh, undef);
677 my $hash = $self->_readline($fh, '');
678 my $subject = $self->_readline($fh, '');
680 last unless (length($subject) && $hash =~ m!^[a-f0-9]{40}$!);
684 # commit already cached, skip lines and use cached object
685 if (exists $Repository::commits
{$hash}) {
686 for ($line = ''; $line ne '@@'; $line = $self->_readline($fh, '@@')) { next; }
687 for ($line = ''; $line ne '@@'; $line = $self->_readline($fh, '@@')) { next; }
689 push @commits, $Repository::commits
{$hash};
695 my ($add, $del) = (0, 0);
697 while ($line ne '@@') {
698 $body .= length($line) ?
"$line\n" : '';
699 $line = $self->_readline($fh, '@@');
704 my $reading_diff = 0;
705 my ($subhistory, $subhistory_url, $subhistory_start, $subhistory_end);
707 while ($line ne '@@') {
708 if ($line =~ m!^diff --git a/!) {
710 undef $subhistory_url;
711 undef $subhistory_start;
712 undef $subhistory_end;
714 elsif ($reading_diff) {
715 if ($line =~ m!^[ +]PKG_SOURCE_URL\s*:?=\s*(\S+)!) {
716 $subhistory_url = $1;
717 $subhistory_url =~ s!\$\(LEDE_GIT\)!https://git.lede-project.org!g;
718 $subhistory_url =~ s!\$\(OPENWRT_GIT\)!https://git.openwrt.org!g;
719 $subhistory_url =~ s!\$\(PROJECT_GIT\)!https://git.openwrt.org!g;
721 elsif ($line =~ m!^-\S+\s*:?=\s*([a-f0-9]{40})\b!) {
722 $subhistory_start = $1;
724 elsif ($line =~ m!^\+\S+\s*:?=\s*([a-f0-9]{40})\b!) {
725 $subhistory_end = $1;
727 if ($subhistory_url && $subhistory_start && $subhistory_end) {
728 $subhistory = Repository
->new($subhistory_url)->parse_history("$subhistory_start..$subhistory_end");
732 elsif ($line =~ m!^(\d+|-)\s+(\d+|-)\s+(.+)$!) {
733 $add += ($1 eq '-') ?
0 : int($1);
734 $del += ($2 eq '-') ?
0 : int($2);
738 $line = $self->_readline($fh, '@@');
741 my $commit = Commit
->new($self, $num++, $hash, $subject, $body, $add, $del, $subhistory, @files);
743 push @commits, $commit;
744 push @Repository::index, $commit;
746 $Repository::commits
{ $commit->sha1 } = $commit;
749 @Repository::index = sort { $a->sha1 cmp $b->sha1 } @Repository::index;
751 return wantarray ?
@commits : \
@commits;
754 sub parse_history
($$) {
755 my ($self, $range) = @_;
756 my $gitdir = sprintf '%s/.git', $self->directory;
759 if (open my $git, '-|', 'git', "--git-dir=$gitdir", 'log', '-p', '--format=@@%n%H%n%s%n%b%n@@', '--numstat', '--reverse', '--no-merges', $range) {
760 @commits = $self->_parse($git);
764 return wantarray ?
@commits : \
@commits;
767 sub find_commit
($$) {
768 my ($self, $hash) = @_;
770 if (exists $Repository::commits
{$hash}) {
771 return $Repository::commits
{$hash};
774 my ($l, $r) = (0, @Repository::index - 1);
777 my $m = $l + int(($r - $l) / 2);
779 if (index($Repository::index[$m]->sha1, $hash) == 0) {
780 return $Repository::index[$m];
782 elsif ($Repository::index[$m]->sha1 gt $hash) {
795 [ qr
'^[^:]+://(git.lede-project.org/)(.+)$' => 'https://%s?p=%s;a=commitdiff;h=%%s' ],
796 [ qr
'^[^:]+://(git.openwrt.org/)(.+)$' => 'https://%s?p=%s;a=commitdiff;h=%%s' ],
797 [ qr
'^[^:]+://(github.com/.+?)(?:\.git)?$' => 'https://%s/commit/%%s' ],
798 [ qr
'^[^:]+://git.kernel.org/pub/scm/(.+)$' => 'https://git.kernel.org/cgit/%s/commit/?id=%%s' ],
799 [ qr
'^[^:]+://w1.fi/(?:.+/)?(.+)\.git$' => 'https://w1.fi/cgit/%s/commit/?id=%%s' ],
800 [ qr
'^[^:]+://git.netfilter.org/(.+)' => 'https://git.netfilter.org/%s/commit/?id=%%s' ],
801 [ qr
'^[^:]+://git.musl-libc.org/(.+)' => 'https://git.musl-libc.org/cgit/%s/commit/?id=%%s' ],
802 [ qr
'^[^:]+://git.zx2c4.com/(.+)' => 'https://git.zx2c4.com/%s/commit/?id=%%s' ],
803 [ qr
'^[^:]+://sourceware.org/git/(.+)' => 'https://sourceware.org/git/?p=%s;a=commitdiff;h=%%s' ]
806 sub commit_link_template
($) {
809 foreach my $lnk ($self->_weblinks) {
810 my @matches = $self->url =~ $lnk->[0];
812 return sprintf $lnk->[1], @matches;
816 Log
::warn("No web link template available for %s", $self->url);
822 return wantarray ? @
{$self->{'log'}} : $self->{'log'};
841 [ qr
'^package/(kernel)/linux', 'Kernel' ],
842 [ qr
'^(target/linux/generic|include/kernel-version.mk)', 'Kernel' ],
843 [ qr
'^package/kernel/(mac80211)', 'Wireless / Common' ],
844 [ qr
'^package/kernel/(ath10k-ct)', 'Wireless / Ath10k CT' ],
845 [ qr
'^package/kernel/(mt76)', 'Wireless / MT76' ],
846 [ qr
'^package/kernel/(mwlwifi)', 'Wireless / Mwlwifi' ],
847 [ qr
'^package/(base-files)/', 'Packages / OpenWrt base files' ],
848 [ qr
'^package/(boot)/', 'Packages / Boot Loaders' ],
849 [ qr
'^package/firmware/', 'Packages / Firmware' ],
850 [ qr
'^package/.+/(uhttpd|usbmode|jsonfilter|ugps|libubox|procd|mountd|ubus|uci|usign|rpcd|fstools|ubox)/', 'Packages / OpenWrt system userland' ],
851 [ 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' ],
852 [ qr
'^package/[^/]+/([^/]+)', 'Packages / Common' ],
853 [ qr
'^target/sdk/', 'Build System / SDK' ],
854 [ qr
'^target/imagebuilder/', 'Build System / Image Builder' ],
855 [ qr
'^target/toolchain/', 'Build System / Toolchain' ],
856 [ qr
'^target/linux/([^/]+)', 'Target / $1' ],
857 [ qr
'^(tools)/[^/]+', 'Build System / Host Utilities' ],
858 [ qr
'^(toolchain)/[^/]+', 'Build System / Toolchain' ],
859 [ qr
'^(config/|include/|scripts/|target/[^/]+$|Makefile|rules\.mk)', 'Build System / Buildroot' ],
860 [ qr
'^(feeds)\b', 'Build System / Feeds' ],
863 sub new
($$$$$$$$@
) {
864 my ($pack, $repo, $pos, $hash, $subject, $body, $add, $del, $shist, @files) = @_;
867 $commit[_REPO
] = $repo;
868 $commit[_POS
] = $pos;
869 $commit[_SHA1
] = $hash;
870 $commit[_SUBJ
] = $subject;
871 $commit[_BODY
] = $body;
872 $commit[_NADD
] = $add;
873 $commit[_NDEL
] = $del;
874 $commit[_SHIST
] = $shist;
875 $commit[_FILES
] = \
@files;
877 return bless \
@commit, $pack;
880 sub repository
{ shift->[_REPO
] }
881 sub pos { shift->[_POS
] }
882 sub sha1
{ shift->[_SHA1
] }
883 sub subject
{ shift->[_SUBJ
] }
884 sub body
{ shift->[_BODY
] }
885 sub added
{ shift->[_NADD
] }
886 sub deleted
{ shift->[_NDEL
] }
887 sub files
{ wantarray ? @
{shift->[_FILES
] || []} : shift->[_FILES
] }
888 sub subhistory
{ wantarray ? @
{shift->[_SHIST
] || []} : shift->[_SHIST
] }
895 foreach my $path ($self->files)
897 if ($path =~ m!^(.+)/\{(.+?) => (.+?)\}$!)
908 foreach my $path (sort keys %paths)
910 foreach my $rs ($self->_topic_map)
912 if ($path =~ $rs->[0])
925 my @topics = sort keys %topics;
926 return (@topics > 0 ?
@topics : ('Miscellaneous'));
932 my $bugtracker = BugTracker
->new;
933 my $candidates = qr
'\b((?:[Pp]ull [Rr]equest |[Bb]ug |[Ii]ssue |PR |FS |GH |PR|FS|GH)#\d+)\b';
934 my $issue = qr
'(?i)^(?:Bug |Issue |FS |GH |FS|GH)#(\d+)$';
937 foreach my $match ($self->subject =~ /$candidates/g, $self->body =~ /$candidates/g) {
938 if ($match =~ $issue) {
939 my $bug = $bugtracker->get($1);
941 $bugs{ $bug->id } = $bug;
946 return map { $bugs{$_} } sort { $a <=> $b } keys %bugs;
951 my $candidates = qr
'\b(CVE-\d+-\d+|\d+-CVE-\d+)\b';
954 foreach my $match ($self->subject =~ /$candidates/g, $self->body =~ /$candidates/g) {
955 # fix misspelled CVE IDs
956 $match =~ s!^(\d+)-CVE-!CVE-$1-!;
960 return sort { $a cmp $b } keys %cves;