class ASF::MeetingUtil

Constants

APPLICATION_EXPIRY_POST_VOTE_DAYS

www.apache.org/foundation/bylaws.html#article-iv application must be received by the Secretary no later than 30 days following the vote. Current thinking is that the vote is considered to have occurred when the results are announced. TBC

APPLICATION_EXPIRY_POST_VOTE_SECS
MEETINGS_DIR
MEETING_FILES

The URL is generated using emit_link() in meeting.cgi if the name includes ‘/’ then use as is unless it starts with ‘runbook/’

PROXIES_FILENAME
RECORDS
VCAL_EVENTS_FILENAME

Public Class Methods

annotate_attendance(dir) click to toggle source

Annotate the attendance.json file with cohorts by id This allows easy use by other tools

# File lib/whimsy/asf/meeting-util.rb, line 271
def self.annotate_attendance(dir)
  attendance = JSON.parse(IO.read(File.join(dir, 'attendance.json')))
  memapps = read_memapps(dir)
  iclas = ASF::ICLA.preload
  memapp_map = JSON.parse(IO.read(File.join(dir, 'memapp-map.json')))
  attendance['cohorts'] = {}
  attendance['unmatched'] = []
  attendance['members'].each do |date, ary|
    next unless date.start_with? '20' # exclude 'active'
    ary.each do |nam|
      found = iclas.select{|i| i.icla.legal_name == nam}
      found = iclas.select{|i| i.icla.name == nam} if found.empty?
      if found.empty?
        if memapps.has_key?(nam)
          attendance['cohorts'][memapps[nam][0]] = date
        elsif memapp_map.has_key?(nam)
          attendance['cohorts'][memapp_map[nam]] = date
        else
          attendance['unmatched'] << nam
        end
      else
        attendance['cohorts'][found[0].icla.id] = date
      end
    end
  end
  File.open(File.join(dir, 'attendance-cohorts.json'), 'w') do |f| # Do not overwrite blindly; manual copy if desired
    f.puts JSON.pretty_generate(attendance)
  end
end
application_time_remaining() click to toggle source

How long remains before applications close? (Time is measured from scheduled end of the meeting in which the votes were declared) Returned as hash, e.g. {:hoursremain=>605, :days=>25, :hours=>5} If applications have expired, :hoursremain is negative and :days/:hours are elapsed time since expiry

# File lib/whimsy/asf/meeting-util.rb, line 438
def self.application_time_remaining
  meetingend = self.meeting_end # this is in seconds
  now = DateTime.now.to_time.to_i
  remain = (meetingend + APPLICATION_EXPIRY_POST_VOTE_SECS - now) / 3600
  {hoursremain: remain, days: remain.abs/24, hours: remain.abs%24}
end
application_valid?(message_datetime) click to toggle source

Is this particular membership application still valid? Used to check if an application was received before the close date return: true/false

# File lib/whimsy/asf/meeting-util.rb, line 455
def self.application_valid?(message_datetime)
  expirytime = self.meeting_end + APPLICATION_EXPIRY_POST_VOTE_SECS
  msgtime = DateTime.iso8601(message_datetime).to_time.to_i
  msgtime <= expirytime
end
applications_valid() click to toggle source

Are membership applications still valid? Applications close date has yet to be reached return: true/false

# File lib/whimsy/asf/meeting-util.rb, line 448
def self.applications_valid
  self.application_time_remaining[:hoursremain] > 0
end
calculate_quorum(mtg_dir) click to toggle source

Calculate how many members required to attend first half for quorum Returns: num_members, quorum_need, num_proxies, attend_irc where: num_members = number of active members (taken from ‘record’ if possible, else members.txt) quorum_need = (num_members + 2) / 3 num_proxies = number of files under ‘proxies-received’ attend_irc = quorum_need - num_proxies

# File lib/whimsy/asf/meeting-util.rb, line 56
def self.calculate_quorum(mtg_dir)
  begin
    begin
      num_members = File.read(File.join(mtg_dir, 'record')).each_line.count
    rescue
      num_members = ASF::Member.list.length - ASF::Member.status.length
    end
    quorum_need = (num_members + 2) / 3
    num_proxies = Dir[File.join(mtg_dir, 'proxies-received', '*')].count
    attend_irc = quorum_need - num_proxies
    attend_irc = 0 if attend_irc < 0 # allow for more proxies than quorum
  rescue StandardError => e
    # Ensure we can't break rest of script
    puts "ERROR: #{e}"
    return 0, 0, 0, 0
  end
  return num_members, quorum_need, num_proxies, attend_irc
end
current_status(cur_mtg_dir) click to toggle source

