whimsy-asf 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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