blob: 6abc854ac3e99c48e2c3fc52f61d49fb13b5eb66 [file] [log] [blame]
package build.bazel.dashboard.github.issuestatus;
import static java.time.temporal.ChronoUnit.DAYS;
import build.bazel.dashboard.github.issue.GithubIssue;
import build.bazel.dashboard.github.issue.GithubIssue.Label;
import build.bazel.dashboard.github.issue.GithubIssue.User;
import build.bazel.dashboard.github.issuestatus.GithubIssueStatus.GithubIssueStatusBuilder;
import build.bazel.dashboard.github.issuestatus.GithubIssueStatus.Status;
import build.bazel.dashboard.github.repo.GithubRepo;
import build.bazel.dashboard.github.repo.GithubRepoService;
import build.bazel.dashboard.github.team.GithubTeam;
import build.bazel.dashboard.github.team.GithubTeamService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.core.Single;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import javax.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class GithubIssueStatusService {
private final ObjectMapper objectMapper;
private final GithubRepoService githubRepoService;
private final GithubIssueStatusRepo githubIssueStatusRepo;
private final GithubTeamService githubTeamService;
public Optional<GithubIssueStatus> findOne(String owner, String repo, int issueNumber) {
return githubIssueStatusRepo.findOne(owner, repo, issueNumber);
}
public Optional<GithubIssueStatus> check(GithubIssue issue, Instant now) throws IOException {
String owner = issue.getOwner();
String repo = issue.getRepo();
var githubRepoOptional = githubRepoService.findOne(owner, repo);
if (githubRepoOptional.isEmpty()) {
return Optional.empty();
}
var githubRepo = githubRepoOptional.get();
var existingIssueStatus = githubIssueStatusRepo.findOne(owner, repo, issue.getIssueNumber());
var newIssueStatus = buildStatus(githubRepo, existingIssueStatus.orElse(null), issue, now);
githubIssueStatusRepo.save(newIssueStatus);
return Optional.of(newIssueStatus);
}
public void markDeleted(String owner, String repo, int issueNumber) {
githubIssueStatusRepo.findOne(owner, repo, issueNumber).ifPresent(status -> {
status.status = Status.DELETED;
githubIssueStatusRepo.save(status);
});
}
private GithubIssueStatus buildStatus(
GithubRepo repo,
@Nullable GithubIssueStatus oldStatus, GithubIssue issue, Instant now) throws IOException {
var data = issue.parseData(objectMapper);
Status newStatus = newStatus(repo, data);
Instant lastNotifiedAt = null;
if (oldStatus != null) {
lastNotifiedAt = oldStatus.getLastNotifiedAt();
}
Instant expectedRespondAt = getExpectedRespondAt(data, newStatus);
Instant nextNotifyAt = expectedRespondAt;
if (lastNotifiedAt != null
&& nextNotifyAt != null
&& lastNotifiedAt.isAfter(expectedRespondAt)) {
nextNotifyAt = lastNotifiedAt.plus(1, DAYS);
}
var actionOwners = findActionOwner(repo, issue, data, newStatus);
return GithubIssueStatus.builder()
.owner(issue.getOwner())
.repo(issue.getRepo())
.issueNumber(issue.getIssueNumber())
.status(newStatus)
.actionOwners(actionOwners)
.updatedAt(data.getUpdatedAt())
.expectedRespondAt(expectedRespondAt)
.lastNotifiedAt(lastNotifiedAt)
.nextNotifyAt(nextNotifyAt)
.checkedAt(now)
.build();
}
static Status newStatus(GithubRepo repo, GithubIssue.Data data) {
if (data.getState().equals("closed")) {
return Status.CLOSED;
}
List<Label> labels = data.getLabels();
if (hasMoreDataNeededLabel(labels)) {
return Status.MORE_DATA_NEEDED;
}
if (!repo.isTeamLabelEnabled() || hasTeamLabel(labels)) {
if (hasPriorityLabel(labels)) {
return Status.TRIAGED;
} else {
return Status.REVIEWED;
}
}
return Status.TO_BE_REVIEWED;
}
// TODO: More serious business days handling
static @Nullable Instant getExpectedRespondAt(GithubIssue.Data data, Status status) {
Instant updatedAt = data.getUpdatedAt();
switch (status) {
case TO_BE_REVIEWED:
case MORE_DATA_NEEDED:
case REVIEWED:
return updatedAt.plus(7, DAYS);
case TRIAGED:
{
List<Label> labels = data.getLabels();
if (hasLabelPrefix(labels, "type: bug")) {
if (hasLabelPrefix(labels, "P0")) {
return updatedAt.plus(1, DAYS);
} else if (hasLabelPrefix(labels, "P1")) {
return updatedAt.plus(7, DAYS);
} else if (hasLabelPrefix(labels, "P2")) {
return updatedAt.plus(120, DAYS);
}
}
return null;
}
default:
}
return null;
}
private ImmutableList<String> findActionOwner(
GithubRepo repo, GithubIssue issue, GithubIssue.Data data, Status status) {
switch (status) {
case TO_BE_REVIEWED:
return ImmutableList.of();
case MORE_DATA_NEEDED:
return ImmutableList.of(data.getUser().getLogin());
case REVIEWED:
case TRIAGED:
User assignee = data.getAssignee();
if (assignee != null) {
return ImmutableList.of(assignee.getLogin());
} else {
List<Label> labels = data.getLabels();
githubTeamService
.findAll(issue.getOwner(), issue.getRepo())
.stream()
.filter(
team ->
labels.stream().anyMatch(label -> label.getName().equals(team.getLabel()))
&& !team.getTeamOwners().isEmpty())
.findFirst()
.map(GithubTeam::getTeamOwners)
.orElseGet(() -> {
if (repo.getActionOwner() != null) {
return ImmutableList.of(repo.getActionOwner());
}
return ImmutableList.of();
});
}
}
return ImmutableList.of();
}
private static boolean hasTeamLabel(List<Label> labels) {
return hasLabelPrefix(labels, "team-");
}
private static boolean hasMoreDataNeededLabel(List<Label> labels) {
return hasLabelPrefix(labels, "more data needed");
}
private static boolean hasPriorityLabel(List<Label> labels) {
return hasLabelPrefix(labels, "P");
}
private static boolean hasLabelPrefix(List<Label> labels, String prefix) {
for (Label label : labels) {
if (label.getName().startsWith(prefix)) {
return true;
}
}
return false;
}
}