| $LOAD_PATH.unshift '/srv/whimsy/lib' if __FILE__ == $PROGRAM_NAME |
| |
| require 'whimsy/asf/string-utils' |
| |
| require_relative 'config' |
| require_relative 'ldap' |
| require_relative 'svn' |
| |
| module ASF |
| |
| class MemberFiles |
| |
| NOMINATED_MEMBERS = 'nominated-members.txt' |
| NOMINATED_BOARD = 'board_nominations.txt' |
| NAME2OUTPUTKEY = { # names (from Regex) and corresponding output keys |
| 'email' => 'Nominee email', |
| 'nomby' => 'Nominated by', |
| 'seconds' => 'Seconded by', |
| 'statement' => 'Nomination Statement', |
| } |
| |
| # Same as MEMBER_REGEX, but no <uid> and no <email> |
| BOARD_REGEX = %r{ |
| \A(?<header>(?<name>[^:]+?):?)\r?\n |
| \s*Nominated\ by:\s*(?<nomby>.*)\r?\n |
| \s*Seconded\ by:\s*(?<seconds>.*?)\r?\n+ |
| \s*Nomination\ [sS]tatement:\s*?\r?\n+(?<statement>.*)\z |
| }mx |
| |
| # This Regex is very similar to the one in the script used to create ballots: |
| # https://svn.apache.org/repos/private/foundation/Meetings/steve-tools/seed-issues.py |
| MEMBER_REGEX = %r{ |
| \A(?<header>(?:(?<uid>[-_.a-z0-9]+)\s+)?(?<name>[^:]+?):?)\r?\n |
| \s*Nominee\ email:\s*(?<email>.*)\r?\n |
| \s*Nominated\ by:\s*(?<nomby>.*)\r?\n |
| \s*Seconded\ by:\s*(?<seconds>.*?)\r?\n+ |
| \s*Nomination\ [sS]tatement:\s*?\r?\n+(?<statement>.*)\z |
| }mx |
| |
| # get the latest meeting directory or nomination file |
| def self.latest_meeting(name=nil) |
| if name.nil? # we want the parent directory |
| name = NOMINATED_MEMBERS # ensure the target directory has been set up |
| File.dirname(Dir[File.join(ASF::SVN['Meetings'], '[2-9][0-9]*', name)].max) |
| else |
| Dir[File.join(ASF::SVN['Meetings'], '[2-9][0-9]*', name)].max |
| end |
| end |
| |
| # Return a hash of nominees. |
| # key: availid (name for board nominees) |
| # value: hash of entries: |
| # keys: |
| # Public Name |
| # Nominee email |
| # Nominated by |
| # Seconded by => array of seconders |
| # Nomination Statement => array of text lines |
| def self.parse_file(name) |
| case name |
| when NOMINATED_BOARD |
| regex = BOARD_REGEX |
| when NOMINATED_MEMBERS |
| regex = MEMBER_REGEX |
| else |
| raise ArgumentError.new "Unexpected name: #{name}" |
| end |
| # N.B. The format has changed over the years. This is the syntax as of 2021. |
| # ----------------------------------------- |
| # <empty line> |
| # header line |
| # Nominee email: (not present in board file) |
| # Nominated by: |
| # Seconded by: |
| |
| # Nomination Statement: |
| |
| # Find most recent file: |
| nomfile = latest_meeting(name) |
| |
| lastheader = nil # what was the last valid header |
| # It does not appear to be possible to have file open or read |
| # automatically transcode strings, so we do it here. |
| # This is necessary to avoid issues with matching Regexes. |
| File.open(nomfile, mode: 'rb:UTF-8') |
| .map(&:scrub) |
| .slice_before(/^\s*-{35,60}\s*/) |
| .drop(2) # instructions and sample block |
| .each do |block| |
| block.shift(1) # divider |
| nominee = {} |
| header = nil |
| data = block.join.strip |
| next if data == '' |
| md = regex.match(data) |
| raise ArgumentError.new "Cannot parse #{data}" unless md |
| md.named_captures.each do |k, v| |
| case k |
| when 'header' |
| header = v.strip |
| when 'uid', 'name' |
| # not currently used |
| else |
| outkey = NAME2OUTPUTKEY[k] |
| raise ArgumentError.new "Unexpected regex capture name: #{k}" if outkey.nil? |
| v = v.split("\n") if k == 'statement' or k == 'seconds' |
| nominee[outkey] = v |
| end |
| end |
| yield header, nominee |
| end |
| end |
| |
| # create a member nomination entry in the standard format |
| # |
| def self.make_member_nomination(fields = {}) |
| availid = fields[:availid] or raise ArgumentError.new(":availid is required") |
| publicname = ASF::Person[availid]&.cn or raise ArgumentError.new(":availid #{availid} is invalid") |
| nomby = fields[:nomby] or raise ArgumentError.new(":nomby is required") |
| ASF::Person[nomby]&.dn or raise ArgumentError.new(":nomby is invalid") |
| secby = fields[:secby] || '' |
| statement = fields[:statement] or raise ArgumentError.new(":statement is required") |
| [ |
| '', |
| " #{availid} <#{publicname}>", |
| '', |
| " Nominee email: #{availid}@apache.org", |
| " Nominated by: #{nomby}@apache.org", |
| " Seconded by: #{secby}", |
| '', |
| ' Nomination Statement:', |
| ASFString.reflow(statement, 4, 80), |
| '' |
| ].compact.join("\n") + "\n" |
| end |
| |
| # create a board nomination entry in the standard format |
| # |
| def self.make_board_nomination(fields = {}) |
| availid = fields[:availid] or raise ArgumentError.new(":availid is required") |
| publicname = ASF::Person[availid]&.cn or raise ArgumentError.new(":availid #{availid} is invalid") |
| nomby = fields[:nomby] or raise ArgumentError.new(":nomby is required") |
| ASF::Person[nomby]&.dn or raise ArgumentError.new(":nomby is invalid") |
| secby = fields[:secby] || '' |
| statement = fields[:statement] or raise ArgumentError.new(":statement is required") |
| [ |
| '', |
| " #{publicname}", |
| " Nominated by: #{nomby}@apache.org", |
| " Seconded by: #{secby}", |
| '', |
| ' Nomination Statement:', |
| ASFString.reflow(statement, 4, 80), |
| '' |
| ].compact.join("\n") + "\n" |
| end |
| |
| # Sort the member_nominees, optionally adding new entries |
| def self.sort_member_nominees(contents, entries=nil) |
| sections = contents.split(%r{^-{10,}\n}) |
| header = sections.shift(2) |
| sections.append(*entries) if entries # add new entries if any |
| ids = {} |
| sections.sort_by! do |s| |
| # sort by last name; check for duplicates |
| m = s.match %r{(\S+) +<([^>]+)>} |
| if m |
| id = m[1] |
| raise ArgumentError.new("Duplicate id: #{id}") if ids.include? id |
| ids[id] = 1 |
| m[2].split.last |
| else |
| 'ZZ' |
| end |
| end |
| # reconstitute the file |
| [header, sections, ''].join("-----------------------------------------\n") |
| end |
| |
| # Sort the board_nominees, optionally adding new entries |
| def self.sort_board_nominees(contents, entries=nil) |
| sections = contents.split(%r{^-{10,}\n}) |
| header = sections.shift(2) |
| sections.pop if sections.last.strip == '' |
| sections.append(*entries) if entries # add new entries if any |
| names = {} |
| # replace 'each' by 'sort_by!' to sort by last name |
| sections.each do |s| |
| # sort by last name; check for duplicates |
| m = s.match %r{\s+(.+)} |
| if m |
| name = m[1] |
| raise ArgumentError.new("Duplicate id: #{name}") if names.include? name |
| names[name] = 1 |
| name.split.last |
| else |
| 'ZZ' |
| end |
| end |
| # reconstitute the file |
| [header, sections, ''].join("---------------------------------------\n") |
| end |
| |
| # update the member nominees |
| def self.update_member_nominees(env, wunderbar, entries=nil, msg=nil, opt={}) |
| nomfile = latest_meeting(NOMINATED_MEMBERS) |
| opt[:diff] = true unless opt.include? :diff # default to true |
| ASF::SVN.update(nomfile, msg || 'Updating nominated members', env, wunderbar, opt) do |_tmpdir, contents| |
| sort_member_nominees(contents, entries) |
| end |
| end |
| |
| # update the board nominees |
| def self.update_board_nominees(env, wunderbar, entries=nil, msg=nil, opt={}) |
| nomfile = latest_meeting(NOMINATED_BOARD) |
| opt[:diff] = true unless opt.include? :diff # default to true |
| ASF::SVN.update(nomfile, msg || 'Updating board nominations', env, wunderbar, opt) do |_tmpdir, contents| |
| sort_board_nominees(contents, entries) |
| end |
| end |
| |
| # TODO: change to return arrays rather than hash. |
| # This would help detect duplicate entries |
| |
| # Return hash of member nominees |
| def self.member_nominees |
| nominees = {} |
| ASF::MemberFiles.parse_file(NOMINATED_MEMBERS) do |hdr, nominee| |
| # for members, the header currently looks like this: |
| # availid <PUBLIC NAME> |
| # In the past, it has had other layouts, for example: |
| # availid PUBLIC NAME |
| # PUBLIC NAME <email address>: |
| id, name = hdr.split(' ', 2) |
| # remove the spurious <> wrapper |
| nominee['Public Name'] = name.sub(%r{^<}, '').chomp('>') |
| # TODO: handle missing availid better |
| nominees[id] = nominee |
| end |
| nominees |
| end |
| |
| # Return hash of board nominees |
| def self.board_nominees |
| nominees = {} |
| ASF::MemberFiles.parse_file(NOMINATED_BOARD) do |hdr, nominee| |
| # for board, the header currently looks like this: |
| # <PUBLIC NAME> |
| id = ASF::Person.find_by_name!(hdr) || hdr # default to full name |
| nominee['Public Name'] = hdr # the board file does not have ids |
| nominees[id] = nominee |
| end |
| nominees |
| end |
| end |
| end |
| |
| if __FILE__ == $0 |
| ASF::MemberFiles.member_nominees.each do |k, v| |
| p [k, |
| v['Public Name'], |
| v['Public Name']&.encoding, |
| v['Public Name']&.valid_encoding?] |
| end |
| puts "--------------" |
| ASF::MemberFiles.board_nominees.each do |k, v| |
| p [k, |
| v['Public Name'], |
| v['Public Name']&.encoding, |
| v['Public Name']&.valid_encoding?] |
| end |
| end |