return a function to determine the current status of a member by id

# File lib/whimsy/asf/meeting-util.rb, line 334
def self.current_status(cur_mtg_dir)
  proxies = Dir["#{cur_mtg_dir}/proxies-received/*"].
    map {|file| File.basename(file, '.*')}

  _tag,emeritus = ASF::SVN.getlisting('emeritus-requests-received')
  emeritus.map! {|file| File.basename(file, '.*')}

  lambda do |id|
    if emeritus.include? id
      'Emeritus request received'
    elsif proxies.include? id
      'Proxy received'
    else
      'No response'
    end
  end
end
getProxyAssignments(mtg_dir=nil) click to toggle source

get list of proxy assignments returns array of: [proxy, subject, subject id]

# File lib/whimsy/asf/meeting-util.rb, line 93
def self.getProxyAssignments(mtg_dir=nil)
  _, assignments = self.parseProxies(mtg_dir)
  hdr = assignments.shift
  # work out the column layout
  re = %r{^((\s+)<name>\s+)<name>}
  if hdr.match re
    total, offset = [$1.length, $2.length]
  else
    raise ArgumentError, "proxies: bad header '#{hdr}'"
  end
  assignments.map do |line|
    proxy = line[offset..total-1].strip
    if line[total..-1].strip.match %r{(.+) +\((.+)\)}
      proxied = $1
      uid = $2
    else
      raise ArgumentError, "proxies: bad assignment '#{line}'"
    end
    [proxy, proxied, uid]
  end
end
getProxyNominees(mtg_dir=nil) click to toggle source

get list of proxy nominees

# File lib/whimsy/asf/meeting-util.rb, line 116
def self.getProxyNominees(mtg_dir=nil)
  assignments = self.getProxyAssignments(mtg_dir)
  assignments.map do |line|
    line[0]
  end.uniq
end
getVolunteers(mtg_dir=nil) click to toggle source

get list of proxy volunteers

# File lib/whimsy/asf/meeting-util.rb, line 86
def self.getVolunteers(mtg_dir=nil)
  volunteers, _ = self.parseProxies(mtg_dir)
  volunteers.each.filter_map {|line| l = line.strip; l if l.length > 0}
end
get_attend_matrices(dir) click to toggle source

Precompute matrix and dates from attendance

# File lib/whimsy/asf/meeting-util.rb, line 302
def self.get_attend_matrices(dir)
  attendance = MeetingUtil.get_attendance(dir)

  # extract and format dates
  dates = attendance['dates'].sort.
    map {|date| Date.parse(date).strftime('%Y-%b')}

  # compute mappings of names to ids
  members = ASF::Member.list
  active = Hash[members.select {|_id, data| not data['status']}]
  nameMap = Hash[members.map {|id, data| [id, data[:name]]}]
  idMap = Hash[nameMap.to_a.map(&:reverse)]

  # analyze attendance
  matrix = attendance['matrix'].map do |name, meetings|
    id = idMap[name]
    next unless id and active[id]

    # exclude 'active entry'
    data = meetings.select {|key, value| key.start_with? '20'}.
      sort.reverse.map(&:last)

    first = data.length
    missed = (data.index {|datum| datum != '-'} || data.length)

    [id, name, first, missed]
  end

  return attendance, matrix.compact, dates, nameMap
end
get_attendance(mtg_root) click to toggle source

Read attendance.json file

# File lib/whimsy/asf/meeting-util.rb, line 174
def self.get_attendance(mtg_root)
  return JSON.parse(IO.read(File.join(mtg_root, 'attendance.json')))
end
get_invite_times() click to toggle source

get the times from the VCAL events file returns: hash with keys: nominations_close:, polls_close:, meeting_start, meeting_close:

# File lib/whimsy/asf/meeting-util.rb, line 380
def self.get_invite_times
  times = {}
  File.readlines(File.join(latest_meeting_dir, VCAL_EVENTS_FILENAME)).slice_before(/^BEGIN:VEVENT/).drop(1).each do |ev|
    uid = nil
    dtstart = dtend = nil
    ev.each do |line|
      case line
        when /^UID:(.+)/
          uid = $1.chomp.sub(/-?\d{4}/, '')
        when /^DTSTART;TZID=(.+):(.+)/
          tz = $1
          if tz == 'UTC'
            dtstart = DateTime.iso8601($2.chomp).to_time.to_i
          else
            raise ArgumentError.new("Cannot parse #{line.chomp} in #{VCAL_EVENTS_FILENAME}")
          end
        when /^DTEND;TZID=(.+):(.+)/
          tz = $1
          if tz == 'UTC'
            dtend = DateTime.iso8601($2.chomp).to_time.to_i
          else
            raise ArgumentError.new("Cannot parse #{line.chomp} in #{VCAL_EVENTS_FILENAME}")
          end
      end
    end
    times[uid] = dtstart
    times['asf-members-end'] = dtend if uid == 'asf-members'
  end
  return {
    nominations_close: times['asf-members-nominations-close'],
    polls_close: times['asf-members-polls-close'],
    meeting_start: times['asf-members'],
    meeting_end: times['asf-members-end'],
  }
