grn-8.x-2.x-dev/GitReleaseNotesDrushCommands.php
GitReleaseNotesDrushCommands.php
<?php
namespace Drush\Commands\grn;
use Drush\Commands\DrushCommands;
use Drush\Drush;
/**
* Drush 9+ commands for the grn project.
*/
class GitReleaseNotesDrushCommands extends DrushCommands {
/**
* Options passed to the release-notes command.
*
* @var array
*/
protected $options;
/**
* Generates release notes using all commits between two tags.
*
* @param string $tag_1
* The previous tag, the starting point for the log.
* @param string $tag_2
* The current tag, the ending point for the log. This can be also be a
* branch.
* @param array $options
* An associative array of options whose values come from cli, aliases,
* config, etc.
*
* @command release:notes
* @aliases rn,relnotes,release-notes
*
* @option baseurl
* Set the base url for all issue links. Defaults to /node/ for drupal.org
* usage. Issue number will be appended to path or replace "%s".
* @option changelog
* Display the commits in the format for CHANGELOG.txt as expected by
* drupal.org.
* @option md
* Display the commits in MD format.
* @option commit-count
* If set, output will show the number of commits between the two tags.
* @option commit-links
* Attach a link to the commit in drupalcode.org repository viewer to the
* end of the commit lines.
* @option git
* Path to the git binary, defaults to "git".
* @option nouser
* Do not try to link to user page using the /u/alias as used on drupal.org.
* @option pretty
* Pretty format of the message, see the git-log man page (section
* "PRETTY FORMATS")
* @option reverse
* Display the commits from old to new instead of the default Git behavior
* that is new to old.
*
* @usage drush release-notes
* Generates release notes from all commits between the two last tags.
* @usage drush rn 8.x-1.1
* Generates release notes from all commits between 8.x-1.1 (as end) and
* the previous tag (as start).
* @usage drush rn 8.x-1.0 8.x-1.1
* Generates release notes from all commits between 8.x-1.0 and 8.x-1.1
* @usage drush rn 8.x-1.0 8.x-1.x
* Use a branch for tag2 (8.x-1.x)
* @usage drush rn 8.x-1.0 origin/8.x-1.x
* If you don't have the branch locally, you might need to use
* "[remote-name]/[branch-name]".
* @usage drush rn 8.x-1.0 8.x-1.x --baseurl="http://community.openatrium.com/node/"
* You can specify the changelog to direct issues to other issue trackers.
* @usage drush rn 8.x-1.0 8.x-1.x --changelog
* Generates release notes from the commits between the two tags in the
* format for CHANGELOG.txt as expected by drupal.org.
* @usage drush rn 8.x-1.0 8.x-1.1 --git=/usr/local/git/bin/git
* Use git in /usr/local/git/bin/git, and using alias
* @usage drush rn 8.x-1.0 8.x-1.x --pretty="%s (%h)"
* Generates release notes using a custom pretty format string.
* @usage drush rn 8.x-1.0 8.x-1.x --reverse
* Generates release notes from the commits between the two tags in reverse
* order.
*
* phpcs:disable Drupal.Arrays.Array.LongLineDeclaration
*/
public function releaseNotes($tag_1 = NULL, $tag_2 = NULL, array $options = ['baseurl' => '/node/', 'changelog' => NULL, 'md' => NULL, 'commit-count' => NULL, 'commit-links' => NULL, 'git' => 'git', 'nouser' => FALSE, 'pretty' => '%s', 'reverse' => FALSE]) {
$this->options = $options;
// Make sure we are in the working directory started.
$cwd = $this->config->get('env.cwd');
if (!chdir($cwd)) {
throw new \Exception('Failed to change current working directory to your Git project.');
}
if (!is_dir('.git')) {
throw new \Exception('This must be run from the root directory of your Git project.');
}
$git = $options['git'];
// Fill in calculated tags if both are not given.
if (!isset($tag_2)) {
// Get all the defined tags in this repository and sort them.
$process = Drush::process([$git, 'rev-parse', '--abbrev-ref', 'HEAD']);
$process->run();
$branch = substr(array_shift(explode(PHP_EOL, $process->getOutput())), 0, -1);
if ($branch === 'HEA') {
// Working from a detached HEAD.
throw new \Exception("Can't run this command from a detached HEAD.");
}
$process = Drush::process([$git, 'tag', '-l', $branch . '*']);
$process->run();
$tags = explode(PHP_EOL, trim($process->getOutput()));
usort($tags, 'version_compare');
if (!isset($tag_1) && count($tags)) {
// If no tags are provided, use the two most recent ones.
$tag_2 = array_pop($tags);
$tag_1 = count($tags) ? array_pop($tags) : $tag_2;
}
else {
// If only one tag is given, it is considered to be <end> and <start> is
// taken to be one tag before it.
$key = array_search($tag_1, $tags);
if (is_int($key)) {
if ($key > 0) {
// Rearrange our tags: the given tag is in fact tag 2.
$tag_2 = $tag_1;
// The <start> tag is one before the given <end> tag.
$tag_1 = $tags[$key - 1];
}
else {
throw new \Exception(dt('@tag is the first tag in the branch.', ['@tag' => $tag_1]));
}
}
else {
throw new \Exception(dt('@tag is not a valid Git tag.', ['@tag' => $tag_1]));
}
}
}
// '^' is the escape character on Windows (like '\' on *nix) - has to be
// contained in the escaped shell argument string ("%s").
$process = Drush::process(
[$git, 'show', '-s', '--pretty=format:%H', $tag_1 . '^{commit}']
);
$process->run();
if (!$process->isSuccessful()) {
throw new \Exception(dt('@tag is not a valid Git tag.', ['@tag' => $tag_1]));
}
$tag1 = $process->getOutput();
// '^' is the escape character on Windows (like '\' on *nix) - has to be
// contained in the escaped shell argument string ("%s").
$process = Drush::process(
[$git, 'show', '-s', '--pretty=format:%H', $tag_2 . '^{commit}']
);
$process->run();
if (!$process->isSuccessful()) {
throw new \Exception(dt('@tag is not a valid Git tag.', ['@tag' => $tag_2]));
}
$tag2 = $process->getOutput();
$changes = $this->grnGetChanges($tag1, $tag2, $git);
// Check if we need to produce commit links.
$commit_path = $this->options['commit-links'] ? $this->getCommitPath($git) : '';
$items = $this->getItemsArray($changes, $commit_path);
if ($this->options['changelog']) {
$formatted_items = $this->formatChangelog($items, $tag_2);
}
elseif ($this->options['md']) {
$formatted_items = $this->formatMd($items, $tag_1, $tag1[0], $tag2[0], $git);
}
else {
$formatted_items = $this->formatChanges($items, $tag_1, $tag1[0], $tag2[0], $git);
}
$this->output->writeln($formatted_items['rendered']);
return TRUE;
}
/**
* Gets the changes and returns them in an array.
*/
protected function grnGetChanges($tag1, $tag2, $git) {
$changes = [];
$reverse = $this->options['reverse'] ? '--reverse' : '--date-order';
$pretty = $this->options['pretty'];
if ($pretty === TRUE) {
// Use default format if pretty option is used with no format.
$pretty = '%s';
}
if (strpos($pretty, '%H') !== 0) {
// Prepend the commit, which we will need when we parse the output.
$pretty = '%H ' . $pretty;
}
$process = Drush::process([
$git,
'log',
'-s',
'--pretty=format:' . $pretty,
$reverse, $tag1 . '..' . $tag2,
]);
$process->mustRun();
if (!$process->isSuccessful()) {
throw new \Exception('git log returned an error.');
}
$output = explode(PHP_EOL, $process->getOutput());
foreach ($output as $line) {
if (empty($line)) {
// Skip blank lines that are left behind in the messages.
continue;
}
[$hash, $message] = explode(' ', $line, 2);
$changes[$hash] = $message;
}
if ($this->options['commit-count']) {
$this->options['commit-count'] = count($changes);
}
return $changes;
}
/**
* Get array of items.
*/
protected function getItemsArray($issues, $commit_path) {
$baseurl = $this->options['baseurl'];
if (strpos($baseurl, '%s') == FALSE) {
$baseurl .= '%s';
}
$items = [];
foreach ($issues as $hash => $line) {
// Pattern is "Issue #[issue number] by [comma-separated usernames]:
// [Short summary of the change]."
// Clean up commit log.
$raw = preg_replace('/^(Patch |- |Issue ){0,3}/', '', $line);
// Add issue links.
$item = ($this->options['md']) ? preg_replace('/#(\d+)/S', '[#$1](' . str_replace('%s', '$1', $baseurl) . ')', $raw) :
preg_replace('/#(\d+)/S', '<a href="' . str_replace('%s', '$1', $baseurl) . '">#$1</a>', $raw);
// Replace usernames with link to user page.
if ($this->options['nouser'] === FALSE) {
// Anything between by and ':' is a comma-separated list of usernames.
$item = preg_replace_callback('/by ([^:]+):/S',
function ($matches) {
$out = [];
// Separate the different usernames.
foreach (explode(',', $matches[1]) as $user) {
// Trim spaces, convert to lowercase, and spaces in middle are
// replaced with dashes.
$ualias = str_replace(' ', '-', strtolower(trim($user)));
$out[] = ($this->options['md']) ? "[$ualias](/u/$ualias)" :
("<a href='/u/$ualias'>" . trim($user) . '</a>');
}
return 'by ' . implode(', ', $out) . ':';
},
$item);
}
// If the command was invoked with the --commit-links option.
if (!empty($commit_path)) {
$item .= ' (<a href="' . $commit_path . $hash . '" title="View commit">#</a>)';
}
$items[$raw] = $item;
}
return $items;
}
/**
* Generated output in Changelog format.
*/
protected function formatChangelog($items, $tag) {
if ($infos = glob('*.info')) {
foreach ($infos as $info) {
$ini_array = parse_ini_file($info);
$name = trim($ini_array['name'], " \t\n\r\0\x0B\"");
}
}
$process = Drush::process(
['git', 'show', '-s', '--pretty=format:%ad', '--date=short', $tag]
);
$process->run();
$date = explode(PHP_EOL, $process->getOutput());
$changelog = empty($name) ? '' : ($name . " ");
$changelog .= $tag . ", " . $date[0];
if ($this->options['commit-count']) {
$changelog .= ' (' . trim($this->options['commit-count']) . ' commits)';
}
$changelog .= "\n" . str_pad("", strlen($changelog), "-");
$changelog .= "\n";
foreach ($items as $raw => $html) {
$changelog .= '- ';
$line = ucfirst(trim((strpos($raw, "#") === 0) ? substr(strstr($raw, ':'), 1) : $raw));
$changelog .= substr($line, -1) == "." ? $line : $line . ".";
$changelog .= "\n";
}
return ['raw' => $items, 'rendered' => $changelog];
}
/**
* Generated output in MD format.
*/
protected function formatMd($items, $prev_tag, $tag1, $tag2, $git) {
$rendered = "**Changes since $prev_tag";
if ($this->options['commit-count']) {
$rendered .= ' (' . trim($this->options['commit-count']) . ' commits)';
}
$rendered .= "**\n\n";
if (!empty($items)) {
$rendered .= "* " . implode("\n* ", $items) . "\n";
}
return ['rendered' => $rendered, 'raw' => $items];
}
/**
* Generates the output.
*/
protected function formatChanges($items, $prev_tag, $tag1, $tag2, $git) {
$rendered = "<p>Changes since $prev_tag";
if ($this->options['commit-count']) {
$rendered .= ' (' . trim($this->options['commit-count']) . ' commits)';
}
$rendered .= ":</p>\n";
if (!empty($items)) {
$rendered .= "<ul>\n <li>" . implode("</li>\n <li>", $items) . "</li>\n</ul>";
}
return ['rendered' => $rendered, 'raw' => $items];
}
/**
* Discover the URL to the 'origin' repository viewer on drupalcode.org.
*
* @param string $git
* The actual git command we are going to use.
*
* @return string
* The URL to the repository viewer.
*
* @throws \Exception
*/
protected function getCommitPath($git) {
// Execute the git config --get remote.origin.url.
$process = Drush::process([$git, 'config', '--get', 'remote.origin.url']);
$process->run();
if (!$process->isSuccessful()) {
throw new \Exception('git config returned an error.');
}
// Get the output of the above command.
$remote_origin_url = trim($process->getOutput());
// Check to see if this project is hosted by Drupal.
if (!strpos($remote_origin_url, 'drupal.org') && !strpos($remote_origin_url, 'drupalcode.org')) {
throw new \Exception('The remote origin url is not on drupal.org or drupalcode.org.');
}
// Format the URL of the project's repository.
$repository_url = '';
if (stripos($remote_origin_url, '@git.drupal.org:') !== FALSE) {
// If the user is a maintainer, the remote origin url will look like:
// git@git.drupal.org:project/grn.git (for a full project)
// or
// git@git.drupal.org:sandbox/user-123456.git (for a sandbox)
// Replace 'git@git.drupal.org' with 'https://git.drupalcode.org'.
$repository_url = preg_replace(
'/git@git\.drupal\.org:/',
'https://git.drupalcode.org/',
$remote_origin_url
);
}
elseif (stripos($remote_origin_url, 'git.drupalcode.org') !== FALSE) {
// If the user is not a maintainer, the remote origin url will look like:
// https://git.drupalcode.org/project/grn.git (for a full project)
// or
// https://git.drupalcode.org/sandbox/user-123456.git (for a sandbox)
// then we will just need to strip the .git from the end, which is done
// after this conditional.
$repository_url = $remote_origin_url;
}
else {
throw new \Exception('The remote origin url is not in the expected format.');
}
// Remove .git from the end of the repository url.
$repository_url = substr($repository_url, 0, -4);
// Add '/commit/' to the end of the repository url.
$repository_url = $repository_url . '/commit/';
return $repository_url;
}
}
