class ASF::Board::Agenda

Class which contains a number of parsers.

Attachments

Back sections:

Additional Officer Reports and Committee Reports

Discussion Items

Executive Officer Reports

Front sections:

Minutes from previous meetings

Special Orders

Creates a summary hash of information from an Agenda

Constants

ACTIONS_KEY
APPROVALS_KEY
APPROVED_KEY
ATTACH_KEY

Strings or symbols returned from ::parse

COMMENT_LEN
CONTENTS

mapping of agenda section numbers to section names

DIRECTOR_MAP

Map director ids->names and ids->initials Only filled in since 2007 or so, once the preapp data in meetings is parseable

ERRORS_KEY

Hash keys returned by summarize()

INDEX_KEY
INITIALS_IDX
OFFICERS_KEY
OFFICER_SEPARATOR

Regex for start of officer reports (accounts for style differences in early agendas)

PEOPLE_KEY
PMCS_KEY
REPORT_LEN
SKIP_AGENDAS
STATS_KEY
TITLE_KEY

Public Class Methods

new() click to toggle source

start with an empty list of sections. Sections are added and returned by calling the parse method.

# File lib/whimsy/asf/agenda.rb, line 47
def initialize
  @sections = {}
end
parse(file=nil, quick=false, &block) click to toggle source

convenience method. If passed a file, will create an instance of this class and call the parse method on that object. If passed a block, will add that block to the list of parsers.

# File lib/whimsy/asf/agenda.rb, line 40
def self.parse(file=nil, quick=false, &block)
  @@parsers << block if block
  new.parse(file, quick)  if file
end
summarize(fname) click to toggle source

Summarize data from these meeting minutes @param fname of agenda file to summarize @return hash of summary statistics from this meeting @note if error, includes details in [ERRORS_KEY] = ['SKIP(meeting): foo', 'ERROR(meeting): bar',…]

