This is an automated email from the ASF dual-hosted git repository. rzo1 pushed a commit to branch team-site-update in repository https://gitbox.apache.org/repos/asf/opennlp-site.git
commit 1c8b29a3cb17f550e77b0b454f1dda0184c5f917 Author: Richard Zowalla <[email protected]> AuthorDate: Thu Apr 30 14:20:39 2026 +0200 Generate team page from live GitHub + Whimsy data; add live dev mode Replaces the hand-maintained bullet list on team.ad with three TomEE-style card grids — Active, Emeritus, Wall of Fame — populated at build time from the OpenNLP committer/PMC roster (Whimsy LDAP) joined with live contributor activity across apache/opennlp{,-site,-addons,-sandbox}. Identity merging (login + apache-id + email + normalized-name) and bot filtering are ported from the opennlp-stats reference tool. Build: - Bumps Java target to 21 and switches packaging to jar. - Drops jbake-maven-plugin in favour of an exec-maven-plugin step that runs the new org.apache.opennlp.website.Site driver. The driver fetches roster + activity, stages the JBake source tree with the team page populated, then bakes via Oven programmatically. Responses are TTL-cached on disk under target/contrib-cache/. - mvn compile -Pserve enables a live-dev mode: bake once, serve the output on http://localhost:\${jbake.port}/ (default 8080), watch src/main/jbake/ recursively and re-bake on change. Contributor data is fetched once and reused across re-bakes. Team page: - Active vs Emeritus is derived from a 2-year activity window, with optional manual overrides in src/main/resources/team-overrides.properties (per-apache-id .gh, .status, .chair flags). Multiple GH logins per person supported as semicolon-separated values. - Cards show initials on a deterministic color, the name, and role pills (C, C-P, Chair). PMC chair pill is OpenNLP brand orange. - A "Last updated: <UTC timestamp>" line plus a legend pill row sit at the top of the page (next to each other). - All sections are sorted deterministically by "lastname firstname". - Card grid styling lives in custom-style.css with a matching dark-mode override block in scheme-dark.css. --- .github/workflows/main.yml | 4 +- .gitignore | 1 + README.md | 31 +- pom.xml | 157 +++++++--- src/main/java/org/apache/opennlp/website/Site.java | 339 +++++++++++++++++++++ .../opennlp/website/contributors/Contributor.java | 85 ++++++ .../opennlp/website/contributors/Contributors.java | 176 +++++++++++ .../opennlp/website/contributors/Github.java | 157 ++++++++++ .../opennlp/website/contributors/HttpCache.java | 148 +++++++++ .../website/contributors/IdentityIndex.java | 175 +++++++++++ .../opennlp/website/contributors/Roster.java | 178 +++++++++++ .../apache/opennlp/website/contributors/Stats.java | 47 +++ src/main/jbake/assets/css/custom-style.css | 113 +++++++ src/main/jbake/assets/css/scheme-dark.css | 40 +++ src/main/jbake/content/team.ad | 68 ++--- src/main/resources/team-overrides.properties | 58 ++++ 16 files changed, 1689 insertions(+), 88 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c35345a5d..4540279e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,10 +29,10 @@ jobs: steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - name: Set up JDK 8 + - name: Set up JDK 21 uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3.14.1 with: - java-version: '8' + java-version: '21' distribution: 'temurin' cache: maven - name: maven-settings-xml-action diff --git a/.gitignore b/.gitignore index e729b3880..c56f57fd7 100755 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ target .project *.log *.iml +tmp \ No newline at end of file diff --git a/README.md b/README.md index c59411cc0..5f1bc14f8 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,41 @@ Welcome to OpenNLP Site Source Code [](https://github.com/apache/opennlp-site/actions) [](https://stackoverflow.com/questions/tagged/opennlp) +#### Requirements + +- Java 21 +- Maven 3.6.3+ + #### Build ```bash mvn clean install ``` -#### Test Site locally - starts a web server on Port 8080 +The output is rendered to `target/opennlp-site/`. Open `target/opennlp-site/index.html` in a browser to preview. + +#### Live dev mode + ```bash -mvn clean package jbake:inline -Djbake.port=8080 -Djbake.listenAddress=0.0.0.0 +mvn compile -Pserve # http://localhost:8080/ +mvn compile -Pserve -Djbake.port=9000 # custom port ``` + +Bakes the site once, then serves `target/opennlp-site/` over HTTP and watches `src/main/jbake/` recursively. Any change to a content file, template, asset or `jbake.properties` triggers a re-bake (debounced ~400 ms); reload the browser to see it. Press Ctrl-C to stop. + +The contributor fetch (Whimsy + GitHub) runs once at startup and is reused across re-bakes — no re-fetch and no new rate-limit cost while you iterate. Cached HTTP responses live under `target/contrib-cache/`. Restart `mvn compile -Pserve` to refresh the contributor data. + +#### Live contributor data + +The team page (`team.html`) is populated at build time by the `org.apache.opennlp.website.Site` driver, which fetches: + +- the OpenNLP committer/PMC roster from [Whimsy LDAP exports](https://whimsy.apache.org/public/), and +- live contributor + activity data from the GitHub REST API across `apache/opennlp`, `apache/opennlp-site`, `apache/opennlp-addons` and `apache/opennlp-sandbox`. + +It then partitions members into **Active Team** (any activity in the last 2 years), **Emeritus** (committer/PMC with no recent activity) and a **Wall of Fame** (everyone with a GitHub login). Identity merging (login + apache id + email + normalized name) and bot filtering match the logic of the `opennlp-stats` reference tool. + +HTTP responses are cached on disk under `target/contrib-cache/` with a 6-hour TTL, so iterative local builds are cheap. The build runs without a GitHub token; anonymous rate limits (60 req/h) may leave a few `/users/{login}` lookups unresolved on a cold cache, which can drop a committer whose GitHub login differs from their Apache id into Emeritus until the cache warms. Re-running the build inside the TTL fills it in. + #### Build Bot -Website is build via ASF BuildBot. You find it [here](https://ci.apache.org/). +Website is built via ASF BuildBot. You find it [here](https://ci.apache.org/). diff --git a/pom.xml b/pom.xml index e188021d0..88775482e 100644 --- a/pom.xml +++ b/pom.xml @@ -22,7 +22,6 @@ <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> <modelVersion>4.0.0</modelVersion> - <packaging>pom</packaging> <groupId>org.apache.opennlp</groupId> <artifactId>opennlp-site</artifactId> @@ -32,66 +31,104 @@ <properties> <!-- Build Properties --> <jbake-core.version>2.6.7</jbake-core.version> - <maven.compiler.source>1.8</maven.compiler.source> - <maven.compiler.target>1.8</maven.compiler.target> - <maven.version>3.3.9</maven.version> + <maven.compiler.release>21</maven.compiler.release> + <maven.version>3.6.3</maven.version> <asciidoctor.version>2.5.10</asciidoctor.version> <freemarker.version>2.3.32</freemarker.version> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> + <dependencies> + <!-- JBake itself; we drive it programmatically from Site.java --> + <dependency> + <groupId>org.jbake</groupId> + <artifactId>jbake-core</artifactId> + <version>${jbake-core.version}</version> + <exclusions> + <exclusion> + <groupId>org.slf4j</groupId> + <artifactId>jul-to-slf4j</artifactId> + </exclusion> + </exclusions> + </dependency> + <!-- Freemarker templates (.ftl) --> + <dependency> + <groupId>org.freemarker</groupId> + <artifactId>freemarker</artifactId> + <version>${freemarker.version}</version> + </dependency> + <!-- AsciiDoc rendering (.ad) --> + <dependency> + <groupId>org.asciidoctor</groupId> + <artifactId>asciidoctorj</artifactId> + <version>${asciidoctor.version}</version> + </dependency> + <!-- JBake 2.6 demoted these to runtime scope, but Site.java touches the API directly --> + <dependency> + <groupId>commons-configuration</groupId> + <artifactId>commons-configuration</artifactId> + <version>1.10</version> + </dependency> + <!-- Pinned to 3.0.37 because JBake 2.6.7 hard-codes admin/admin to open the embedded + "cache" database. OrientDB 3.2 disabled those default credentials and JBake's + ContentStore.startup fails with OSecurityAccessException, aborting the bake. --> + <dependency> + <groupId>com.orientechnologies</groupId> + <artifactId>orientdb-core</artifactId> + <version>3.0.37</version> + </dependency> + <!-- OrientDB 3.0.37 transitively pulls JNA 4.5.0, which ships only i386/x86_64 native + stubs. 5.13.0 adds arm64-darwin so the build runs on Apple Silicon. --> + <dependency> + <groupId>net.java.dev.jna</groupId> + <artifactId>jna</artifactId> + <version>5.13.0</version> + </dependency> + <dependency> + <groupId>net.java.dev.jna</groupId> + <artifactId>jna-platform</artifactId> + <version>5.13.0</version> + </dependency> + <!-- JSON parsing for the GitHub + Whimsy clients in contributors/ --> + <dependency> + <groupId>org.json</groupId> + <artifactId>json</artifactId> + <version>20240303</version> + </dependency> + </dependencies> + <build> <finalName>opennlp-site</finalName> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> - <version>3.10.1</version> + <version>3.13.0</version> <configuration> - <source>1.8</source> - <target>1.8</target> + <release>${maven.compiler.release}</release> </configuration> - </plugin> + </plugin> <plugin> - <groupId>org.jbake</groupId> - <artifactId>jbake-maven-plugin</artifactId> - <version>0.3.5</version> - - <!-- dependencies --> - <dependencies> - <!-- optional : a jbake version --> - <dependency> - <groupId>org.jbake</groupId> - <artifactId>jbake-core</artifactId> - <version>${jbake-core.version}</version> - </dependency> - <!-- for freemarker templates (.ftl) --> - <dependency> - <groupId>org.freemarker</groupId> - <artifactId>freemarker</artifactId> - <version>${freemarker.version}</version> - </dependency> - <!-- for ascii doc format (.ad) --> - <dependency> - <groupId>org.asciidoctor</groupId> - <artifactId>asciidoctorj</artifactId> - <version>${asciidoctor.version}</version> - </dependency> - <!-- Overriding orientdb, required to work on Apple Silicon (M1,..) --> - <dependency> - <groupId>com.orientechnologies</groupId> - <artifactId>orientdb-core</artifactId> - <version>3.1.16</version> - </dependency> - - </dependencies> - + <groupId>org.codehaus.mojo</groupId> + <artifactId>exec-maven-plugin</artifactId> + <version>3.1.1</version> <executions> <execution> - <id>default-generate</id> + <id>opennlp-site</id> <phase>compile</phase> <goals> - <goal>generate</goal> + <goal>java</goal> </goals> + <configuration> + <includeProjectDependencies>true</includeProjectDependencies> + <mainClass>org.apache.opennlp.website.Site</mainClass> + <arguments> + <argument>${project.basedir}/src/main/jbake</argument> + <argument>${project.build.directory}/${project.build.finalName}</argument> + <argument>${project.build.directory}/jbake-staged</argument> + <argument>${project.build.directory}/contrib-cache</argument> + </arguments> + </configuration> </execution> </executions> </plugin> @@ -632,4 +669,38 @@ </plugins> </build> + <profiles> + <!-- mvn compile -Pserve -> bake once, serve target/opennlp-site/ on http://localhost:${jbake.port}/, + and re-bake whenever something under src/main/jbake/ changes. --> + <profile> + <id>serve</id> + <properties> + <jbake.port>8080</jbake.port> + </properties> + <build> + <plugins> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>exec-maven-plugin</artifactId> + <executions> + <execution> + <id>opennlp-site</id> + <configuration> + <arguments> + <argument>${project.basedir}/src/main/jbake</argument> + <argument>${project.build.directory}/${project.build.finalName}</argument> + <argument>${project.build.directory}/jbake-staged</argument> + <argument>${project.build.directory}/contrib-cache</argument> + <argument>--serve</argument> + <argument>--port=${jbake.port}</argument> + </arguments> + </configuration> + </execution> + </executions> + </plugin> + </plugins> + </build> + </profile> + </profiles> + </project> diff --git a/src/main/java/org/apache/opennlp/website/Site.java b/src/main/java/org/apache/opennlp/website/Site.java new file mode 100644 index 000000000..a3be4f324 --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/Site.java @@ -0,0 +1,339 @@ +/* + * 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. + */ +package org.apache.opennlp.website; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.apache.opennlp.website.contributors.Contributor; +import org.apache.opennlp.website.contributors.Contributors; +import org.jbake.app.Oven; +import org.jbake.app.configuration.JBakeConfiguration; +import org.jbake.app.configuration.JBakeConfigurationFactory; + +import java.io.File; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** Build driver: fetches live contributor data, stages the JBake tree, then bakes. */ +public final class Site { + + private static final DateTimeFormatter STAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'", Locale.ROOT).withZone(ZoneOffset.UTC); + + public static void main(final String[] args) throws Exception { + if (args.length < 4) { + throw new IllegalArgumentException( + "usage: Site <source> <dest> <staged> <cache> [--serve] [--port=N]"); + } + final Path source = Path.of(args[0]); + final Path dest = Path.of(args[1]); + final Path staged = Path.of(args[2]); + final Path cacheDir = Path.of(args[3]); + + boolean serve = false; + int port = 8080; + for (int i = 4; i < args.length; i++) { + final String a = args[i]; + if (a.equals("--serve")) serve = true; + else if (a.startsWith("--port=")) port = Integer.parseInt(a.substring("--port=".length())); + } + + System.out.println("[site] fetching contributor data..."); + final Contributors.Sections sections = Contributors.load(cacheDir); + System.out.printf("[site] active=%d emeritus=%d wall-of-fame=%d%n", + sections.active().size(), sections.emeritus().size(), sections.wallOfFame().size()); + + bake(source, dest, staged, sections); + + if (serve) { + startServer(dest, port); + watchAndRebake(source, dest, staged, sections); + } + } + + private static void bake( + final Path source, final Path dest, final Path staged, final Contributors.Sections sections) + throws Exception { + System.out.println("[site] staging JBake source tree to " + staged); + rsync(source, staged); + + final Path teamFile = staged.resolve("content/team.ad"); + if (Files.exists(teamFile)) { + final String stamp = STAMP_FORMAT.format(Instant.now()); + final String original = Files.readString(teamFile, StandardCharsets.UTF_8); + final String body = original + .replace("<!-- ACTIVE -->", grid(sections.active())) + .replace("<!-- EMERITUS -->", grid(sections.emeritus())) + .replace("<!-- WALL_OF_FAME -->", grid(sections.wallOfFame())) + .replace("<!-- LAST_UPDATED -->", lastUpdatedBlock(stamp)); + Files.writeString(teamFile, body, StandardCharsets.UTF_8); + } else { + System.err.println("[site] WARN: " + teamFile + " not found; skipping contributor injection"); + } + + Files.createDirectories(dest); + System.out.println("[site] baking JBake site to " + dest); + final JBakeConfiguration config = new JBakeConfigurationFactory() + .createDefaultJbakeConfiguration(staged.toFile(), dest.toFile(), false); + new Oven(config).bake(); + System.out.println("[site] done."); + } + + /* ---------------- Live dev mode (--serve) ---------------- */ + + private static void startServer(final Path dest, final int port) throws Exception { + final HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + server.createContext("/", new StaticHandler(dest)); + server.setExecutor(null); + server.start(); + System.out.printf("[site] serving %s on http://localhost:%d/ (Ctrl-C to stop)%n", dest, port); + } + + private static void watchAndRebake( + final Path source, final Path dest, final Path staged, final Contributors.Sections sections) + throws Exception { + try (WatchService ws = source.getFileSystem().newWatchService()) { + registerRecursive(source, ws); + long lastBakeMs = 0; + while (true) { + final WatchKey key = ws.poll(500, TimeUnit.MILLISECONDS); + if (key == null) continue; + boolean relevant = false; + for (final WatchEvent<?> ev : key.pollEvents()) { + if (ev.kind() == StandardWatchEventKinds.OVERFLOW) continue; + relevant = true; + if (ev.kind() == StandardWatchEventKinds.ENTRY_CREATE) { + final Object ctx = ev.context(); + final Path watchedDir = (Path) key.watchable(); + final Path child = ctx instanceof Path p ? watchedDir.resolve(p) : null; + if (child != null && Files.isDirectory(child)) { + registerRecursive(child, ws); + } + } + } + key.reset(); + if (!relevant) continue; + final long now = System.currentTimeMillis(); + if (now - lastBakeMs < 400) continue; // debounce burst writes + lastBakeMs = now; + System.out.println("[site] change detected, re-baking..."); + try { + bake(source, dest, staged, sections); + } catch (final Exception e) { + System.err.println("[site] re-bake failed: " + e.getMessage()); + } + } + } + } + + private static void registerRecursive(final Path root, final WatchService ws) throws Exception { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) + throws java.io.IOException { + dir.register(ws, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE); + return FileVisitResult.CONTINUE; + } + }); + } + + private static final class StaticHandler implements HttpHandler { + private static final Map<String, String> MIME = Map.ofEntries( + Map.entry(".html", "text/html; charset=utf-8"), + Map.entry(".css", "text/css; charset=utf-8"), + Map.entry(".js", "application/javascript; charset=utf-8"), + Map.entry(".json", "application/json; charset=utf-8"), + Map.entry(".xml", "application/xml; charset=utf-8"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".png", "image/png"), + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".gif", "image/gif"), + Map.entry(".ico", "image/x-icon"), + Map.entry(".woff", "font/woff"), + Map.entry(".woff2", "font/woff2"), + Map.entry(".ttf", "font/ttf"), + Map.entry(".pdf", "application/pdf")); + + private final Path root; + + StaticHandler(final Path root) { + this.root = root; + } + + @Override + public void handle(final HttpExchange ex) throws java.io.IOException { + final String requested = ex.getRequestURI().getPath(); + final String path = requested.endsWith("/") ? requested + "index.html" : requested; + // strip leading "/" and resolve safely under root + final Path resolved = root.resolve(path.substring(1)).normalize(); + if (!resolved.startsWith(root) || !Files.exists(resolved) || Files.isDirectory(resolved)) { + final byte[] body = ("404 - " + path).getBytes(StandardCharsets.UTF_8); + ex.sendResponseHeaders(404, body.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(body); + } + return; + } + final String name = resolved.getFileName().toString(); + final int dot = name.lastIndexOf('.'); + final String ext = dot >= 0 ? name.substring(dot).toLowerCase(Locale.ROOT) : ""; + final String mime = MIME.getOrDefault(ext, "application/octet-stream"); + final byte[] body = Files.readAllBytes(resolved); + ex.getResponseHeaders().set("Content-Type", mime); + ex.getResponseHeaders().set("Cache-Control", "no-store"); + ex.sendResponseHeaders(200, body.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(body); + } + } + } + + private static String grid(final List<Contributor> people) { + final StringBuilder sb = new StringBuilder(); + sb.append("++++\n"); + sb.append("<div class=\"contributor-grid\">\n"); + if (people.isEmpty()) { + sb.append(" <p class=\"contributor-empty\">No contributors to show.</p>\n"); + } else { + final String[] palette = { + "#3a1c71", "#6a3093", "#1f4068", "#2c5364", "#283c86", "#0f2027", + "#5614b0", "#11324d", "#16222a", "#373b44", "#1d2671", "#3b1f5b"}; + for (final Contributor c : people) { + final String name = c.displayName(); + final String url = c.profileUrl(); + final String tag = openTag(url); + final String close = url == null ? "</span>" : "</a>"; + final String initials = initials(name); + final String color = palette[Math.floorMod(name.hashCode(), palette.length)]; + final String roleBadge = roleBadge(c); + sb.append(" ").append(tag) + .append("<span class=\"contributor-badge\" style=\"background:").append(color) + .append(";\" aria-hidden=\"true\">").append(escape(initials)).append("</span>") + .append("<span class=\"contributor-name\">").append(escape(name)).append("</span>") + .append(roleBadge) + .append(close).append("\n"); + } + } + sb.append("</div>\n"); + sb.append("++++\n"); + return sb.toString(); + } + + private static String lastUpdatedBlock(final String stamp) { + return "++++\n" + + "<div class=\"team-meta\">\n" + + " <p class=\"team-last-updated\">Last updated: <time datetime=\"" + + escape(Instant.now().toString()) + "\">" + + escape(stamp) + "</time></p>\n" + + " <p class=\"contributor-legend\">" + + "<span class=\"contributor-role\">C</span> indicates a committer, " + + "<span class=\"contributor-role\">C-P</span> a PMC member, and " + + "<span class=\"contributor-role contributor-role-chair\">Chair</span> the current PMC chair." + + "</p>\n" + + "</div>\n" + + "++++\n"; + } + + private static String openTag(final String url) { + if (url == null) return "<span class=\"contributor-card\">"; + return "<a class=\"contributor-card\" href=\"" + escape(url) + + "\" rel=\"noopener\" target=\"_blank\">"; + } + + private static String roleBadge(final Contributor c) { + final StringBuilder sb = new StringBuilder(); + final String flags = c.roleFlags(); + if (!flags.isEmpty()) { + sb.append("<span class=\"contributor-role\">").append(flags).append("</span>"); + } + if (c.isChair()) { + sb.append("<span class=\"contributor-role contributor-role-chair\">Chair</span>"); + } + return sb.toString(); + } + + private static String initials(final String name) { + final String[] parts = name.trim().split("\\s+"); + if (parts.length >= 2) { + return ("" + parts[0].charAt(0) + parts[parts.length - 1].charAt(0)) + .toUpperCase(Locale.ROOT); + } + if (name.length() >= 2) { + return name.substring(0, 2).toUpperCase(Locale.ROOT); + } + return name.toUpperCase(Locale.ROOT); + } + + private static String escape(final String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } + + private static void rsync(final Path from, final Path to) throws Exception { + if (Files.exists(to)) { + try (Stream<Path> walk = Files.walk(to)) { + walk.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + Files.createDirectories(to); + try (Stream<Path> walk = Files.walk(from)) { + walk.forEach(src -> { + try { + final Path rel = from.relativize(src); + final Path target = to.resolve(rel); + if (Files.isDirectory(src)) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + } + } + + private Site() {} +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/Contributor.java b/src/main/java/org/apache/opennlp/website/contributors/Contributor.java new file mode 100644 index 000000000..3bd1dcdc7 --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/Contributor.java @@ -0,0 +1,85 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import java.util.HashSet; +import java.util.Set; + +public final class Contributor { + private String name; + private String ghLogin; + private String apacheId; + private String avatarUrl; + private boolean pmc; + private boolean committer; + private boolean chair; + private Roster.ForcedStatus forcedStatus; + private final Stats stats = new Stats(); + private final Set<String> emails = new HashSet<>(); + private final Set<String> aliases = new HashSet<>(); + + public String name() { return name; } + public String ghLogin() { return ghLogin; } + public String apacheId() { return apacheId; } + public String avatarUrl() { return avatarUrl; } + public boolean isPmc() { return pmc; } + public boolean isCommitter() { return committer; } + public boolean isChair() { return chair; } + public Roster.ForcedStatus forcedStatus() { return forcedStatus; } + public Stats stats() { return stats; } + public Set<String> emails() { return emails; } + public Set<String> aliases() { return aliases; } + + public void setName(final String name) { this.name = name; } + public void setGhLogin(final String ghLogin) { this.ghLogin = ghLogin; } + public void setApacheId(final String apacheId) { this.apacheId = apacheId; } + public void setAvatarUrl(final String avatarUrl) { this.avatarUrl = avatarUrl; } + public void setPmc(final boolean pmc) { this.pmc = pmc; } + public void setCommitter(final boolean committer) { this.committer = committer; } + public void setChair(final boolean chair) { this.chair = chair; } + public void setForcedStatus(final Roster.ForcedStatus forcedStatus) { this.forcedStatus = forcedStatus; } + + public String displayName() { + if (name != null && !name.isBlank()) return name; + if (ghLogin != null && !ghLogin.isBlank()) return ghLogin; + if (apacheId != null && !apacheId.isBlank()) return apacheId; + return "(unknown)"; + } + + /** Sort key: "lastname firstname" — used for stable, deterministic ordering. */ + public String sortKey() { + final String n = displayName().trim(); + if (n.isEmpty()) return ""; + final int lastSpace = n.lastIndexOf(' '); + if (lastSpace < 0) return n.toLowerCase(java.util.Locale.ROOT); + final String last = n.substring(lastSpace + 1); + final String first = n.substring(0, lastSpace); + return (last + " " + first).toLowerCase(java.util.Locale.ROOT); + } + + public String profileUrl() { + return ghLogin != null && !ghLogin.isBlank() + ? "https://github.com/" + ghLogin + : null; + } + + public String roleFlags() { + if (pmc) return "C-P"; + if (committer) return "C"; + return ""; + } +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/Contributors.java b/src/main/java/org/apache/opennlp/website/contributors/Contributors.java new file mode 100644 index 000000000..64c57083c --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/Contributors.java @@ -0,0 +1,176 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class Contributors { + + public static final int ACTIVE_WINDOW_YEARS = 2; + + public record Sections(List<Contributor> active, List<Contributor> emeritus, List<Contributor> wallOfFame) {} + + public static Sections load(final Path cacheDir) throws Exception { + final String token = System.getenv("GITHUB_TOKEN"); + final HttpCache cache = new HttpCache(cacheDir, Duration.ofHours(6), token); + final Instant cutoff = LocalDate.now(ZoneOffset.UTC) + .minusYears(ACTIVE_WINDOW_YEARS) + .atStartOfDay() + .toInstant(ZoneOffset.UTC); + + final IdentityIndex idx = new IdentityIndex(); + + // 1) Seed from the ASF roster so PMC + committers appear even if inactive on GitHub. + final List<Roster.Member> roster = Roster.fetch(cache); + for (final Roster.Member m : roster) { + final Contributor c = idx.findOrCreate(m.primaryGhLogin(), m.apacheId, null, m.name); + if (c == null) continue; + if (m.pmc) c.setPmc(true); + if (m.committer) c.setCommitter(true); + if (m.chair) c.setChair(true); + if (m.forcedStatus != null) c.setForcedStatus(m.forcedStatus); + // Members with multiple GH accounts: link the secondary logins as aliases + // so future events on those logins merge into this record. + for (int i = 1; i < m.ghLogins.size(); i++) { + idx.linkLoginAlias(c, m.ghLogins.get(i)); + } + } + + // 2) Pull live data per repo, but defer the merge until after we resolve display + // names: Whimsy rarely carries githubUsername for ASF committers, so we have to + // bridge login <-> roster-name via /users/{login}.name. + final Github gh = new Github(cache); + final Set<String> logins = new LinkedHashSet<>(); + record Touch(String login, Instant ts) {} + record AvatarCommits(String login, String avatar, int commits) {} + final List<AvatarCommits> contribRows = new ArrayList<>(); + final List<Touch> touches = new ArrayList<>(); + + for (final String repo : Github.REPOS) { + ingest(() -> gh.contributors(repo), row -> { + logins.add(row.login()); + contribRows.add(new AvatarCommits(row.login(), row.avatarUrl(), row.commits())); + }); + ingest(() -> gh.prsOpenedSince(repo, cutoff), e -> { + logins.add(e.login()); + touches.add(new Touch(e.login(), e.timestamp())); + }); + ingest(() -> gh.issueCommentsSince(repo, cutoff), e -> { + logins.add(e.login()); + touches.add(new Touch(e.login(), e.timestamp())); + }); + ingest(() -> gh.reviewCommentsSince(repo, cutoff), e -> { + logins.add(e.login()); + touches.add(new Touch(e.login(), e.timestamp())); + }); + ingest(() -> gh.commitsSince(repo, cutoff), e -> { + logins.add(e.login()); + touches.add(new Touch(e.login(), e.timestamp())); + }); + } + + // 3) Resolve display name per login (cached), then merge into the index by login + name. + final Map<String, String> loginToName = new HashMap<>(); + for (final String login : logins) { + final String name = gh.userName(login); + if (name != null) loginToName.put(login, name); + } + + // Pass apacheId=login only when the index already has a roster entry for that id, + // so the GH event merges into that committer record (very common for ASF + // committers whose GH login matches their apache_id — rzo1, mawiesne, joern, ...). + for (final AvatarCommits row : contribRows) { + final String apId = idx.hasApacheId(row.login()) ? row.login() : null; + final Contributor c = idx.findOrCreate(row.login(), apId, null, loginToName.get(row.login())); + if (c == null) continue; + if (c.avatarUrl() == null) c.setAvatarUrl(row.avatar()); + for (int i = 0; i < row.commits(); i++) c.stats().touch(null); + } + for (final Touch t : touches) { + final String apId = idx.hasApacheId(t.login()) ? t.login() : null; + final Contributor c = idx.findOrCreate(t.login(), apId, null, loginToName.get(t.login())); + if (c == null) continue; + c.stats().touch(t.ts()); + } + + final List<Contributor> all = idx.all(); + + final List<Contributor> active = new ArrayList<>(); + final List<Contributor> emeritus = new ArrayList<>(); + for (final Contributor c : all) { + if (!c.isCommitter() && !c.isPmc()) continue; + final Roster.ForcedStatus forced = c.forcedStatus(); + if (forced == Roster.ForcedStatus.ACTIVE) { + active.add(c); + continue; + } + if (forced == Roster.ForcedStatus.EMERITUS) { + emeritus.add(c); + continue; + } + final Instant last = c.stats().lastActivity(); + if (last != null && !last.isBefore(cutoff)) { + active.add(c); + } else { + emeritus.add(c); + } + } + // Deterministic order across all sections: "Lastname, Firstname". + final Comparator<Contributor> byLastName = Comparator.comparing(Contributor::sortKey); + active.sort(byLastName); + emeritus.sort(byLastName); + + final List<Contributor> wallOfFame = new ArrayList<>(); + for (final Contributor c : all) { + // Committers + PMC members are already shown in Active or Emeritus; skip them here. + if (c.isCommitter() || c.isPmc()) continue; + if (c.ghLogin() == null && c.apacheId() == null) continue; + wallOfFame.add(c); + } + wallOfFame.sort(byLastName); + + return new Sections(active, emeritus, wallOfFame); + } + + @FunctionalInterface + private interface Fetcher<T> { List<T> get() throws Exception; } + + @FunctionalInterface + private interface RowSink<T> { void accept(T row); } + + private static <T> void ingest(final Fetcher<T> fetcher, final RowSink<T> sink) { + try { + for (final T row : fetcher.get()) sink.accept(row); + } catch (final Exception e) { + System.err.println("[contributors] fetch failed: " + e.getMessage() + + " — continuing with partial data"); + } + } + + private Contributors() {} +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/Github.java b/src/main/java/org/apache/opennlp/website/contributors/Github.java new file mode 100644 index 000000000..82f4a87c6 --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/Github.java @@ -0,0 +1,157 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public final class Github { + + public static final List<String> REPOS = List.of( + "apache/opennlp", + "apache/opennlp-site", + "apache/opennlp-addons", + "apache/opennlp-sandbox"); + + private final HttpCache cache; + + public Github(final HttpCache cache) { + this.cache = cache; + } + + public record ContributorRow(String login, String avatarUrl, int commits) {} + + public record EventRow(String login, Instant timestamp) {} + + public List<ContributorRow> contributors(final String repoFull) throws Exception { + final List<ContributorRow> out = new ArrayList<>(); + paginate("https://api.github.com/repos/" + repoFull + "/contributors?per_page=100", + arr -> { + for (int i = 0; i < arr.length(); i++) { + final JSONObject c = arr.getJSONObject(i); + final String login = c.optString("login", null); + final String avatar = c.optString("avatar_url", null); + final int commits = c.optInt("contributions", 0); + if (login == null || login.isBlank()) continue; + if (IdentityIndex.isBot(login)) continue; + out.add(new ContributorRow(login, avatar, commits)); + } + }); + return out; + } + + /** PR-opened events since cutoff (issues + PRs combined endpoint, filtered to PRs). */ + public List<EventRow> prsOpenedSince(final String repoFull, final Instant since) throws Exception { + final List<EventRow> out = new ArrayList<>(); + final String url = "https://api.github.com/repos/" + repoFull + + "/issues?state=all&per_page=100&since=" + since.toString(); + paginate(url, arr -> { + for (int i = 0; i < arr.length(); i++) { + final JSONObject item = arr.getJSONObject(i); + if (!item.has("pull_request")) continue; + final JSONObject user = item.optJSONObject("user"); + final String login = user != null ? user.optString("login", null) : null; + final Instant ts = parseTs(item.optString("created_at", null)); + if (login == null || ts == null) continue; + out.add(new EventRow(login, ts)); + } + }); + return out; + } + + public List<EventRow> issueCommentsSince(final String repoFull, final Instant since) throws Exception { + return commentsSince(repoFull, "/issues/comments", since); + } + + public List<EventRow> reviewCommentsSince(final String repoFull, final Instant since) throws Exception { + return commentsSince(repoFull, "/pulls/comments", since); + } + + private List<EventRow> commentsSince(final String repoFull, final String path, final Instant since) + throws Exception { + final List<EventRow> out = new ArrayList<>(); + final String url = "https://api.github.com/repos/" + repoFull + + path + "?per_page=100&since=" + since.toString(); + paginate(url, arr -> { + for (int i = 0; i < arr.length(); i++) { + final JSONObject item = arr.getJSONObject(i); + final JSONObject user = item.optJSONObject("user"); + final String login = user != null ? user.optString("login", null) : null; + final Instant ts = parseTs(item.optString("created_at", null)); + if (login == null || ts == null) continue; + out.add(new EventRow(login, ts)); + } + }); + return out; + } + + /** Returns the display name (`name` field) from /users/{login}, or null on 404. */ + public String userName(final String login) { + if (login == null || login.isBlank() || IdentityIndex.isBot(login)) return null; + try { + final String body = cache.fetch("https://api.github.com/users/" + login); + final JSONObject obj = new JSONObject(body); + final String name = obj.optString("name", null); + return (name == null || name.isBlank()) ? null : name; + } catch (final Exception e) { + return null; + } + } + + public List<EventRow> commitsSince(final String repoFull, final Instant since) throws Exception { + final List<EventRow> out = new ArrayList<>(); + final String url = "https://api.github.com/repos/" + repoFull + + "/commits?per_page=100&since=" + since.toString(); + paginate(url, arr -> { + for (int i = 0; i < arr.length(); i++) { + final JSONObject item = arr.getJSONObject(i); + final JSONObject author = item.optJSONObject("author"); + final String login = author != null ? author.optString("login", null) : null; + final JSONObject commit = item.optJSONObject("commit"); + final JSONObject commitAuthor = commit != null ? commit.optJSONObject("author") : null; + final Instant ts = commitAuthor != null ? parseTs(commitAuthor.optString("date", null)) : null; + if (login == null || ts == null) continue; + out.add(new EventRow(login, ts)); + } + }); + return out; + } + + private void paginate(final String startUrl, final Consumer<JSONArray> consumer) throws Exception { + String url = startUrl; + while (url != null) { + final HttpCache.Page page = cache.fetchPage(url); + final JSONArray arr = new JSONArray(page.body()); + consumer.accept(arr); + url = page.nextUrl(); + if (arr.isEmpty()) break; + } + } + + private static Instant parseTs(final String s) { + try { + return s == null ? null : Instant.parse(s); + } catch (final Exception e) { + return null; + } + } +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/HttpCache.java b/src/main/java/org/apache/opennlp/website/contributors/HttpCache.java new file mode 100644 index 000000000..11d926cc8 --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/HttpCache.java @@ -0,0 +1,148 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** TTL-keyed disk cache for HTTP GETs; falls back to stale data on transient errors. */ +public final class HttpCache { + + private final Path dir; + private final HttpClient client; + private final Duration ttl; + private final String token; + + public HttpCache(final Path dir, final Duration ttl, final String token) throws IOException { + this.dir = dir; + this.ttl = ttl; + this.token = token; + Files.createDirectories(dir); + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + public String fetch(final String url) throws IOException, InterruptedException { + final Path file = dir.resolve(hash(url) + ".json"); + if (Files.exists(file)) { + final long ageMs = System.currentTimeMillis() - Files.getLastModifiedTime(file).toMillis(); + if (ageMs < ttl.toMillis()) { + return Files.readString(file, StandardCharsets.UTF_8); + } + } + final HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url)) + .header("Accept", "application/vnd.github+json, application/json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "opennlp-site-build") + .timeout(Duration.ofSeconds(45)); + if (token != null && !token.isBlank() && url.startsWith("https://api.github.com/")) { + req.header("Authorization", "Bearer " + token); + } + final HttpResponse<String> resp = client.send(req.build(), HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() / 100 != 2) { + // Fall back to stale cache rather than fail the build + if (Files.exists(file)) { + System.err.println("[http-cache] " + url + " -> " + resp.statusCode() + + ", serving stale cache"); + return Files.readString(file, StandardCharsets.UTF_8); + } + throw new IOException("GET " + url + " -> " + resp.statusCode() + ": " + truncate(resp.body())); + } + Files.writeString(file, resp.body(), StandardCharsets.UTF_8); + return resp.body(); + } + + /** Returns the response body and the parsed Link-header `next` URL (if any). */ + public Page fetchPage(final String url) throws IOException, InterruptedException { + final Path file = dir.resolve(hash(url) + ".json"); + final Path linkFile = dir.resolve(hash(url) + ".link"); + if (Files.exists(file)) { + final long ageMs = System.currentTimeMillis() - Files.getLastModifiedTime(file).toMillis(); + if (ageMs < ttl.toMillis()) { + final String body = Files.readString(file, StandardCharsets.UTF_8); + final String next = Files.exists(linkFile) ? Files.readString(linkFile).trim() : ""; + return new Page(body, next.isEmpty() ? null : next); + } + } + final HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "opennlp-site-build") + .timeout(Duration.ofSeconds(45)); + if (token != null && !token.isBlank() && url.startsWith("https://api.github.com/")) { + req.header("Authorization", "Bearer " + token); + } + final HttpResponse<String> resp = client.send(req.build(), HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() / 100 != 2) { + if (Files.exists(file)) { + System.err.println("[http-cache] " + url + " -> " + resp.statusCode() + + ", serving stale cache"); + final String body = Files.readString(file, StandardCharsets.UTF_8); + final String next = Files.exists(linkFile) ? Files.readString(linkFile).trim() : ""; + return new Page(body, next.isEmpty() ? null : next); + } + throw new IOException("GET " + url + " -> " + resp.statusCode() + ": " + truncate(resp.body())); + } + Files.writeString(file, resp.body(), StandardCharsets.UTF_8); + final Optional<String> link = resp.headers().firstValue("link"); + final String next = link.map(HttpCache::parseNextLink).orElse(null); + Files.writeString(linkFile, next == null ? "" : next, StandardCharsets.UTF_8); + return new Page(resp.body(), next); + } + + private static String parseNextLink(final String header) { + for (final String part : header.split(",")) { + final List<String> bits = List.of(part.split(";")); + if (bits.size() < 2) continue; + final String rel = bits.get(1).trim(); + if (rel.equals("rel=\"next\"")) { + final String url = bits.get(0).trim(); + if (url.startsWith("<") && url.endsWith(">")) return url.substring(1, url.length() - 1); + } + } + return null; + } + + private static String hash(final String s) { + try { + final MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(s.getBytes(StandardCharsets.UTF_8))).substring(0, 24); + } catch (final Exception e) { + throw new IllegalStateException(e); + } + } + + private static String truncate(final String body) { + if (body == null) return ""; + return body.length() > 240 ? body.substring(0, 240) + "..." : body; + } + + public record Page(String body, String nextUrl) {} +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/IdentityIndex.java b/src/main/java/org/apache/opennlp/website/contributors/IdentityIndex.java new file mode 100644 index 000000000..5ef581e63 --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/IdentityIndex.java @@ -0,0 +1,175 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Merges identities across multiple sources (ASF roster, GitHub commits, GitHub events). + * Ported from opennlp-stats/aggregate.py: walks login -> apache-id -> email -> normalized-name + * keys to find an existing record, otherwise creates one. + */ +public final class IdentityIndex { + + private static final Set<String> BOT_LOGINS = Set.of( + "dependabot[bot]", "github-actions[bot]", "actions-user", "buildbot", + "renovate[bot]", "codecov-commenter", "copilot", "copilot-swe-agent[bot]"); + + private final List<Contributor> records = new ArrayList<>(); + private final Map<String, Contributor> byLogin = new HashMap<>(); + private final Map<String, Contributor> byApacheId = new HashMap<>(); + private final Map<String, Contributor> byEmail = new HashMap<>(); + private final Map<String, Contributor> byName = new HashMap<>(); + + /** Returns true if some seeded record exists under this apache id (read-only). */ + public boolean hasApacheId(final String apacheId) { + return apacheId != null && byApacheId.containsKey(apacheId.toLowerCase(Locale.ROOT)); + } + + /** + * Registers an additional GitHub login as an alias of an existing contributor. + * Used for people with multiple GH accounts: future events keyed by the alias + * resolve to the same record without overwriting the primary login. + */ + public void linkLoginAlias(final Contributor c, final String login) { + if (c == null || login == null) return; + final String trimmed = login.trim(); + if (trimmed.isEmpty() || isBot(trimmed)) return; + byLogin.put(trimmed.toLowerCase(Locale.ROOT), c); + } + + public List<Contributor> all() { + // de-dupe by reference, preserving first-seen order + final Set<Contributor> seen = new LinkedHashSet<>(records); + return new ArrayList<>(seen); + } + + /** Looks up an existing record by any matching key, otherwise creates one. Merges as needed. */ + public Contributor findOrCreate( + final String loginIn, final String apacheId, final String email, final String name) { + final String sanitized = sanitizeLogin(loginIn); + if (sanitized != null && isBot(sanitized)) { + return null; + } + // Recover login from <login>@users.noreply.github.com + final String login = sanitized != null ? sanitized + : (email != null ? loginFromNoreply(email) : null); + + final List<Contributor> candidates = new ArrayList<>(); + if (login != null) addIfPresent(candidates, byLogin.get(login.toLowerCase(Locale.ROOT))); + if (apacheId != null) addIfPresent(candidates, byApacheId.get(apacheId.toLowerCase(Locale.ROOT))); + if (email != null) addIfPresent(candidates, byEmail.get(email.toLowerCase(Locale.ROOT))); + if (name != null) addIfPresent(candidates, byName.get(normalizeName(name))); + + final Contributor c; + if (candidates.isEmpty()) { + c = new Contributor(); + records.add(c); + } else { + c = candidates.get(0); + for (int i = 1; i < candidates.size(); i++) { + merge(c, candidates.get(i)); + } + } + if (login != null && c.ghLogin() == null) c.setGhLogin(login); + if (apacheId != null && c.apacheId() == null) c.setApacheId(apacheId); + if (name != null && (c.name() == null || c.name().isBlank())) c.setName(name); + if (email != null) c.emails().add(email.toLowerCase(Locale.ROOT)); + if (name != null) c.aliases().add(name); + link(c); + return c; + } + + private void merge(final Contributor into, final Contributor other) { + if (into == other) return; + if (into.ghLogin() == null) into.setGhLogin(other.ghLogin()); + if (into.apacheId() == null) into.setApacheId(other.apacheId()); + if (into.name() == null || into.name().isBlank()) into.setName(other.name()); + if (into.avatarUrl() == null) into.setAvatarUrl(other.avatarUrl()); + if (other.isPmc()) into.setPmc(true); + if (other.isCommitter()) into.setCommitter(true); + if (other.isChair()) into.setChair(true); + if (into.forcedStatus() == null) into.setForcedStatus(other.forcedStatus()); + into.stats().add(other.stats()); + into.emails().addAll(other.emails()); + into.aliases().addAll(other.aliases()); + + records.remove(other); + for (final Map<String, Contributor> table : + Arrays.<Map<String, Contributor>>asList(byLogin, byApacheId, byEmail, byName)) { + for (final Map.Entry<String, Contributor> e : new HashMap<>(table).entrySet()) { + if (e.getValue() == other) table.put(e.getKey(), into); + } + } + } + + private void link(final Contributor c) { + if (c.ghLogin() != null) byLogin.put(c.ghLogin().toLowerCase(Locale.ROOT), c); + if (c.apacheId() != null) byApacheId.put(c.apacheId().toLowerCase(Locale.ROOT), c); + if (c.name() != null) { + final String n = normalizeName(c.name()); + if (!n.isBlank()) byName.put(n, c); + } + for (final String alias : c.aliases()) { + final String n = normalizeName(alias); + if (!n.isBlank()) byName.putIfAbsent(n, c); + } + for (final String email : c.emails()) { + byEmail.put(email, c); + } + } + + private static void addIfPresent(final List<Contributor> list, final Contributor c) { + if (c != null && !list.contains(c)) list.add(c); + } + + private static String sanitizeLogin(final String login) { + if (login == null) return null; + final String trimmed = login.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + public static boolean isBot(final String login) { + if (login == null) return false; + final String lower = login.toLowerCase(Locale.ROOT); + return lower.endsWith("[bot]") || BOT_LOGINS.contains(lower); + } + + private static String loginFromNoreply(final String email) { + if (email == null) return null; + final String lower = email.toLowerCase(Locale.ROOT); + if (!lower.endsWith("@users.noreply.github.com")) return null; + final String local = lower.substring(0, lower.indexOf('@')); + final int plus = local.indexOf('+'); + return plus >= 0 ? local.substring(plus + 1) : local; + } + + private static String normalizeName(final String name) { + if (name == null) return ""; + final String stripped = Normalizer.normalize(name, Normalizer.Form.NFD) + .replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + return stripped.toLowerCase(Locale.ROOT).trim().replaceAll("\\s+", " "); + } +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/Roster.java b/src/main/java/org/apache/opennlp/website/contributors/Roster.java new file mode 100644 index 000000000..ee5c8ebb7 --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/Roster.java @@ -0,0 +1,178 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; + +/** + * Pulls the OpenNLP PMC + committer roster from Whimsy LDAP exports. + * `public_ldap_projects.json` lists `members` (committers) and `owners` (PMC) per project; + * `public_ldap_people.json` maps each apache id to a real name and (sometimes) a github login. + */ +public final class Roster { + + private static final String PROJECTS_URL = "https://whimsy.apache.org/public/public_ldap_projects.json"; + private static final String PEOPLE_URL = "https://whimsy.apache.org/public/public_ldap_people.json"; + private static final String PROJECT_KEY = "opennlp"; + + /** `null` when the bucket should be derived from live activity. */ + public enum ForcedStatus { ACTIVE, EMERITUS } + + public static final class Member { + public final String name; + public final String apacheId; + /** Ordered list of GitHub logins; index 0 is the primary used for display. Empty if unknown. */ + public final List<String> ghLogins; + public final boolean pmc; + public final boolean committer; + public final boolean chair; + public final ForcedStatus forcedStatus; + + Member(final String name, final String apacheId, final List<String> ghLogins, + final boolean pmc, final boolean committer, final boolean chair, + final ForcedStatus forcedStatus) { + this.name = name; + this.apacheId = apacheId; + this.ghLogins = ghLogins; + this.pmc = pmc; + this.committer = committer; + this.chair = chair; + this.forcedStatus = forcedStatus; + } + + public String primaryGhLogin() { + return ghLogins.isEmpty() ? null : ghLogins.get(0); + } + } + + public static List<Member> fetch(final HttpCache cache) { + try { + final JSONObject projects = new JSONObject(cache.fetch(PROJECTS_URL)); + final JSONObject project = projects.getJSONObject("projects").optJSONObject(PROJECT_KEY); + if (project == null) { + throw new IllegalStateException("project '" + PROJECT_KEY + "' not in " + PROJECTS_URL); + } + final Set<String> committerIds = jsonArrayToSet(project, "members"); + final Set<String> pmcIds = jsonArrayToSet(project, "owners"); + final Set<String> all = new TreeSet<>(); + all.addAll(committerIds); + all.addAll(pmcIds); + + final JSONObject people = new JSONObject(cache.fetch(PEOPLE_URL)).optJSONObject("people"); + final Properties overrides = loadOverrides(); + + final List<Member> members = new ArrayList<>(); + for (final String id : all) { + final JSONObject info = people != null ? people.optJSONObject(id) : null; + final String name = info != null ? info.optString("name", id) : id; + // Override > Whimsy githubUsername > github.com link in Whimsy urls. + final List<String> ghLogins; + final String overrideGh = overrides.getProperty(id + ".gh"); + if (overrideGh != null && !overrideGh.isBlank()) { + ghLogins = parseLoginList(overrideGh); + } else { + final String declaredGh = info != null ? info.optString("githubUsername", null) : null; + final String single = (declaredGh != null && !declaredGh.isBlank()) + ? declaredGh : githubLoginFromUrls(info); + ghLogins = single == null ? List.of() : List.of(single); + } + final ForcedStatus forced = parseStatus(overrides.getProperty(id + ".status")); + final boolean chair = Boolean.parseBoolean( + overrides.getProperty(id + ".chair", "false").trim()); + members.add(new Member(name, id, ghLogins, pmcIds.contains(id), true, chair, forced)); + } + return members; + } catch (final Exception e) { + System.err.println("[roster] failed to fetch Whimsy roster: " + e.getMessage() + + " — Active/Emeritus sections will be empty"); + return List.of(); + } + } + + private static List<String> parseLoginList(final String value) { + final List<String> out = new ArrayList<>(); + for (final String raw : value.split(";")) { + final String trimmed = raw.trim(); + if (!trimmed.isEmpty()) out.add(trimmed); + } + return out; + } + + private static ForcedStatus parseStatus(final String value) { + if (value == null) return null; + final String lower = value.trim().toLowerCase(java.util.Locale.ROOT); + if (lower.isEmpty()) return null; + if (lower.equals("active")) return ForcedStatus.ACTIVE; + if (lower.equals("emeritus")) return ForcedStatus.EMERITUS; + System.err.println("[roster] team-overrides: unknown status '" + value + + "' (expected active|emeritus); ignoring"); + return null; + } + + /** Loads the apache-id -> override map shipped on the classpath. */ + private static Properties loadOverrides() { + final Properties props = new Properties(); + try (InputStream in = Roster.class.getResourceAsStream("/team-overrides.properties")) { + if (in != null) props.load(in); + } catch (final Exception e) { + System.err.println("[roster] failed to load team-overrides.properties: " + e.getMessage()); + } + return props; + } + + /** Some Whimsy people entries list a GitHub profile URL but no `githubUsername`. */ + private static String githubLoginFromUrls(final JSONObject info) { + if (info == null || !info.has("urls")) return null; + final JSONArray urls = info.optJSONArray("urls"); + if (urls == null) return null; + for (int i = 0; i < urls.length(); i++) { + final String url = urls.optString(i, ""); + final String host = "https://github.com/"; + final int idx = url.indexOf(host); + if (idx < 0) continue; + final String tail = url.substring(idx + host.length()); + // strip trailing path/query/fragment + int cut = tail.length(); + for (int c = 0; c < tail.length(); c++) { + final char ch = tail.charAt(c); + if (ch == '/' || ch == '?' || ch == '#') { cut = c; break; } + } + final String login = tail.substring(0, cut); + if (!login.isBlank()) return login; + } + return null; + } + + private static Set<String> jsonArrayToSet(final JSONObject obj, final String key) { + final Set<String> out = new HashSet<>(); + if (!obj.has(key) || obj.isNull(key)) return out; + final JSONArray arr = obj.getJSONArray(key); + for (int i = 0; i < arr.length(); i++) out.add(arr.getString(i)); + return out; + } + + private Roster() {} +} diff --git a/src/main/java/org/apache/opennlp/website/contributors/Stats.java b/src/main/java/org/apache/opennlp/website/contributors/Stats.java new file mode 100644 index 000000000..1e47f00ab --- /dev/null +++ b/src/main/java/org/apache/opennlp/website/contributors/Stats.java @@ -0,0 +1,47 @@ +/* + * 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. + */ +package org.apache.opennlp.website.contributors; + +import java.time.Instant; + +public final class Stats { + private int contributions; + private Instant lastActivity; + + public void touch(final Instant ts) { + contributions++; + if (ts != null && (lastActivity == null || ts.isAfter(lastActivity))) { + lastActivity = ts; + } + } + + public void add(final Stats other) { + contributions += other.contributions; + if (other.lastActivity != null + && (lastActivity == null || other.lastActivity.isAfter(lastActivity))) { + lastActivity = other.lastActivity; + } + } + + public int contributions() { + return contributions; + } + + public Instant lastActivity() { + return lastActivity; + } +} diff --git a/src/main/jbake/assets/css/custom-style.css b/src/main/jbake/assets/css/custom-style.css index 02e010475..80f9b6629 100644 --- a/src/main/jbake/assets/css/custom-style.css +++ b/src/main/jbake/assets/css/custom-style.css @@ -148,4 +148,117 @@ div.qlist.qanda > ol > li { #forkongithub { display: none; } +} + +/* Contributor card grid (Active / Emeritus / Wall of Fame on team.html) +-------------------------------------------------- */ +.contributor-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + list-style: none; + padding: 0; + margin: 0 0 1.5em 0; +} + +.contributor-card { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid #d0d0d0; + border-radius: 6px; + background: #fff; + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease; +} + +.contributor-card:hover, +.contributor-card:focus { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border-color: #5614b0; + text-decoration: none; + transform: translateY(-1px); +} + +.contributor-badge { + flex: 0 0 auto; + width: 40px; + height: 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 0.95em; + letter-spacing: 0.5px; +} + +.contributor-name { + flex: 1 1 auto; + color: #333; + font-size: 1em; + font-weight: 500; + word-break: break-word; +} + +.contributor-role { + flex: 0 0 auto; + font-size: 0.75em; + font-weight: 600; + color: #5614b0; + border: 1px solid #5614b0; + border-radius: 3px; + padding: 1px 5px; + letter-spacing: 0.5px; +} + +.contributor-role-chair { + color: #fff; + background: #f59523; + border-color: #f59523; + letter-spacing: 0.3px; +} + +.contributor-empty { + grid-column: 1 / -1; + color: #777; + font-style: italic; + margin: 0; +} + +.contributor-legend { + color: #555; + font-size: 0.85em; + margin: 0; + display: inline-flex; + flex-wrap: wrap; + gap: 0.4em; + align-items: center; +} + +.contributor-legend .contributor-role { + display: inline-flex; + align-items: center; +} + +.team-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em 1.5em; + margin: 0 0 1.5em 0; +} + +.team-last-updated { + color: #777; + font-size: 0.85em; + margin: 0; +} + +.team-last-updated time { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #555; } \ No newline at end of file diff --git a/src/main/jbake/assets/css/scheme-dark.css b/src/main/jbake/assets/css/scheme-dark.css index 6782680db..4cccdfe94 100644 --- a/src/main/jbake/assets/css/scheme-dark.css +++ b/src/main/jbake/assets/css/scheme-dark.css @@ -95,4 +95,44 @@ color: #f59523; background-color: #444; } + + /* Contributor cards (team page) */ + .contributor-card { + background: #2c2c2c; + border-color: #3a3a3a; + color: #eee; + } + .contributor-card:hover, + .contributor-card:focus { + border-color: #A09fff; + background: #333; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); + } + .contributor-name { + color: #eee; + } + .contributor-role { + color: #c8c4ff; + border-color: #A09fff; + } + .contributor-role-chair { + color: #1a1a1a; + background: #f59523; + border-color: #f59523; + } + .contributor-badge { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); + } + .contributor-empty { + color: #aaa; + } + .team-last-updated { + color: #aaa; + } + .team-last-updated time { + color: #ccc; + } + .contributor-legend { + color: #bbb; + } } \ No newline at end of file diff --git a/src/main/jbake/content/team.ad b/src/main/jbake/content/team.ad index e70d03cab..932a8f66f 100644 --- a/src/main/jbake/content/team.ad +++ b/src/main/jbake/content/team.ad @@ -14,52 +14,40 @@ "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. + under the License. //// -= Apache OpenNLP project team += Apache OpenNLP Project Team :jbake-type: page -:jbake-tags: maven +:jbake-tags: team :jbake-status: published :idprefix: -The OpenNLP team currently consists of: +<!-- LAST_UPDATED --> -* Jörn Kottmann (joern) (C-P) -* Grant Ingersoll (gsingers) (C-P) -* Isabel Drost (isabel) (P) -* James Kosin (jkosin) (C-P) -* Jason Baldridge (jbaldrid) (C-P) -* Thomas Morton (tsmorton) (C-P) -* William Silva (colen) (C-P) -* Rodrigo Agerri (ragerri) (C-P) -* Aliaksandr Autayeu (autayeu) ( C ) -* Boris Galitsky (bgalitsky) ( C ) -* Mark Giaconia ( C ) -* Tommaso Teofili (tommaso) (C-P) -* Vinh Khuc (vkhuc) ( C ) -* Anthony Beylerian (beylerian) ( C ) -* Mondher Bouazizi (mondher) ( C ) -* Chris Mattmann (mattmann) ( C ) -* Anastasija Mensikova (anastasijam) ( C ) -* Suneel Marthi (smarthi) (C-P) -* Daniel Russ (druss) (C-P) -* Peter Thygesen (thygesen) ( C-P ) -* Koji Sekiguchi (koji) ( C-P ) -* Bruno P. Kinoshita (kinow) (C-P) -* Jeff Zemerick (jzemerick) **Chair** ( C-P ) -* Richard Zowalla (rzo1) ( C-P ) -* Martin Wiesner (mawiesne) ( C-P ) -* Atita Arora (atarora) ( C-P ) -* Nishant Shrivastava (shrivnis) ( C ) +== Active Team -These people contributed to OpenNLP: +The following people are currently active on Apache OpenNLP. -* Sean Adams -* Thilo Goetz (twgoetz) -* Gann Bierner -* Eric Friedman -* Joao Cavalcanti -* Hyosup Shim +<!-- ACTIVE --> -*C* indicates a committer and *P* a PMC member. - +== Emeritus + +Open source contribution is voluntary, and priorities, jobs and life circumstances change. +The people below have served Apache OpenNLP in the past as committers or PMC members and +are not currently active on the project. Their merit doesn't expire, and they're warmly +welcome to return to active contribution at any time; a short note to the dev or private +list is enough. + +<!-- EMERITUS --> + +== Wall of Fame -- Thanks to our Contributors + +Apache OpenNLP exists because of the people below, who have contributed code, reviews, +documentation and bug reports across https://github.com/apache/opennlp[opennlp], +https://github.com/apache/opennlp-site[opennlp-site], +https://github.com/apache/opennlp-addons[opennlp-addons] and +https://github.com/apache/opennlp-sandbox[opennlp-sandbox]. The list is generated +automatically from GitHub: if you've contributed and don't see yourself, please ping us +on the dev@ mailing list. + +<!-- WALL_OF_FAME --> diff --git a/src/main/resources/team-overrides.properties b/src/main/resources/team-overrides.properties new file mode 100644 index 000000000..2c5ac00f3 --- /dev/null +++ b/src/main/resources/team-overrides.properties @@ -0,0 +1,58 @@ +# Manual overrides for the OpenNLP roster on the team page. +# +# Whimsy LDAP rarely carries `githubUsername` for ASF committers, so the team +# page's identity merge cannot reliably bridge an apache_id to a GitHub login on +# its own. The Site driver also falls back to /users/{login}.name, but that hits +# the anonymous GitHub rate limit (60/h) on cold-cache builds, which can leave +# a few active members listed under Emeritus. +# +# Pin both the GitHub login *and* the active/emeritus bucket here so the result +# is deterministic regardless of how the live fetch went. +# +# Keys are flat properties; one entry per (apache-id, attribute) pair. +# +# <apacheId>.gh = <login>[;<login>...] # GitHub login override(s) +# <apacheId>.status = active | emeritus # forces the section bucket +# <apacheId>.chair = true # marks the current PMC chair +# +# `.gh` may list multiple `;`-separated logins for people who contribute under +# more than one GitHub account; the first one is used for the card link and +# avatar, the rest are merged into the same record so their activity (commits, +# PRs, comments, review comments) and Wall-of-Fame contribution counts roll up. +# +# Status forces win over the live activity check: if you mark someone `active` +# they appear in Active even when the GitHub fetch sees no recent activity, and +# vice versa for `emeritus`. +# +# Whitespace and lines starting with # are ignored. + +aarora.gh = atarora +aarora.status = active + +anastasijam.gh = amensiko +anastasijam.status = emeritus + +colen.gh = wcolen +colen.status = emeritus + +druss.gh = danielruss +druss.status = emeritus + +joern.gh = kottmann +joern.status = active + +jzemerick.gh = jzonthemtn +jzemerick.status = active +jzemerick.chair = true + +koji.gh = kojisekig +koji.status = emeritus + +mattmann.gh = chrismattmann +mattmann.status = emeritus + +tommaso.gh = tteofili +tommaso.status = emeritus + +tallison.gh = tballison +tallison.status = emeritus
