Skip to content

Commit

Permalink
✨ Count seconds/alternates
Browse files Browse the repository at this point in the history
  • Loading branch information
ebullient committed Nov 24, 2024
1 parent 2ab9a91 commit fbedae1
Show file tree
Hide file tree
Showing 8 changed files with 439 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,41 @@ public static Threshold fromString(String group) {
}
}

public static class StatusLinks {
public String badge;
public String page;
public record StatusLinks(
String badge,
String page) {
}

public record TeamMapping(
String data,
String team) {
boolean valid() {
return data != null && team != null;
}
}

public record AlternateDefinition(
String field,
TeamMapping primary,
TeamMapping secondary) {
boolean valid() {
return field != null
&& primary != null && primary.valid()
&& secondary != null && secondary.valid();
}
}

public record AlternateConfig(
String source,
String repo,
List<AlternateDefinition> mapping) {
boolean valid() {
return source != null
&& repo != null
&& mapping != null
&& !mapping.isEmpty()
&& mapping.stream().allMatch(AlternateDefinition::valid);
}
}

public static final VoteConfig DISABLED = new VoteConfig() {
Expand Down Expand Up @@ -102,6 +134,11 @@ public boolean isDisabled() {
@JsonDeserialize(contentUsing = ThresholdDeserializer.class)
public Map<String, Threshold> voteThreshold;

/**
* Configuration for alternate representatives.
*/
public List<AlternateConfig> alternates;

/**
* Link templates for status badges and pages.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,48 @@

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.commonhaus.automation.github.context.DataActor;
import org.commonhaus.automation.github.context.DataPullRequestReview;
import org.commonhaus.automation.github.context.DataReaction;
import org.commonhaus.automation.github.context.QueryCache;
import org.commonhaus.automation.github.context.QueryContext;
import org.commonhaus.automation.github.context.TeamList;
import org.commonhaus.automation.github.voting.VoteConfig.AlternateConfig;
import org.commonhaus.automation.github.voting.VoteConfig.AlternateDefinition;
import org.commonhaus.automation.github.voting.VoteConfig.Threshold;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.ReactionContent;

import com.fasterxml.jackson.databind.JsonNode;

import io.quarkus.logging.Log;

public class VoteInformation {
static final QueryCache ALT_ACTORS = QueryCache.create("", b -> b.expireAfterWrite(1, TimeUnit.DAYS));

enum Type {
marthas,
manualReactions,
manualComments,
undefined
}

static final Pattern groupPattern = Pattern.compile("voting group[^@]+@([^\\s]+)", Pattern.CASE_INSENSITIVE);
public record Alternates(int hash, Map<String, Map<String, DataActor>> alternates) {
}

static final Pattern groupPattern = Pattern.compile("voting group[^@]+@([\\S]+)", Pattern.CASE_INSENSITIVE);

static final String quoted = "['\"]([^'\"]*)['\"]";
static final Pattern votePattern = Pattern.compile("<!--vote(.*?)-->", Pattern.CASE_INSENSITIVE);
Expand All @@ -40,7 +60,8 @@ enum Type {
public final List<ReactionContent> approve;

public final String group;
public final TeamList teamList;
public final Set<DataActor> teamList;
public final Map<String, DataActor> alternates;
public final VoteConfig.Threshold votingThreshold;

public VoteInformation(VoteEvent event) {
Expand All @@ -50,20 +71,24 @@ public VoteInformation(VoteEvent event) {
VoteConfig voteConfig = event.getVotingConfig();
String bodyString = event.getVoteBody();
String groupValue = null;
TeamList teamList = null;
Set<DataActor> teamList = null;
Map<String, DataActor> alternates = null;

// Test body for "Voting group" followed by a team name
Matcher groupM = groupPattern.matcher(bodyString);
if (groupM.find()) {
groupValue = groupM.group(1);
teamList = qc.getTeamList(groupValue);
if (teamList != null) {
teamList.removeExcludedMembers(
TeamList list = qc.getTeamList(groupValue);
if (list != null) {
list.removeExcludedMembers(
a -> qc.isBot(a.login) || voteConfig.isMemberExcluded(a.login));
teamList = list.members;
}
alternates = getAlternates(qc, groupValue, voteConfig);
}
this.group = groupValue;
this.teamList = teamList;
this.alternates = alternates;

Matcher voteM = votePattern.matcher(bodyString);
String voteDefinition = voteM.find() ? voteM.group(1).toLowerCase() : null;
Expand All @@ -77,11 +102,9 @@ public VoteInformation(VoteEvent event) {
List<ReactionContent> ok = List.of();
List<ReactionContent> revise = List.of();

if (voteDefinition == null) {
voteType = Type.undefined;
} else {
if (voteDefinition != null) {
Matcher thresholdM = thresholdPattern.matcher(voteDefinition);
votingThreshold = thresholdM != null && thresholdM.find()
votingThreshold = thresholdM.find()
? Threshold.fromString(thresholdM.group(1))
: voteConfig.votingThreshold(this.group);

Expand Down Expand Up @@ -140,11 +163,9 @@ public boolean invalidReactions() {
if (approve.isEmpty() || ok.isEmpty() || revise.isEmpty()) {
return true;
}
if (Collections.disjoint(ok, approve) && Collections.disjoint(ok, revise) && Collections.disjoint(approve, revise)) {
// None of the groups overlap
return false;
}
return true;
// None of the groups should overlap
return !Collections.disjoint(ok, approve) || !Collections.disjoint(ok, revise)
|| !Collections.disjoint(approve, revise);
}

public String getErrorContent() {
Expand Down Expand Up @@ -180,9 +201,9 @@ private String showReactionGroups() {

return String.format("""
- %s:\r
- approve: %s\r
- ok: %s\r
- revise: %s""",
- approve: %s\r
- ok: %s\r
- revise: %s""",
description,
showReactions(approve) + (isPullRequest() ? ", PR review approved" : ""),
showReactions(ok) + (isPullRequest() ? ", PR review closed with comments" : ""),
Expand Down Expand Up @@ -251,4 +272,133 @@ private List<ReactionContent> listFrom(String group) {
public String getTitle() {
return event.getTitle();
}

private Map<String, DataActor> getAlternates(QueryContext qc, String teamName, VoteConfig voteConfig) {
// Generate a cache key using the repository ID
String key = "ALTS_" + qc.getRepositoryId();

// Look up or compute the alternates for the given key
Alternates alts = ALT_ACTORS.computeIfAbsent(key, k -> {
List<AlternateConfig> alternates = voteConfig.alternates;
int hash = alternates == null ? 0 : alternates.hashCode();

// If no alternates are configured, return an empty map
if (alternates == null) {
return new Alternates(hash, Map.of());
}

// Iterate over the list of alternate configurations
Map<String, Map<String, DataActor>> githubTeamToAlternates = new HashMap<>();
for (AlternateConfig alt : alternates) {
if (!alt.valid()) {
continue;
}

// Retrieve the configuration data for the alternate
Optional<JsonNode> configDataNode = getAlternateConfigData(qc, alt);
if (configDataNode.isEmpty()) {
continue;
}

// Map logins from the primary team to the secondary team
JsonNode data = configDataNode.get();
for (AlternateDefinition altDef : alt.mapping()) {
String primaryTeam = altDef.primary().team();
Map<String, DataActor> loginToSecond = mapLoginToSecond(qc, data, altDef);
if (loginToSecond.isEmpty()) {
continue;
}
githubTeamToAlternates.computeIfAbsent(primaryTeam, x -> new HashMap<>()).putAll(loginToSecond);
}
}
// Return the computed alternates
return new Alternates(hash, githubTeamToAlternates);
});

// Return the alternates for the specified team name (if any)
return alts.alternates().get(teamName);
}

private Optional<JsonNode> getAlternateConfigData(QueryContext qc, AlternateConfig altConfig) {
GHRepository repo = qc.getRepository(altConfig.repo());
if (repo == null) {
Log.warnf("[%s] voteInformation.getAlternateConfigData: source repository %s not found",
qc.getLogId(), altConfig.repo());
return Optional.empty();
}
// get contents of file from the specified repo + path
Optional<JsonNode> config = Optional.ofNullable(qc.readYamlSourceFile(repo, altConfig.source()));
if (config.isEmpty()) {
Log.warnf("[%s] voteInformation.getAlternateConfigData: source %s:%s not found",
qc.getLogId(), altConfig.repo(), altConfig.repo());
return Optional.empty();
}
return config;
}

private Map<String, DataActor> mapLoginToSecond(QueryContext qc, JsonNode data, AlternateDefinition altDef) {
// AlternateDefinition has been checked for validity.
// Contents of data (JsonNode) have not.
TeamList primaryTeam = qc.getTeamList(altDef.primary().team());
Optional<JsonNode> primaryDataNode = getValidDataNode(altDef.primary().data(), data);
if (primaryDataNode.isEmpty()) {
Log.warnf("[%s] voteInformation.getAlternates: primary config group (%s) or github team (%s) not found",
qc.getLogId(), altDef.primary().data(), altDef.primary().team());
return Map.of();
}

TeamList secondaryTeam = qc.getTeamList(altDef.secondary().team());
Optional<JsonNode> secondaryDataNode = getValidDataNode(altDef.secondary().data(), data);
if (secondaryDataNode.isEmpty()) {
Log.warnf("[%s] voteInformation.getAlternates: secondary config group (%s) or github team (%s) not found",
qc.getLogId(), altDef.secondary().data(), altDef.secondary().team());
return Map.of();
}

String match = altDef.field();
Map<String, DataActor> result = new HashMap<>();
Map<String, String> primaryMap = fieldToLoginMap(match, primaryDataNode.get());
Map<String, String> secondaryMap = fieldToLoginMap(match, secondaryDataNode.get());
for (Entry<String, String> entry : primaryMap.entrySet()) {
String primaryLogin = entry.getValue();
String secondaryLogin = secondaryMap.get(entry.getKey());
if (primaryLogin == null || secondaryLogin == null) {
continue;
}
if (primaryTeam.hasLogin(primaryLogin)) {
secondaryTeam.members.stream()
.filter(a -> a.login.equals(secondaryLogin))
.findFirst()
.ifPresent(a -> result.put(primaryLogin, a));
}
}
return result;
}

private Optional<JsonNode> getValidDataNode(String key, JsonNode node) {
if (key == null || key.isEmpty()) {
return Optional.empty();
}
return Optional.ofNullable(node.get(key)).filter(JsonNode::isArray);
}

// egc:
// - project: jbang
// login: maxandersen
private Map<String, String> fieldToLoginMap(String field, JsonNode node) {
Map<String, String> fieldToLogin = new HashMap<>();
node.elements().forEachRemaining(x -> {
String fieldValue = stringFrom(x, field);
String login = stringFrom(x, "login");
if (fieldValue != null && login != null) {
fieldToLogin.put(fieldValue, login);
}
});
return fieldToLogin;
}

private String stringFrom(JsonNode x, String fieldName) {
JsonNode field = x.get(fieldName);
return field == null ? null : field.asText();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
import io.quarkus.runtime.annotations.RegisterForReflection;

@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
public class VoteRecord {
public final String login;
public final String url;
public final Date createdAt;
public final String reaction;
boolean alternate = false;

public VoteRecord(DataReaction reaction) {
this.login = reaction.user.login;
Expand All @@ -35,6 +36,14 @@ public String login() {
return login;
}

public boolean isAlternate() {
return alternate;
}

public void setAlternate(boolean alternate) {
this.alternate = alternate;
}

@Override
public int hashCode() {
final int prime = 31;
Expand Down
Loading

0 comments on commit fbedae1

Please sign in to comment.