ruby/tool/commit-mail.rb

400 lines
10 KiB
Ruby

#!/usr/bin/env ruby
require "optparse"
require "ostruct"
require "nkf"
require "shellwords"
CommitEmailInfo = Struct.new(
:author,
:author_email,
:revision,
:entire_sha256,
:date,
:log,
:branch,
:diffs,
:added_files, :deleted_files, :updated_files,
:added_dirs, :deleted_dirs, :updated_dirs,
)
class GitInfoBuilder
GitCommandFailure = Class.new(RuntimeError)
def initialize(repo_path)
@repo_path = repo_path
end
def build(oldrev, newrev, refname)
diffs = build_diffs(oldrev, newrev)
info = CommitEmailInfo.new
info.author = git_show(newrev, format: '%an')
info.author_email = normalize_email(git_show(newrev, format: '%aE'))
info.revision = newrev[0...10]
info.entire_sha256 = newrev
info.date = Time.at(Integer(git_show(newrev, format: '%at')))
info.log = git_show(newrev, format: '%B')
info.branch = git('rev-parse', '--symbolic', '--abbrev-ref', refname).strip
info.diffs = diffs
info.added_files = find_files(diffs, status: :added)
info.deleted_files = find_files(diffs, status: :deleted)
info.updated_files = find_files(diffs, status: :modified)
info.added_dirs = [] # git does not deal with directory
info.deleted_dirs = [] # git does not deal with directory
info.updated_dirs = [] # git does not deal with directory
info
end
private
# Force git-svn email address to @ruby-lang.org to avoid email bounce by invalid email address.
def normalize_email(email)
if email.match(/\A[^@]+@\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/) # git-svn
svn_user, _ = email.split('@', 2)
"#{svn_user}@ruby-lang.org"
else
email
end
end
def find_files(diffs, status:)
files = []
diffs.each do |path, values|
if values.keys.first == status
files << path
end
end
files
end
# SVN version:
# {
# "filename" => {
# "[modified|added|deleted|copied|property_changed]" => {
# type: "[modified|added|deleted|copied|property_changed]",
# body: "diff body", # not implemented because not used
# added: Integer,
# deleted: Integer,
# }
# }
# }
def build_diffs(oldrev, newrev)
diffs = {}
numstats = git('diff', '--numstat', oldrev, newrev).lines.map { |l| l.strip.split("\t", 3) }
git('diff', '--name-status', oldrev, newrev).each_line do |line|
status, path, _newpath = line.strip.split("\t", 3)
diff = build_diff(path, numstats)
case status
when 'A'
diffs[path] = { added: { type: :added, **diff } }
when 'M'
diffs[path] = { modified: { type: :modified, **diff } }
when 'C'
diffs[path] = { copied: { type: :copied, **diff } }
when 'D'
diffs[path] = { deleted: { type: :deleted, **diff } }
when /\AR/ # R100 (which does not exist in git.ruby-lang.org's git 2.1.4)
# TODO: implement something
else
$stderr.puts "unexpected git diff status: #{status}"
end
end
diffs
end
def build_diff(path, numstats)
diff = { added: 0, deleted: 0 } # :body not implemented because not used
line = numstats.find { |(_added, _deleted, file, *)| file == path }
return diff if line.nil?
added, deleted, _ = line
if added
diff[:added] = Integer(added)
end
if deleted
diff[:deleted] = Integer(deleted)
end
diff
end
def git_show(revision, format:)
git('show', "--pretty=#{format}", '--no-patch', revision).strip
end
def git(*args)
command = ['git', '-C', @repo_path, *args]
output = with_gitenv { IO.popen(command, external_encoding: 'UTF-8', &:read) }
unless $?.success?
raise GitCommandFailure, "failed to execute '#{command.join(' ')}':\n#{output}"
end
output
end
def with_gitenv
orig = ENV.to_h.dup
begin
ENV.delete('GIT_DIR')
yield
ensure
ENV.replace(orig)
end
end
end
CommitEmail = Module.new
class << CommitEmail
SENDMAIL = ENV.fetch('SENDMAIL', '/usr/sbin/sendmail')
private_constant :SENDMAIL
def parse(args)
options = OpenStruct.new
options.error_to = nil
options.viewvc_uri = nil
opts = OptionParser.new do |opts|
opts.separator('')
opts.on('-e', '--error-to [TO]',
'Add [TO] to to address when error is occurred') do |to|
options.error_to = to
end
opts.on('--viewer-uri [URI]',
'Use [URI] as URI of revision viewer') do |uri|
options.viewer_uri = uri
end
opts.on_tail('--help', 'Show this message') do
puts opts
exit
end
end
return opts.parse(args), options
end
def main(repo_path, to, rest)
args, options = parse(rest)
infos = args.each_slice(3).flat_map do |oldrev, newrev, refname|
revisions = IO.popen(['git', 'log', '--reverse', '--pretty=%H', "#{oldrev}^..#{newrev}"], &:read).lines.map(&:strip)
revisions[0..-2].zip(revisions[1..-1]).map do |old, new|
GitInfoBuilder.new(repo_path).build(old, new, refname)
end
end
infos.each do |info|
next if info.branch.start_with?('notes/')
puts "#{info.branch}: #{info.revision} (#{info.author})"
from = make_from(name: info.author, email: "noreply@ruby-lang.org")
sendmail(to, from, make_mail(to, from, info, viewer_uri: options.viewer_uri))
end
end
def sendmail(to, from, mail)
IO.popen([*SENDMAIL.shellsplit, to], 'w') do |f|
f.print(mail)
end
unless $?.success?
raise "Failed to run `#{SENDMAIL} #{to}` with: '#{mail}'"
end
end
private
def b_encode(str)
NKF.nkf('-WwM', str)
end
def make_body(info, viewer_uri:)
body = ''
body << "#{info.author}\t#{format_time(info.date)}\n"
body << "\n"
body << " New Revision: #{info.revision}\n"
body << "\n"
body << " #{viewer_uri}#{info.revision}\n"
body << "\n"
body << " Log:\n"
body << info.log.lstrip.gsub(/^\t*/, ' ').rstrip
body << "\n\n"
body << added_dirs(info)
body << added_files(info)
body << deleted_dirs(info)
body << deleted_files(info)
body << modified_dirs(info)
body << modified_files(info)
[body.rstrip].pack('M')
end
def format_time(time)
time.strftime('%Y-%m-%d %X %z (%a, %d %b %Y)')
end
def changed_items(title, type, items)
rv = ''
unless items.empty?
rv << " #{title} #{type}:\n"
rv << items.collect {|item| " #{item}\n"}.join('')
end
rv
end
def changed_files(title, files)
changed_items(title, 'files', files)
end
def added_files(info)
changed_files('Added', info.added_files)
end
def deleted_files(info)
changed_files('Removed', info.deleted_files)
end
def modified_files(info)
changed_files('Modified', info.updated_files)
end
def changed_dirs(title, files)
changed_items(title, 'directories', files)
end
def added_dirs(info)
changed_dirs('Added', info.added_dirs)
end
def deleted_dirs(info)
changed_dirs('Removed', info.deleted_dirs)
end
def modified_dirs(info)
changed_dirs('Modified', info.updated_dirs)
end
def changed_dirs_info(info, uri)
rev = info.revision
(info.added_dirs.collect do |dir|
" Added: #{dir}\n"
end + info.deleted_dirs.collect do |dir|
" Deleted: #{dir}\n"
end + info.updated_dirs.collect do |dir|
" Modified: #{dir}\n"
end).join("\n")
end
def diff_info(info, uri)
info.diffs.collect do |key, values|
[
key,
values.collect do |type, value|
case type
when :added
command = 'cat'
rev = "?revision=#{info.revision}&view=markup"
when :modified, :property_changed
command = 'diff'
prev_revision = (info.revision.is_a?(Integer) ? info.revision - 1 : "#{info.revision}^")
rev = "?r1=#{info.revision}&r2=#{prev_revision}&diff_format=u"
when :deleted, :copied
command = 'cat'
rev = ''
else
raise "unknown diff type: #{value[:type]}"
end
link = [uri, key.sub(/ .+/, '') || ''].join('/') + rev
desc = ''
[desc, link]
end
]
end
end
def make_header(to, from, info)
headers = []
headers << x_author(info)
headers << x_repository(info)
headers << x_revision(info)
headers << x_id(info)
headers << 'Mime-Version: 1.0'
headers << 'Content-Type: text/plain; charset=utf-8'
headers << 'Content-Transfer-Encoding: quoted-printable'
headers << "From: #{from}"
headers << "To: #{to}"
headers << "Subject: #{make_subject(info)}"
headers.find_all do |header|
/\A\s*\z/ !~ header
end.join("\n")
end
def make_subject(info)
subject = ''
subject << "#{info.revision}"
subject << " (#{info.branch})"
subject << ': '
subject << info.log.lstrip.lines.first.to_s.strip
b_encode(subject)
end
# https://tools.ietf.org/html/rfc822#section-4.1
# https://tools.ietf.org/html/rfc822#section-6.1
# https://tools.ietf.org/html/rfc822#appendix-D
# https://tools.ietf.org/html/rfc2047
def make_from(name:, email:)
if name.ascii_only?
escaped_name = name.gsub(/["\\\n]/) { |c| "\\#{c}" }
%Q["#{escaped_name}" <#{email}>]
else
escaped_name = "=?UTF-8?B?#{NKF.nkf('-WwMB', name)}?="
%Q[#{escaped_name} <#{email}>]
end
end
def x_author(info)
"X-SVN-Author: #{b_encode(info.author)}"
end
def x_repository(info)
'X-SVN-Repository: XXX'
end
def x_id(info)
"X-SVN-Commit-Id: #{info.entire_sha256}"
end
def x_revision(info)
"X-SVN-Revision: #{info.revision}"
end
def make_mail(to, from, info, viewer_uri:)
"#{make_header(to, from, info)}\n#{make_body(info, viewer_uri: viewer_uri)}"
end
end
repo_path, to, *rest = ARGV
begin
CommitEmail.main(repo_path, to, rest)
rescue StandardError => e
$stderr.puts "#{e.class}: #{e.message}"
$stderr.puts e.backtrace
_, options = CommitEmail.parse(rest)
to = options.error_to
CommitEmail.sendmail(to, to, <<-MAIL)
From: #{to}
To: #{to}
Subject: Error
#{$!.class}: #{$!.message}
#{$@.join("\n")}
MAIL
exit 1
end