# File lib/whimsy/asf/agenda/summary.rb, line 69
def self.summarize(fname)
  summary = {}
  meeting = File.basename(fname, '.*')
  if SKIP_AGENDAS.has_key?(meeting)
    summary[ERRORS_KEY] = "SKIP(#{meeting}) was: #{SKIP_AGENDAS[meeting]}"
    return summary
  end
  begin
    agenda = ASF::Board::Agenda.parse(File.read(fname.untaint))
  rescue StandardError => e
    summary[ERRORS_KEY] = "ERROR(#{meeting}) Agenda parse error: #{e.message} #{e.backtrace[0]}"
    return summary
  end
  begin
    summary[PEOPLE_KEY] = Hash[agenda[1][PEOPLE_KEY]]
    summary[PEOPLE_KEY].each do |id, data|
      # Note: this adds initials to everyone who was *ever* a director, who was at this meeting
      data['initials'] = DIRECTOR_MAP[id][INITIALS_IDX] if DIRECTOR_MAP[id]
    end
  rescue StandardError => e
    summary[ERRORS_KEY] = "ERROR(#{meeting}) no attendance error: #{e.message} #{e.backtrace[0]}"
    return summary
  end
  begin
    # Gather statistics about reports with preapprovals
    approvals = agenda.select{ |v| v.has_key?(APPROVED_KEY) }
    # PMC report :attach starts with letter; rest are officer or misc reports
    preports, oreports = approvals.partition{ |v| /\A[[:alpha:]]/ =~ v[ATTACH_KEY] }
    summary[OFFICERS_KEY] = Hash.new{|h,k| h[k] = {} }
    oreports.each do |r|
      summary[OFFICERS_KEY][r[TITLE_KEY]]['owner'] = r['owner'] if r.has_key?('owner')
      summary[OFFICERS_KEY][r[TITLE_KEY]][APPROVALS_KEY] =  Array.new(r['approved'])
      summary[OFFICERS_KEY][r[TITLE_KEY]][COMMENT_LEN] = r['comments'].length
      summary[OFFICERS_KEY][r[TITLE_KEY]][REPORT_LEN] = r['report'].length if r['report']
    end
    summary[PMCS_KEY] = Hash.new{|h,k| h[k] = {} }
    preports.each do |r|
      summary[PMCS_KEY][r[TITLE_KEY]]['owner'] = r['owner']
      if r.has_key?('missing')
        summary[PMCS_KEY][r[TITLE_KEY]]['missing'] = true
      else
        summary[PMCS_KEY][r[TITLE_KEY]][APPROVALS_KEY] =  Array.new(r['approved'])
        summary[PMCS_KEY][r[TITLE_KEY]][COMMENT_LEN] = r['comments'].length
        summary[PMCS_KEY][r[TITLE_KEY]][REPORT_LEN] = r['report'].length if r['report']
      end
    end
    actions = agenda.select{ |v| v.has_key?(INDEX_KEY) && v[INDEX_KEY] == "Action Items" }[0][ACTIONS_KEY]
    if actions
      summary[ACTIONS_KEY] = Hash.new{|h,k| h[k] = [] }
      actions.each do |r|
        summary[ACTIONS_KEY][r[:owner]] << r[:pmc]
      end
    end
    # Summarize across this report
    summary[STATS_KEY] = {}
    summary[STATS_KEY]['specialorders'] = agenda.select{ |v| /\A7/ =~ v[ATTACH_KEY] }.length
    summary[STATS_KEY]['discusstextlen'] = agenda.select{ |v| 
      v[INDEX_KEY] == "Discussion Items" || /\A8[A-Z]/ =~ v[ATTACH_KEY]
    }.map {|v| v['text'].length}.sum
    totapprovals = 0
    totcommentlen = 0
    totreportlen = 0
    totreports = (summary[OFFICERS_KEY].length + summary[PMCS_KEY].length).to_f
    # TODO figure out the ruby way to average these
    summary[OFFICERS_KEY].each do |x, data|
      totapprovals += data[APPROVALS_KEY].length if data[APPROVALS_KEY]
      totcommentlen += data[COMMENT_LEN] if data[COMMENT_LEN]
      totreportlen += data[REPORT_LEN] if data[REPORT_LEN]
    end
    summary[PMCS_KEY].each do |x, data|
      totapprovals += data[APPROVALS_KEY].length if data[APPROVALS_KEY]
      totcommentlen += data[COMMENT_LEN] if data[COMMENT_LEN]
      totreportlen += data[REPORT_LEN] if data[REPORT_LEN]
    end
    if totreports != 0 # Avoid NaN in minutes that aren't parsed fully
      summary[STATS_KEY]['avgapprovals'] = (totapprovals / totreports).round(2)
      summary[STATS_KEY]['avgcommentlen'] = (totcommentlen / totreports).round(0)
      summary[STATS_KEY]['avgreportlen'] = (totreportlen / totreports).round(0)
    end
  rescue StandardError => e
    summary[ERRORS_KEY] ||= "ERROR(#{meeting}) process error: #{e.message} #{e.backtrace[0]}"
  end
  return summary
end

Public Instance Methods

minutes(title) click to toggle source

provide a link to the collated minutes for a given report

# File lib/whimsy/asf/agenda.rb, line 176
def minutes(title)
  "https://whimsy.apache.org/board/minutes/#{title.gsub(/\W/,'_')}"
end
parse(file, quick=false) click to toggle source

parse a board agenda file by passing it through each parser. Additionally, converts the file to utf-8, adds index markers for major sections, looks for flagged reports, and performs various minor cleanup actions.

If quick is false, cross-checks with committee membership will be performed. This supports the board agenda tools's strategy to quickly display possibly stale and possible incomplete data and then to update the presentation using React.JS once later and/or more complete data is available.

Returns a list of sections.

