whimsy-asf 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/asf.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ version = File.read(File.expand_path('../asf.version', __FILE__)).strip
2
+
3
+ Gem::Specification.new do |s|
4
+
5
+ # Change these as appropriate
6
+ s.name = "whimsy-asf"
7
+ s.license = 'Apache License, Version 2.0'
8
+ s.version = version
9
+ s.summary = "Whimsy 'model' of the ASF"
10
+ s.author = "Sam Ruby"
11
+ s.email = "rubys@intertwingly.net"
12
+ s.homepage = "https://whimsy.apache.org/"
13
+ s.description = <<-EOD
14
+ This package contains a set of classes which encapsulate access
15
+ to a number of data sources such as LDAP, ICLAs, auth lists, etc.
16
+ EOD
17
+
18
+ # Add any extra files to include in the gem
19
+ s.files = Dir.glob(["asf.*", "lib/**/*"])
20
+ s.require_paths = ["lib"]
21
+
22
+ # If you want to depend on other gems, add them here, along with any
23
+ # relevant versions
24
+ s.add_dependency("nokogiri")
25
+ s.add_dependency("rack")
26
+ s.add_dependency("ruby-ldap")
27
+ s.add_dependency("tzinfo")
28
+ s.add_dependency("wunderbar")
29
+
30
+ # If your tests use any gems, include them here
31
+ # s.add_development_dependency("mocha") # for example
32
+ end
data/asf.version ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
data/lib/whimsy/asf.rb ADDED
@@ -0,0 +1,9 @@
1
+ require File.expand_path('../asf/committee', __FILE__)
2
+ require File.expand_path('../asf/ldap', __FILE__)
3
+ require File.expand_path('../asf/mail', __FILE__)
4
+ require File.expand_path('../asf/svn', __FILE__)
5
+ require File.expand_path('../asf/watch', __FILE__)
6
+ require File.expand_path('../asf/nominees', __FILE__)
7
+ require File.expand_path('../asf/icla', __FILE__)
8
+ require File.expand_path('../asf/auth', __FILE__)
9
+ require File.expand_path('../asf/member', __FILE__)
@@ -0,0 +1,117 @@
1
+ require_relative '../asf'
2
+
3
+ require 'time'
4
+ require 'tzinfo'
5
+ require 'digest/md5'
6
+
7
+ module ASF
8
+ module Board
9
+ end
10
+ end
11
+
12
+ class ASF::Board::Agenda
13
+ CONTENTS = {
14
+ '2.' => 'Roll Call',
15
+ '3A' => 'Minutes',
16
+ '4A' => 'Executive Officer',
17
+ '1' => 'Additional Officer',
18
+ 'A' => 'Committee Reports',
19
+ '7A' => 'Special Orders',
20
+ '8.' => 'Discussion Items',
21
+ '9.' => 'Action Items'
22
+ }
23
+
24
+ @@parsers = []
25
+ def self.parse(file=nil, &block)
26
+ @@parsers << block if block
27
+ new.parse(file) if file
28
+ end
29
+
30
+ def initialize
31
+ @sections = {}
32
+ end
33
+
34
+ def scan(text, pattern, &block)
35
+ text.scan(pattern).each do |matches|
36
+ hash = Hash[pattern.names.zip(matches)]
37
+ yield hash if block
38
+
39
+ section = hash.delete('section')
40
+ section ||= hash.delete('attach')
41
+
42
+ if section
43
+ hash['approved'] &&= hash['approved'].strip.split(/[ ,]+/)
44
+
45
+ @sections[section] ||= {}
46
+ next if hash['text'] and @sections[section]['text']
47
+ @sections[section].merge!(hash)
48
+ end
49
+ end
50
+ end
51
+
52
+ def parse(file)
53
+ @file = file
54
+ @@parsers.each { |parser| instance_exec(&parser) }
55
+
56
+ # add index markers for major sections
57
+ CONTENTS.each do |section, index|
58
+ @sections[section][:index] = index if @sections[section]
59
+ end
60
+
61
+ # cleanup text and comment whitespace
62
+ @sections.each do |section, hash|
63
+ text = hash['text'] || hash['report']
64
+ if text
65
+ text.sub!(/\A\s*\n/, '')
66
+ text.sub!(/\s+\Z/, '')
67
+ unindent = text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min || 1
68
+ text.gsub! /^ {#{unindent-1}}/, ''
69
+ end
70
+
71
+ text = hash['comments']
72
+ if text
73
+ text.sub!(/\A\s*\n/, '')
74
+ text.sub!(/\s+\Z/, '')
75
+ unindent = text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min || 1
76
+ text.gsub! /^ {#{unindent-1}}/, ''
77
+ end
78
+ end
79
+
80
+ # add roster and prior report link
81
+ whimsy = 'https://whimsy.apache.org'
82
+ @sections.each do |section, hash|
83
+ next unless section =~ /^(4[A-Z]|\d+|[A-Z][A-Z]?)$/
84
+ committee = ASF::Committee.find(hash['title'] ||= 'UNKNOWN')
85
+ unless section =~ /^4[A-Z]$/
86
+ hash['roster'] =
87
+ "#{whimsy}/roster/committee/#{CGI.escape committee.name}"
88
+ end
89
+ hash['prior_reports'] = minutes(committee.display_name)
90
+ end
91
+
92
+ # add attach to section
93
+ @sections.each do |section, hash|
94
+ hash[:attach] = section
95
+ end
96
+
97
+ @sections.values
98
+ end
99
+
100
+ def minutes(title)
101
+ "https://whimsy.apache.org/board/minutes/#{title.gsub(/\W/,'_')}"
102
+ end
103
+
104
+ def timestamp(time)
105
+ date = @file[/(\w+ \d+, \d+)/]
106
+ tz = TZInfo::Timezone.get('America/Los_Angeles')
107
+ tz.local_to_utc(Time.parse("#{date} #{time}")).to_i * 1000
108
+ end
109
+ end
110
+
111
+ require_relative 'agenda/front'
112
+ require_relative 'agenda/minutes'
113
+ require_relative 'agenda/exec-officer'
114
+ require_relative 'agenda/attachments'
115
+ require_relative 'agenda/committee'
116
+ require_relative 'agenda/special'
117
+ require_relative 'agenda/back'
@@ -0,0 +1,42 @@
1
+ # Attachments
2
+
3
+ class ASF::Board::Agenda
4
+ parse do
5
+ pattern = /
6
+ -{41}\n
7
+ Attachment\s\s?(?<attach>\w+):\s(?<title>.*?)\n+
8
+ (?<report>.*?)
9
+ (?=-{41,}\n(?:End|Attach))
10
+ /mx
11
+
12
+ scan @file, pattern do |attrs|
13
+
14
+ attrs['title'].sub! /^Report from the VP of /, ''
15
+ attrs['title'].sub! /^Report from the /, ''
16
+ attrs['title'].sub! /^Status report for the /, ''
17
+ attrs['title'].sub! /^Apache /, ''
18
+
19
+ if attrs['title'] =~ /\s*\[.*\]$/
20
+ attrs['owner'] = attrs['title'][/\[(.*?)\]/, 1]
21
+ attrs['title'].sub! /\s*\[.*\]$/, ''
22
+ end
23
+
24
+ attrs['title'].sub! /\sTeam$/, ''
25
+ attrs['title'].sub! /\sCommittee$/, ''
26
+ attrs['title'].sub! /\sProject$/, ''
27
+
28
+ attrs['digest'] = Digest::MD5.hexdigest(attrs['report'])
29
+
30
+ attrs['report'].sub! /\n+\Z/, "\n"
31
+ attrs.delete('report') if attrs['report'] == "\n"
32
+
33
+ attrs['missing'] = true if attrs['report'].strip.empty?
34
+
35
+ begin
36
+ attrs['chair_email'] =
37
+ ASF::Committee.find(attrs['title']).chair.mail.first
38
+ rescue
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,29 @@
1
+ # Back sections:
2
+ # * Discussion Items
3
+ # * Review Outstanding Action Items
4
+ # * Unfinished Business
5
+ # * New Business
6
+ # * Announcements
7
+ # * Adjournment
8
+
9
+ class ASF::Board::Agenda
10
+ parse do
11
+ pattern = /
12
+ ^(?<attach>(?:\s[89]|\s9|1\d)\.)
13
+ \s(?<title>.*?)\n
14
+ (?<text>.*?)
15
+ (?=\n[\s1]\d\.|\n===)
16
+ /mx
17
+
18
+ scan @file, pattern do |attrs|
19
+ attrs['attach'].strip!
20
+ attrs['title'].sub! /^Review Outstanding /, ''
21
+
22
+ if attrs['title'] =~ /Discussion|Action|Business|Announcements/
23
+ attrs['prior_reports'] = minutes(attrs['title'])
24
+ elsif attrs['title'] == 'Adjournment'
25
+ attrs['timestamp'] = timestamp(attrs['text'][/\d+:\d+([ap]m)?/])
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ # Additional Officer Reports and Committee Reports
2
+
3
+ class ASF::Board::Agenda
4
+ parse do
5
+ pattern = /
6
+ \[(?<owner>[^\n]+)\]\n\n
7
+ \s{7}See\sAttachment\s\s?(?<attach>\w+)[^\n]*?\s+
8
+ \[\s[^\n]*\s*approved:\s*?(?<approved>.*?)
9
+ \s*comments:(?<comments>.*?)\n\s{9}\]
10
+ /mx
11
+
12
+ scan @file, pattern do |attrs|
13
+ attrs['shepherd'] = attrs['owner'].split('/').last.strip
14
+ attrs['owner'] = attrs['owner'].split('/').first.strip
15
+
16
+ attrs['comments'].gsub! /^ {1,10}(\w+:)/, '\1'
17
+ attrs['comments'].gsub! /^ {11}/, ''
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ # Executive Officer Reports
2
+
3
+ class ASF::Board::Agenda
4
+ parse do
5
+ reports = @file.split(/^ 4. Executive Officer Reports/,2).last.
6
+ split(/^ 5. Additional Officer Reports/,2).first
7
+
8
+ pattern = /
9
+ \s{4}(?<section>[A-Z])\.
10
+ \s(?<title>[^\[]+?)
11
+ \s\[(?<owner>[^\]]+?)\]
12
+ (?<report>.*?)
13
+ (?=\n\s{4}[A-Z]\.\s|\z)
14
+ /mx
15
+
16
+ scan reports, pattern do |attrs|
17
+ attrs['section'] = '4' + attrs['section']
18
+ attrs['shepherd'] = attrs['owner'].split('/').last
19
+ attrs['owner'] = attrs['owner'].split('/').first
20
+
21
+ attrs['report'].sub! /\A\s*\n/, ''
22
+
23
+ attrs['report'].gsub! /\n\s*\n\s+\[ comments:(.*)\]\s*$/m do
24
+ attrs['comments'] = $1.sub(/\A\s*\n/, '').sub(/\s+\Z/, '')
25
+ "\n"
26
+ end
27
+
28
+ report = attrs['report'].strip
29
+ if report.empty? or report[0..12] == 'Additionally,'
30
+ attrs['missing'] = true
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,72 @@
1
+ # Front sections:
2
+ # * Call to Order
3
+ # * Roll Call
4
+
5
+ class ASF::Board::Agenda
6
+ @@people_cache = {}
7
+
8
+ parse do
9
+ pattern = /
10
+ ^\n\x20(?<section>[12]\.)
11
+ \s(?<title>.*?)\n\n+
12
+ (?<text>.*?)
13
+ (?=\n\s[23]\.)
14
+ /mx
15
+
16
+ scan @file, pattern do |attr|
17
+ if attr['title'] == 'Roll Call'
18
+ attr['people'] = {}
19
+ list = nil
20
+
21
+ # attempt to identify the people mentioned in the Roll Call
22
+ people = attr['text'].scan(/ {8}(\w.*)/).flatten.each do |sname|
23
+ name = sname
24
+
25
+ # first try the cache
26
+ person = @@people_cache[name]
27
+
28
+ # next try a simple name look up
29
+ if not person
30
+ search = ASF::Person.list("cn=#{name}")
31
+ person = search.first if search.length == 1
32
+ end
33
+
34
+ # finally try harder to match the name
35
+ if not person
36
+ sname = sname.strip.downcase.split(/\s+/)
37
+
38
+ if not list
39
+ ASF::Person.preload('cn')
40
+ list = ASF::Person.list
41
+ end
42
+
43
+ search = []
44
+ list.select do |person|
45
+ next if person == 'none'
46
+ pname = person.public_name.downcase.split(/\s+/)
47
+ if sname.all? {|t1| pname.any? {|t2| t2.start_with? t1}}
48
+ search << person
49
+ elsif pname.all? {|t1| sname.any? {|t2| t2.start_with? t1}}
50
+ search << person
51
+ end
52
+ end
53
+
54
+ person = search.first if search.length == 1
55
+ end
56
+
57
+ # save results in both the cache and the attributes
58
+ if person
59
+ @@people_cache[name] = person
60
+
61
+ attr['people'][person.id] = {
62
+ name: name,
63
+ member: person.asf_member?
64
+ }
65
+ end
66
+ end
67
+ elsif attr['title'] == 'Call to order'
68
+ attr['timestamp'] = timestamp(attr['text'][/\d+:\d+([ap]m)?/])
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,23 @@
1
+ # Minutes from previous meetings
2
+
3
+ class ASF::Board::Agenda
4
+ parse do
5
+ minutes = @file.split(/^ 3. Minutes from previous meetings/,2).last.
6
+ split(/^ 4. Executive Officer Reports/,2).first
7
+
8
+ pattern = /
9
+ \s{4}(?<section>[A-Z])\.
10
+ \sThe.meeting.of\s+(?<title>.*?)\n
11
+ (?<text>.*?)
12
+ \[\s(?:.*?):\s*?(?<approved>.*?)
13
+ \s*comments:(?<comments>.*?)\n
14
+ \s{8,9}\]\n
15
+ /mx
16
+
17
+ scan minutes, pattern do |attrs|
18
+ attrs['section'] = '3' + attrs['section']
19
+ attrs['text'] = attrs['text'].strip
20
+ attrs['approved'] = attrs['approved'].strip.gsub(/\s+/, ' ')
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,107 @@
1
+ # Special Orders
2
+
3
+ class ASF::Board::Agenda
4
+ parse do
5
+ orders = @file.split(/^ 7. Special Orders/,2).last.
6
+ split(/^ 8. Discussion Items/,2).first
7
+
8
+ pattern = /
9
+ \n+(?<indent>\s{3,5})(?<section>[A-Z])\.
10
+ \s(?<title>.*?)\n
11
+ (?<text>.*?)
12
+ (?=\n\s{4}[A-Z]\.\s|\z)
13
+ /mx
14
+
15
+ people = []
16
+ scan orders, pattern do |attrs|
17
+ attrs['section'] = '7' + attrs['section']
18
+
19
+ title = attrs['title']
20
+ fulltitle = title.dup
21
+ title.sub! /^Resolution to /, ''
22
+ title.sub! /\sthe\s/, ' '
23
+ title.sub! /\sApache\s/, ' '
24
+ title.sub! /\sCommittee\s/, ' '
25
+ title.sub! /\sProject(\s|$)/, '\1'
26
+ title.sub! /\sPMC(\s|$)/, '\1'
27
+ title.sub! /\s\(.*\)$/, ''
28
+
29
+ attrs['fulltitle'] = fulltitle if title != fulltitle
30
+
31
+ text = attrs['text']
32
+ attrs['digest'] = Digest::MD5.hexdigest(attrs['text'])
33
+
34
+ attrs['warnings'] = []
35
+ if attrs['indent'] != ' '
36
+ attrs['warnings'] << 'Heading is not indented 4 spaces'
37
+ attrs['warnings'] << attrs['indent'].inspect
38
+ attrs['warnings'] << attrs['indent'].length
39
+ end
40
+ if text.sub(/s+\Z/,'').scan(/^ *\S/).map(&:length).min != 8
41
+ attrs['warnings'] << 'Resolution is not indented 7 spaces'
42
+ end
43
+ attrs.delete 'indent'
44
+ attrs.delete 'warnings' if attrs['warnings'].empty?
45
+
46
+ asfid = '[a-z][-.a-z0-9_]+' # dot added to help detect errors
47
+ list_item = '^\s*(?:[-*\u2022]\s*)?(.*?)\s+'
48
+
49
+ people = text.scan(/#{list_item}\((#{asfid})\)\s*$/)
50
+ people += text.scan(/#{list_item}\((#{asfid})(?:@|\s*at\s*)
51
+ (?:\.\.\.|apache\.org)\)\s*$/x)
52
+ people += text.scan(/#{list_item}<(#{asfid})(?:@|\s*at\s*)
53
+ (?:\.\.\.|apache\.org)>\s*$/x)
54
+
55
+ whimsy = 'https://whimsy.apache.org'
56
+ if people.empty?
57
+ if title =~ /Change (.*?) Chair/ or title =~ /Terminate (\w+)$/
58
+ committee = ASF::Committee.find($1)
59
+ attrs['roster'] =
60
+ "#{whimsy}/roster/committee/#{CGI.escape committee.name}"
61
+ attrs['prior_reports'] = minutes(committee.display_name)
62
+ name1 = text[/heretofore\sappointed\s(\w.*)\sto/,1]
63
+ sname1 = name1.to_s.downcase.gsub('.', ' ').split(/\s+/)
64
+ name2 = text[/recommend\s(\w.*)\sas/,1]
65
+ sname2 = name2.to_s.downcase.gsub('.', ' ').split(/\s+/)
66
+ next unless committee.names
67
+ committee.names.each do |id, name|
68
+ name.sub!(/ .* /,' ') unless text.include? name
69
+ pname = name.downcase.split(/\s+/)
70
+ if text.include? name
71
+ people << [name, id]
72
+ elsif name1 && sname1.all? {|t1| pname.any? {|t2| t2.start_with? t1}}
73
+ people << [name1, id]
74
+ elsif name1 && pname.all? {|t1| sname1.any? {|t2| t2.start_with? t1}}
75
+ people << [name1, id]
76
+ elsif name2 && sname2.all? {|t1| pname.any? {|t2| t2.start_with? t1}}
77
+ people << [name2, id]
78
+ elsif name2 && pname.all? {|t1| sname2.any? {|t2| t2.start_with? t1}}
79
+ people << [name2, id]
80
+ end
81
+ end
82
+ end
83
+ else
84
+ if title =~ /Establish (.*)/
85
+ name = $1
86
+ attrs['prior_reports'] =
87
+ "#{whimsy}/board/minutes/#{name.gsub(/\W/,'_')}"
88
+ if text =~ /FURTHER RESOLVED, that ([^,]*?),?\s+be\b/
89
+ chairname = $1.gsub(/\s+/, ' ').strip
90
+ chair = people.find {|person| person.first == chairname}
91
+ attrs['chair'] = (chair ? chair.last : nil)
92
+ end
93
+ end
94
+
95
+ end
96
+
97
+ people.map! do |name, id|
98
+ person = ASF::Person.new(id)
99
+ icla = person.icla
100
+ [id, {name: name, icla: icla ? person.icla.name : false,
101
+ member: person.asf_member?}]
102
+ end
103
+
104
+ attrs['people'] = Hash[people] unless people.empty?
105
+ end
106
+ end
107
+ end