end
get_latest(mtg_root) click to toggle source

Get the latest available Meetings dir

# File lib/whimsy/asf/meeting-util.rb, line 164
def self.get_latest(mtg_root)
  return Dir[File.join(mtg_root, '2*')].max
end
get_latest_completed(mtg_root, sentinel='raw-irc-log') click to toggle source

Get the latest completed Meetings dir (i.e. has raw-irc-log; this can be overridden) TODO: is that the most appropriate file to check?

# File lib/whimsy/asf/meeting-util.rb, line 155
def self.get_latest_completed(mtg_root, sentinel='raw-irc-log')
  return Dir[File.join(mtg_root, '2*')].select {|d| File.exist? File.join(d, sentinel) }.max
end
get_latest_file(file='.', mtg_root=nil) click to toggle source
# File lib/whimsy/asf/meeting-util.rb, line 159
def self.get_latest_file(file='.', mtg_root=nil)
  return Dir[File.join(mtg_root || MEETINGS_DIR, '2???????', file)].max
end
get_previous(mtg_root) click to toggle source

Get the second latest available Meetings dir

# File lib/whimsy/asf/meeting-util.rb, line 169
def self.get_previous(mtg_root)
  return Dir[File.join(mtg_root, '2*')].sort[-2]
end
is_user_proxied(mtg_dir, id) click to toggle source

Get proxy info for current user @return “help text”, [“id | name (proxy)”, …] if they are a proxy for other(s) @return “You have already submitted a proxy form” to someone else @return nil otherwise

# File lib/whimsy/asf/meeting-util.rb, line 127
def self.is_user_proxied(mtg_dir, id)
  proxylist = self.getProxyAssignments(mtg_dir)
  user = ASF::Person.find(id)
  help = nil
  copypasta = [] # theiravailid | Their Name in Rolls (proxy)
  max_uid_len = 16 # for alignment
  begin
    proxylist.each do |proxy, subject, uid|
      if user.cn == proxy
        copypasta << "#{uid.ljust(max_uid_len)} | #{subject} (proxy)"
      elsif user.id == uid
        help = "NOTE: You have already submitted a proxy form for #{proxy} to mark your attendance (be sure they know to mark you at Roll Call)! "
      end
    end
  rescue StandardError => e
    (help ||= '') << "ERROR, could not read LDAP, proxy data may not be correct: #{e.message}"
  end
  if copypasta.empty?
    return help
  else
    (help ||= '') << "During the meeting, to mark your proxies' attendance, AFTER the 2. Roll Call is called, you may copy/paste the below lines to mark your and your proxies attendance."
    copypasta.unshift("#{user.id.ljust(max_uid_len)} | #{user.cn}")
    return help, copypasta
  end
end
latest_meeting_dir() click to toggle source

return the dir containing the latest meeting files

# File lib/whimsy/asf/meeting-util.rb, line 353
def self.latest_meeting_dir
  MeetingUtil.get_latest(MEETINGS_DIR)
end
meeting_end() click to toggle source
# File lib/whimsy/asf/meeting-util.rb, line 429
def self.meeting_end
  self.get_invite_times[:meeting_end]
end
meeting_start() click to toggle source
# File lib/whimsy/asf/meeting-util.rb, line 425
def self.meeting_start
  self.get_invite_times[:meeting_start]
end
nominations_close() click to toggle source

Shorthand methods for callers

# File lib/whimsy/asf/meeting-util.rb, line 417
def self.nominations_close
  self.get_invite_times[:nominations_close]
end
parseProxies(mtg_dir=nil) click to toggle source

parse the proxies file

# File lib/whimsy/asf/meeting-util.rb, line 76
def self.parseProxies(mtg_dir=nil)
  mtg_dir ||= latest_meeting_dir
  lines = IO.readlines(File.join(mtg_dir, PROXIES_FILENAME))
  parts = lines.slice_before(%r{^(Volunteers|Assignments):}).drop(1)
  volunteers = parts.shift.drop(3) # heading
  assignments = parts.shift.drop(4)
  return volunteers, assignments