# File lib/whimsy/asf/agenda.rb, line 86
def parse(file, quick=false)
  @file = file
  @quick = quick
  
  if not @file.valid_encoding?
    filter = Proc.new {|c| c.unpack('U').first rescue 0xFFFD}
    @file = @file.chars.map(&filter).pack('U*').force_encoding('utf-8')
  end

  @@parsers.each { |parser| instance_exec(&parser) }

  # add index markers for major sections
  CONTENTS.each do |section, index|
    @sections[section][:index] = index if @sections[section]
  end

  # quick exit if none found -- non-standard format agenda
  return [] if @sections.empty?

  # look for flags
  flagged_reports = Hash[@file[/ \d\. Committee Reports.*?\n\s+A\./m].
    scan(/# (.*?) \[(.*)\]/)] rescue {}

  president = @sections.values.find {|item| item['title'] == 'President'}
  return [] unless president # quick exit if non-standard format agenda
  pattach = president['report'][/\d+ through \d+\.$/]
  # pattach is nil before https://whimsy.apache.org/board/minutes/Change_Officers_to_Serve_at_the_Direction_of_the_President.html
  preports = Range.new(*pattach.scan(/\d+/)) if pattach
  # cleanup text and comment whitespace, add flags
  @sections.each do |section, hash|
    text = hash['text'] || hash['report']
    if text
      text.sub!(/\A\s*\n/, '')
      text.sub!(/\s+\Z/, '')
      unindent = text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min || 1
      text.gsub! /^ {#{unindent-1}}/, ''
    end

    text = hash['comments']
    if text
      text.sub!(/\A\s*\n/, '')
      text.sub!(/\s+\Z/, '')
      unindent = text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min || 1
      text.gsub! /^ {#{unindent-1}}/, ''
    end

    # add flags
    flags = flagged_reports[hash['title']]
    hash['flagged_by'] = flags.split(', ') if flags

    # mark president reports
    hash['to'] = 'president' if preports && preports.include?(section)
  end

  unless @quick
    # add roster and prior report link
    whimsy = 'https://whimsy.apache.org'
    @sections.each do |section, hash|
      next unless section =~ /^(4[A-Z]|\d+|[A-Z][A-Z]?)$/
      committee = ASF::Committee.find(hash['title'] ||= 'UNKNOWN')
      unless section =~ /^4[A-Z]$/
        hash['roster'] = 
          "#{whimsy}/roster/committee/#{CGI.escape committee.name}"
      end
      if section =~ /^[A-Z][A-Z]?$/
        hash['stats'] = 
          "https://reporter.apache.org/?#{CGI.escape committee.name}"
      end
      hash['prior_reports'] = minutes(committee.display_name)
    end
  end

  # add attach to section
  @sections.each do |section, hash|
    hash[:attach] = section
  end

  # look for missing titles
  @sections.each do |section, hash|
    hash['title'] ||= "UNKNOWN"

    if hash['title'] == "UNKNOWN"
      hash['warnings'] = ['unable to find attachment']
    end
  end

  @sections.values
end
scan(text, pattern) { |hash| ... } click to toggle source

helper method to scan a section for a pattern. Regular expression named matches will be captured and the section will be added to @sections if a match is found.

# File lib/whimsy/asf/agenda.rb, line 54
def scan(text, pattern, &block)
  # convert tabs to spaces
  text.gsub!(/^(\t+)/) {|tabs| ' ' * (8*tabs.length)}

  text.scan(pattern).each do |matches|
    hash = Hash[pattern.names.zip(matches)]
    yield hash if block

    section = hash.delete('section')
    section ||= hash.delete('attach')

    if section
      hash['approved'] &&= hash['approved'].strip.split(/[ ,]+/)

      @sections[section] ||= {}
      next if hash['text'] and @sections[section]['text']
      @sections[section].merge!(hash)
    end
  end
end
timestamp(time) click to toggle source

convert a PST/PDT time to UTC as a JavaScript integer

# File lib/whimsy/asf/agenda.rb, line 181
def timestamp(time)
  date = @file[/(\w+ \d+, \d+)/]
  tz = TZInfo::Timezone.get('America/Los_Angeles')
  tz.local_to_utc(Time.parse("#{date} #{time}")).to_i * 1000
end