blob: a3c2dd6330e68985d7d4645ab7eba539e2bda2bc [file]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require 'json'
require 'net/http'
require 'uri'
require 'time'
require 'digest'
require 'fileutils'
require 'set'
# Populates `site.data['team']` and `site.data['wall_of_fame']` at build time.
#
# Team roster comes from the Apache Whimsy LDAP exports:
# https://whimsy.apache.org/public/public_ldap_projects.json
# -> projects.<key>.members = committers
# -> projects.<key>.owners = PMC members
# https://whimsy.apache.org/public/public_ldap_people.json
# -> people.<id>.name + githubUsername
#
# Wall of Fame comes from GitHub's /contributors endpoint, aggregated across
# the configured repos and filtered to exclude PMC + committers + bots.
#
# Manual overrides come from `_data/team_overrides.yml` (chair flag, GH login).
#
# Set `JEKYLL_TEAM_NO_FETCH=1` to skip the network and reuse on-disk cache.
module StormcrawlerTeam
PROJECTS_URL = 'https://whimsy.apache.org/public/public_ldap_projects.json'.freeze
PEOPLE_URL = 'https://whimsy.apache.org/public/public_ldap_people.json'.freeze
GITHUB_API = 'https://api.github.com'.freeze
CACHE_TTL = 6 * 60 * 60
PALETTE = %w[
#3a1c71 #6a3093 #1f4068 #2c5364 #283c86 #0f2027
#5614b0 #11324d #16222a #373b44 #1d2671 #3b1f5b
].freeze
BOT_LOGINS = %w[
dependabot[bot] github-actions[bot] actions-user buildbot
renovate[bot] codecov-commenter copilot copilot-swe-agent[bot]
].map(&:downcase).to_set.freeze
class Generator < Jekyll::Generator
safe false
priority :high
def generate(site)
cfg = site.config['team'] || {}
project_key = cfg['whimsy_project'] || 'stormcrawler'
repos = cfg['repos'] || %w[apache/incubator-stormcrawler apache/incubator-stormcrawler-site]
cache_dir = File.join(site.source, '.jekyll-cache', 'team')
FileUtils.mkdir_p(cache_dir)
no_fetch = %w[1 true yes].include?((ENV['JEKYLL_TEAM_NO_FETCH'] || '').downcase)
projects_json = fetch_json(PROJECTS_URL, File.join(cache_dir, 'projects.json'), no_fetch)
people_json = fetch_json(PEOPLE_URL, File.join(cache_dir, 'people.json'), no_fetch)
overrides = site.data['team_overrides'] || {}
# Pull GitHub contributor data once: drives both gh-login auto-discovery
# for the team list and the wall-of-fame aggregation.
contributor_rows = repos.flat_map { |r| fetch_contributors(r, cache_dir, no_fetch) }
contributor_login_index = {} # login.downcase -> original-cased login
contributor_rows.each do |r|
login = r['login']
next if login.nil? || login.to_s.strip.empty?
contributor_login_index[login.downcase] ||= login
end
members = build_members(projects_json, people_json, project_key, overrides, contributor_login_index)
members.sort_by! { |m| sort_key(m['name']) }
site.data['team'] = members
excluded = excluded_logins(members)
wall = build_wall_of_fame(contributor_rows, cache_dir, no_fetch, excluded)
site.data['wall_of_fame'] = wall
site.data['team_meta'] = {
'last_updated' => Time.now.utc.strftime('%Y-%m-%d %H:%M UTC'),
'project_key' => project_key,
'count' => members.size,
'wall_of_fame_count' => wall.size,
'repos' => repos
}
Jekyll.logger.info 'Team:',
"#{members.size} member(s), wall-of-fame: #{wall.size} for project '#{project_key}'"
end
private
# ---- Whimsy roster ------------------------------------------------------
def build_members(projects, people, key, overrides, contributor_login_index)
return [] unless projects && projects['projects'] && projects['projects'][key]
project = projects['projects'][key]
committers = Array(project['members'])
pmc = Array(project['owners'])
ids = (committers + pmc).uniq.sort
people_map = (people && people['people']) || {}
ids.map do |id|
info = people_map[id] || {}
ov = overrides[id] || {}
name = ov['name'] || info['name'] || id
# `gh` override may be a list (`[a, b]` or `"a;b"`) for committers with
# multiple GitHub accounts; the first entry is the primary used for the
# profile link, the rest are aliases that get excluded from the Wall of
# Fame so the same person isn't listed twice.
gh_list = parse_gh_list(ov['gh'])
if gh_list.empty?
single = info['githubUsername'] || gh_from_urls(info['urls'])
gh_list = [single] if single && !single.to_s.strip.empty?
end
if gh_list.empty? && contributor_login_index.key?(id.downcase)
gh_list = [contributor_login_index[id.downcase]]
end
gh = gh_list.first
gh_aliases = gh_list.drop(1)
is_pmc = pmc.include?(id)
is_committer = committers.include?(id) || is_pmc
is_chair = ov['chair'] == true
gh_present = gh && !gh.to_s.strip.empty?
{
'name' => name,
'apache_id' => id,
'gh' => gh_present ? gh : nil,
'gh_aliases' => gh_aliases,
'pmc' => is_pmc,
'committer' => is_committer,
'chair' => is_chair,
'role_flags' => role_flags(is_committer, is_pmc),
'profile_url' => gh_present ? "https://github.com/#{gh}" : nil,
'initials' => initials(name),
'color' => PALETTE[name.hash.abs % PALETTE.size]
}
end
end
def parse_gh_list(value)
return [] if value.nil?
list = value.is_a?(Array) ? value : value.to_s.split(/[;,]/)
list.map { |v| v.to_s.strip }.reject(&:empty?)
end
def role_flags(committer, pmc)
return 'C-P' if committer && pmc
return 'P' if pmc
return 'C' if committer
''
end
def initials(name)
parts = name.to_s.strip.split(/\s+/)
return name.to_s.upcase if parts.empty?
return (parts.first[0].to_s + parts.last[0].to_s).upcase if parts.size >= 2
parts.first[0, 2].to_s.upcase
end
def sort_key(name)
parts = name.to_s.strip.split(/\s+/)
return name.to_s.downcase if parts.size < 2
"#{parts.last}, #{parts.first}".downcase
end
def gh_from_urls(urls)
return nil unless urls.is_a?(Array)
urls.each do |u|
if u =~ %r{https://github\.com/([^/?#]+)}
return Regexp.last_match(1)
end
end
nil
end
# ---- Wall of Fame -------------------------------------------------------
def excluded_logins(members)
out = Set.new
members.each do |m|
out << m['gh'].downcase if m['gh']
out << m['apache_id'].downcase if m['apache_id']
Array(m['gh_aliases']).each { |a| out << a.downcase }
end
out
end
def bot?(login)
lower = login.to_s.downcase
lower.end_with?('[bot]') || BOT_LOGINS.include?(lower)
end
def build_wall_of_fame(contributor_rows, cache_dir, no_fetch, excluded)
acc = {}
contributor_rows.each do |r|
login = r['login']
next if login.nil? || login.to_s.strip.empty?
next if bot?(login)
next if excluded.include?(login.downcase)
key = login.downcase
rec = acc[key] ||= { 'login' => login, 'commits' => 0 }
rec['commits'] += (r['contributions'] || 0).to_i
end
# Sort by commit count for ordering only; the count is not rendered.
list = acc.values.sort_by { |r| [-r['commits'], r['login'].downcase] }
list.each do |rec|
name = fetch_user_name(rec['login'], cache_dir, no_fetch) || rec['login']
rec['name'] = name
rec['profile_url'] = "https://github.com/#{rec['login']}"
rec['initials'] = initials(name)
rec['color'] = PALETTE[name.hash.abs % PALETTE.size]
end
list
end
def fetch_contributors(repo, cache_dir, no_fetch)
out = []
url = "#{GITHUB_API}/repos/#{repo}/contributors?per_page=100&anon=0"
page = 1
cache_id = repo.gsub('/', '_')
loop do
cache_path = File.join(cache_dir, "gh_contrib_#{cache_id}_p#{page}.json")
body, link = github_get_cached(url, cache_path, no_fetch)
break unless body
begin
arr = JSON.parse(body)
rescue JSON::ParserError => e
Jekyll.logger.warn 'Team:', "wall-of-fame parse failed for #{repo} p#{page}: #{e.message}"
break
end
break unless arr.is_a?(Array)
arr.each { |c| out << c if c.is_a?(Hash) }
nxt = parse_next_link(link)
break unless nxt
url = nxt
page += 1
end
out
rescue StandardError => e
Jekyll.logger.warn 'Team:', "wall-of-fame fetch failed for #{repo}: #{e.message}"
[]
end
def fetch_user_name(login, cache_dir, no_fetch)
cache_path = File.join(cache_dir, "gh_user_#{login.downcase}.json")
body, _link = github_get_cached("#{GITHUB_API}/users/#{login}", cache_path, no_fetch)
return nil unless body
obj = JSON.parse(body)
n = obj['name']
n && !n.to_s.strip.empty? ? n : nil
rescue StandardError
nil
end
# GitHub fetch with on-disk cache + sidecar Link-header file. Returns [body, link]
# or [nil, nil] when there is neither a fresh cache nor a successful fetch.
def github_get_cached(url, cache_path, no_fetch)
link_path = cache_path + '.link'
if File.exist?(cache_path) && (no_fetch || (Time.now - File.mtime(cache_path)) < CACHE_TTL)
body = File.read(cache_path)
link = File.exist?(link_path) ? File.read(link_path) : nil
return [body, link]
end
return [nil, nil] if no_fetch
body, link = github_get(url)
File.write(cache_path, body)
File.write(link_path, link.to_s)
[body, link]
rescue StandardError => e
Jekyll.logger.warn 'Team:', "GitHub GET #{url} failed: #{e.message}"
if File.exist?(cache_path)
[File.read(cache_path), File.exist?(link_path) ? File.read(link_path) : nil]
else
[nil, nil]
end
end
def github_get(url, limit = 5)
raise 'too many redirects' if limit <= 0
uri = URI.parse(url)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 30) do |http|
req = Net::HTTP::Get.new(uri.request_uri,
'User-Agent' => 'stormcrawler-site-jekyll-plugin',
'Accept' => 'application/vnd.github+json')
token = ENV['GITHUB_TOKEN']
req['Authorization'] = "Bearer #{token}" if token && !token.empty?
res = http.request(req)
case res
when Net::HTTPSuccess then [res.body, res['Link']]
when Net::HTTPRedirection then github_get(res['location'], limit - 1)
else raise "HTTP #{res.code} for #{url}"
end
end
end
def parse_next_link(link_header)
return nil if link_header.nil? || link_header.empty?
link_header.split(',').each do |part|
if part =~ /<([^>]+)>;\s*rel="next"/
return Regexp.last_match(1)
end
end
nil
end
# ---- Whimsy fetch -------------------------------------------------------
def fetch_json(url, cache_path, no_fetch)
if no_fetch
return load_cache(cache_path) || (Jekyll.logger.warn('Team:', "no-fetch and no cache for #{url}"); nil)
end
if File.exist?(cache_path) && (Time.now - File.mtime(cache_path)) < CACHE_TTL
cached = load_cache(cache_path)
return cached if cached
end
begin
body = http_get(url)
File.write(cache_path, body)
JSON.parse(body)
rescue StandardError => e
Jekyll.logger.warn 'Team:', "fetch failed for #{url}: #{e.message}"
load_cache(cache_path)
end
end
def load_cache(path)
return nil unless File.exist?(path)
JSON.parse(File.read(path))
rescue StandardError
nil
end
def http_get(url, limit = 5)
raise 'too many redirects' if limit <= 0
uri = URI.parse(url)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
open_timeout: 10, read_timeout: 30) do |http|
req = Net::HTTP::Get.new(uri.request_uri,
'User-Agent' => 'stormcrawler-site-jekyll-plugin',
'Accept' => 'application/json')
res = http.request(req)
case res
when Net::HTTPSuccess then res.body
when Net::HTTPRedirection then http_get(res['location'], limit - 1)
else raise "HTTP #{res.code} for #{url}"
end
end
end
end
end