#!/usr/bin/env perl use strict; use warnings; use JSON; use Time::Local; use Text::CSV; my $range = $ARGV[0]; our $workdir = './openwrt-changelog-data'; unless (defined $range) { printf STDERR "Usage: $0 range\n"; exit 1; } unless (-d $workdir) { unless (system('mkdir', '-p', $workdir) == 0) { printf STDERR "Unable to create work directory!\n"; exit 1; } } my %topics; sub format_stat($) { my ($commit) = @_; my $s = ''; my $c = '%s'; my $g = '%s'; my $r = '%s'; if ($commit->added > 1000) { $s .= sprintf $g, sprintf '+%.1fK', $commit->added / 1000; } elsif ($commit->added > 0) { $s .= sprintf $g, sprintf '+%d', $commit->added; } if ($commit->deleted > 1000) { $s .= $s ? sprintf($c, ',') : ''; $s .= sprintf $r, sprintf '-%.1fK', $commit->deleted / 1000; } elsif ($commit->deleted > 0) { $s .= $s ? sprintf($c, ',') : ''; $s .= sprintf $r, sprintf '-%d', $commit->deleted; } return sprintf($c, '(') . $s . sprintf($c, ')'); } sub format_subject($$) { my ($subject, $body) = @_; if (length($subject) > 80) { $subject = substr($subject, 0, 77) . '...'; } $subject =~ s!^([^\s:]+):\s*!**$1:** !g; $subject = sprintf '%s', $subject; $subject =~ s!!!g; return $subject; } sub format_change($) { my ($change) = @_; printf "''[[%s|%s]]'' %s //%s//\\\\\n", sprintf($change->repository->commit_link_template, $change->sha1), substr($change->sha1, 0, 7), format_subject($change->subject, $change->body), format_stat($change); my @subhistory = $change->subhistory; if (@subhistory > 0) { my $n = 0; my $link_tpl; foreach my $subchange (@subhistory) { if ($n == 0) { $link_tpl = $subchange->repository->commit_link_template; } if ($link_tpl) { printf " => ''[[%s|%s]]'' %s //%s//\\\\\n", sprintf($link_tpl, $subchange->sha1), substr($subchange->sha1, 0, 7), format_subject($subchange->subject, $subchange->body), format_stat($subchange); } else { printf " => ''%s'' %s //%s//\\\\\n", substr($subchange->sha1, 0, 7), format_subject($subchange->subject, $subchange->body), format_stat($subchange); } if (++$n > 15 && @subhistory > $n) { printf " => + //%u more...//\\\\\n", @subhistory - $n; last; } } } } sub fetch_cve_info() { unless (-f "$workdir/cveinfo.csv") { system('wget', '-O', "$workdir/cveinfo.csv.gz", 'https://cve.mitre.org/data/downloads/allitems.csv.gz') && return 0; system('gunzip', '-f', "$workdir/cveinfo.csv.gz") && return 0; } return 1; } sub parse_cves(@) { my $csv = Text::CSV->new({ binary => 1 }); my %cves; if (fetch_cve_info() && $csv) { if (open CVE, '<', "$workdir/cveinfo.csv") { while (defined(my $row = $csv->getline(*CVE))) { foreach my $cve_id (@_) { if ($row->[0] eq $cve_id) { $cves{$cve_id} = [$row->[2], $row->[6]]; last; } } } close CVE; } } return \%cves; } my $repository = Repository->new('https://git.openwrt.org/openwrt/openwrt.git'); my $bugtracker = BugTracker->new; my @commits = $repository->parse_history($range); my (%bugs, %cves, %sha1s); foreach my $commit (@commits) { if ($commit->subject =~ m!\b(?:LEDE|OpenWrt) v\d\d\.\d\d\.\d+(?:-rc\d+)?: (?:adjust config|revert to branch) defaults\b!) { Log::info("Skipping maintenance commit %s (%s)", $commit->sha1, $commit->subject); next; } my @topics = $commit->topics; foreach my $topic (@topics) { $topics{$topic} ||= [ ]; push @{$topics{$topic}}, $commit; } foreach my $bug ($commit->bugs) { if ($bug->status ne 'closed') { Log::warn("Commit %s closes bug #%d", $commit->sha1, $bug->id); } $bugs{ $bug->id } ||= [ ]; push @{$bugs{ $bug->id }}, $commit; } foreach my $cve_id ($commit->cve_ids) { $cves{$cve_id} ||= [ ]; push @{$cves{$cve_id}}, $commit; } $sha1s{$commit->[1]}++; } Log::info("Finding commit references in bugs..."); foreach my $bug ($bugtracker->bugs) { next if exists $bugs{ $bug->id }; foreach my $hash ($bug->refs) { my $commit = $repository->find_commit($hash); next unless defined $commit; if ($bug->status ne 'closed') { Log::warn("Bug #%d closed by commit %s", $bug->id, $commit->sha1); } $bugs{ $bug->id } ||= [ ]; push @{$bugs{ $bug->id }}, $commit; } } my @topics = sort { (($a eq 'Miscellaneous') <=> ($b eq 'Miscellaneous')) || $a cmp $b } keys %topics; foreach my $topic (@topics) { my @commits = @{$topics{$topic}}; printf "==== %s (%d change%s) ====\n", $topic, 0 + @commits, @commits > 1 ? 's' : ''; foreach my $change (sort { $a->pos <=> $b->pos } @commits) { format_change($change); } print "\n"; } my @bugs = map { $bugtracker->get($_) } sort { int($a) <=> int($b) } keys %bugs; if (@bugs > 0) { printf "===== Addressed bugs =====\n"; foreach my $bug (@bugs) { if ($bug->fsid) { printf "=== FS#%d (#%d) ===\n", $bug->fsid, $bug->id; } else { printf "=== #%d ===\n", $bug->id; } printf "**Description:** %s\\\\\n", $bug->summary; printf "**Link:** [[https://github.com/openwrt/openwrt/issues/%d]]\\\\\n", $bug->id; printf "**Commits:**\\\\\n"; foreach my $commit (@{$bugs{ $bug->id }}) { format_change($commit); } printf "\\\\\n"; } printf "\n"; } my @cves = map { $_->[1] } sort { ($a->[0] <=> $b->[0]) || ($a->[1] cmp $b->[1]) } map { $_ =~ m!^CVE-(\d+)-(\d+)$! ? [ $1 * 10000000 + $2, $_ ] : [ 0, $_ ] } keys %cves; my $cve_info = parse_cves(@cves); if (@cves > 0) { printf "===== Security fixes ====\n"; foreach my $cve (@cves) { printf "=== %s ===\n", $cve; if ($cve_info->{$cve} && $cve_info->{$cve}[0]) { printf "**Description:** %s\n\n", $cve_info->{$cve}[0]; } printf "**Link:** [[https://cve.mitre.org/cgi-bin/cvename.cgi?name=%s]]\\\\\n", $cve; printf "**Commits:**\\\\\n"; foreach my $commit (@{$cves{$cve}}) { format_change($commit); } printf "\\\\\n"; } printf "\n"; } package Log; sub info { my ($fmt, @args) = @_; printf STDERR "[I] %s\n", sprintf $fmt, @args; return 0; } sub warn { my ($fmt, @args) = @_; printf STDERR "[W] %s\n", sprintf $fmt, @args; return 1; } sub err { my ($fmt, @args) = @_; printf STDERR "[E] %s\n", sprintf $fmt, @args; return 1; } package GitHubQuery; sub _date { my ($self, $ts) = @_; my @loc = gmtime $ts; return sprintf '%04d-%02d-%02dT%02d:%02d:%02dZ', $loc[5] + 1900, $loc[4] + 1, $loc[3], $loc[2], $loc[1], $loc[0]; } sub _ts { my ($self, $date) = @_; return 0 unless $date; my ($year, $mon, $mday, $hour, $min, $sec) = $date =~ m!^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$!; return Time::Local::timegm_posix($sec, $min, $hour, $mday, $mon - 1, $year - 1900); } sub _read_cache { my ($self, $path, $records) = @_; if (open my $file, '<:utf8', $path) { local $/; eval { push @$records, @{ JSON::decode_json(readline $file) }; }; close $file; if ($@) { return Log::err("Unable to read $path: $@"); } } return 0; } sub _fetch_one_page { my ($self, $since, $page) = @_; my $url = $self->{'url'}; my $sep = ($url =~ m!\?!) ? '&' : '?'; my $res; if ($since) { $url .= $sep . 'since=' . $self->_date($since); $sep = '&'; } if ($page) { $url .= $sep . 'per_page=100&page=' . $page; $sep = '&'; } if (open my $wget, '-|', 'wget', '--auth-no-challenge', '-q', '-O', '-', $url) { local $/; eval { $res = JSON::decode_json(readline $wget); }; if ($@) { Log::err("Failed to parse result from $url: $@"); } close $wget; } else { Log::err("Failed to fetch $url via wget: $!"); } return $res; } sub _fetch { my ($self) = @_; my $cache = "$main::workdir/" . $self->{'cachefile'}; my @stat = stat $cache; my $since = defined($stat[9]) ? $stat[9] : $self->{'since'}; $since -= ($since % 86400); my @new_records; my @old_records; my $page = 1; Log::info("Updating " . $self->{'cachefile'} . " database..."); while (1) { my $res = $self->_fetch_one_page($since, $page); if (ref($res) ne 'ARRAY') { return Log::err("Aborting update due to invalid response"); } push @new_records, @$res; Log::info(" Fetched " . @new_records . " records..."); last if @$res < 100; $page++; } if ($self->_read_cache($cache, \@old_records)) { return 1; } my $updated = 0; my %index; foreach my $record (@old_records) { if (ref($record) ne 'HASH' || !exists($record->{ $self->{'idprop'} })) { next; } $index{ $record->{ $self->{'idprop'} } } = $record; } foreach my $record (@new_records) { if (ref($record) ne 'HASH' || !exists($record->{ $self->{'idprop'} })) { next; } my $old = $index{ $record->{ $self->{'idprop'} } }; if (!$old || $self->_ts($record->{'updated_at'}) != $self->_ts($old->{'updated_at'})) { $index{ $record->{ $self->{'idprop'} } } = $record; $updated++; } } if (!defined($stat[9]) || $updated) { Log::info(" Found " . $updated . " updated records..."); if (open my $file, '>:utf8', $cache) { print $file JSON::encode_json([ values %index ]); close $file; } else { return Log::err("Unable to update $cache: $!"); } my $now = time(); if (!utime($now, $now, $cache)) { Log::warn("Unable to change $cache modification time: $!"); } } return 0; } sub fetch { my ($self, $force_update) = @_; my $cache = "$main::workdir/" . $self->{'cachefile'}; my @records; if ($force_update) { $self->_fetch(); } if (-f $cache && $self->_read_cache($cache, \@records)) { return undef; } return wantarray ? @records : \@records; } sub new { my ($pack, $baseurl, $cachefile, $idprop, $since) = @_; return bless { url => $baseurl, cachefile => $cachefile, idprop => $idprop, since => $since }, $pack; } package BugTracker; our $inst; sub _parse { my ($self) = @_; return 0 if $self->{'bugs'}; my $issues = GitHubQuery->new( "https://api.github.com/repos/openwrt/openwrt/issues?state=all&sort=updated&direction=desc", "issues.json", "number", 1640995200 )->fetch(1); return 1 unless $issues; $self->{'bugs'} = { }; $self->{'fsbugs'} = { }; foreach my $issue (@$issues) { my ($date_opened, $date_closed, $date_modified) = (0, 0, 0); if (exists($issue->{'created_at'})) { $date_opened = GitHubQuery->_ts($issue->{'created_at'}); } if (exists($issue->{'updated_at'})) { $date_modified = GitHubQuery->_ts($issue->{'updated_at'}); } if (exists($issue->{'closed_at'})) { $date_closed = GitHubQuery->_ts($issue->{'closed_at'}); } my $bug = Bug->new( $issue->{'number'}, $issue->{'title'}, $issue->{'state'}, $date_opened, $date_closed, $date_modified ); $self->{'bugs'}{ $bug->id } = $bug; if ($issue->{'title'} =~ /^FS#(\d+) - /) { $self->{'fsbugs'}{$1} = $bug; } } return 0; } sub new { my ($pack) = @_; unless ($inst) { $inst = bless {}, $pack; } return $inst; } sub get($$) { my ($self, $id) = @_; return undef if $self->_parse; return $self->{'bugs'}{$id}; } sub get_fs($$) { my ($self, $id) = @_; return undef if $self->_parse; return $self->{'fsbugs'}{$id}; } sub bugs($) { my ($self) = @_; return undef if $self->_parse; my @bugs = map { $self->{'bugs'}{$_} } sort { $a <=> $b } keys %{$self->{'bugs'}}; return wantarray ? @bugs : \@bugs; } package Bug; use File::Basename; use constant { '_ID' => 0, '_SUM' => 1, '_STAT' => 2, '_OPEN' => 3, '_CLOSE' => 4, '_CHANGE' => 5, '_FSID' => 6, '_REFS' => 7 }; sub new { my ($pack, $id, $summary, $status, $opened, $closed, $modified) = @_; my $fsid = undef; if ($summary =~ s/^FS#(\d+) - //) { $fsid = $1; } return bless [ $id, $summary, $status, $opened, $closed, $modified, $fsid ], $pack; } sub id { shift->[_ID] } sub fsid { shift->[_FSID] } sub url { sprintf 'https://api.github.com/repos/openwrt/openwrt/issues/%d/comments', shift->id } sub file { sprintf '%s/issue/%d.json', $main::workdir, shift->id } sub summary { shift->[_SUM] } sub status { shift->[_STAT] } sub _fetch() { my ($self) = @_; my @stat = stat $self->file; my $refresh = 0; if (!defined($stat[9]) || ($stat[9] < $self->[_CHANGE])) { $refresh = 1; } #Log::info("Fetching details for Bug #%d ...", $self->id); if (system('mkdir', '-p', "$main::workdir/issue")) { return Log::err("Unable to create directory!"); } my $comments = GitHubQuery->new( $self->url, sprintf('issue/%d.json', $self->id), 'id', 0 )->fetch($refresh); if (!$comments) { Log::err("Unable to fetch bug details!"); return undef; } return wantarray ? @$comments : $comments; } sub _find_commit_references() { my ($self) = @_; my $comments = $self->_fetch; return undef unless $comments; foreach my $comment (@$comments) { my $str = $comment->{'body'}; my @refs = $str =~ m! (?: Fixed \s+ with \s+ | Fixed \s+ in \s+ | Fixed \s+ by \s+ | fix \s+ (?: in | into ) \s+ (?: \w+ \s+ )* ) (?: 0; } } sub refs ($) { my ($self) = @_; unless (defined $self->[_REFS]) { my %sha1; foreach my $ref ($self->_find_commit_references) { if ($ref =~ m!\b([a-fA-F0-9]{4,40})$!) { $sha1{lc $1}++; } } $self->[_REFS] = [ sort keys %sha1 ]; } return wantarray ? @{$self->[_REFS]} : $self->[_REFS]; } package Repository; use File::Basename; our %repositories; our %commits; our @index; sub new($$) { my ($pack, $url) = @_; my $id = $url; $id =~ s!\bgit\.lede-project\.org\b!git.openwrt.org!; $id =~ s![^a-z0-9_-]+!-!g; unless (exists $repositories{$id}) { $repositories{$id} = bless { 'id' => $id, 'url' => $url, 'cache' => { } }, $pack; $repositories{$id}->_fetch; } return $repositories{$id}; } sub id { shift->{'id'} } sub url { shift->{'url'} } sub directory { sprintf '%s/repos/%s', $main::workdir, shift->id } sub _fetch($) { my ($self) = @_; if (-d $self->directory) { Log::info("Updating repository %s ...", $self->url); my $tree = $self->directory; my $git = $tree . '/.git'; if (system('git', "--work-tree=$tree", "--git-dir=$git", 'fetch', '--all', '--quiet')) { return Log::err("Unable to pull repository!"); } return 0; } Log::info("Cloning repository %s ...", $self->url); if (system('mkdir', '-p', $self->directory)) { return Log::err("Unable to create directory!"); } elsif (system('git', 'clone', '--quiet', $self->url, $self->directory)) { return Log::err("Unable to clone repository!"); } return 0; } sub _readline($*$) { my ($self, $fh, $default) = @_; my $line = readline $fh; if (defined $line) { chomp $line; return $line; } return $default; } sub _parse($*) { my ($self, $fh) = @_; my @commits; my $num = 0; # skip header line $self->_readline($fh, undef); while (1) { my $hash = $self->_readline($fh, ''); my $subject = $self->_readline($fh, ''); last unless (length($subject) && $hash =~ m!^[a-f0-9]{40}$!); my $line = ''; # commit already cached, skip lines and use cached object if (exists $Repository::commits{$hash}) { for ($line = ''; $line ne '@@'; $line = $self->_readline($fh, '@@')) { next; } for ($line = ''; $line ne '@@'; $line = $self->_readline($fh, '@@')) { next; } push @commits, $Repository::commits{$hash}; next; } my $body = ''; my @files; my ($add, $del) = (0, 0); while ($line ne '@@') { $body .= length($line) ? "$line\n" : ''; $line = $self->_readline($fh, '@@'); } $line = ''; my $reading_diff = 0; my ($subhistory, $subhistory_url, $subhistory_start, $subhistory_end); while ($line ne '@@') { if ($line =~ m!^diff --git a/!) { $reading_diff = 1; undef $subhistory_url; undef $subhistory_start; undef $subhistory_end; } elsif ($reading_diff) { if ($line =~ m!^[ +]PKG_SOURCE_URL\s*:?=\s*(\S+)!) { $subhistory_url = $1; $subhistory_url =~ s!\$\(LEDE_GIT\)!https://git.lede-project.org!g; $subhistory_url =~ s!\$\(OPENWRT_GIT\)!https://git.openwrt.org!g; $subhistory_url =~ s!\$\(PROJECT_GIT\)!https://git.openwrt.org!g; } elsif ($line =~ m!^-\S+\s*:?=\s*([a-f0-9]{40})\b!) { $subhistory_start = $1; } elsif ($line =~ m!^\+\S+\s*:?=\s*([a-f0-9]{40})\b!) { $subhistory_end = $1; if ($subhistory_url && $subhistory_start && $subhistory_end) { $subhistory = Repository->new($subhistory_url)->parse_history("$subhistory_start..$subhistory_end"); } } } elsif ($line =~ m!^(\d+|-)\s+(\d+|-)\s+(.+)$!) { $add += ($1 eq '-') ? 0 : int($1); $del += ($2 eq '-') ? 0 : int($2); push @files, $3; } $line = $self->_readline($fh, '@@'); } my $commit = Commit->new($self, $num++, $hash, $subject, $body, $add, $del, $subhistory, @files); push @commits, $commit; push @Repository::index, $commit; $Repository::commits{ $commit->sha1 } = $commit; } @Repository::index = sort { $a->sha1 cmp $b->sha1 } @Repository::index; return wantarray ? @commits : \@commits; } sub parse_history($$) { my ($self, $range) = @_; my $gitdir = sprintf '%s/.git', $self->directory; my @commits; if (open my $git, '-|', 'git', "--git-dir=$gitdir", 'log', '-p', '--format=@@%n%H%n%s%n%b%n@@', '--numstat', '--reverse', '--no-merges', $range) { @commits = $self->_parse($git); close $git; } return wantarray ? @commits : \@commits; } sub find_commit($$) { my ($self, $hash) = @_; if (exists $Repository::commits{$hash}) { return $Repository::commits{$hash}; } else { my ($l, $r) = (0, @Repository::index - 1); while ($l <= $r) { my $m = $l + int(($r - $l) / 2); if (index($Repository::index[$m]->sha1, $hash) == 0) { return $Repository::index[$m]; } elsif ($Repository::index[$m]->sha1 gt $hash) { $r = $m - 1; } else { $l = $m + 1; } } } return undef; } sub _weblinks { ( [ qr'^[^:]+://(git.lede-project.org/)(.+)$' => 'https://%s?p=%s;a=commitdiff;h=%%s' ], [ qr'^[^:]+://(git.openwrt.org/)(.+)$' => 'https://%s?p=%s;a=commitdiff;h=%%s' ], [ qr'^[^:]+://(github.com/.+?)(?:\.git)?$' => 'https://%s/commit/%%s' ], [ qr'^[^:]+://git.kernel.org/pub/scm/(.+)$' => 'https://git.kernel.org/cgit/%s/commit/?id=%%s' ], [ qr'^[^:]+://w1.fi/(?:.+/)?(.+)\.git$' => 'https://w1.fi/cgit/%s/commit/?id=%%s' ], [ qr'^[^:]+://git.netfilter.org/(.+)' => 'https://git.netfilter.org/%s/commit/?id=%%s' ], [ qr'^[^:]+://git.musl-libc.org/(.+)' => 'https://git.musl-libc.org/cgit/%s/commit/?id=%%s' ], [ qr'^[^:]+://git.zx2c4.com/(.+)' => 'https://git.zx2c4.com/%s/commit/?id=%%s' ], [ qr'^[^:]+://sourceware.org/git/(.+)' => 'https://sourceware.org/git/?p=%s;a=commitdiff;h=%%s' ] ) } sub commit_link_template($) { my ($self) = @_; foreach my $lnk ($self->_weblinks) { my @matches = $self->url =~ $lnk->[0]; if (@matches > 0) { return sprintf $lnk->[1], @matches; } } Log::warn("No web link template available for %s", $self->url); return undef; } sub log($) { my ($self) = @_; return wantarray ? @{$self->{'log'}} : $self->{'log'}; } package Commit; use constant { '_REPO' => 0, '_POS' => 1, '_SHA1' => 2, '_SUBJ' => 3, '_BODY' => 4, '_FILES' => 5, '_SHIST' => 6, '_NADD' => 7, '_NDEL' => 8 }; sub _topic_map { ( [ qr'^package/(kernel)/linux', 'Kernel' ], [ qr'^(target/linux/generic|include/kernel-version.mk)', 'Kernel' ], [ qr'^package/kernel/(mac80211)', 'Wireless / Common' ], [ qr'^package/kernel/(ath10k-ct)', 'Wireless / Ath10k CT' ], [ qr'^package/kernel/(mt76)', 'Wireless / MT76' ], [ qr'^package/kernel/(mwlwifi)', 'Wireless / Mwlwifi' ], [ qr'^package/(base-files)/', 'Packages / OpenWrt base files' ], [ qr'^package/(boot)/', 'Packages / Boot Loaders' ], [ qr'^package/firmware/', 'Packages / Firmware' ], [ qr'^package/.+/(uhttpd|usbmode|jsonfilter|ugps|libubox|procd|mountd|ubus|uci|usign|rpcd|fstools|ubox)/', 'Packages / OpenWrt system userland' ], [ 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' ], [ qr'^package/[^/]+/([^/]+)', 'Packages / Common' ], [ qr'^target/sdk/', 'Build System / SDK' ], [ qr'^target/imagebuilder/', 'Build System / Image Builder' ], [ qr'^target/toolchain/', 'Build System / Toolchain' ], [ qr'^target/linux/([^/]+)', 'Target / $1' ], [ qr'^(tools)/[^/]+', 'Build System / Host Utilities' ], [ qr'^(toolchain)/[^/]+', 'Build System / Toolchain' ], [ qr'^(config/|include/|scripts/|target/[^/]+$|Makefile|rules\.mk)', 'Build System / Buildroot' ], [ qr'^(feeds)\b', 'Build System / Feeds' ], ) } sub new ($$$$$$$$@) { my ($pack, $repo, $pos, $hash, $subject, $body, $add, $del, $shist, @files) = @_; my @commit; $commit[_REPO] = $repo; $commit[_POS] = $pos; $commit[_SHA1] = $hash; $commit[_SUBJ] = $subject; $commit[_BODY] = $body; $commit[_NADD] = $add; $commit[_NDEL] = $del; $commit[_SHIST] = $shist; $commit[_FILES] = \@files; return bless \@commit, $pack; } sub repository { shift->[_REPO] } sub pos { shift->[_POS] } sub sha1 { shift->[_SHA1] } sub subject { shift->[_SUBJ] } sub body { shift->[_BODY] } sub added { shift->[_NADD] } sub deleted { shift->[_NDEL] } sub files { wantarray ? @{shift->[_FILES] || []} : shift->[_FILES] } sub subhistory { wantarray ? @{shift->[_SHIST] || []} : shift->[_SHIST] } sub topics($) { my ($self) = @_; my %topics; my %paths; foreach my $path ($self->files) { if ($path =~ m!^(.+)/\{(.+?) => (.+?)\}$!) { $paths{"$1/$2"}++; $paths{"$1/$3"}++; } else { $paths{$path}++; } } foreach my $path (sort keys %paths) { foreach my $rs ($self->_topic_map) { if ($path =~ $rs->[0]) { my $m = $1; my $s = $rs->[1]; $s =~ s!\$1!$m!g; $topics{$s}++; last; } } } my @topics = sort keys %topics; return (@topics > 0 ? @topics : ('Miscellaneous')); } sub bugs($) { my ($self) = @_; my $bugtracker = BugTracker->new; my $candidates = qr'\b((?:[Pp]ull [Rr]equest |[Bb]ug |[Ii]ssue |PR |FS |GH |PR|FS|GH)#\d+)\b'; my %bugs; foreach my $match ($self->subject =~ /$candidates/g, $self->body =~ /$candidates/g) { my $bug; if ($match =~ /^FS ?#(\d+)$/) { $bug = $bugtracker->get_fs($1); } elsif ($match =~ /^(GH|PR|[Pp]ull [Rr]equest) ?#(\d+)$/i) { $bug = $bugtracker->get($1); } elsif ($match =~ /^#(\d+)$/) { $bug = $bugtracker->get_fs($1) || $bugtracker->get($1); } if ($bug) { $bugs{ $bug->id } = $bug; } } foreach my $tag (qw(Fixes Closes Supersedes)) { my ($ids) = $self->body =~ /\b$tag: *((?:GH|PR|FS|)#\d+(?:[, ]+#\d+)*)/; foreach my $id (split /[, ]+/, ($ids || '')) { my $bug; if ($id =~ /^FS#(\d+)$/) { $bug = $bugtracker->get_fs($1); } elsif ($id =~ /^(GH|PR)#(\d+)$/) { $bug = $bugtracker->get($1); } elsif ($id =~ /^#(\d+)$/) { $bug = $bugtracker->get_fs($1) || $bugtracker->get($1); } if ($bug) { $bugs{ $bug->id } = $bug; } } } return map { $bugs{$_} } sort { $a <=> $b } keys %bugs; } sub cve_ids($) { my ($self) = @_; my $candidates = qr'\b(CVE-\d+-\d+|\d+-CVE-\d+)\b'; my %cves; foreach my $match ($self->subject =~ /$candidates/g, $self->body =~ /$candidates/g) { # fix misspelled CVE IDs $match =~ s!^(\d+)-CVE-!CVE-$1-!; $cves{$match}++; } return sort { $a cmp $b } keys %cves; }