Skip to content

Commit

Permalink
New bot: Releasable (with tests)
Browse files Browse the repository at this point in the history
Automatically detects release dates, commits that were logged, etc.
Also, prints warnings if something is not quite right.

Technically, β€œchangelogable” would be more precise (given that the bot
does not make releases itself), but β€œreleasable” is easier to remember.

The data is taken from the rakudo repo directly (docs/ChangeLog,
docs/release_guide.pod, VERSION). This means that no special actions
should be taken to keep the bot up-to-date. Anyone can edit the
changelog and the bot will pick up these changes automatically.

Commits that should not be logged must be mentioned in one of
the commits to the changelog file. This is possibly LTA, but we will
see how it goes.

Generally, this is an experiment in making the release process
more awesome. Whether it is going to work better or not, I don't
know. In any case, this is an independent bot that can be used in
addition to anything else.

There is a dependency on R6 to check for release blockers. As of
today, there are no plans to change that (mainly because the whitelist
approach of R6 is pretty much flawless).

One of the cool features of releasable is that you can give it a link
to the modified changelog file, and it will give you the information
about it as if it was in rakudo repo (e.g. commits that are not logged
and which ones are logged incorrectly). By the way, there's room for
implementing other checks.

Which abbreviated hash length the bot should enforce is
debatable. Currently, it suggests the sha length of 8, and I was
thinking about increasing it to 12. In the end I decided not to, but
here was my logic:

* From 2016.11 to 2017.04.2, seven characters were used.
* 2017.04.3 had ten character shas in the changelog.
* From 2017.05 to 2017.07, shas had eight characters.
* If you ask git to give you an abbreviated hash, it gives
  nine characters.
* It makes little sense to use abbreviated hashes that are smaller
  than what git produces.
* Therefore, let's bump it to 9 characters…
* However, git project repo already has collisions with 8 characters
  and linux repo has collisions with 9, so in a long run even 9 is not
  that good of an idea…
* So let's use 12.
* But even 12 may at some point may stop being sufficient (it's
  never enough).
* Instead, let's keep it at 8. Once we start approaching birthday
  collisions for some length, we can bump it in the whole changelog.

And there you go! Yet another blog post in a commit message πŸ˜‰
  • Loading branch information
AlexDaniel committed Aug 14, 2017
1 parent a1aa0e5 commit 5d7445b
Show file tree
Hide file tree
Showing 3 changed files with 348 additions and 1 deletion.
187 changes: 187 additions & 0 deletions bin/Releasable.p6
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env perl6
# Copyright Β© 2017
# Aleks-Daniel Jakimenko-Aleksejev <alex.jakimenko@gmail.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

use Whateverable;
use Misc;

use IRC::Client;

unit class Releasable does Whateverable;

# ↓ Git itself suggests 9 characters, and 12 characters may be a better
# ↓ choice for the hundred-year language… but let's increase it only
# ↓ when needed
my $SHA-LENGTH = 8;
my $RELEASE-HOUR = 19; # GMT+0
my $BLOCKERS-URL = β€˜https://perl6.fail/release/blockers.json’;
my $TICKET-URL = β€˜https://rt.perl.org/rt3/Public/Bug/Display.html?id=’;

method help($msg) {
β€œstatus | status link”
}

sub ignored-commits() {
my $last-release = to-full-commit chomp slurp β€œ$RAKUDO/VERSION”;
die β€˜Cannot resolve the tag for the last release’ unless $last-release;
my $result = run :out, :cwd($RAKUDO), β€˜git’, β€˜log’, β€˜--pretty=%b’,
β€˜-z’, β€œ$last-release..HEAD”, β€˜--’, β€˜docs/ChangeLog’;
die β€˜Cannot git log the changelog’ unless $result;

return gather for $result.out.split(0.chr, :skip-empty) {
next unless /β€˜not logged’\N*β€˜:’ \s* [ @<shas>=[<.xdigit>**4..* ] ]+ % \s+/;
{ take ~$_ if .chars == $SHA-LENGTH } for @<shas>
}
}

