blob: cf487f4962bc1754dd64aa13a7a6c044e75ca4d2 [file] [log] [blame]
package build.bazel.dashboard.github.notification;
import build.bazel.dashboard.config.DashboardConfig;
import build.bazel.dashboard.github.issue.GithubIssue;
import build.bazel.dashboard.github.issue.GithubIssue.Label;
import build.bazel.dashboard.github.issuecomment.GithubIssueCommentService;
import build.bazel.dashboard.github.issuelist.GithubIssueList;
import build.bazel.dashboard.github.issuelist.GithubIssueListService;
import build.bazel.dashboard.github.issuelist.GithubIssueListService.ListParams;
import build.bazel.dashboard.github.issuestatus.GithubIssueStatus;
import build.bazel.dashboard.github.user.GithubUser;
import build.bazel.dashboard.github.user.GithubUserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.core.Single;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.awt.Color;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@Profile("notification")
@RestController
@RequiredArgsConstructor
@Slf4j
public class NotificationTask {
private final DashboardConfig dashboardConfig;
private final ObjectMapper objectMapper;
private final JavaMailSender javaMailSender;
private final GithubIssueListService githubIssueListService;
private final GithubUserService githubUserService;
private final GithubIssueCommentService githubIssueCommentService;
@PostMapping("/internal/github/issues/notifications")
public void notifyIssueStatus() {
startNotifyIssueStatus();
}
@GetMapping("/internal/github/issues/notifications/triage")
public Single<String> getTriageTeamNotificationBody() {
return buildTriageTeamNotificationBody();
}
@GetMapping("/internal/github/issues/notifications/{user}")
public Single<String> getGithubUserNotificationBody(@PathVariable("user") String username) {
return githubUserService
.findAll()
.filter(user -> user.getUsername().equals(username))
.concatMapSingle(this::buildUserNotificationBody)
.collect(Collectors.joining());
}
@PostMapping("/internal/github/issues/notifications/{user}")
public Completable notifyGithubUser(@PathVariable("user") String username) {
return githubUserService
.findAll()
.filter(user -> user.getUsername().equals(username))
.flatMapCompletable(
user ->
buildUserNotificationBody(user)
.flatMapCompletable(
body -> {
if (!body.isBlank()) {
return Completable.fromCallable(
() -> {
sendNotification(user.getEmail(), body, "triage/update");
return null;
});
}
return Completable.complete();
}));
}
@Scheduled(cron = "0 0 2 * * MON-FRI", zone = "UTC")
public void startNotifyIssueStatus() {
notifyTriageTeam().andThen(notifyUsers()).blockingAwait();
}
Single<String> buildTriageTeamNotificationBody() {
ListParams params = new ListParams();
params.setStatus(GithubIssueStatus.Status.TO_BE_REVIEWED);
return githubIssueListService
.find(params)
.flatMap(
list -> {
if (list.getTotal() > 0) {
String reviewLink =
dashboardConfig.getHost()
+ "/issues?q=%7B%22status%22%3A%22TO_BE_REVIEWED%22%2C%22page%22%3A1%7D";
return buildNotificationBody(reviewLink, "issues", "review", list);
} else {
return Single.just("");
}
});
}
Completable notifyTriageTeam() {
return buildTriageTeamNotificationBody()
.flatMapCompletable(
body -> {
if (!Strings.isNullOrEmpty(body)) {
return Completable.fromCallable(
() -> {
sendNotification(
dashboardConfig.getGithub().getNotification().getToNeedReviewEmail(),
body,
"review");
return null;
});
} else {
return Completable.complete();
}
});
}
private void sendNotification(String to, String body, String action) throws MessagingException {
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper message = new MimeMessageHelper(mimeMessage);
message.setFrom(dashboardConfig.getGithub().getNotification().getFromEmail());
message.setTo(to);
Instant now = Instant.ofEpochSecond(Instant.now().getEpochSecond());
message.setSubject("Please " + action + " Github issues. " + now.toString());
StringBuilder text = new StringBuilder();
text.append("<p>Hi there,</p>");
text.append(body);
text.append(
"<p style=\"font-size:small;color:#666\">----<br>This email is generated by the <a"
+ " href=\"")
.append(dashboardConfig.getHost())
.append("\">Dashboard</a>.</p>");
message.setText(text.toString(), true);
javaMailSender.send(mimeMessage);
}
private Single<String> buildNotificationBody(
String reviewLink, String type, String action, GithubIssueList list) {
return Flowable.fromIterable(list.getItems())
.concatMapMaybe(this::buildIssueListItem)
.collect(Collectors.toList())
.map(
issues -> {
StringBuilder body = new StringBuilder();
body.append("<p>You have ");
appendLink(body, reviewLink, list.getTotal() + " " + type);
body.append(" to ").append(action).append(". Below are some of them:</p>");
body.append("<table style=\"text-align: left;\">");
body.append("<thead>");
body.append("<tr>");
body.append("<th>Issue / PR</th>");
body.append("<th></th>");
body.append("<th>Author</th>");
body.append("<th>Participants</th>");
body.append("</tr>");
body.append("</thead>");
body.append("<tbody>");
for (String issue : issues) {
body.append(issue);
}
body.append("</tbody>");
body.append("</table>");
return body.toString();
});
}
private static class Rgb {
public int r;
public int g;
public int b;
}
private static Rgb hexToRgb(String hex) {
Rgb rgb = new Rgb();
rgb.r = Integer.parseInt(hex.substring(0, 2), 16);
rgb.g = Integer.parseInt(hex.substring(2, 4), 16);
rgb.b = Integer.parseInt(hex.substring(4, 6), 16);
return rgb;
}
private void appendLabel(StringBuilder s, Label label) {
Rgb rgb = hexToRgb(label.getColor());
float[] hsl = Color.RGBtoHSB(rgb.r, rgb.g, rgb.b, null);
float perceivedLightness = ((rgb.r * 0.2126f) + (rgb.g * 0.7152f) + (rgb.b * 0.0722f)) / 255;
float lightnessThreshold = 0.453f;
float borderThreshold = 0.96f;
float lightnessSwitch =
Math.max(0, Math.min((perceivedLightness - lightnessThreshold) * -1000, 1));
float borderAlpha = Math.max(0, Math.min((perceivedLightness - borderThreshold) * 100, 1));
s.append("<span style=\"");
s.append("display: inline-block; padding: 0 7px; margin-right: 4px;");
s.append("border: 1px solid; border-radius: 2em;");
s.append("background: rgb(")
.append(rgb.r)
.append(", ")
.append(rgb.g)
.append(", ")
.append(rgb.b)
.append(");");
s.append("color: hsl(0, 0%, calc(").append(lightnessSwitch).append(" * 100%));");
s.append("border-color: hsla(")
.append(hsl[0])
.append(", calc(")
.append(hsl[1])
.append(" * 1%), calc((")
.append(hsl[2])
.append(" - 25) * 1%), ")
.append(borderAlpha)
.append(");");
s.append("\">");
s.append(label.getName());
s.append("</span>");
}
private Maybe<String> buildIssueListItem(GithubIssueList.Item issue) {
GithubIssue.Data data;
try {
data = GithubIssue.parseData(objectMapper, issue.getData());
} catch (JsonProcessingException e) {
return Maybe.empty();
}
return findParticipants(issue, data)
.map(
participants -> {
StringBuilder body = new StringBuilder();
body.append("<tr style=\"vertical-align: baseline\">");
body.append("<td>");
String repo = "";
if (!(issue.getOwner().equals("bazelbuild") && issue.getRepo().equals("bazel"))) {
repo = issue.getRepo();
}
appendLink(
body,
String.format(
"https://github.com/%s/%s/issues/%s",
issue.getOwner(), issue.getRepo(), issue.getIssueNumber()),
repo + "#" + issue.getIssueNumber());
body.append("</td>");
body.append("<td style=\"white-space: nowrap;\">");
String title = data.getTitle();
if (title.length() > 80) {
title = title.substring(0, 77) + "...";
}
boolean isPullRequest = issue.getData().get("pull_request") != null;
body.append(isPullRequest ? "PR: " : "Issue: ");
body.append(title);
body.append("</td>");
body.append("<td>");
body.append("@");
body.append(data.getUser().getLogin());
body.append("</td>");
body.append("<td>");
for (String participant : participants) {
body.append("@");
body.append(participant);
body.append(" ");
}
body.append("</td>");
body.append("<td>");
for (Label label : data.getLabels()) {
appendLabel(body, label);
}
body.append("</td>");
body.append("</tr>");
return body.toString();
})
.toMaybe();
}
private Single<List<String>> findParticipants(GithubIssueList.Item issue, GithubIssue.Data data) {
return githubIssueCommentService
.findIssueComments(issue.getOwner(), issue.getRepo(), issue.getIssueNumber())
.map(comment -> comment.getUser().getLogin())
.collect(Collectors.toSet())
.map(
participants -> {
participants.remove(data.getUser().getLogin());
List<String> result = new ArrayList<>(participants);
result.sort(String::compareTo);
return result;
});
}
private void appendLink(StringBuilder sb, String href, String text) {
sb.append("<a href=\"");
sb.append(href);
sb.append("\">");
sb.append(text);
sb.append("</a>");
}
private Single<String> buildUserNotificationBody(GithubUser user) {
return Flowable.concatArray(
buildNeedTriageMessage(user).toFlowable(),
buildReviewPrMessage(user).toFlowable(),
buildFixP0BugsMessage(user).toFlowable(),
buildFixP1BugsMessage(user).toFlowable(),
buildFixP2BugsMessage(user).toFlowable())
.collect(Collectors.joining());
}
private Completable notifyUsers() {
return githubUserService
.findAll()
.flatMapCompletable(
user ->
buildUserNotificationBody(user)
.flatMapCompletable(
body -> {
if (!body.isBlank()) {
return Completable.fromCallable(
() -> {
sendNotification(user.getEmail(), body, "triage/update");
return null;
});
}
return Completable.complete();
}));
}
Single<String> buildNeedTriageMessage(GithubUser user) {
ListParams params = new ListParams();
params.setStatus(GithubIssueStatus.Status.REVIEWED);
params.setActionOwner(user.getUsername());
return githubIssueListService
.find(params)
.flatMap(
list -> {
if (list.getTotal() > 0) {
String reviewLink =
dashboardConfig.getHost()
+ "/issues?q=%7B%22status%22%3A%22REVIEWED%22%2C%22page%22%3A1%2C%22actionOwner%22%3A%22"
+ user.getUsername()
+ "%22%7D";
return buildNotificationBody(reviewLink, "issues", "triage", list);
}
return Single.just("");
});
}
Single<String> buildReviewPrMessage(GithubUser user) {
ListParams params = new ListParams();
params.setIsPullRequest(true);
params.setLabels(ImmutableList.of("awaiting-review"));
params.setActionOwner(user.getUsername());
return githubIssueListService
.find(params)
.flatMap(
list -> {
if (list.getTotal() > 0) {
String reviewLink =
dashboardConfig.getHost()
+ "/issues?q=%7B%22isPullRequest%22%3Atrue%2C%22actionOwner%22%3A%22"
+ user.getUsername()
+ "%22%2C%22page%22%3A1%2C%22extraLabels%22%3A%5B%22awaiting-review%22%5D%7D";
return buildNotificationBody(reviewLink, "PRs", "review", list);
}
return Single.just("");
});
}
Single<String> buildFixP0BugsMessage(GithubUser user) {
ListParams params = new ListParams();
params.setIsPullRequest(false);
params.setStatus(GithubIssueStatus.Status.TRIAGED);
params.setLabels(ImmutableList.of("P0", "type: bug"));
params.setActionOwner(user.getUsername());
return githubIssueListService
.find(params)
.flatMap(
list -> {
if (list.getTotal() > 0) {
String reviewLink =
dashboardConfig.getHost()
+ "/issues?q=%7B%22status%22%3A%22TRIAGED%22%2C%22page%22%3A1%2C%22labels%22%3A%5B%22P0%22%5D%2C%22actionOwner%22%3A%22"
+ user.getUsername()
+ "%22%7D";
return buildNotificationBody(reviewLink, "P0 bugs", "fix", list);
}
return Single.just("");
});
}
Single<String> buildFixP1BugsMessage(GithubUser user) {
ListParams params = new ListParams();
params.setIsPullRequest(false);
params.setStatus(GithubIssueStatus.Status.TRIAGED);
params.setLabels(ImmutableList.of("P1", "type: bug"));
params.setActionOwner(user.getUsername());
return githubIssueListService
.find(params)
.flatMap(
list -> {
if (list.getTotal() > 0) {
String reviewLink =
dashboardConfig.getHost()
+ "/issues?q=%7B%22status%22%3A%22TRIAGED%22%2C%22page%22%3A1%2C%22labels%22%3A%5B%22P1%22%5D%2C%22actionOwner%22%3A%22"
+ user.getUsername()
+ "%22%7D";
return buildNotificationBody(reviewLink, "P1 bugs", "fix", list);
}
return Single.just("");
});
}
Single<String> buildFixP2BugsMessage(GithubUser user) {
ListParams params = new ListParams();
params.setIsPullRequest(false);
params.setStatus(GithubIssueStatus.Status.TRIAGED);
params.setLabels(ImmutableList.of("P2", "type: bug"));
params.setActionOwner(user.getUsername());
return githubIssueListService
.find(params)
.flatMap(
list -> {
if (list.getTotal() > 0) {
String reviewLink =
dashboardConfig.getHost()
+ "/issues?q=%7B%22status%22%3A%22TRIAGED%22%2C%22page%22%3A1%2C%22labels%22%3A%5B%22P2%22%5D%2C%22actionOwner%22%3A%22"
+ user.getUsername()
+ "%22%7D";
return buildNotificationBody(reviewLink, "P2 bugs", "fix", list);
}
return Single.just("");
});
}
}