www/members/check_invitations.cgi (322 lines of code) (raw):
#!/usr/bin/env ruby
PAGETITLE = "Cross-check new Member invitations/applications" # Wvisible:meeting,members
$LOAD_PATH.unshift '/srv/whimsy/lib'
require 'date'
require 'wunderbar/bootstrap'
require 'whimsy/asf'
require 'mail'
require 'whimsy/asf/meeting-util'
require 'whimsy/asf/member-files'
require 'yaml'
MAIL_DIR = '/srv/mail/members'
MAIL_DIR_SEC = '/srv/mail/secretary'
ENV['HTTP_ACCEPT'] = 'application/json' if ENV['QUERY_STRING'].include? 'json'
# Get a link to lists.a.o for an email
def lists_link(email)
mid = email[:MessageId]
return "https://lists.apache.org/thread/<#{mid}>?<members.apache.org>" if mid
# No mid; try another way
datime = DateTime.parse email[:EnvelopeDate] # '2024-03-07T23:20:23+00:00'
date1 = datime.strftime('%Y-%-m-%-d')
date2 = (datime+1).strftime('%Y-%-m-%-d') # allow for later arrival
from = email[:From]
text = "Invitation to join The Apache Software Foundation Membership #{from}"
"https://lists.apache.org/list?members@apache.org:dfr=#{date1}|dto=#{date2}:#{text}"
end
# Encapsulate gathering data to improve error processing
def setup_data
memappfile = ASF::MeetingUtil.get_latest_file('memapp-received.txt')
# which entries are shown as uninvited; get availid and name
notinvited = {}
notapplied = []
fields = %i(invite apply mail karma id name)
ASF::MeetingUtil.parse_memapp(memappfile).filter_map do |a|
entry = fields.zip(a).to_h
entry[:id] = 'n/a_' + entry[:name] if entry[:id] == 'n/a' # Allow for n/a entries
if entry[:invite] == 'no'
notinvited[entry[:id]] = {name: entry[:name]}
elsif %i(apply mail karma).any? {|e| entry[e] == 'no'} # any no apart from invite?
notapplied << entry
end
end
yyyymm = File.basename(File.dirname(memappfile))[0..5]
applications = []
# find relevant secretary files (exclude ones before the meeting)
syamls = Dir[File.join(MAIL_DIR_SEC, '2?????.yml')].select {|n| File.basename(n, 'yml') >= yyyymm }
syamls.each do |index|
mail = YamlFile.read(index)
mail.each do |k, v|
next if v[:status] == :deleted
next unless v[:attachments] and v[:attachments].size > 0
if (v['Subject'] =~ %r{[Mm]embership}) or (v[:attachments].first[:name] =~ %r{[Mm]embership})
applications << v[:from]
name = v['From'].sub(%r{<[^>\s]+>}, '').strip
applications << name if name
end
end
end
# find relevant members email files (exclude ones before the meeting)
yamls = Dir[File.join(MAIL_DIR, '2?????.yaml')].select {|n| File.basename(n, 'yaml') >= yyyymm }
# now find invitations and replies
invites = {emails: {}, names: {}}
replies = {emails: {}, names: {}}
yamls.each do |index|
mail = YamlFile.read(index)
mail.each do |k, v|
link = lists_link(v)
envdate = v[:EnvelopeDate]
age = (Date.today - Date.parse(envdate)).to_i # How long since it was
# This may not find all the invites ...
# Note: occasionally someone will forget to copy members@, in which case the email
# may be sent as a reply
# The alternative prefix has been seen in a reply from China
# Looks like ': ' is being treated as a separate character
# Allow for forwarded mail (may not catch original and reply ...)
if v[:Subject] =~ /^(R[eE]: ?|R[eE]:|AW: )?(?:Fwd: )?Invitation to (?:re-)?join The Apache Software Foundation/
pfx = $1
to = Mail::AddressList.new(v[:To])
cc = Mail::AddressList.new(v[:Cc])
(to.addresses + cc.addresses).each do |add|
addr = add.address
next if addr == 'members@apache.org'
prev = invites[:emails][addr] || [nil, 100]
if age < prev[1] # Only store later dates
invites[:emails][addr] = [link, age] # temp save the timestamp
invites[:names][add.display_name] = [link, age] if add.display_name
end
end
if pfx # it's a reply
add = Mail::Address.new(v[:From])
replies[:emails][add.address] = [link, age]
replies[:names][add.display_name] = [link, age] if add.display_name
end
end
end
end
nominated_by = {}
na_emails = {} # emails for n/a ids from member-nominations
# n/a entries are not necessarily in the same order as in member-apps
ASF::MemberFiles.member_nominees.each do |k, v|
if k.start_with? 'n/a_'
k = 'n/a_' + v['Public Name']
na_emails[k] = [v['Nominee email']]
end
nominated_by[k] = v['Nominated by']
end
# Load extra emails from override file if it exists
begin
extras = YAML.load_file(File.join(File.dirname(memappfile),'notinavail.yml'))
extras[:emails].each do |name, email|
k = 'n/a_' + name
na_emails[k] ||= []
na_emails[k] += email
end
rescue StandardError
# ignored
end
notinvited.each do |id, v|
# na_emails entries only exist for non-commiters
mails = na_emails[id] || ASF::Person.new(id).all_mail
v[:invited] = match_person(invites, id, v[:name], mails)
v[:replied] = match_person(replies, id, v[:name], mails)
v[:nominators] = nominated_by[id] || ['unknown']
end
notapplied.each do |record|
id = record[:id]
name = record[:name]
# na_emails entries only exist for non-commiters
mails = na_emails[id] || ASF::Person.new(id).all_mail
record[:replied] = match_person(replies, id, name, mails)
record[:invited] = match_person(invites, id, name, mails)
record[:applied] = applications.any? {|x| mails.include? x or x == name}
end
return notinvited, memappfile, invites, replies, nominated_by, notapplied
end
# return a link to the email (if any)
def match_person(hash, id, name, mails)
mail = "#{id}@apache.org"
link = hash[:emails][mail] || hash[:names][name]
return link if link
mails.each do |m|
link = hash[:emails][m]
return link if link
end
return nil
end
meeting_end = ASF::MeetingUtil.meeting_end
remain = ASF::MeetingUtil.application_time_remaining
# produce HTML output of reports, highlighting ones that have not (yet)
# been posted
_html do
_style %{
.missing {background-color: yellow}
.flexbox {display: flex; flex-flow: row wrap}
.flexitem {flex-grow: 1}
.flexitem:first-child {order: 2}
.flexitem:last-child {order: 1}
.count {margin-left: 4em}
}
_body? do
notinvited, memappfile, _, _, nominated_by, notapplied = setup_data
memappurl = ASF::SVN.getInfoItem(memappfile, 'url')
nominationsurl = memappurl.sub('memapp-received.txt', 'nominated-members.txt')
_whimsy_body(
title: PAGETITLE,
related: {
memappurl => 'memapp-received.txt',
'https://lists.apache.org/list.html?members@apache.org' => 'members@apache.org',
nominationsurl => 'nominated-members.txt',
'https://github.com/apache/whimsy/blob/master/www/members/check_invitations.cgi' => 'Source code for this page'
},
helpblock: -> {
_p do
_ 'This script checks'
_a 'memapp-received.txt', href: memappurl
_ 'against invitation emails and replies seen in'
_a 'members@apache.org', href: 'https://lists.apache.org/list.html?members@apache.org'
end
_p do
_ 'It also tries to check against applications which are pending processing by the secretary.'
_ 'These must have a subject or attachment name that mentions "membership".'
_ 'Also, the From address must be one of the ones registered to the applicant, or must match the full name.'
end
_p 'The invite and reply columns link to the relevant emails in members@ if possible'
_p %{
N.B. The code only looks at the subject to determine if an email is an invite or its reply.
Also the members@ emails are only scanned every 10 minutes or so.
}
_p do
if remain[:hoursremain] > 0
_b "Applications close in #{remain[:days]} days and #{remain[:hours]} hours"
else
_b "Applications can no longer be accepted, sorry."
_ "The meeting ended at #{Time.at(meeting_end).getutc.strftime('%Y-%m-%d %H:%M %Z')}."
_ "Applications closed #{remain[:days]} days and #{remain[:hours]} hours ago."
end
end
}
) do
_h1 'Nominations listed as not yet invited in memapp-received.txt'
_p do
_ 'If an invite or reply has been seen, the relevant table cell is'
_span.missing 'flagged'
_ '. After confirming that the invite was correctly identified, the memapp-received.txt file can be updated'
end
_table.table.table_striped do
_tr do
_th 'id'
_th 'name'
_th 'invite seen?'
_th 'reply seen?'
_th 'nominator(s)'
end
# sort by nominators to make it easier to send reminders
notinvited.sort_by{|k,v| v[:nominators]}.each do |id, v|
_tr_ do
_td do
if id.start_with? 'n/a_'
_ id
else
_a id, href: "https://whimsy.apache.org/roster/committer/#{id}"
end
end
_td v[:name]
url, age = v[:invited]
daysn = age == 1 ? 'day' : 'days'
if url
_td.missing do
_a "#{age} #{daysn} ago", href: url
end
else
_td 'false'
end
url, age = v[:replied]
daysn = age == 1 ? 'day' : 'days'
if url
_td.missing do
_a "#{age} #{daysn} ago", href: url
end
else
_td 'false'
end
_td v[:nominators]
end
end
end
_h1 'Invitees who have yet to be granted membership'
_ 'If an invite email (or reply) cannot be found, the table cell is'
_span.missing 'flagged'
_p do
_ 'There is currently no way to record declined invitations.'
_ 'Nor is there a way to record replies that do not match the list search criteria.'
_br
_ 'Some replies may be incorrectly recorded as missing'
_ 'and some applications will never be received.'
end
_table.table.table_striped do
_tr do
_th 'invited?'
_th 'Reply seen?'
# No point showing these, as we don't check them
# _th 'applied?'
# _th 'members@?'
# _th 'karma?'
_th 'Application seen?'
_th 'id'
_th 'name'
_th 'Nominators'
end
notapplied.each do |entry|
_tr do
url, age = entry[:invited]
daysn = age == 1 ? 'day' : 'days'
if url
_td do
_a "#{age} #{daysn} ago", href: url
end
else
_td.missing 'no'
end
url, age = entry[:replied]
daysn = age == 1 ? 'day' : 'days'
if url
_td do
_a "#{age} #{daysn} ago", href: url
end
else
if entry[:applied]
_td.missing 'no'
else
_td 'no'
end
end
# _td entry[:apply]
# _td entry[:mail]
# _td entry[:karma]
_td entry[:applied] ? 'yes' : 'no'
_td do
if entry[:id].start_with? 'n/a_'
_ entry[:id]
else
_a entry[:id], href: "https://whimsy.apache.org/roster/committer/#{entry[:id]}"
end
end
_td entry[:name]
_td nominated_by[entry[:id]] || 'unknown'
end
end
end
end
end
end
# produce JSON output
# N.B. This is activated if the ACCEPT header references 'json'
_json do
notinvited, memappfile, invites, replies, nominated_by, notapplied = setup_data
_notinvited notinvited
_memappfile memappfile
_invites invites
_replies replies
_nominated_by nominated_by
_notapplied notapplied
end