sub time-left($then) {
my $time-left = $then.Instant - now;
my ($seconds, $minutes, $hours, $days) = $time-left.polymod: 60, 60, 24;
return β€˜is just a few moments away’ if not $days and not $hours;
my $answer = β€˜in ’;
$answer ~= β€œ$days day{$days β‰  1 ?? β€˜s’ !! β€˜β€™} and ” if $days;
$answer ~= β€œβ‰ˆ$hours hour{$hours β‰  1 ?? β€˜s’ !! β€˜β€™}”;
$answer
}

sub time-to-release($msg) {
my $guide = slurp β€œ$RAKUDO/docs/release_guide.pod”;
die β€˜Unable to parse the release guide’ unless $guide ~~ /
^^ β€˜=head2 Planned future releases’ $$
.*?
(^^β€˜ ’(\d\d\d\dβ€˜-’\d\dβ€˜-’\d\d)\s+ β€˜Rakudo #’(\d+) [\s+β€˜(’ (<-[)]>+) β€˜)’]? \n)+
/;
my @dates = $0.map: { %(date => Date.new(~.[0]), id => +.[1], manager => (.Str with .[2])) };
my $important-date;
my $annoying-warning = False;
for @dates {
my $release = .<date>.yyyy-mm-dd.split(β€˜-’)[0,1].join: β€˜.’;
if not to-full-commit $release {
$important-date = $_;
if not .<manager> and not $annoying-warning {
$msg.reply: β€œRelease manager is not specified yet.”
}
last
}
if not $annoying-warning {
$annoying-warning = True;
$msg.reply: β€œRelease date for Rakudo $release is listed in”
~ β€œ β€œPlanned future releases”, but it was already released.”;
}
}
die β€˜Release date not found’ without $important-date;
my $time-left = time-left DateTime.new(date => $important-date<date>,
hour => $RELEASE-HOUR);
β€œNext release $time-left”
}

sub changelog-to-stats($changelog) {
if not $changelog.match: /^ β€˜New in ’ (.*?) β€˜:’ (.*?) ^^ β€˜New in ’ (.*?) β€˜:’/ {
return { summary => β€˜Unknown changelog format’ }
}
my ($version, $changes, $version-old) = ~$0, ~$1, ~$2;

my $actual-commit = to-full-commit $version;
with $actual-commit {
return { summary => β€˜Changelog for this release was not started yet’ }
}
my $actual-commit-old = to-full-commit $version-old;
die β€˜Cannot resolve the tag for the previous release’ without $actual-commit-old;

my @shas = $changes.match(:g, / [β€˜[’ (<.xdigit>**4..*) β€˜]’ \s*]+ $$/)Β»[0].flatΒ».Str;
my $result = run :out, :cwd($RAKUDO), β€˜git’, β€˜log’, β€˜-z’, β€˜--pretty=%H’,
β€˜--reverse’, β€œ$actual-commit-old..HEAD”;
die β€˜Failed to query rakudo git log’ unless $result;
my @git-commits = $result.out.slurp-rest.split(0.chr, :skip-empty)
.map: *.substr: 0, $SHA-LENGTH;
my @warnings;
my $commit-mentioned = set gather for @shas {
when .chars β‰  $SHA-LENGTH {
@warnings.push: β€œ$_ should be $SHA-LENGTH characters in length”
}
when @git-commits.none {
@warnings.push: β€œ$_ was referenced but there is no commit with this id”
}
default { take $_ }
}
my $ignored = set ignored-commits;
my @unlogged = @git-commits.grep: * !∈ ($commit-mentioned βˆͺ $ignored); # ordered
my $summary = β€œ{@git-commits - @unlogged} out of {+@git-commits} commits logged”;
{ :$summary, :@unlogged, :@warnings }
}

sub blockers() {
use HTTP::UserAgent;
my $ua = HTTP::UserAgent.new;
my $response = try { $ua.get: $BLOCKERS-URL };
return { summary => β€˜R6 is down’ } without $response;
return { summary => β€˜R6 is down’ } unless $response.is-success;
if $response.content-type ne β€˜application/json;charset=UTF-8’ {
return { summary => β€˜R6 is weird’ }
}
my %data = from-json $response.decoded-content;
return { summary => β€˜R6 is weird’ } unless %data<tickets>:exists;
my @tickets = %data<tickets>.list;
return { summary => β€˜No blockers’ } unless @tickets;
my $summary = β€œ{+@tickets} blocker{@tickets β‰  1 ?? β€˜s’ !! β€˜β€™}”;
{:$summary, :@tickets}
}

multi method irc-to-me($msg where /^ \s* [changelog|status|release|when]β€˜?’?
[\s+ $<url>=[β€˜http’.*]]? $/) {
my $changelog = process-url ~$_, $msg with $<url>;
$changelog //= slurp β€œ$RAKUDO/docs/ChangeLog”;
my $answer = time-to-release($msg) ~ β€˜. ’ without $<url>;
my %stats = changelog-to-stats $changelog;
my %blockers = blockers without $<url>;

# ↓ All code here just to make the message pretty ↓
$answer ~= β€œ$_. ” with %blockers<summary>;
$answer ~= %stats<summary>;
$answer ~= β€œ (⚠ {+%stats<warnings>} warnings)” if %stats<warnings>;
$msg.reply: $answer;
return if none %blockers<tickets>, %stats<unlogged>, %stats<warnings>;

# ↓ And here just to make a pretty gist ↓
my &escape-html = { .trans: (β€˜&’, β€˜<’, β€˜>’) => (β€˜&amp;’, β€˜&lt;’, β€˜&gt;’) };
my %files;

my $blockers = join β€œ\n”, (%blockers<tickets> // ()).map: { β€˜<a href="’
~ $TICKET-URL ~ .<ticket_id> ~ β€˜">RT #’
~ .<ticket_id> ~ β€˜</a> ’ ~ escape-html .<subject> };
%files<!blockers!.md> = β€˜<pre>’ ~ $blockers ~ β€˜</pre>’ if %blockers<tickets>;

my $warnings = .join(β€œ\n”) with %stats<warnings>;
%files<!warnings!> = $warnings if $warnings;

with %stats<unlogged> {
my $descs = run :out, :cwd($RAKUDO), β€˜git’, β€˜show’,
β€˜--format=%s’,
β€œ--abbrev=$SHA-LENGTH”, β€˜--quiet’, |$_;
my $links = run :out, :cwd($RAKUDO), β€˜git’, β€˜show’,
β€˜--format=[<a href="’ ~ $RAKUDO-REPO ~ β€˜/commit/%H">%h</a>]’,
β€œ--abbrev=$SHA-LENGTH”, β€˜--quiet’, |$_;
my $unreviewed = join β€œ\n”, ($descs.out.lines Z $links.out.lines).map:
{β€˜ + ’ ~ escape-html(.[0]) ~ β€˜ ’ ~ .[1]};
%files<unreviewed.md> = β€˜<pre>’ ~ $unreviewed ~ β€˜</pre>’ if $unreviewed;
}
(β€˜β€™ but FileStore(%files)) but PrettyLink({β€œDetails: $_”})
}

Releasable.new.selfrun: β€˜releasable6’, [ / release6? <before β€˜:’> /,
fuzzy-nick(β€˜releasable6’, 2) ]

# vim: expandtab shiftwidth=4 ft=perl6
6 changes: 5 additions & 1 deletion lib/Whateverable.pm6
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ use Text::Diff::Sift4;

use Misc;

our $RAKUDO = β€˜./data/rakudo-moar’.IO.absolute;
our $RAKUDO = (%*ENV<TESTABLE> // β€˜β€™).contains(β€˜rakudo-mock’)
?? β€˜./t/data/rakudo’.IO.absolute
!! β€˜./data/rakudo-moar’.IO.absolute;
constant MOARVM = β€˜./data/moarvm’.IO.absolute;
constant CONFIG = β€˜./config.json’.IO.absolute;
constant SOURCE = β€˜https://github.com/perl6/whateverable’;
Expand All @@ -43,6 +45,8 @@ constant MESSAGE-LIMIT is export = 260;
constant COMMITS-LIMIT = 500;
constant PARENTS = β€˜AlexDaniel’, β€˜MasterDuke’;

our $RAKUDO-REPO = β€˜https://github.com/rakudo/rakudo’;

constant Message = IRC::Client::Message;

unit role Whateverable[:$default-timeout = 10] does IRC::Client::Plugin does Helpful;
Expand Down
156 changes: 156 additions & 0 deletions t/releasable.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env perl6
BEGIN %*ENV<PERL6_TEST_DIE_ON_FAIL> = 1;
%*ENV<TESTABLE> = β€˜rakudo-mock’;

use lib β€˜t/lib’;
use File::Directory::Tree;
use IRC::Client;
use Test;
use Testable;

my $t = Testable.new: bot => β€˜Releasable’;

$t.common-tests: help => β€œstatus | status link”;

$t.shortcut-tests: <release: release6:>,
<release release, release6 release6,>;

# The idea is to generate a pseudorealistic repo that
# is good enough for testing purposes.

my $mock = β€˜t/data/rakudo’.IO;
rmtree $mock if $mock.e;
mkdir $mock;
run :cwd($mock), :out(Nil), β€˜git’, β€˜init’;
mkdir $mock.add: β€˜docs’;
spurt $mock.add(β€˜docs/ChangeLog’), β€œNew in 2090.07:\n + Additions:\n\n”;

my @releases = (
β€˜2090-08-19 Rakudo #990 (AlexDaniel)’,
β€˜2090-09-16 Rakudo #991’,
β€˜2090-10-21 Rakudo #992’,
β€˜2090-11-18 Rakudo #993’,
);

sub changelog(Block $do) {
my $path = $mock.add: β€˜docs/ChangeLog’;
spurt $path, $do(slurp $path);
}

sub tag-last($tag-name, $new-section = β€œNew in {$tag-name + 0.01}” #← hahaha
~ β€œ:\n + Additions:\n\n” ) {
my $sha = run(:cwd($mock), :out, β€˜git’, β€˜rev-parse’, β€˜HEAD’)
.out.slurp-rest.chomp;
run :cwd($mock), β€˜git’, β€˜tag’, β€˜--annotate’,
β€œ--message=Blah $tag-name”, $tag-name;
spurt $mock.add(β€˜VERSION’), $tag-name;
changelog { $new-section ~ $_ } if defined $new-section
}

sub commit($message, :$log = True) {
my $foo = ^9⁹ .pick;
spurt $mock.add($foo), $foo;
run :cwd($mock), β€˜git’, β€˜add’, β€˜--’, $foo;
run :cwd($mock), :out(Nil), β€˜git’, β€˜commit’, β€œ--message=$message”;

my $release-guide = β€œ=head2 Planned future releases\n\n… … …\n\n”
~ @releases.map({ β€œ $_\n” }).join;
spurt $mock.add(β€˜docs/release_guide.pod’), $release-guide;

my $sha = run(:cwd($mock), :out, β€˜git’, β€˜rev-parse’, β€˜HEAD’)
.out.slurp-rest.chomp.substr: 0, 8;
my $log-entry = $log ~~ Bool ?? β€œ + $message [$sha]\n” !! $log;
changelog -> $file is copy {
die without $file ~~ s/<after \n><before \n>/$log-entry/;
$file
} if $log;
$sha
}

# TODO the number of blockers and the time left is not controllable

# Basics

commit β€˜$!.pending (RT #68320)’;

$t.test(β€˜unknown format’,
β€œ{$t.bot-nick}: when?”,
/^ <me($t)>β€˜, Next release in ’\d+β€˜ day’s?β€˜ and β‰ˆβ€™\d+β€˜ hour’s?β€˜. ’
[ \d+β€˜ blockers’ | β€˜No blockers’ ]β€˜. ’
β€˜Unknown changelog format’ $/,
β€œ{$t.our-nick}, Details: https://whatever.able/fakeupload”);


tag-last β€˜2090.07’;
commit β€˜.hyper and .race finally re-added’;
tag-last β€˜2090.08’, Nil;

$t.test(β€˜not started yet’,
β€œ{$t.bot-nick}: status”,
β€œ{$t.our-nick}, Release date for Rakudo 2090.08 is listed in”
~ β€œ β€œPlanned future releases”, but it was already released.”,
/^ <me($t)>β€˜, Next release in ’\d+β€˜ day’s?β€˜ and β‰ˆβ€™\d+β€˜ hour’s?β€˜. ’
[ \d+β€˜ blockers’ | β€˜No blockers’ ]β€˜. ’
β€˜Changelog for this release was not started yet’ $/,
β€œ{$t.our-nick}, Details: https://whatever.able/fakeupload”);


@releases.shift;

my $to-be-logged = commit β€˜A change that should be logged’, :!log;
my $to-be-logged-not = commit β€˜A change that should not be logged’, :!log;
my @real = β€˜Furious whitespace changes’ xx 4;
@real.push: β€˜Fix nothing’;
@real .= map: { commit $_, :!log };
my $log-entries = qq:to/END/;
New in 2090.09:
+ Deprecations:
+ Deprecate everything [de1e7ea1]
+ Fixes:
+ Fix nothing [@real[*-1]]
+ Furious whitespace changes [@real[0]] [@real[1]] [@real[2]]
[@real[3]] [abcabcabcabc]
+ No really, this change is very important [@real[1]]
END
changelog { $log-entries ~ $_ };

run :cwd($mock), β€˜git’, β€˜add’, β€˜--’, β€˜docs/ChangeLog’;
commit β€œChangelog\n\nIntentionally not logged: $to-be-logged-not”, :!log;

$t.test(β€˜realistic output’,
β€œ{$t.bot-nick}: release”,
β€œ{$t.our-nick}, Release manager is not specified yet.”,
/^ <me($t)>β€˜, Next release in ’\d+β€˜ day’s?β€˜ and β‰ˆβ€™\d+β€˜ hour’s?β€˜. ’
[ \d+β€˜ blockers’ | β€˜No blockers’ ]β€˜. ’
β€˜6 out of 8 commits logged (⚠ 2 warnings)’ $/, # TODO ideally should be 7 out of 8
β€œ{$t.our-nick}, Details: https://whatever.able/fakeupload”);

$t.test-gist(β€˜gisted files look alright’,
%(β€˜!warnings!’ =>
β€˜de1e7ea1 was referenced but there is no commit with this id’
~ β€œ\n” ~ β€˜abcabcabcabc should be 8 characters in length’,
β€˜unreviewed.md’ =>
/ο½’<pre> + A change that should be loggedο½£
ο½’ [<a href="https://github.com/rakudo/rakudo/commit/ο½£
$to-be-logged <.xdigit>+ ο½’">ο½£ $to-be-logged ο½’</a>]ο½£.*ο½’</pre>ο½£ # TODO .*
/,
)
);


$t.test(β€˜uncommitted changed from a link’,
β€œ{$t.bot-nick}: changelog https://gist.github.com/AlexDaniel/45b98a8bd5935a53a3ed4762ea5f5d43/raw/”,
β€œ{$t.our-nick}, Successfully fetched the code from the provided URL.”,
β€œ{$t.our-nick}, 1 out of 8 commits logged”,
β€œ{$t.our-nick}, Details: https://whatever.able/fakeupload”);

# $t.last-test; # Deliberately no $t.last-test! (we can't repeat the first test)
$t.test(β€˜No uncaught messages’,
β€œ{$t.bot-nick}: help”,
/^ <me($t)>β€˜, status | status link’ /);

done-testing;
END $t.end;

# vim: expandtab shiftwidth=4 ft=perl6

0 comments on commit 5d7445b

Please sign in to comment.