Update dashboard (#1743)

Check in bunch of fixes/updates:

- Sort repo by names when displaying them on the sidebar.
- Use JDK20.
- Remove duplicated `team_owner` field in `github_team_table`. Make it
easier to change owners of repo.
- Use case-insensitive text for labels to match the search result on
Github.
- Fix a bug that sometimes action owners for an issue are not correctly
computed.
- Update the logic for determining whether an issue/PR is triaged
according to today's triaging process.
- Update nagging emails to have a separated PRs section.
- Improve sync process.

Fixes #1732.
diff --git a/dashboard/server/Dockerfile b/dashboard/server/Dockerfile
index e0179b3..853cf0a 100644
--- a/dashboard/server/Dockerfile
+++ b/dashboard/server/Dockerfile
@@ -1,4 +1,4 @@
-FROM azul/zulu-openjdk-alpine:19
+FROM azul/zulu-openjdk-alpine:20
 ARG DEPENDENCY=dependency
 COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
 COPY ${DEPENDENCY}/META-INF /app/META-INF
diff --git a/dashboard/server/database.sql b/dashboard/server/database.sql
index c15cc4b..9c548b5 100644
--- a/dashboard/server/database.sql
+++ b/dashboard/server/database.sql
@@ -1,3 +1,5 @@
+CREATE EXTENSION IF NOT EXISTS citext;
+
 CREATE TABLE json_state
 (
     key       TEXT PRIMARY KEY,
@@ -55,7 +57,7 @@
     title           TEXT        NOT NULL,
     body            TEXT        NOT NULL,
     milestone       TEXT        NOT NULL,
-    labels          TEXT[]      NOT NULL,
+    labels          CITEXT[]    NOT NULL,
     created_at      TIMESTAMPTZ NOT NULL,
     updated_at      TIMESTAMPTZ,
     closed_at       TIMESTAMPTZ,
@@ -204,7 +206,6 @@
     created_at      TIMESTAMPTZ NOT NULL,
     updated_at      TIMESTAMPTZ NOT NULL,
     name            TEXT        NOT NULL,
-    none_team_owner TEXT        NOT NULL DEFAULT '',
     PRIMARY KEY (owner, repo, id)
 );
 
@@ -278,6 +279,20 @@
 );
 
 --
+-- Table that stores the raw pull requests data fetched from Github
+--
+CREATE TABLE github_pull_request_data
+(
+    owner        TEXT        NOT NULL,
+    repo         TEXT        NOT NULL,
+    issue_number INTEGER     NOT NULL,
+    timestamp    TIMESTAMPTZ NOT NULL,
+    etag         TEXT        NOT NULL,
+    data         JSONB       NOT NULL,
+    PRIMARY KEY (owner, repo, issue_number)
+);
+
+--
 -- Table that stores the raw builds data fetched from Buildkite
 --
 CREATE TABLE buildkite_build_data
diff --git a/dashboard/server/pom.xml b/dashboard/server/pom.xml
index 442a165..2b7b643 100644
--- a/dashboard/server/pom.xml
+++ b/dashboard/server/pom.xml
@@ -15,7 +15,7 @@
     <description>The server for the dashboard</description>
 
     <properties>
-        <java.version>19</java.version>
+        <java.version>20</java.version>
         <project.version>DEV</project.version>
     </properties>
 
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/api/FetchPullRequestRequest.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/api/FetchPullRequestRequest.java
new file mode 100644
index 0000000..e287376
--- /dev/null
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/api/FetchPullRequestRequest.java
@@ -0,0 +1,3 @@
+package build.bazel.dashboard.github.api;
+
+public record FetchPullRequestRequest(String owner, String repo, int issueNumber, String etag) {}
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/api/GithubApi.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/api/GithubApi.java
index 07b47a9..dda107e 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/api/GithubApi.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/api/GithubApi.java
@@ -11,6 +11,8 @@
 
   RestApiResponse fetchIssue(FetchIssueRequest request);
 
+  RestApiResponse fetchPullRequest(FetchPullRequestRequest request);
+
   RestApiResponse listIssueComments(ListIssueCommentsRequest request);
 
   RestApiResponse searchIssues(SearchIssuesRequest request);
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/api/WebClientGithubApi.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/api/WebClientGithubApi.java
index 30c693b..a679436 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/api/WebClientGithubApi.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/api/WebClientGithubApi.java
@@ -105,6 +105,24 @@
   }
 
   @Override
