team_api 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6740624d81a90a97d8d0f86e4495d74fd26d1b54
4
+ data.tar.gz: 3fc55eee6ee7d09912249b15962525fe9add3da7
5
+ SHA512:
6
+ metadata.gz: 474780225962c7350a00ea29b8e1fa80dc6b062918b4746a8e6984f0bcd7577319febf787cbf29dd70c8c3aabf34e9d993c3dd6ec3c7851c3c6880c4fe97e905
7
+ data.tar.gz: 7c53f0fcb51b54a3e30d62f93d48068e5c3e5b497c715d8c5f9d66f4b9eed4b02608dce56463eb3949973c23b7fbadf6e6aa6475f2b435d71f161e722b5748af
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,15 @@
1
+ ## Welcome!
2
+
3
+ We're so glad you're thinking about contributing to an 18F open source project! If you're unsure or afraid of anything, just ask or submit the issue or pull request anyways. The worst that can happen is that you'll be politely asked to change something. We appreciate any sort of contribution, and don't want a wall of rules to get in the way of that.
4
+
5
+ Before contributing, we encourage you to read our CONTRIBUTING policy (you are here), our LICENSE, and our README, all of which should be in this repository. If you have any questions, or want to read more about our underlying policies, you can consult the 18F Open Source Policy GitHub repository at https://github.com/18f/open-source-policy, or just shoot us an email/official government letterhead note to [18f@gsa.gov](mailto:18f@gsa.gov).
6
+
7
+ ## Public domain
8
+
9
+ This project is in the public domain within the United States, and
10
+ copyright and related rights in the work worldwide are waived through
11
+ the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/).
12
+
13
+ All contributions to this project will be released under the CC0
14
+ dedication. By submitting a pull request, you are agreeing to comply
15
+ with this waiver of copyright interest.
data/LICENSE.md ADDED
@@ -0,0 +1,31 @@
1
+ As a work of the United States Government, this project is in the
2
+ public domain within the United States.
3
+
4
+ Additionally, we waive copyright and related rights in the work
5
+ worldwide through the CC0 1.0 Universal public domain dedication.
6
+
7
+ ## CC0 1.0 Universal Summary
8
+
9
+ This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode).
10
+
11
+ ### No Copyright
12
+
13
+ The person who associated a work with this deed has dedicated the work to
14
+ the public domain by waiving all of his or her rights to the work worldwide
15
+ under copyright law, including all related and neighboring rights, to the
16
+ extent allowed by law.
17
+
18
+ You can copy, modify, distribute and perform the work, even for commercial
19
+ purposes, all without asking permission.
20
+
21
+ ### Other Information
22
+
23
+ In no way are the patent or trademark rights of any person affected by CC0,
24
+ nor are the rights that other persons may have in the work or in how the
25
+ work is used, such as publicity or privacy rights.
26
+
27
+ Unless expressly stated otherwise, the person who associated a work with
28
+ this deed makes no warranties about the work, and disclaims liability for
29
+ all uses of the work, to the fullest extent permitted by applicable law.
30
+ When using or citing the work, you should not imply endorsement by the
31
+ author or the affirmer.
data/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # 18F Team API
2
+
3
+ Compiles information about team members, projects, etc. and exposes it via a
4
+ JSON API.
5
+
6
+ Targeted consumers of this API include:
7
+
8
+ - [18F Hub](https://github.com/18F/hub)
9
+ - [18F Dashboard](https://github.com/18F/dashboard)
10
+ - [18F.gsa.gov](https://github.com/18F/18f.gsa.gov)
11
+
12
+ ## Installation
13
+
14
+ This gem currently serves as a [Jekyll](https://jekyllrb.com/) plugin, though
15
+ it may become decoupled in the future. Presuming you're using
16
+ [Bundler](http://bundler.io) in your Jekyll project, add the following to your
17
+ `Gemfile`:
18
+
19
+ ```ruby
20
+ group :jekyll_plugins do
21
+ gem 'team_api'
22
+ end
23
+ ```
24
+
25
+ Then, make sure to add an entry for `api_index_layout:` to your `_config.yml`
26
+ file. The index page will have an `endpoints` collection with one entry per
27
+ data collection, where each element has:
28
+
29
+ * `endpoint`: the URL of the collection's JSON endpoint
30
+ * `title`: title of the collection
31
+ * `description`: description of the collection
32
+
33
+ Here's a sample bare-bones template you can drop into your prefered layout:
34
+
35
+ ```html
36
+ <h1>API Endpoint Index</h1>
37
+ <br/>{% for i in page.endpoints %}
38
+ <div class="api_endpoint_desc">
39
+ <h2><a href="{{ i.endpoint }}"<code>{{ i.endpoint }}</code></a> - {{ i.title }}</h2>
40
+ <p>{{ i.description }}</p>
41
+ </div>
42
+ {% endfor %}
43
+ ```
44
+
45
+ ## Public domain
46
+
47
+ This project is in the worldwide [public domain](LICENSE.md). As stated in [CONTRIBUTING](CONTRIBUTING.md):
48
+
49
+ > This project is in the public domain within the United States, and copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/).
50
+ >
51
+ > All contributions to this project will be released under the CC0
52
+ >dedication. By submitting a pull request, you are agreeing to comply
53
+ >with this waiver of copyright interest.
@@ -0,0 +1,33 @@
1
+ ## 18F Team API Plugins
2
+
3
+ Plugins are used to create data joins and cross-references needed to produce
4
+ the API. The basic flow is:
5
+
6
+ * Join private data with public data
7
+ * Process snippet data
8
+ * Build cross-references between data elements
9
+ * Perform canonicalization of names and their ordering
10
+ * Generate API endpoints based on the joined, cross-referenced data
11
+
12
+ [generator.rb](generator.rb) is the entry point for this entire process. It
13
+ contains `TeamApi::Generator`, which performs all of the above steps in order.
14
+
15
+ ### Data Joining
16
+
17
+ [joiner.rb](joiner.rb) contains the plugins that join public, private, and
18
+ local data into the `site.data` object.
19
+
20
+ ### Cross-Referencing
21
+
22
+ [cross_referencer.rb](cross_referencer.rb) builds links between `site.data`
23
+ data collections which are used to generate cross-referenced pages.
24
+
25
+ ### Canonicalization
26
+
27
+ [canonicalizer.rb](canonicalizer.rb) contains functions used to canonicalize
28
+ names and the sort order of collections in `site.data`.
29
+
30
+ ### API Endpoint Generation
31
+
32
+ [api.rb](api.rb) generates all API endpoints and provides an index under
33
+ `/api`.
@@ -0,0 +1,217 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require_relative 'canonicalizer'
4
+ require_relative 'config'
5
+ require 'jekyll'
6
+ require 'json'
7
+ require 'safe_yaml'
8
+
9
+ module TeamApi
10
+ class IndexPage < ::Jekyll::Page
11
+ private_class_method :new
12
+
13
+ def initialize(site)
14
+ @site = site
15
+ @base = site.source
16
+ @dir = File.join site.config['baseurl'], Api::BASEURL
17
+ @name = 'index.html'
18
+ @data = {}
19
+ end
20
+
21
+ def self.create(site, index_endpoints)
22
+ index_page = new site
23
+ index_page.process index_page.name
24
+ layout = site.config['api_index_layout']
25
+ fail '`api_index_layout:` not defined in _config.yml' unless layout
26
+ index_page.read_yaml File.join(site.source, '_layouts'), layout
27
+ index_page.data['endpoints'] = index_endpoints
28
+ site.pages << index_page
29
+ end
30
+ end
31
+
32
+ class Endpoint < ::Jekyll::Page
33
+ private_class_method :new
34
+
35
+ def initialize(site, endpoint_path)
36
+ @site = site
37
+ @base = site.source
38
+ @dir = endpoint_path
39
+ @name = 'api.json'
40
+ @data = {}
41
+ end
42
+
43
+ def self.create(site, endpoint_path, data)
44
+ endpoint = new site, endpoint_path
45
+ endpoint.process endpoint.name
46
+ endpoint.content = data.to_json
47
+ site.pages << endpoint
48
+ end
49
+ end
50
+
51
+ # Functions for generating JSON objects as part of an API
52
+ class Api
53
+ BASEURL = 'api'
54
+
55
+ # Generates all of the API endpoints.
56
+ # +site+:: Jekyll site object
57
+ def self.generate_api(site)
58
+ impl = ApiImpl.new site, BASEURL
59
+ generate_collection_endpoints impl
60
+ generate_tag_category_endpoints impl
61
+ impl.generate_snippets_endpoints
62
+ IndexPage.create site, impl.index_endpoints
63
+ end
64
+
65
+ # Calculates the full URL prefix for every API endpoint, used to generate
66
+ # `self:` links. It is generated by concatenating the `url:` and
67
+ # `baseurl:` values from _config.yml, and the Api::BASEURL value, e.g.
68
+ # localhost:4001/api, https://team-api.18f.gov/public/api.
69
+ def self.baseurl(site)
70
+ File.join site.config['url'], site.config['baseurl'], BASEURL
71
+ end
72
+
73
+ def self.add_self_links(site)
74
+ baseurl = self.baseurl site
75
+ Config.endpoint_config.each do |endpoint_info|
76
+ collection_name = endpoint_info['collection']
77
+ (site.data[collection_name] || {}).values.each do |item|
78
+ slug = Canonicalizer.canonicalize item[endpoint_info['item_id']]
79
+ item['self'] = File.join baseurl, collection_name, slug
80
+ end
81
+ end
82
+ end
83
+
84
+ def self.generate_collection_endpoints(impl)
85
+ Config.endpoint_config.each do |endpoint_info|
86
+ impl.generate_index_endpoint_for_collection endpoint_info
87
+ impl.generate_item_endpoints endpoint_info['collection']
88
+ end
89
+ end
90
+ private_class_method :generate_collection_endpoints
91
+
92
+ def self.generate_tag_category_endpoints(impl)
93
+ %w(Skills Interests).each do |tag_category|
94
+ impl.generate_tag_category_endpoint tag_category
95
+ impl.generate_item_endpoints Canonicalizer.canonicalize(tag_category)
96
+ end
97
+ end
98
+ private_class_method :generate_tag_category_endpoints
99
+ end
100
+
101
+ module ApiImplSnippetHelpers
102
+ private
103
+
104
+ def snippet_dates
105
+ @snippet_dates ||= (data['snippets'] || {}).keys.sort.reverse
106
+ end
107
+
108
+ def snippets
109
+ @snippets ||= snippet_dates.map { |t| [t, data['snippets'][t]] }.to_h
110
+ end
111
+
112
+ def snippets_by_user
113
+ @snippets_by_user ||= snippets
114
+ .flat_map { |date, batch| batch.map { |snippet| [date, snippet] } }
115
+ .group_by { |_date, snippet| snippet['name'] }
116
+ .map { |name, mapping| [name, mapping.to_h] }
117
+ .to_h
118
+ end
119
+
120
+ def snippets_summary
121
+ @snippet_summary ||= {
122
+ 'latest' => snippet_dates.first,
123
+ 'all' => snippet_dates,
124
+ 'users' => Canonicalizer.team_xrefs(
125
+ data['team'], snippets_by_user.keys),
126
+ }
127
+ end
128
+
129
+ def generate_latest_snippet_endpoint
130
+ return if snippets.empty?
131
+ latest = snippets.first
132
+ endpoint = 'snippets/latest'
133
+ Endpoint.create(site, "#{baseurl}/#{endpoint}",
134
+ { 'datestamp' => latest[0] }.merge(envelop(endpoint, latest[1])))
135
+ end
136
+
137
+ def generate_snippets_by_date_endpoints
138
+ snippets.each do |timestamp, batch|
139
+ endpoint = "snippets/#{timestamp}"
140
+ Endpoint.create site, "#{baseurl}/#{endpoint}", envelop(endpoint, batch)
141
+ end
142
+ end
143
+
144
+ def generate_snippets_by_user_endpoints
145
+ snippets_by_user.each do |name, batch|
146
+ Endpoint.create site, "#{baseurl}/snippets/#{name}", batch
147
+ Endpoint.create(
148
+ site, "#{baseurl}/snippets/#{name}/latest", [batch.first].to_h)
149
+ end
150
+ end
151
+
152
+ def generate_snippets_index_summary_endpoint
153
+ generate_index_endpoint(
154
+ 'snippets', 'Snippets', 'Summary of all available snippets',
155
+ snippets_summary) unless snippets.empty?
156
+ end
157
+ end
158
+
159
+ class ApiImpl
160
+ attr_accessor :site, :data, :index_endpoints, :baseurl
161
+ include ApiImplSnippetHelpers
162
+
163
+ def initialize(site, baseurl)
164
+ @site = site
165
+ @data = site.data
166
+ @index_endpoints = []
167
+ @baseurl = baseurl
168
+ end
169
+
170
+ def self_link(endpoint)
171
+ File.join site.config['url'], baseurl, endpoint
172
+ end
173
+
174
+ def envelop(endpoint, items)
175
+ return if items.nil? || items.empty?
176
+ { 'self' => self_link(endpoint), 'results' => items }
177
+ end
178
+
179
+ def generate_index_endpoint(endpoint, title, description, items)
180
+ return if items.nil? || items.empty?
181
+ Endpoint.create site, "#{baseurl}/#{endpoint}", items
182
+ index_endpoints << {
183
+ 'endpoint' => endpoint, 'title' => title, 'description' => description
184
+ }
185
+ end
186
+
187
+ def generate_tag_category_endpoint(category)
188
+ canonicalized = Canonicalizer.canonicalize(category)
189
+ generate_index_endpoint(canonicalized, category,
190
+ "Index of team members by #{category.downcase}",
191
+ envelop(canonicalized, (data[canonicalized] || {}).values))
192
+ end
193
+
194
+ def generate_index_endpoint_for_collection(endpoint_info)
195
+ collection = endpoint_info['collection']
196
+ generate_index_endpoint(
197
+ endpoint_info['collection'], endpoint_info['title'],
198
+ endpoint_info['description'],
199
+ envelop(collection, (data[collection] || {}).values))
200
+ end
201
+
202
+ def generate_item_endpoints(collection_name)
203
+ (data[collection_name] || {}).each do |identifier, value|
204
+ identifier = Canonicalizer.canonicalize(identifier)
205
+ url = "#{baseurl}/#{collection_name}/#{identifier}"
206
+ Endpoint.create site, url, value
207
+ end
208
+ end
209
+
210
+ def generate_snippets_endpoints
211
+ generate_latest_snippet_endpoint
212
+ generate_snippets_by_date_endpoints
213
+ generate_snippets_by_user_endpoints
214
+ generate_snippets_index_summary_endpoint
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,125 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require_relative 'config'
4
+ require_relative 'cross_referencer'
5
+
6
+ module TeamApi
7
+ # Contains utility functions for canonicalizing names and the order of data.
8
+ class Canonicalizer
9
+ # Canonicalizes the order and names of certain fields within site_data.
10
+ def self.canonicalize_data(site_data)
11
+ sort_collections site_data
12
+ canonicalize_tag_category site_data['skills']
13
+ canonicalize_tag_category site_data['interests']
14
+ end
15
+
16
+ def self.sort_collections(site_data)
17
+ Config.endpoint_config.each do |endpoint_info|
18
+ collection = endpoint_info['collection']
19
+ next unless site_data.member? collection
20
+ sorted = sort_collection_values(endpoint_info,
21
+ site_data[collection].values)
22
+ sort_item_xrefs endpoint_info, sorted
23
+ item_id_field = endpoint_info['item_id']
24
+ site_data[collection] = sorted.map { |i| [i[item_id_field], i] }.to_h
25
+ end
26
+ end
27
+
28
+ def self.sort_collection_values(endpoint_info, values)
29
+ sort_by_field = endpoint_info['sort_by']
30
+ if sort_by_field == 'last_name'
31
+ sort_by_last_name values
32
+ else
33
+ values.sort_by { |i| (i[sort_by_field] || '').downcase }
34
+ end
35
+ end
36
+ private_class_method :sort_collection_values
37
+
38
+ def self.sort_item_xrefs(endpoint_info, collection)
39
+ collection.each do |item|
40
+ sortable_item_fields(item, endpoint_info).each do |field, field_info|
41
+ item[field] = sort_collection_values field_info, item[field]
42
+ end
43
+ end
44
+ end
45
+ private_class_method :sort_item_xrefs
46
+
47
+ def self.sortable_item_fields(item, collection_endpoint_info)
48
+ collection_endpoint_info['item_collections'].map do |item_spec|
49
+ field, endpoint_info = parse_collection_spec item_spec
50
+ [field, endpoint_info] if item[field]
51
+ end.compact
52
+ end
53
+ private_class_method :sortable_item_fields
54
+
55
+ def self.parse_collection_spec(collection_spec)
56
+ if collection_spec.instance_of? Hash
57
+ [collection_spec['field'],
58
+ Config.endpoint_info_by_collection[collection_spec['collection']]]
59
+ else
60
+ [collection_spec, Config.endpoint_info_by_collection[collection_spec]]
61
+ end
62
+ end
63
+
64
+ # Returns a canonicalized, URL-friendly substitute for an arbitrary string.
65
+ # +s+:: string to canonicalize
66
+ def self.canonicalize(s)
67
+ s.downcase.gsub(/\s+/, '-')
68
+ end
69
+
70
+ def self.comparable_name(person)
71
+ if person['last_name']
72
+ [person['last_name'].downcase, person['first_name'].downcase]
73
+ else
74
+ # Trim off title suffix, if any.
75
+ full_name = person['full_name'].downcase.split(',')[0]
76
+ last_name = full_name.split.last
77
+ [last_name, full_name]
78
+ end
79
+ end
80
+ private_class_method :comparable_name
81
+
82
+ # Sorts an array of team member data hashes based on the team members'
83
+ # last names.
84
+ # +team+:: An array of team member data hashes
85
+ def self.sort_by_last_name(team)
86
+ team.sort_by { |member| comparable_name member }
87
+ end
88
+
89
+ def self.team_xrefs(team, usernames)
90
+ fields = CrossReferencer::TEAM_FIELDS
91
+ usernames
92
+ .map { |username| team[username] }
93
+ .compact
94
+ .map { |member| member.select { |field, _| fields.include? field } }
95
+ .sort_by { |member| comparable_name member }
96
+ end
97
+
98
+ # Breaks a YYYYMMDD timestamp into a hyphenated version: YYYY-MM-DD
99
+ # +timestamp+:: timestamp in the form YYYYMMDD
100
+ def self.hyphenate_yyyymmdd(timestamp)
101
+ "#{timestamp[0..3]}-#{timestamp[4..5]}-#{timestamp[6..7]}"
102
+ end
103
+
104
+ # Consolidate tags entries that are not exactly the same. Selects the
105
+ # lexicographically smaller version of the tag as a standard.
106
+ #
107
+ # In the future, we may just consider raising an error if there are two
108
+ # different strings for the same thing.
109
+ def self.canonicalize_tag_category(tags_xrefs)
110
+ return if tags_xrefs.nil? || tags_xrefs.empty?
111
+ tags_xrefs.replace(CrossReferencer.map_reduce(tags_xrefs.values,
112
+ ->(xref) { [[xref['slug'], xref]] }, method(:consolidate_xrefs)).to_h)
113
+ end
114
+
115
+ def self.consolidate_xrefs(slug, xrefs)
116
+ xrefs.sort_by! { |xref| xref['name'] }
117
+ result = xrefs.each_with_object(xrefs.shift) do |xref, consolidated|
118
+ consolidated['members'].concat xref['members']
119
+ end
120
+ result['members'].sort_by! { |member| comparable_name member }
121
+ [slug, result]
122
+ end
123
+ private_class_method :consolidate_xrefs
124
+ end
125
+ end
@@ -0,0 +1,18 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ module TeamApi
4
+ class Config
5
+ def self.endpoint_config
6
+ @endpoint_config ||= begin
7
+ endpoint_config_path = File.join File.dirname(__FILE__), 'endpoints.yml'
8
+ SafeYAML.load_file endpoint_config_path, safe: true
9
+ end
10
+ end
11
+
12
+ def self.endpoint_info_by_collection
13
+ @endpoint_info_by_collection ||= Config.endpoint_config.map do |item|
14
+ [item['collection'], item]
15
+ end.to_h
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,202 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require_relative 'api'
4
+ require_relative 'canonicalizer'
5
+
6
+ module TeamApi
7
+ # Signals that a cross-reference ID value in one object is not present in
8
+ # the target collection. Only raised in "private" mode, since "public" mode
9
+ # may legitimately filter out data.
10
+ class UnknownCrossReferenceTargetId < StandardError
11
+ end
12
+
13
+ # Provides a collection with the ability to replace identifiers with more
14
+ # detailed cross-reference values from another collection, and with the
15
+ # ability to construct its own cross-reference values to assign to values
16
+ # from other collections.
17
+ #
18
+ # The intent is to provide enough cross-reference information to surface in
19
+ # an API without requiring the client to join the data necessary to produce
20
+ # cross-links. For example, instead of surfacing `['mbland']` in a list of
21
+ # team members, this class will produce `[{'name' => 'mbland', 'full_name'
22
+ # => 'Mike Bland', 'first_name' => 'Mike', 'last_name' => 'Bland'}]`, which
23
+ # the client can use to more easily sort multiple values and transform into:
24
+ # `<a href="https://hub.18f.gov/team/mbland/">Mike Bland</a>`.
25
+ class CrossReferenceData
26
+ attr_accessor :collection_name, :data, :item_xref_fields, :public_mode
27
+
28
+ # @param site [Jekyll::Site] site object
29
+ # @param collection_name [String] name of collection within site.data
30
+ # @param field_to_xref [String] name of the field to cross-reference
31
+ # @param item_xref_fields [Array<String>] list of fields from which to
32
+ # produce cross-references for this collection
33
+ def initialize(site, collection_name, item_xref_fields)
34
+ @collection_name = collection_name
35
+ @data = site.data[collection_name] || {}
36
+ @item_xref_fields = item_xref_fields
37
+ @public_mode = site.config['public']
38
+ end
39
+
40
+ # Selects fields from `item` to produce a smaller hash as a
41
+ # cross-reference.
42
+ def item_to_xref(item)
43
+ item.select { |field, _| item_xref_fields.include? field }
44
+ end
45
+
46
+ # Translates identifiers into cross-reference values in both this object's
47
+ # collection and the `target` collection.
48
+ #
49
+ # This object's collection is considered the "source", and references to
50
+ # its values will be injected into "target". For each "source" object,
51
+ # `source[target.collection_name]` should be an existing field containing
52
+ # identifiers that are keys into `target.data`. The `target` collection
53
+ # values should not contain a `target[source.collection_name]` field; that
54
+ # field will be created by this method.
55
+ #
56
+ # @param target [CrossReferenceData] contains data to cross-reference with
57
+ # items from this object's collection
58
+ # @param source_to_target_field [String] if specified, the field from this
59
+ # collection's objects that contain identifiers of objects stored within
60
+ # target; if not specified, target.collection_name will be used instead
61
+ def create_xrefs(target, source_to_target_field: nil)
62
+ target_collection_field = source_to_target_field || target.collection_name
63
+ data.values.each do |source|
64
+ create_xrefs_for_source source, target_collection_field, target
65
+ end
66
+ target.data.values.each { |item| (item[collection_name] || []).uniq! }
67
+ end
68
+
69
+ private
70
+
71
+ def create_xrefs_for_source(source, target_collection_field, target)
72
+ source_xref = item_to_xref source
73
+ target_ids = filter_target_ids target, source, target_collection_field
74
+ link_source_to_targets source_xref, target_ids, target
75
+ source[target_collection_field] = target_xrefs target, target_ids
76
+ end
77
+
78
+ def filter_target_ids(target_xref, source_item, target_collection_field)
79
+ (source_item[target_collection_field] || []).map do |target_id|
80
+ if target_xref.data.member? target_id
81
+ target_id
82
+ elsif !public_mode
83
+ fail UnknownCrossReferenceTargetId, unknown_cross_reference_msg(
84
+ collection_name, source_item, target_collection_field,
85
+ target_xref, target_id)
86
+ end
87
+ end.compact
88
+ end
89
+
90
+ def unknown_cross_reference_msg(collection_name,
91
+ source_item, target_collection_field, target_xref, target_id)
92
+ "source collection: \"#{collection_name}\" " \
93
+ "source xref: #{item_to_xref source_item} " \
94
+ "target collection field: \"#{target_collection_field}\" " \
95
+ "target collection: \"#{target_xref.collection_name}\" " \
96
+ "target ID: \"#{target_id}\""
97
+ end
98
+
99
+ def link_source_to_targets(source_xref, target_ids, target_xref)
100
+ target_ids.each do |target_id|
101
+ (target_xref.data[target_id][collection_name] ||= []) << source_xref
102
+ end
103
+ end
104
+
105
+ def target_xrefs(target_xref, target_ids)
106
+ target_ids.map { |id| target_xref.item_to_xref target_xref.data[id] }
107
+ end
108
+ end
109
+
110
+ # Builds cross-references between data sets.
111
+ class CrossReferencer
112
+ TEAM_FIELDS = %w(name last_name first_name full_name self)
113
+ PROJECT_FIELDS = %w(name project self)
114
+ WORKING_GROUP_FIELDS = %w(name full_name self)
115
+ GUILD_FIELDS = %w(name full_name self)
116
+ TAG_CATEGORIES = %w(skills interests)
117
+
118
+ # Build cross-references between data sets.
119
+ # +site_data+:: Jekyll +site.data+ object
120
+ def self.build_xrefs(site)
121
+ team, projects, working_groups, guilds = create_xref_data site
122
+
123
+ projects.create_xrefs team
124
+ [working_groups, guilds].each do |grouplet|
125
+ grouplet.create_xrefs team, source_to_target_field: 'leads'
126
+ grouplet.create_xrefs team, source_to_target_field: 'members'
127
+ end
128
+
129
+ xref_tags_and_team_members site, TAG_CATEGORIES, team
130
+ xref_locations site.data, team, [projects, working_groups, guilds]
131
+ end
132
+
133
+ def self.create_xref_data(site)
134
+ [CrossReferenceData.new(site, 'team', TEAM_FIELDS),
135
+ CrossReferenceData.new(site, 'projects', PROJECT_FIELDS),
136
+ CrossReferenceData.new(site, 'working-groups', WORKING_GROUP_FIELDS),
137
+ CrossReferenceData.new(site, 'guilds', GUILD_FIELDS),
138
+ ]
139
+ end
140
+ private_class_method :create_xref_data
141
+
142
+ def self.xref_tags_and_team_members(site, tag_categories, team_xref)
143
+ tag_categories.each do |category|
144
+ xrefs = create_tag_xrefs(site, (site.data['team'] || {}).values,
145
+ category, team_xref)
146
+ site.data[category] = xrefs unless xrefs.empty?
147
+ end
148
+ end
149
+
150
+ def self.create_tag_xrefs(site, items, category, xref_data)
151
+ map_items_to_tags = lambda do |item|
152
+ item_xref = xref_data.item_to_xref item
153
+ item[category].map { |tag| [tag, item_xref] } unless item[category].nil?
154
+ end
155
+ create_tag_xrefs = lambda do |tag, item_xrefs|
156
+ [tag, tag_xref(site, category, tag, item_xrefs)]
157
+ end
158
+ map_reduce(items, map_items_to_tags, create_tag_xrefs).to_h
159
+ end
160
+
161
+ # Returns an Array of objects after mapping and reducing items.
162
+ # mapper takes a single item and returns an Array of [key, value] pairs.
163
+ # reducer takes a [key, Array of values] pair and returns a single item.
164
+ def self.map_reduce(items, mapper, reducer)
165
+ items.flat_map { |item| mapper.call(item) }.compact
166
+ .each_with_object({}) { |kv, shuffle| (shuffle[kv[0]] ||= []) << kv[1] }
167
+ .map { |key, values| reducer.call(key, values) }.compact
168
+ end
169
+
170
+ def self.tag_xref(site, category, tag, members)
171
+ category_slug = Canonicalizer.canonicalize category
172
+ tag_slug = Canonicalizer.canonicalize tag
173
+ { 'name' => tag,
174
+ 'slug' => tag_slug,
175
+ 'self' => File.join(Api.baseurl(site), category_slug, tag_slug),
176
+ 'members' => Canonicalizer.sort_by_last_name(members || []),
177
+ }
178
+ end
179
+
180
+ def self.group_names_to_team_xrefs(team, collection_xrefs)
181
+ collection_xrefs.map do |xref|
182
+ xrefs = team.flat_map { |i| i[xref.collection_name] }.compact.uniq
183
+ [xref.collection_name, xrefs] unless xrefs.empty?
184
+ end.compact.to_h
185
+ end
186
+
187
+ # Produces an array of locations containing cross references to team
188
+ # members and all projects, working groups, guilds, etc. associated with
189
+ # each team member. All team member cross-references must already exist.
190
+ def self.xref_locations(site_data, team_xref, collection_xrefs)
191
+ location_xrefs = site_data['team'].values.group_by { |i| i['location'] }
192
+ .map do |location_code, team|
193
+ [location_code,
194
+ {
195
+ 'team' => team.map { |member| team_xref.item_to_xref member },
196
+ }.merge(group_names_to_team_xrefs(team, collection_xrefs)),
197
+ ] unless location_code.nil?
198
+ end
199
+ HashJoiner.deep_merge site_data['locations'], location_xrefs.compact.to_h
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,58 @@
1
+ - collection: team
2
+ title: Team
3
+ description: Team member info, indexed by username
4
+ item_id: name
5
+ sort_by: last_name
6
+ item_collections:
7
+ - projects
8
+ - working-groups
9
+ - guilds
10
+
11
+ - collection: locations
12
+ title: Locations
13
+ description: Index of team members by location code
14
+ item_id: code
15
+ sort_by: label
16
+ item_collections:
17
+ - team
18
+ - projects
19
+ - working-groups
20
+ - guilds
21
+
22
+ - collection: projects
23
+ title: Projects
24
+ description: Project info, indexed by short project name
25
+ item_id: name
26
+ sort_by: full_name
27
+ item_collections:
28
+ - team
29
+
30
+ - collection: departments
31
+ title: Departments
32
+ description: Department info, indexed by department name
33
+ item_id: name
34
+ sort_by: name
35
+ item_collections:
36
+ - team
37
+
38
+ - collection: working-groups
39
+ title: Working Groups
40
+ description: Working Groups info, indexed by name
41
+ item_id: name
42
+ sort_by: full_name
43
+ item_collections:
44
+ - field: leads
45
+ collection: team
46
+ - field: members
47
+ collection: team
48
+
49
+ - collection: guilds
50
+ title: Guilds
51
+ description: Guilds info, indexed by name
52
+ item_id: name
53
+ sort_by: full_name
54
+ item_collections:
55
+ - field: leads
56
+ collection: team
57
+ - field: members
58
+ collection: team
@@ -0,0 +1,33 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require 'safe_yaml'
4
+
5
+ module TeamApi
6
+ class FrontMatter
7
+ class Error < StandardError
8
+ end
9
+
10
+ MARKER = '---'
11
+ START_MARKER = "#{MARKER}\n"
12
+ END_MARKER = "\n#{MARKER}\n"
13
+
14
+ def self.update_front_matter(filename)
15
+ end_front_matter = front_matter_end_index filename, content
16
+ front_matter = content[0..end_front_matter]
17
+ content = content[end_front_matter..-1]
18
+ front_matter = SafeYAML.load front_matter, safe: true
19
+ yield front_matter
20
+ File.write filename, "#{front_matter.to_yaml}#{content}"
21
+ end
22
+
23
+ def self.front_matter_end_index(filename, content)
24
+ unless content.start_with? START_MARKER
25
+ fail Error, "#{filename}: contains no front matter"
26
+ end
27
+ end_front_matter = content.index END_MARKER, START_MARKER.size
28
+ return end_front_matter unless end_front_matter.nil?
29
+ fail Error, "#{filename}: front matter does not end with '#{MARKER}'"
30
+ end
31
+ private_class_method :front_matter_end_index
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require_relative 'api'
4
+ require_relative 'canonicalizer'
5
+ require_relative 'cross_referencer'
6
+ require_relative 'joiner'
7
+ require_relative 'snippets'
8
+ require 'hash-joiner'
9
+ require 'jekyll'
10
+
11
+ module TeamApi
12
+ # Processes site data, generates authorization artifacts, publishes an API,
13
+ # and generates cross-linked Hub pages.
14
+ class Generator < ::Jekyll::Generator
15
+ safe true
16
+
17
+ # Executes all of the data processing and artifact/page generation phases
18
+ # for the Hub.
19
+ def generate(site)
20
+ Joiner.join_data(site)
21
+ Snippets.publish(site)
22
+ CrossReferencer.build_xrefs(site)
23
+ Canonicalizer.canonicalize_data(site.data)
24
+ ::HashJoiner.prune_empty_properties(site.data)
25
+ Api.generate_api(site)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,151 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require_relative 'api'
4
+ require 'hash-joiner'
5
+
6
+ module TeamApi
7
+ class UnknownTeamMemberReferenceError < StandardError
8
+ end
9
+
10
+ class UnknownSnippetUsernameError < StandardError
11
+ end
12
+
13
+ # Joins the data from collections into +site.data+. Also filters out private
14
+ # data when +site.config[+'public'] is +true+ (aka "public mode").
15
+ class Joiner
16
+ # Executes all of the steps to join the different data sources into
17
+ # +site.data+ and filters out private data when in public mode.
18
+ #
19
+ # +site+:: Jekyll site data object
20
+ def self.join_data(site)
21
+ impl = JoinerImpl.new site
22
+ site.data.merge! impl.collection_data
23
+ impl.promote_or_remove_data
24
+ impl.join_project_data
25
+ Api.add_self_links site
26
+ impl.join_snippet_data
27
+ end
28
+ end
29
+
30
+ # Implements Joiner operations.
31
+ class JoinerImpl
32
+ attr_reader :site, :data, :public_mode
33
+
34
+ # +site+:: Jekyll site data object
35
+ def initialize(site)
36
+ @site = site
37
+ @data = site.data
38
+ @public_mode = site.config['public']
39
+ end
40
+
41
+ def collection_data
42
+ @collection_data ||= site.collections.map do |data_class, collection|
43
+ groups = groups collection
44
+ result = (groups[:public] || {})
45
+ result.merge!('private' => groups[:private]) if groups[:private]
46
+ [data_class, result] unless result.empty?
47
+ end.compact.to_h
48
+ end
49
+
50
+ def groups(collection)
51
+ collection.docs
52
+ .select { |doc| doc.data['published'] != false }
53
+ .group_by { |doc| doc_visibility doc }
54
+ .map { |group, docs| [group, docs_data(docs)] }
55
+ .to_h
56
+ end
57
+
58
+ def doc_visibility(doc)
59
+ parent = File.basename File.dirname(doc.cleaned_relative_path)
60
+ (parent == 'private') ? :private : :public
61
+ end
62
+
63
+ def docs_data(docs)
64
+ docs.map { |doc| [doc.basename_without_ext, doc.data] }.to_h
65
+ end
66
+
67
+ def promote_or_remove_data
68
+ private_data_method = public_mode ? :remove_data : :promote_data
69
+ HashJoiner.send private_data_method, data, 'private'
70
+ end
71
+
72
+ def join_project_data
73
+ # A little bit of project data munging. Can go away after the .about.yml
74
+ # convention takes hold, hopefully.
75
+ projects = (data['projects'] ||= {})
76
+ projects.delete_if { |_, p| p['status'] == 'Hold' } if @public_mode
77
+ projects.values.each { |p| join_team_list p['team'] }
78
+ end
79
+
80
+ def team
81
+ data['team'] ||= {}
82
+ end
83
+
84
+ # Returns an index of team member usernames keyed by email address.
85
+ def team_by_email
86
+ @team_by_email ||= team_index_by_field 'email'
87
+ end
88
+
89
+ # Returns an index of team member usernames keyed by email address.
90
+ def team_by_github
91
+ @team_by_github ||= team_index_by_field 'github'
92
+ end
93
+
94
+ # Returns an index of team member usernames keyed by a particular field.
95
+ def team_index_by_field(field)
96
+ team.values.map do |member|
97
+ value = member[field]
98
+ [value, member['name']] unless value.nil?
99
+ end.compact.to_h
100
+ end
101
+
102
+ # Replaces each member of team_list with a key into the team hash.
103
+ # Values can be:
104
+ # - Strings that are already team hash keys
105
+ # - Strings that are email addresses
106
+ # - Strings that are GitHub usernames
107
+ # - Hashes that contain an 'email' property
108
+ # - Hashes that contain a 'github' property
109
+ def join_team_list(team_list)
110
+ (team_list || []).map! do |reference|
111
+ member = team_member_from_reference reference
112
+ if member.nil?
113
+ fail UnknownTeamMemberReferenceError, member unless public_mode
114
+ else
115
+ member['name']
116
+ end
117
+ end.compact
118
+ end
119
+
120
+ def team_member_from_reference(reference)
121
+ key = (reference.instance_of? String) ? reference : (
122
+ reference['email'] || reference['github'])
123
+ team[key] || team[team_by_email[key] || team_by_github[key]]
124
+ end
125
+
126
+ SNIPPET_JOIN_FIELDS = %w(name full_name first_name last_name self)
127
+
128
+ # Joins snippet data into +site.data[+'snippets'] and filters out snippets
129
+ # from team members not appearing in +site.data[+'team'] or
130
+ # +team_by_email+.
131
+ def join_snippet_data
132
+ data['snippets'] = data['snippets'].map do |timestamp, snippets|
133
+ joined = snippets.map { |snippet| join_snippet snippet }
134
+ .compact.each { |i| i.delete 'username' }
135
+ [timestamp, joined] unless joined.empty?
136
+ end.compact.to_h
137
+ end
138
+
139
+ def join_snippet(snippet)
140
+ username = snippet['username']
141
+ member = team[username] || team[team_by_email[username]]
142
+
143
+ if member.nil?
144
+ fail UnknownSnippetUsernameError, username unless public_mode
145
+ else
146
+ member = member.select { |k, _| SNIPPET_JOIN_FIELDS.include? k }
147
+ snippet.merge member
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,24 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require 'weekly_snippets/publisher'
4
+
5
+ module TeamApi
6
+ class Snippets
7
+ # Used to convert snippet headline markers to h4, since the layout uses
8
+ # h3.
9
+ HEADLINE = "\n####"
10
+
11
+ MARKDOWN_SNIPPET_MUNGER = proc do |text|
12
+ text.gsub!(/^::: (.*) :::$/, "#{HEADLINE} \\1") # For jtag. ;-)
13
+ text.gsub!(/^\*\*\*/, HEADLINE) # For elaine. ;-)
14
+ end
15
+
16
+ # TODO(mbland): Push this to the snippet import script.
17
+ def self.publish(site)
18
+ publisher = ::WeeklySnippets::Publisher.new(
19
+ headline: HEADLINE, public_mode: site.config['public'],
20
+ markdown_snippet_munger: MARKDOWN_SNIPPET_MUNGER)
21
+ site.data['snippets'] = publisher.publish site.data['snippets']
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,9 @@
1
+ - category: skills
2
+ title: Skills
3
+ description: Index of team members by technical skills
4
+ source: team
5
+
6
+ - category: interests
7
+ title: Interests
8
+ description: Index of team members by general interests
9
+ source: team
@@ -0,0 +1,5 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ module TeamApi
4
+ VERSION = '0.0.0'
5
+ end
data/lib/team_api.rb ADDED
@@ -0,0 +1,11 @@
1
+ # @author Mike Bland (michael.bland@gsa.gov)
2
+
3
+ require_relative 'team_api/api'
4
+ require_relative 'team_api/canonicalizer'
5
+ require_relative 'team_api/config'
6
+ require_relative 'team_api/cross_referencer'
7
+ require_relative 'team_api/front_matter'
8
+ require_relative 'team_api/generator'
9
+ require_relative 'team_api/joiner'
10
+ require_relative 'team_api/snippets'
11
+ require_relative 'team_api/version'
metadata ADDED
@@ -0,0 +1,229 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: team_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Mike Bland
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-08-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.10'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: safe_yaml
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: jekyll
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: weekly_snippets
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: hash-joiner
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: go_script
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.1'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '10.4'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '10.4'
111
+ - !ruby/object:Gem::Dependency
112
+ name: minitest
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: codeclimate-test-reporter
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: coveralls
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: about_yml
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ description: Compiles information about team members, projects, etc. and exposes it
182
+ via a JSON API.
183
+ email:
184
+ - michael.bland@gsa.gov
185
+ executables: []
186
+ extensions: []
187
+ extra_rdoc_files: []
188
+ files:
189
+ - CONTRIBUTING.md
190
+ - LICENSE.md
191
+ - README.md
192
+ - lib/team_api.rb
193
+ - lib/team_api/README.md
194
+ - lib/team_api/api.rb
195
+ - lib/team_api/canonicalizer.rb
196
+ - lib/team_api/config.rb
197
+ - lib/team_api/cross_referencer.rb
198
+ - lib/team_api/endpoints.yml
199
+ - lib/team_api/front_matter.rb
200
+ - lib/team_api/generator.rb
201
+ - lib/team_api/joiner.rb
202
+ - lib/team_api/snippets.rb
203
+ - lib/team_api/tag_categories.yml
204
+ - lib/team_api/version.rb
205
+ homepage: https://github.com/18F/team_api
206
+ licenses:
207
+ - CC0
208
+ metadata: {}
209
+ post_install_message:
210
+ rdoc_options: []
211
+ require_paths:
212
+ - lib
213
+ required_ruby_version: !ruby/object:Gem::Requirement
214
+ requirements:
215
+ - - ">="
216
+ - !ruby/object:Gem::Version
217
+ version: '0'
218
+ required_rubygems_version: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ requirements: []
224
+ rubyforge_project:
225
+ rubygems_version: 2.4.5.1
226
+ signing_key:
227
+ specification_version: 4
228
+ summary: Compiles team information and publishes it as a JSON API
229
+ test_files: []