lib/whimsy/asf/committee.rb (487 lines of code) (raw):

require 'time' require 'whimsy/asf/yaml' module ASF # Define super class to prevent circular references. This class is # actually defined in ldap.rb which is require'd after committee.rb. class Base # :nodoc: end # # Representation for a committee (either a PMC, a board committee, or # a President's committee). This data is parsed from # <tt>committee-info.txt|.yaml</tt>, and is augmented by data from LDAP, # and ASF::Mail. # # Note that the simple attributes which are sourced from # <tt>committee-info.txt</tt> data is generally not available until # ASF::Committee.load_committee_info is called. # # Similarly, the simple attributes which are sourced from LDAP is # generally not available until ASF::Project.preload is called. class Committee < Base # type of non-pmc entry (from its paragraph heading in committee-info.txt) attr_accessor :paragraph # list of chairs for this committee. Returned as a list of hashes # containing the <tt>:name</tt> and <tt>:id</tt>. Data is obtained from # <tt>committee-info.txt</tt>. attr_accessor :chairs # list of members for this committee. Returned as a list of ids. # Data is obtained from <tt>committee-info.txt</tt>. attr_reader :info # when this committee is next expected to report. May be a string # containing values such as "Next month: missing in May", "Next month: new, # monthly through July". Data is obtained from <tt>committee-info.txt</tt>. attr_writer :report # list of members for this committee. Returned as a list of hash # mapping ids to a hash of <tt>:name</tt> and <tt>:date</tt> values. # Data is obtained from <tt>committee-info.txt</tt>. attr_accessor :roster # Date this committee was established in the format MM/YYYY. # Data is obtained from <tt>committee-info.txt</tt>. attr_accessor :established # list of months when this committee typically reports. Returned # as a comma separated string. Data is obtained from # <tt>committee-info.txt</tt>. attr_accessor :schedule # create an empty committee instance def initialize(*args) @info = [] @chairs = [] @roster = {} super end # Original field sizes for PMC section NAMELEN = 26 # length of name field NAMEADDRLEN = 59 # length of name + email address fields (including separator) # mapping of committee names to canonical names (generally from ldap) # See also www/roster/committee.rb @@aliases = Hash.new { |_hash, name| name.downcase} @@aliases.merge! \ 'brand management' => 'brand', 'c++ standard library' => 'stdcxx', 'community development' => 'comdev', # TODO: are the concom entries correct? See INFRA-17782 'conference planning' => 'concom', 'conferences' => 'concom', 'distributed release audit tool' => 'drat', 'diversity and inclusion' => 'diversity', 'http server' => 'httpd', 'httpserver' => 'httpd', 'incubating' => 'incubator', # special for index.html 'java community process' => 'jcp', 'legal affairs' => 'legal', 'logging services' => 'logging', 'logo development' => 'logodev', 'lucene.net' => 'lucenenet', 'open climate workbench' => 'climate', 'ocw' => 'climate', # is OCW used? 'portable runtime' => 'apr', 'quetzalcoatl' => 'quetz', 'security team' => 'security', 'travel assistance' => 'tac', 'web services' => 'ws' @@namemap = proc do |name| # Drop parenthesized comments and downcase before lookup; drop all spaces after lookup # So aliases table does not need to contain entries for Traffic Server and XML Graphics. # Also compress white-space before lookup so tabs etc from index.html don't matter cname = @@aliases[name.sub(/\s+\(.*?\)/, '').strip.gsub(/\s+/, ' ').downcase].gsub(/\s+/, '') cname end # convert committee name to canonical name def self.to_canonical(name) @@namemap.call(name.downcase) end # mailing list for this committee. Generally returns the first name in # the dns (e.g. whimsical). If so, it can be prefixed by a number of # list names (e.g. dev, private) and <tt>.apache.org</tt> is to be # appended. In some cases, the name contains an <tt>@</tt> sign and # is the full name for the mail list. # TODO: this is awkward to use as some non-PMCs have their own domain and some don't # Should probably be replaced by mail_private and mail_dev def mail_list case name.downcase when 'comdev' 'community' when 'httpcomponents' 'hc' when 'whimsy' 'whimsical' when 'brand' 'trademarks@apache.org' when 'infrastructure', 'infra' 'private@infra.apache.org' when 'dataprivacy' 'privacy@apache.org' when 'legalaffairs' # Not sure what uses this 'legal-internal@apache.org' when 'legal' # This seems to be used by the board agenda 'legal-private@apache.org' when 'fundraising' 'fundraising-private@apache.org' when 'marketingandpublicity' 'markpub@apache.org' when 'w3crelations' 'w3c@apache.org' when 'concom' 'planners@apachecon.com' when 'publicaffairs' 'public-affairs-private@apache.org' when 'logodev' 'logo-dev@apache.org' # their only list as at 2024-08-25 else name.downcase end end # Return the committee private list def private_mail_list ml = mail_list return mail_list if mail_list.include? '@' "private@#{mail_list}.apache.org" end # load committee info from <tt>committee-info.txt</tt>. Will not reparse # if the file has already been parsed and the underlying file has not # changed. # the parameters are currently only used by www/board/agenda/routes.rb def self.load_committee_info(contents = nil, info = nil) if contents if info @committee_mtime = @@svn_change = Time.parse(info[/Last Changed Date: (.*) \(/, 1]).gmtime else @committee_mtime = @@svn_change = Time.now end @nonpmcs, @officers, @committee_info = parse_committee_info_nocache(contents) else board = ASF::SVN.find('board') raise ArgumentError.new("Could not find 'board' checkout") unless board file = File.join(board, 'committee-info.txt') raise ArgumentError.new("Could not find #{file}") unless File.exist? file if @committee_mtime and File.mtime(file) <= @committee_mtime return @committee_info if @committee_info end @committee_mtime = File.mtime(file) @@svn_change = Time.parse(ASF::SVN.getInfoItem(file, 'last-changed-date')).gmtime Wunderbar.debug 'Parsing CI file' @nonpmcs, @officers, @committee_info = parse_committee_info_nocache(File.read(file)) end @committee_info end # update next month section. Remove entries that have reported or # or expired; add (or update) entries that are missing; add entries # for new committees. def self.update_next_month(contents, date, missing, rejected, todos) # extract next month section; and then extract the lines containing # '#' signs from within that section next_month = contents[/Next month.*?\n\n/m].chomp block = next_month[/(.*#.*\n)+/] || '' # remove expired entries month = date.strftime('%B') block.gsub!(/.* # new, monthly through #{month}\n/, '') # update/remove existing 'missing' entries existing = [] block.gsub! %r{(.*?)# (missing|not accepted) in .*\n} do |line| if missing.include? $1.strip existing << $1.strip if line.chomp.end_with? month line elsif line.split(',').last.include? 'not accepted' "#{line.chomp}, missing #{month}\n" else "#{line.chomp}, #{month}\n" end elsif rejected.include? $1.strip existing << $1.strip if line.chomp.end_with? month line else "#{line.chomp}, not accepted #{month}\n" end else '' end end # add new 'missing' entries (missing - existing).each do |pmc| block += " #{pmc.ljust(22)} # missing in #{month}\n" end # add new 'rejected' entries (rejected - missing - existing).each do |pmc| block += " #{pmc.ljust(22)} # not accepted in #{month}\n" end # add new 'established' entries and remove 'terminated' entries month = (date + 91).strftime('%B') todos.each do |resolution| pmc = resolution['display_name'] if resolution['action'] == 'terminate' block.sub! %r{^ #{Regexp.escape(pmc).ljust(22)} # .*\n}, '' elsif resolution['action'] == 'establish' and not existing.include? pmc block += " #{pmc.ljust(22)} # new, monthly through #{month}\n" end end # replace/append block if next_month.include? '#' next_month[/(.*#.*\n)+/] = block.split("\n").sort.join("\n") else next_month += block end # replace next month section contents[/Next month.*?\n\n/m] = next_month + "\n\n" # return result contents end # update chairs def self.update_chairs(contents, todos) # extract committee section; and then extract the lines containing # committee names and chairs section = contents[/^1\..*?\n=+/m] committees = section[/-\n(.*?)\n\n/m, 1].scan(/^ +(.*?) +(.*)/).to_h # update/add chairs based on resolutions todos.each do |resolution| name = resolution['display_name'] if resolution['action'] == 'terminate' committees.delete(name) elsif resolution['chair'] person = ASF::Person.find(resolution['chair']) committees[name] = "#{person.public_name} <#{person.id}@apache.org>" end end # sort and concatenate committees committees = committees.sort_by { |name, _chair| name.downcase }. # ensure 2 spaces before chair email even if name is long map { |name, chair| " #{name.ljust(22)} #{chair}" }. join("\n") # replace committee info in the section, and then replace the # section in the committee-info contents section[/-\n(.*?)\n\n/m, 1] = committees contents[/^1\..*?\n=+/m] = section # return result contents end # update roster for a project # Intended for use in ASF::SVN.update() block # # contents = current contents (normally provided by ASF::SVN.update); will be updated # cttee = committee id (lower case) # people = array of Person objects # action = add|remove # Note: ignores duplicate changes (e.g. if person to add is already present) def self.update_roster(contents, cttee, people, action) found = false contents.scan(/^\* (?:.|\n)*?\n\s*?\n/).each do |block| # find committee next unless ASF::Committee.find(block[/\* (.*?)\s+\(/, 1]).id == cttee # split block into lines lines = block.strip.split("\n") header = lines.shift # get the first line and use that to calculate the default offsets to use # This is done to avoid changing the spacing needlessly sample = lines.first namelen = NAMELEN # original nameaddrlen = NAMEADDRLEN # original # N.B. 4 spaces are assumed at the start if sample =~ %r{^ (\S.+) (<\S+?>\s+)\[} namelen = $1.size nameaddrlen = namelen + $2.size end # add or remove people # There are generally more people already in a PMC than are added or removed, # so try to scan the lines once # Get list of emails affected yyyymmdd = Time.new.gmtime.strftime('[%Y-%m-%d]') # gather list of potential new entries (some may be removed below) newentries = people.map do |person| [person.public_name, "<#{person.id}@apache.org>", yyyymmdd] end.to_a if action == 'add' # parse the lines so we can use format_pmc to recreate the entry, adjusting lengths if need be parsed = lines.map do |line| m = line.match(%r{^ (\S.+?) (<[^>]+>)\s+(\[\d.+)}) if m newentries.reject! {|entry| m[2] == entry[1]} [m[1].strip, m[2], m[3]] else raise ArgumentError.new("Unexpected entry: #{line}") end end parsed += newentries lines = format_pmc(parsed, namelen, nameaddrlen) elsif action == 'remove' lines.reject! {|line| newentries.any? {|entry| line.include? entry[1]}} else raise ArgumentError.new("Expected action=[add|remove], found '#{action}'") end # replace committee block with new information contents.sub! block, ([header] + lines.sort).join("\n") + "\n\n" found = true break end raise ArgumentError.new("Could not find project id='#{cttee}'") unless found contents end # record termination date in committee-info.yaml # Params: # - input: the contents of committee-info.yaml # - pmc: the pmc name # - yyyymm: YYYY-MM retirement date # Returns: the updated contents def self.record_termination(input, pmc, yyyymm) YamlFile.replace_section(input, :tlps) do |section, _yaml| key = ASF::Committee.to_canonical(pmc) if section[key] section[key][:retired] = yyyymm section[key][:name] = pmc else section[key] = {retired: yyyymm, name: pmc} end section.sort.to_h end end # remove committee from committee-info.txt def self.terminate(contents, pmc) ######################################################################## # remove from assigned quarterly reporting periods # ######################################################################## # split into blocks blocks = contents.split("\n\n") # find the reporting schedules index = blocks.find_index {|section| section =~ /January/} # remove from each reporting period blocks[index + 0].sub! "\n #{pmc}\n", "\n" blocks[index + 1].sub! "\n #{pmc}\n", "\n" blocks[index + 2].sub! "\n #{pmc}\n", "\n" # re-attach blocks contents = blocks.join("\n\n") ######################################################################## # remove from COMMITTEE MEMBERSHIP AND CHANGE PROCESS # ######################################################################## contents.sub! %r{^\* #{Regexp.escape(pmc)} ?\(est.*?\n\n+}m, '' contents end # insert (replacing if necessary) a new committee into committee-info.txt def self.establish(contents, pmc, date, people) ######################################################################## # insert into assigned quarterly reporting periods # ######################################################################## # split into blocks blocks = contents.split("\n\n") # find the reporting schedules index = blocks.find_index {|section| section =~ /January/} # extract reporting schedules slots = [ blocks[index + 0].split("\n"), blocks[index + 1].split("\n"), blocks[index + 2].split("\n"), ] unless slots.any? {|slot| slot.include? ' ' + pmc} # ensure that spacing is uniform slots.each {|slot| slot.unshift '' unless slot[0] == ''} # determine tie breakers between months of the same length preference = [(date.month) % 3, (date.month - 1) % 3, (date.month - 2) % 3] # pick the month with the shortest list slot = (0..2).map {|i| [slots[i].length, preference, i]}.min.last # temporarily remove headers headers = slots[slot].shift(3) # insert pmc into the reporting schedule slots[slot] << ' ' + pmc # sort entries, case insensitive slots[slot].sort_by!(&:downcase) # restore headers slots[slot].unshift(*headers) # () are required here to prevent warning # re-insert reporting schedules blocks[index + 0] = slots[0].join("\n") blocks[index + 1] = slots[1].join("\n") blocks[index + 2] = slots[2].join("\n") # re-attach blocks contents = blocks.join("\n\n") end ######################################################################## # insert into COMMITTEE MEMBERSHIP AND CHANGE PROCESS # ######################################################################## # split into foot, sections (array) and head foot = contents[/^=+\s*\Z/] contents.sub! %r{^=+\s*\Z}, '' sections = contents.split(/^\* /) head = sections.shift # remove existing section (if present) sections.delete_if {|section| section.downcase.start_with? pmc.downcase} # build new section entries = people.map do |id, person| [person[:name], "<#{id}@apache.org>", "[#{date.strftime('%Y-%m-%d')}]"] end people = format_pmc(entries) section = ["#{pmc} (est. #{date.strftime('%m/%Y')})"] + people.sort # add new section sections << section.join("\n") + "\n\n\n" # sort sections sections.sort_by!(&:downcase) # re-attach parts head + '* ' + sections.join('* ') + foot end # format a PMC entry # people: array of entries in the form [name, email, date+comment] # namelen: default size to allow for name field # nameaddrlen: default size to allow for name + email field # fields will be separated by at least one space on output # The defaults are taken from the originals to avoid needless change def self.format_pmc(people, namelen=NAMELEN, nameaddrlen=NAMEADDRLEN) maillen = 0 people.each do |name, email, _datefield| namelen = [namelen, name.size].max maillen = [maillen, email.size].max end # +1 for space between fields nameaddrlen = [nameaddrlen, namelen + maillen + 1].max people.map do |name, email, datefield| nameaddr = "#{name.ljust(namelen)} #{email}" " #{(nameaddr).ljust(nameaddrlen)} #{datefield}" end end # extract chairs, list of nonpmcs, roster, start date, and reporting # information from <tt>committee-info.txt</tt>. # @return nonpmcs, officers, committees (including nonpmcs) # This can safely be called with any input as it is idempotent # For general use, use ASF::Committee.load_committee_info # which caches the data def self.parse_committee_info_nocache(contents) # List uses full (display) names as keys, but the entries use the canonical names # - the local version of find() converts the name # - and stores the original as the display name if it has some upper case list = Hash.new {|hash, name| hash[name] = find(name, true)} # Split the file on lines starting "* ", i.e. the start of each group in section 3 info = contents.split(/^\* /) # Extract the text before first entry in section 3 and split on section headers, # keeping sections 1 (COMMITTEES) and 2 (REPORTING). head, report = info.shift.split(/^\d\./)[1..2] # Drop lines which could match group headers head.gsub! %r{^\s+NAME\s+CHAIR\s*$}, '' head.gsub! %r{^\s+Office\s+Officer\s*$}i, '' # extract the committee chairs (e-mail address is required here) # Note: this includes the non-PMC entries # Scan for entries even if there is a missing extra space before the chair column head.scan(/^[ \t]+\w.*?[ \t]+.*[ \t]+<.*?@apache\.org>/).each do |line| # Now weed out the malformed lines m = line.match(/^[ \t]+(\w.*?)[ \t][ \t]+(.*)[ \t]+<(.*?)@apache\.org>/) if m committee, name, id = m.captures # committee may not be canonical here unless list[committee].chairs.any? {|chair| chair[:id] == id} list[committee].chairs << {name: name, id: id} end else # not possible to determine where one name starts and the other begins Wunderbar.warn "Missing separator before chair name in: '#{line}'" end end # Any duplicates? dupes = list.group_by{|x| x.first.downcase}.select{|k,v|v.size!=1} if dupes.size > 0 Wunderbar.warn "Duplicate chairs: #{dupes}}" end # Extract the non-PMC committees (e-mail address may be absent) # first drop leading text (and Officers) so we only match non-PMCs nonpmcs = head.sub(/.*?also has /m, '').sub(/ Officers:.*/m, ''). scan(/^[ \t]+(\w.*?)(?:[ \t][ \t]|[ \t]?$)/).flatten.uniq. map {|name| list[name]} # Extract officers # first drop leading text so we only match officers at end of section officers = head.sub(/.*?also has .*? Officers/m, ''). scan(/^[ \t]+(\w.*?)(?:[ \t][ \t]|[ \t]?$)/).flatten.uniq. map {|name| list[name]} # store the paragraph identifiers: Board Committees etc head_parts = head.split(/^The ASF also has the following +/) (1..head_parts.size - 1).each do |h| # skip the first section part = head_parts[h] type = part[/^([^:]+)/, 1] # capture remains of line excluding colon part.scan(/^[ \t]+(\w.*?)(?:[ \t][ \t]|[ \t]?$)/).flatten.uniq.each do |cttee| list[cttee].paragraph = type end end # for each committee in section 3 info.each do |roster| # extract the committee name (and parenthesised comment if any) name = roster[/(\w.*?)[ \t]+\(est/, 1] unless list.include?(name) Wunderbar.warn "No chair entry detected for #{name} in section 3" end committee = list[name] # get and normalize the start date established = roster[/\(est\. (.*?)\)/, 1] established = "0#{established}" if established =~ /^\d\// committee.established = established # match non-empty entries and check the syntax roster.scan(/^[ \t]+.+$/) do |line| Wunderbar.warn "Invalid syntax: #{committee.name} '#{line}'" unless line =~ /\s<(.*?)@apache\.org>\s/ end # extract the availids (is this used?) committee.info = roster.scan(/<(.*?)@apache\.org>/).flatten # drop (chair) markers and extract 0: name, 1: availid, 2: [date], 3: date # the date is optional (e.g. infrastructure) committee.roster = Hash[roster.gsub(/\(\w+\)/, ''). scan(/^[ \t]*(.*?)[ \t]*<(.*?)@apache\.org>(?:[ \t]+(\[(.*?)\]))?/). map {|l| [l[1], {name: l[0], date: l[3]}]}] end # process report section report.scan(/^([^\n]+)\n---+\n(.*?)\n\n/m).each do |period, committees| committees.scan(/^ [ \t]*(.*)/).each do |committee| committee, comment = committee.first.split(/[ \t]+#[ \t]+/, 2) unless list.include? committee Wunderbar.warn "Unexpected name '#{committee}' in report section; ignored" next end committee = list[committee] if comment committee.report = "#{period}: #{comment}" elsif period == 'Next month' committee.report = 'Every month' else committee.schedule = period end end end committee_info = (list.values - officers).uniq # Check if there are duplicates. committee_info.each do |c| if c.chairs.length != 1 && c.name != 'fundraising' # hack to avoid reporting non-PMC entry Wunderbar.warn "Unexpected chair count for #{c.display_name}: #{c.chairs.inspect rescue ''}" end end return nonpmcs, officers, committee_info end # return a list of PMC committees. Data is obtained from # <tt>committee-info.txt</tt> def self.pmcs committees = ASF::Committee.load_committee_info committees - @nonpmcs - @officers end # return a list of non-PMC committees. Data is obtained from # <tt>committee-info.txt</tt> def self.nonpmcs ASF::Committee.load_committee_info # ensure data exists @nonpmcs end # return a list of officers. Data is obtained from # <tt>committee-info.txt</tt>. Note that these entries are returned # as instances of ASF::Committee with display_name being the name of # the office, and chairs being the individuals who hold that office. def self.officers ASF::Committee.load_committee_info # ensure data exists @officers end # look up an individual officer def self.officer(role) office = self.officers.find {|officer| officer.name == role} office && ASF::Person.find(office.chairs.first[:id]) end # Finds a committee based on the name of the Committee. Is aware of # a number of aliases for a given committee. Will set display name # if the name being searched on contains an uppercase character. # If clear is true, then remove any cached entry def self.find(name, clear=false) raise ArgumentError.new('name: must not be nil') unless name namelc = @@namemap.call(name.downcase) collection[namelc] = nil if clear result = super(namelc) result.display_name = name if name =~ /[A-Z]/ result end # Return the Last Changed Date for <tt>committee-info.txt</tt> in svn as # a <tt>Time</tt> object. Data is based on the previous call to # ASF::Committee.load_committee_info. def self.svn_change @@svn_change end # returns the (first) chair as an instance of the ASF::Person class. def chair Committee.load_committee_info if @chairs.length >= 1 ASF::Person.find(@chairs.first[:id]) else nil end end # Version of name suitable for display purposes. Typically in uppercase. # Data is sourced from <tt>committee-info.txt</tt>. def display_name Committee.load_committee_info @display_name || name end # setter for display_name, should only be used by # ASF::Committee.load_committee_info def display_name=(name) @display_name ||= name end # when this committee is next expected to report. May be a string # containing values such as "Next month: missing in May", "Next month: new, # monthly through July". Or may be a list of months, separated by commas. # Data is obtained from <tt>committee-info.txt</tt>. def report @report || @schedule end # setter for display_name, should only be used by # ASF::Committee.load_committee_info def info=(list) @info = list end # hash of availid => public_name for members (owners) of this committee # Data is obtained from <tt>committee-info.txt</tt>. def names Committee.load_committee_info Hash[@roster.map {|id, info| [id, info[:name]]}] end # if true, this committee is not a PMC. # Data is obtained from <tt>committee-info.txt</tt>. def nonpmc? Committee.load_committee_info # ensure data is there Committee.nonpmcs.include? self end # if true, this committee is a PMC. # Data is obtained from <tt>committee-info.txt</tt>. def pmc? Committee.load_committee_info # ensure data is there Committee.pmcs.include? self end # load committee metadata from <tt>committee-info.yaml</tt>. Will not reparse # if the file has already been parsed and the underlying file has not changed. def self.load_committee_metadata board = ASF::SVN.find('board') return unless board file = File.join(board, 'committee-info.yaml') return unless File.exist? file return @committee_metadata if @committee_metadata and @committee_metadata_mtime and File.mtime(file) <= @committee_metadata_mtime @committee_metadata_mtime = File.mtime(file) @committee_metadata = YAML.load_file file end # get the changed date for the meta data def self.meta_change @committee_metadata_mtime end # get the metadata for a given committee. def self.metadata(committee) committee = committee.name if committee.is_a? ASF::Committee load_committee_metadata[:tlps][committee] || load_committee_metadata[:cttees][committee] end # website for this committee. def site meta = ASF::Committee.metadata(name) meta[:site] if meta end # description for this committee. def description meta = ASF::Committee.metadata(name) meta[:description] if meta end # append the description for a new tlp committee. # this is intended to be called from todos.json.rb in the block for ASF::SVN.update def self.appendtlpmetadata(input, committee, description, date_established) YamlFile.replace_section(input, :tlps) do |section, yaml| output = section # default no change if yaml[:cttees][committee] && !yaml[:cttees][committee][:retired] Wunderbar.warn "Entry for '#{committee}' already exists under :cttees" elsif yaml[:tlps][committee] && !yaml[:tlps][committee][:retired] Wunderbar.warn "Entry for '#{committee}' already exists under :tlps" else if section[committee] # already exists; must be retired diary = section[committee][:diary] if !diary diary = section[committee][:diary] = [] diary << {established: section[committee][:established]} end diary << {retired: section[committee].delete(:retired)} diary << {resumed: date_established.strftime('%Y-%m')} else section[committee] = { site: "http://#{committee}.apache.org", description: description, established: date_established.strftime('%Y-%m'), } end output = section.sort.to_h end output end end end # ensure the CI data is pre-loaded # If this is not done, the first committee instance may be incomplete Wunderbar.debug 'Initialising CI file' ASF::Committee.load_committee_info end