+  public RestApiResponse fetchPullRequest(FetchPullRequestRequest request) {
+    log.debug("{}", request);
+
+    WebClient.RequestHeadersSpec<?> spec =
+        get(
+            uriBuilder ->
+                uriBuilder
+                    .pathSegment(
+                        "repos",
+                        request.owner(),
+                        request.repo(),
+                        "pulls",
+                        String.valueOf(request.issueNumber()))
+                    .build(), request.etag());
+    return exchange(spec);
+  }
+
+  @Override
   public RestApiResponse listIssueComments(ListIssueCommentsRequest request) {
     log.debug("{}", request);
 
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssue.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssue.java
index 390f342..21d174e 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssue.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssue.java
@@ -5,6 +5,8 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.PropertyNamingStrategies;
 import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.time.Instant;
 import java.util.List;
 import javax.annotation.Nullable;
@@ -21,13 +23,15 @@
   String etag;
   JsonNode data;
 
-  public static GithubIssue empty(String owner, String repo, int issueNumber) {
+  public static GithubIssue empty(
+      String owner, String repo, int issueNumber, ObjectMapper objectMapper) {
     return GithubIssue.builder()
         .owner(owner)
         .repo(repo)
         .issueNumber(issueNumber)
         .timestamp(Instant.EPOCH)
         .etag("")
+        .data(objectMapper.createObjectNode())
         .build();
   }
 
@@ -48,9 +52,14 @@
     String title;
     String state;
     List<Label> labels;
-    @Nullable User assignee;
+    List<User> assignees;
     Instant createdAt;
     Instant updatedAt;
+    @Nullable JsonNode pullRequest;
+
+    public boolean isPullRequest() {
+      return pullRequest != null;
+    }
   }
 
   @Builder
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssueService.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssueService.java
index 06e9f35..77860b7 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssueService.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubIssueService.java
@@ -1,12 +1,15 @@
 package build.bazel.dashboard.github.issue;
 
 import build.bazel.dashboard.github.api.FetchIssueRequest;
+import build.bazel.dashboard.github.api.FetchPullRequestRequest;
 import build.bazel.dashboard.github.api.GithubApi;
 import build.bazel.dashboard.github.issuestatus.GithubIssueStatus;
 import build.bazel.dashboard.github.issuestatus.GithubIssueStatusService;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Optional;
+import javax.annotation.Nullable;
 import lombok.Builder;
 import lombok.RequiredArgsConstructor;
 import lombok.Value;
@@ -20,12 +23,15 @@
 
   private final GithubApi githubApi;
   private final GithubIssueRepo githubIssueRepo;
+  private final GithubPullRequestRepo githubPullRequestRepo;
   private final GithubIssueStatusService githubIssueStatusService;
+  private final ObjectMapper objectMapper;
 
   @Builder
   @Value
   public static class FetchResult {
     GithubIssue issue;
+    @Nullable GithubPullRequest pullRequest;
     GithubIssueStatus status;
     boolean added;
     boolean updated;
@@ -33,8 +39,17 @@
     Throwable error;
 
     static FetchResult create(
-        GithubIssue result, GithubIssueStatus status, boolean added, boolean updated, boolean deleted, Throwable error) {
+        GithubIssue result,
+        GithubPullRequest pullRequest,
+        GithubIssueStatus status,
+        boolean added,
+        boolean updated,
+        boolean deleted,
+        Throwable error) {
       FetchResultBuilder builder = FetchResult.builder().issue(result).status(status);
+      if (pullRequest != null) {
+        builder.pullRequest(pullRequest);
+      }
       if (error != null) {
         builder.error(error);
       } else if (added) {
@@ -56,7 +71,7 @@
     var existed =
         githubIssueRepo
             .findOne(owner, repo, issueNumber)
-            .orElse(GithubIssue.empty(owner, repo, issueNumber));
+            .orElse(GithubIssue.empty(owner, repo, issueNumber, objectMapper));
 
     FetchIssueRequest request =
         FetchIssueRequest.builder()
@@ -79,27 +94,32 @@
               .build();
       try {
         githubIssueRepo.save(githubIssue);
-        var status = githubIssueStatusService.check(githubIssue, Instant.now());
-        return FetchResult.create(githubIssue, status.orElse(null), !exists, exists, false, null);
+        var pullRequest = fetchAndSavePullRequest(githubIssue);
+        var status = githubIssueStatusService.check(githubIssue, pullRequest, Instant.now());
+        return FetchResult.create(
+            githubIssue, pullRequest, status.orElse(null), !exists, exists, false, null);
       } catch (IOException e) {
-        return FetchResult.create(githubIssue, null, !exists, exists, false, e);
+        return FetchResult.create(githubIssue, null, null, !exists, exists, false, e);
       }
     } else if (response.getStatus().value() == 304) {
       // Not modified
       try {
-        var status = githubIssueStatusService.check(existed, Instant.now());
-        return FetchResult.create(existed, status.orElse(null), false, false, false, null);
+        var pullRequest = fetchAndSavePullRequest(existed);
+        var status = githubIssueStatusService.check(existed, pullRequest, Instant.now());
+        return FetchResult.create(
+            existed, pullRequest, status.orElse(null), false, false, false, null);
       } catch (IOException e) {
-        return FetchResult.create(existed, null, false, false, false, e);
+        return FetchResult.create(existed, null, null, false, false, false, e);
       }
     } else if (response.getStatus().value() == 301
         || response.getStatus().value() == 404
         || response.getStatus().value() == 410) {
       // Transferred or deleted
       githubIssueRepo.delete(owner, repo, issueNumber);
+      githubPullRequestRepo.delete(owner, repo, issueNumber);
       // Mark existing status to DELETED
       githubIssueStatusService.markDeleted(owner, repo, issueNumber);
-      return FetchResult.create(existed, null, false, false, true, null);
+      return FetchResult.create(existed, null, null, false, false, true, null);
     } else {
       log.error(
           "Failed to fetch {}/{}/issues/{}: {}",
@@ -108,11 +128,57 @@
           issueNumber,
           response.getStatus().toString());
       return FetchResult.create(
-          existed, null, false, false, false, new IOException(response.getStatus().toString()));
+          existed,
+          null,
+          null,
+          false,
+          false,
+          false,
+          new IOException(response.getStatus().toString()));
     }
   }
 
   public Integer findMaxIssueNumber(String owner, String repo) {
     return githubIssueRepo.findMaxIssueNumber(owner, repo);
   }
+
+  @Nullable
+  private GithubPullRequest fetchAndSavePullRequest(GithubIssue issue) throws IOException {
+    if (!issue.parseData(objectMapper).isPullRequest()) {
+      return null;
+    }
+
+    var existed =
+        githubPullRequestRepo
+            .findOne(issue.getOwner(), issue.getRepo(), issue.getIssueNumber())
+            .orElse(
+                GithubPullRequest.empty(
+                    issue.getOwner(), issue.getRepo(), issue.getIssueNumber(), objectMapper));
+
+    var request =
+        new FetchPullRequestRequest(
+            existed.owner(), existed.repo(), existed.issueNumber(), existed.etag());
+
+    var response = githubApi.fetchPullRequest(request);
+    if (response.getStatus().is2xxSuccessful()) {
+      var pullRequest =
+          new GithubPullRequest(
+              existed.owner(),
+              existed.repo(),
+              existed.issueNumber(),
+              Instant.now(),
+              response.getEtag(),
+              response.getBody());
+      githubPullRequestRepo.save(pullRequest);
+      return pullRequest;
+    } else if (response.getStatus().value() == 304) {
+      // Not modified
+      return existed;
+    } else {
+      throw new IOException(
+          String.format(
+              "Failed to fetch %s/%s/pulls/%s: %s",
+              existed.owner(), existed.repo(), existed.issueNumber(), response.getStatus()));
+    }
+  }
 }
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubPullRequest.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubPullRequest.java
new file mode 100644
index 0000000..65181f5
--- /dev/null
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubPullRequest.java
@@ -0,0 +1,27 @@
+package build.bazel.dashboard.github.issue;
+
+import build.bazel.dashboard.github.issue.GithubIssue.User;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import java.time.Instant;
+import java.util.List;
+
+public record GithubPullRequest(
+    String owner, String repo, int issueNumber, Instant timestamp, String etag, JsonNode data) {
+
+  public static GithubPullRequest empty(
+      String owner, String repo, int issueNumber, ObjectMapper objectMapper) {
+    return new GithubPullRequest(
+        owner, repo, issueNumber, Instant.EPOCH, "", objectMapper.createObjectNode());
+  }
+
+  public Data parseData(ObjectMapper objectMapper) throws JsonProcessingException {
+    return objectMapper.treeToValue(data, Data.class);
+  }
+
+  @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+  public record Data(List<User> requestedReviewers) {}
+}
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubPullRequestRepo.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubPullRequestRepo.java
new file mode 100644
index 0000000..89f90d8
--- /dev/null
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issue/GithubPullRequestRepo.java
@@ -0,0 +1,78 @@
+package build.bazel.dashboard.github.issue;
+
+import static build.bazel.dashboard.utils.PgJson.toPgJson;
+import static java.util.Objects.requireNonNull;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.r2dbc.postgresql.codec.Json;
+import io.r2dbc.spi.Readable;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.r2dbc.core.DatabaseClient;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class GithubPullRequestRepo {
+  private final DatabaseClient databaseClient;
+  private final ObjectMapper objectMapper;
+
+  public void save(GithubPullRequest pullRequest) {
+    databaseClient
+        .sql(
+            "INSERT INTO github_pull_request_data (owner, repo, issue_number, timestamp, etag, data)"
+                + " VALUES (:owner, :repo, :issue_number, :timestamp, :etag, :data) ON"
+                + " CONFLICT (owner, repo, issue_number) DO UPDATE SET etag = excluded.etag,"
+                + " timestamp = excluded.timestamp, data = excluded.data")
+        .bind("owner", pullRequest.owner())
+        .bind("repo", pullRequest.repo())
+        .bind("issue_number", pullRequest.issueNumber())
+        .bind("timestamp", pullRequest.timestamp())
+        .bind("etag", pullRequest.etag())
+        .bind("data", toPgJson(objectMapper, pullRequest.data()))
+        .then()
+        .block();
+  }
+
+  public void delete(String owner, String repo, int issueNumber) {
+    databaseClient
+        .sql(
+            "DELETE FROM github_pull_request_data WHERE owner = :owner AND repo = :repo AND"
+                + " issue_number = :issue_number")
+        .bind("owner", owner)
+        .bind("repo", repo)
+        .bind("issue_number", issueNumber)
+        .then()
+        .block();
+  }
+
+  public Optional<GithubPullRequest> findOne(String owner, String repo, int issueNumber) {
+    return Optional.ofNullable(
+        databaseClient
+            .sql(
+                "SELECT owner, repo, issue_number, timestamp, etag, data FROM github_pull_request_data "
+                    + "WHERE owner=:owner AND repo=:repo AND issue_number=:issue_number")
+            .bind("owner", owner)
+            .bind("repo", repo)
+            .bind("issue_number", issueNumber)
+            .map(this::toPullRequest)
+            .one()
+            .block());
+  }
+
+  private GithubPullRequest toPullRequest(Readable row) {
+    try {
+      return new GithubPullRequest(
+          requireNonNull(row.get("owner", String.class)),
+          requireNonNull(row.get("repo", String.class)),
+          requireNonNull(row.get("issue_number", Integer.class)),
+          requireNonNull(row.get("timestamp", Instant.class)),
+          requireNonNull(row.get("etag", String.class)),
+          objectMapper.readTree((requireNonNull(row.get("data", Json.class))).asArray()));
+    } catch (IOException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+}
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuelist/GithubIssueListRepoPg.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuelist/GithubIssueListRepoPg.java
index 017134d..ac965e8 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuelist/GithubIssueListRepoPg.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuelist/GithubIssueListRepoPg.java
@@ -8,6 +8,8 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableList;
 import io.r2dbc.postgresql.codec.Json;
+import io.r2dbc.postgresql.codec.PostgresqlObjectId;
+import io.r2dbc.spi.Parameters;
 import io.r2dbc.spi.Readable;
 import io.reactivex.rxjava3.core.Flowable;
 import io.reactivex.rxjava3.core.Single;
@@ -76,7 +78,9 @@
 
     if (params.getLabels() != null && !params.getLabels().isEmpty()) {
       where.append(" AND gi.labels @> :labels");
-      bindings.put("labels", params.getLabels().toArray(new String[0]));
+      bindings.put(
+          "labels",
+          Parameters.in(PostgresqlObjectId.UNSPECIFIED, params.getLabels().toArray(new String[0])));
     }
 
     if (where.length() > 0) {
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuequery/GithubIssueQueryExecutorPg.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuequery/GithubIssueQueryExecutorPg.java
index 9c0a56a..23ec7ab 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuequery/GithubIssueQueryExecutorPg.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuequery/GithubIssueQueryExecutorPg.java
@@ -5,8 +5,8 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableList;
 import io.r2dbc.postgresql.codec.Json;
-import io.reactivex.rxjava3.core.Flowable;
-import io.reactivex.rxjava3.core.Single;
+import io.r2dbc.postgresql.codec.PostgresqlObjectId;
+import io.r2dbc.spi.Parameters;
 import lombok.Builder;
 import lombok.RequiredArgsConstructor;
 import lombok.Value;
@@ -129,12 +129,19 @@
 
     if (!parsedQuery.getLabels().isEmpty()) {
       and(condition, "labels @> :labels");
-      bindings.put("labels", parsedQuery.getLabels().toArray(new String[0]));
+      bindings.put(
+          "labels",
+          Parameters.in(
+              PostgresqlObjectId.UNSPECIFIED, parsedQuery.getLabels().toArray(new String[0])));
     }
 
     if (!parsedQuery.getExcludeLabels().isEmpty()) {
       and(condition, "NOT labels && :excluded_labels");
-      bindings.put("excluded_labels", parsedQuery.getExcludeLabels().toArray(new String[0]));
+      bindings.put(
+          "excluded_labels",
+          Parameters.in(
+              PostgresqlObjectId.UNSPECIFIED,
+              parsedQuery.getExcludeLabels().toArray(new String[0])));
     }
 
     return SqlCondition.builder().condition(condition.toString()).bindings(bindings).build();
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusRestController.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusRestController.java
index ab342ce..e942749 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusRestController.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusRestController.java
@@ -3,6 +3,7 @@
 import static build.bazel.dashboard.utils.RxJavaVirtualThread.completable;
 
 import build.bazel.dashboard.github.issue.GithubIssueService;
+import build.bazel.dashboard.github.issue.GithubPullRequestRepo;
 import io.reactivex.rxjava3.core.Completable;
 import java.time.Instant;
 import lombok.RequiredArgsConstructor;
@@ -17,6 +18,7 @@
 @RequiredArgsConstructor
 public class GithubIssueStatusRestController {
   private final GithubIssueService githubIssueService;
+  private final GithubPullRequestRepo githubPullRequestRepo;
   private final GithubIssueStatusService githubIssueStatusService;
 
   @PutMapping("/internal/github/{owner}/{repo}/issues/status")
@@ -29,8 +31,9 @@
         () -> {
           for (var number = start; number < start + count; ++number) {
             var issue = githubIssueService.findOne(owner, repo, number);
+            var pullRequest = githubPullRequestRepo.findOne(owner, repo, number).orElse(null);
             if (issue.isPresent()) {
-              githubIssueStatusService.check(issue.get(), Instant.now());
+              githubIssueStatusService.check(issue.get(), pullRequest, Instant.now());
             }
           }
         });
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusService.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusService.java
index 6abc854..50bc2c7 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusService.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/issuestatus/GithubIssueStatusService.java
@@ -5,17 +5,13 @@
 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.issue.GithubPullRequest;
 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;
@@ -38,7 +34,8 @@
     return githubIssueStatusRepo.findOne(owner, repo, issueNumber);
   }
 
-  public Optional<GithubIssueStatus> check(GithubIssue issue, Instant now) throws IOException {
+  public Optional<GithubIssueStatus> check(
+      GithubIssue issue, @Nullable GithubPullRequest pullRequest, Instant now) throws IOException {
     String owner = issue.getOwner();
     String repo = issue.getRepo();
 
@@ -50,7 +47,8 @@
     var githubRepo = githubRepoOptional.get();
     var existingIssueStatus = githubIssueStatusRepo.findOne(owner, repo, issue.getIssueNumber());
 
-    var newIssueStatus = buildStatus(githubRepo, existingIssueStatus.orElse(null), issue, now);
+    var newIssueStatus =
+        buildStatus(githubRepo, existingIssueStatus.orElse(null), issue, pullRequest, now);
     githubIssueStatusRepo.save(newIssueStatus);
     return Optional.of(newIssueStatus);
   }
@@ -64,9 +62,17 @@
 
   private GithubIssueStatus buildStatus(
       GithubRepo repo,
-      @Nullable GithubIssueStatus oldStatus, GithubIssue issue, Instant now) throws IOException  {
+      @Nullable GithubIssueStatus oldStatus,
+      GithubIssue issue,
+      @Nullable GithubPullRequest pullRequest,
+      Instant now)
+      throws IOException {
     var data = issue.parseData(objectMapper);
-    Status newStatus = newStatus(repo, data);
+    GithubPullRequest.Data pullRequestData = null;
+    if (pullRequest != null) {
+      pullRequestData = pullRequest.parseData(objectMapper);
+    }
+    Status newStatus = newStatus(repo, data, pullRequestData);
     Instant lastNotifiedAt = null;
 
     if (oldStatus != null) {
@@ -82,22 +88,23 @@
       nextNotifyAt = lastNotifiedAt.plus(1, DAYS);
     }
 
-    var actionOwners = findActionOwner(repo, issue, data, newStatus);
+    var actionOwners = findActionOwner(repo, issue, data, pullRequestData, newStatus);
     return GithubIssueStatus.builder()
-            .owner(issue.getOwner())
-            .repo(issue.getRepo())
-            .issueNumber(issue.getIssueNumber())
-            .status(newStatus)
+        .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();
+        .expectedRespondAt(expectedRespondAt)
+        .lastNotifiedAt(lastNotifiedAt)
+        .nextNotifyAt(nextNotifyAt)
+        .checkedAt(now)
+        .build();
   }
 
-  static Status newStatus(GithubRepo repo, GithubIssue.Data data) {
+  static Status newStatus(
+      GithubRepo repo, GithubIssue.Data data, @Nullable GithubPullRequest.Data pullRequestData) {
     if (data.getState().equals("closed")) {
       return Status.CLOSED;
     }
@@ -108,8 +115,16 @@
       return Status.MORE_DATA_NEEDED;
     }
 
+    if (pullRequestData != null && !pullRequestData.requestedReviewers().isEmpty()) {
+      return Status.TRIAGED;
+    }
+
+    if (!data.getAssignees().isEmpty()) {
+      return Status.TRIAGED;
+    }
+
     if (!repo.isTeamLabelEnabled() || hasTeamLabel(labels)) {
-      if (hasPriorityLabel(labels)) {
+      if (isTriaged(labels)) {
         return Status.TRIAGED;
       } else {
         return Status.REVIEWED;
@@ -121,68 +136,78 @@
 
   // TODO: More serious business days handling
   static @Nullable Instant getExpectedRespondAt(GithubIssue.Data data, Status status) {
-    Instant updatedAt = data.getUpdatedAt();
+    return switch (status) {
+      case TO_BE_REVIEWED, MORE_DATA_NEEDED, REVIEWED -> data.getUpdatedAt().plus(7, DAYS);
+      case TRIAGED -> getExpectedRespondAtForTriaged(data);
+      default -> null;
+    };
+  }
 
-    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:
+  static @Nullable Instant getExpectedRespondAtForTriaged(GithubIssue.Data data) {
+    List<Label> labels = data.getLabels();
+    if (hasLabelPrefix(labels, "type: bug")) {
+      if (hasLabelPrefix(labels, "P0")) {
+        return data.getUpdatedAt().plus(1, DAYS);
+      } else if (hasLabelPrefix(labels, "P1")) {
+        return data.getUpdatedAt().plus(7, DAYS);
+      } else if (hasLabelPrefix(labels, "P2")) {
+        return data.getUpdatedAt().plus(120, DAYS);
+      }
     }
-
     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();
-              });
-        }
+      GithubRepo repo,
+      GithubIssue issue,
+      GithubIssue.Data data,
+      @Nullable GithubPullRequest.Data pullRequestData,
+      Status status) {
+    return switch (status) {
+      case MORE_DATA_NEEDED -> ImmutableList.of(data.getUser().getLogin());
+      case REVIEWED, TRIAGED -> findActionOwnerForReviewedOrTriaged(
+          repo, issue, data, pullRequestData);
+      default -> ImmutableList.of();
+    };
+  }
+
+  private ImmutableList<String> findActionOwnerForReviewedOrTriaged(
+      GithubRepo repo,
+      GithubIssue issue,
+      GithubIssue.Data data,
+      @Nullable GithubPullRequest.Data pullRequestData) {
+
+    if (pullRequestData != null) {
+      if (!pullRequestData.requestedReviewers().isEmpty()) {
+        return pullRequestData.requestedReviewers().stream()
+            .map(User::getLogin)
+            .collect(ImmutableList.toImmutableList());
+      }
     }
 
-    return ImmutableList.of();
+    var assignees = data.getAssignees();
+    if (!assignees.isEmpty()) {
+      return assignees.stream().map(User::getLogin).collect(ImmutableList.toImmutableList());
+    } else {
+      List<Label> labels = data.getLabels();
+      var teams =
+          githubTeamService.findAll(issue.getOwner(), issue.getRepo()).stream()
+              .filter(
+                  team ->
+                      labels.stream()
+                          .anyMatch(label -> label.getName().equalsIgnoreCase(team.getLabel())))
+              .toList();
+      if (teams.isEmpty()) {
+        if (repo.getActionOwner() != null) {
+          return ImmutableList.of(repo.getActionOwner());
+        } else {
+          return ImmutableList.of();
+        }
+      }
+      return teams.stream()
+          .flatMap(team -> team.getTeamOwners().stream())
+          .collect(ImmutableList.toImmutableList());
+    }
   }
 
   private static boolean hasTeamLabel(List<Label> labels) {
@@ -190,16 +215,33 @@
   }
 
   private static boolean hasMoreDataNeededLabel(List<Label> labels) {
-    return hasLabelPrefix(labels, "more data needed");
+    return hasLabel(labels, "more data needed")
+        || hasLabel(labels, "awaiting-user-response");
   }
 
-  private static boolean hasPriorityLabel(List<Label> labels) {
-    return hasLabelPrefix(labels, "P");
+  private static boolean isTriaged(List<Label> labels) {
+    return hasLabel(labels, "P0")
+        || hasLabel(labels, "P1")
+        || hasLabel(labels, "P2")
+        || hasLabel(labels, "P3")
+        || hasLabel(labels, "P4")
+        || hasLabel(labels, "awaiting-review")
+        || hasLabel(labels, "awaiting-PR-merge");
   }
 
   private static boolean hasLabelPrefix(List<Label> labels, String prefix) {
     for (Label label : labels) {
-      if (label.getName().startsWith(prefix)) {
+      if (label.getName().toLowerCase().startsWith(prefix.toLowerCase())) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  private static boolean hasLabel(List<Label> labels, String name) {
+    for (Label label : labels) {
+      if (label.getName().equalsIgnoreCase(name)) {
         return true;
       }
     }
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/notification/NotificationTask.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/notification/NotificationTask.java
index fe4338e..cf487f4 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/notification/NotificationTask.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/notification/NotificationTask.java
@@ -326,6 +326,7 @@
   private Single<String> buildUserNotificationBody(GithubUser user) {
     return Flowable.concatArray(
             buildNeedTriageMessage(user).toFlowable(),
+            buildReviewPrMessage(user).toFlowable(),
             buildFixP0BugsMessage(user).toFlowable(),
             buildFixP1BugsMessage(user).toFlowable(),
             buildFixP2BugsMessage(user).toFlowable())
@@ -372,8 +373,31 @@
             });
   }
 
+  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());
@@ -396,6 +420,7 @@
 
   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());
@@ -418,6 +443,7 @@
 
   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());
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/repo/GithubRepoRepoPg.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/repo/GithubRepoRepoPg.java
index aa9af92..0a240c4 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/repo/GithubRepoRepoPg.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/repo/GithubRepoRepoPg.java
@@ -36,7 +36,7 @@
     return databaseClient
         .sql(
             "SELECT owner, repo, created_at, updated_at, action_owner, is_team_label_enabled"
-                + " FROM github_repo")
+                + " FROM github_repo ORDER BY owner, repo")
         .map(this::toGithubRepo)
         .all()
         .collect(toImmutableList())
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/sync/issue/GithubSyncIssueTask.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/sync/issue/GithubSyncIssueTask.java
index 7fa73db..546377e 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/sync/issue/GithubSyncIssueTask.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/sync/issue/GithubSyncIssueTask.java
@@ -8,6 +8,8 @@
 import build.bazel.dashboard.github.repo.GithubRepoService;
 import build.bazel.dashboard.utils.JsonStateStore;
 import build.bazel.dashboard.utils.JsonStateStore.JsonState;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
 import io.reactivex.rxjava3.core.Completable;
 import java.time.Instant;
 import javax.annotation.Nullable;
@@ -33,12 +35,14 @@
 
   @Builder
   @Value
+  @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
   static class SyncIssueState {
     String owner;
     String repo;
     int start;
     @With int current;
     int end;
+    Instant createdAt;
   }
 
   @PutMapping("/internal/github/{owner}/{repo}/sync/issues")
@@ -67,6 +71,7 @@
             .start(start)
             .current(start)
             .end(end)
+            .createdAt(Instant.now())
             .build());
   }
 
@@ -80,10 +85,9 @@
     for (var repo : githubRepoService.findAll()) {
       String stateKey = buildSyncStateKey(repo.getOwner(), repo.getRepo());
       var jsonState = jsonStateStore.load(stateKey, SyncIssueState.class);
-      if (jsonState.getData() != null) {
-        return;
+      if (jsonState.getData() == null) {
+        saveAllSyncIssueState(repo.getOwner(), repo.getRepo(), jsonState.getTimestamp());
       }
-      saveAllSyncIssueState(repo.getOwner(), repo.getRepo(), jsonState.getTimestamp());
     }
   }
 
@@ -96,7 +100,6 @@
               buildSyncStateKey(repo.getOwner(), repo.getRepo()), SyncIssueState.class);
       if (jsonState.getData() != null) {
         this.syncGithubIssue(jsonState);
-        return;
       }
     }
   }
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepo.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepo.java
index f37e94a..0abc040 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepo.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepo.java
@@ -18,7 +18,6 @@
     Instant createdAt;
     Instant updatedAt;
     String name;
