team_api 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CONTRIBUTING.md +15 -0
- data/LICENSE.md +31 -0
- data/README.md +53 -0
- data/lib/team_api/README.md +33 -0
- data/lib/team_api/api.rb +217 -0
- data/lib/team_api/canonicalizer.rb +125 -0
- data/lib/team_api/config.rb +18 -0
- data/lib/team_api/cross_referencer.rb +202 -0
- data/lib/team_api/endpoints.yml +58 -0
- data/lib/team_api/front_matter.rb +33 -0
- data/lib/team_api/generator.rb +28 -0
- data/lib/team_api/joiner.rb +151 -0
- data/lib/team_api/snippets.rb +24 -0
- data/lib/team_api/tag_categories.yml +9 -0
- data/lib/team_api/version.rb +5 -0
- data/lib/team_api.rb +11 -0
- metadata +229 -0
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`.
|
data/lib/team_api/api.rb
ADDED
@@ -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
|
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: []
|