end
parse_memapp(path=nil,header=false) click to toggle source

parse a memapp file, optionally returning the format Params:

- path to file; if omitted, pick the latest found
- parse header to extract format, default false

Does not support files before 2010 Return: array of arrays or [array of arrays, format, hdr lines] The original contents can be regenerated as follows: Parse the file:

list,hdr,fmt = ASF::MeetingUtil.parse_memapp(nil, true)

Regenerate an indidividual entry: fmt % entry Regenerate all the contents:

[hdr, list.map{|item| fmt % item}].join("\n")

N.B. you may need to add a trailing EOL or two when writing the file

# File lib/whimsy/asf/meeting-util.rb, line 205
def self.parse_memapp(path=nil,header=false)
  path ||= get_latest_file('memapp-received.txt')
  text = File.read(path)
  # latest layout; look for at least one yes column; trim the user name
  list = text.scan(/^(no|yes)\s+(no|yes)(?:\s+(no|yes)\s+(no|yes))?\s+(\S+)\s+(.+)/).each {|a| a.last.strip!}
  if header
    hdr = text.split(/\R/)[0..1] # Assume 2 line header
    # Assume 6 columns for now
    hyphens=hdr[1].scan(/^(--+ +)(---+ +)(---+ +)(---+ +)(---+ +)(----+ *)$/).first
    hyphens.pop # drop last; don't want to pad that
    fmt = [hyphens.map{|h| '%%-%ds' % (h.size - 1)},'%s'].join(' ')
    return [list, hdr, fmt]
  else
    return list
  end
end
parse_memapp_to_h(path=nil,header=false) click to toggle source

parse a memapp file; if omitted, pick the latest found optionally return the line format and key list Does not support files before 2010 Return: array of hash entries with the symbolic keys: :invite :apply :mail :karma :id :name optionally followed by format, keylist, hdr The original contents can be regenerated as follows: Parse the file:

list,hdr,fmt,keys = ASF::MeetingUtil.parse_memapp_to_h(nil,true)

Regenerate an indidividual entry: fmt % keys.map{|key| entry} Regenerate all the contents:

[hdr, list.map{|item| fmt % keys.map{|key| item[key]} }].join("\n")

N.B. you may need to add a trailing EOL or two when writing the file

# File lib/whimsy/asf/meeting-util.rb, line 237
def self.parse_memapp_to_h(path=nil,header=false)
  keys = %i(invite apply mail karma id name)
  res = self.parse_memapp(path, header)
  if header
    list, hdr, fmt = res # split the response
    return [list.map{|entry| keys.zip(entry).to_h}, hdr, fmt, keys]
  else
    return res.map{|entry| keys.zip(entry).to_h}
  end
end
polls_close() click to toggle source
# File lib/whimsy/asf/meeting-util.rb, line 421
def self.polls_close
  self.get_invite_times[:polls_close]
end
read_memapps(dir) click to toggle source

Parse all memapp-received.txt files to get better set of names @see whimsy/www/members/attendance-xcheck.cgi

# File lib/whimsy/asf/meeting-util.rb, line 250
def self.read_memapps(dir)
  memapps = Hash.new('unknown')
  Dir[File.join(dir, '*', 'memapp-received.txt')].each do |received|
    meeting = File.basename(File.dirname(received))
    next if meeting.include? 'template'
    text = File.read(received)
    list = text.scan(/(.+)\s<(.*)@.*>.*Yes/i) # early layout
    if list.empty?
      # latest layout; look for at least one yes column
      list = text.scan(/^(?:no\s*)*(?:yes\s+)+(\w\S*)\s+(.*)\s*/)
    else
      # reverse order of id name type files
      list.each {|a| a[0], a[1] = a[1], a[0] }
    end
    list.each { |itm| memapps[itm[1].strip] = [itm[0], meeting] }
  end
  return memapps
end
tracker(meetingsMissed) click to toggle source

return the current status of all inactive members

# File lib/whimsy/asf/meeting-util.rb, line 358
def self.tracker(meetingsMissed)
  cur_mtg_dir = MeetingUtil.get_latest(MEETINGS_DIR)
  current_status = self.current_status(cur_mtg_dir)

  _attendance, matrix, dates, _nameMap = MeetingUtil.get_attend_matrices(MEETINGS_DIR)
  inactive = matrix.select do |id, _name, _first, missed|
    id and missed >= meetingsMissed
  end

  Hash[inactive.map {|id, name, first, missed|
    [id, {
      'name' => name,
      'missed' => missed,
      'status' => current_status[id],
      'since' => dates[-first-1] || dates.first,
      'last' => dates[-missed-1]
    }]
  }]
end