mirror of
https://github.com/ruby/ruby.git
synced 2026-01-27 04:24:23 +00:00
post_push.yml: Backport commit-mail to ruby_3_4 (#14781)
This commit is contained in:
parent
a0937ff3c7
commit
9b5d6505ef
15
.github/workflows/post_push.yml
vendored
15
.github/workflows/post_push.yml
vendored
@ -30,6 +30,21 @@ jobs:
|
||||
if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/ruby_') }}
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 500
|
||||
|
||||
- name: Notify commit to ruby-cvs
|
||||
run: |
|
||||
SENDMAIL="ssh -i ${HOME}/.ssh/id_ed25519 git-sync@git.ruby-lang.org /usr/sbin/sendmail" \
|
||||
ruby tool/commit-mail.rb . ruby-cvs@g.ruby-lang.org \
|
||||
"$GITHUB_OLD_SHA" "$GITHUB_NEW_SHA" "$GITHUB_REF" \
|
||||
--viewer-uri "https://github.com/ruby/ruby/commit/" \
|
||||
--error-to cvs-admin@ruby-lang.org
|
||||
env:
|
||||
GITHUB_OLD_SHA: ${{ github.event.before }}
|
||||
GITHUB_NEW_SHA: ${{ github.event.after }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/ruby_') }}
|
||||
|
||||
- uses: ./.github/actions/slack
|
||||
with:
|
||||
|
||||
399
tool/commit-mail.rb
Normal file
399
tool/commit-mail.rb
Normal file
@ -0,0 +1,399 @@
|
||||
#!/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
|
||||
Loading…
x
Reference in New Issue
Block a user