-    String noneTeamOwner;
     List<Header> headers;
 
     @Builder
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepoPg.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepoPg.java
index f5218d3..e6b1154 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepoPg.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableRepoPg.java
@@ -20,7 +20,7 @@
         Optional.ofNullable(
             databaseClient
                 .sql(
-                    "SELECT owner, repo, id, created_at, updated_at, name, none_team_owner FROM"
+                    "SELECT owner, repo, id, created_at, updated_at, name FROM"
                         + " github_team_table WHERE owner = :owner AND repo = :repo AND id = :id")
                 .bind("owner", owner)
                 .bind("repo", repo)
@@ -33,8 +33,7 @@
                             .id(row.get("id", String.class))
                             .createdAt(row.get("created_at", Instant.class))
                             .updatedAt(row.get("updated_at", Instant.class))
-                            .name(row.get("name", String.class))
-                            .noneTeamOwner(row.get("none_team_owner", String.class)))
+                            .name(row.get("name", String.class)))
                 .one()
                 .block());
     if (builderOptional.isEmpty()) {
diff --git a/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableService.java b/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableService.java
index 8d4d2bb..4b1e7b0 100644
--- a/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableService.java
+++ b/dashboard/server/src/main/java/build/bazel/dashboard/github/teamtable/GithubTeamTableService.java
@@ -4,6 +4,7 @@
 
 import build.bazel.dashboard.github.GithubUtils;
 import build.bazel.dashboard.github.issuequery.GithubIssueQueryExecutor;
+import build.bazel.dashboard.github.repo.GithubRepoService;
 import build.bazel.dashboard.github.team.GithubTeam;
 import build.bazel.dashboard.github.team.GithubTeamService;
 import build.bazel.dashboard.github.teamtable.GithubTeamTable.Row;
@@ -32,6 +33,7 @@
 @RequiredArgsConstructor
 public class GithubTeamTableService {
   private final GithubTeamTableRepo githubTeamTableRepo;
+  private final GithubRepoService githubRepoService;
   private final GithubTeamService githubTeamService;
   private final GithubIssueQueryExecutor githubIssueQueryExecutor;
 
@@ -62,13 +64,21 @@
   }
 
   GithubTeamTable loadOne(String owner, String repo, String tableId) {
-    return githubTeamTableRepo
-        .findOne(owner, repo, tableId)
-        .map(
-            table -> {
-              var teams = findAllTeams(owner, repo, table.getNoneTeamOwner());
-              return fetchTable(teams, table);
-            })
+    return githubRepoService
+        .findOne(owner, repo)
+        .flatMap(
+            githubRepo ->
+                githubTeamTableRepo
+                    .findOne(githubRepo.getOwner(), githubRepo.getRepo(), tableId)
+                    .map(
+                        table -> {
+                          var noneTeamOwner = "";
+                          if (githubRepo.getActionOwner() != null) {
+                            noneTeamOwner = githubRepo.getActionOwner();
+                          }
+                          var teams = findAllTeams(owner, repo, noneTeamOwner);
+                          return fetchTable(teams, table);
+                        }))
         .orElseGet(() -> GithubTeamTable.buildNone(owner, repo, tableId, ""));
   }