diff --git a/internal/gitea/client.go b/internal/gitea/client.go
index 6df4917..40e6e89 100644
--- a/internal/gitea/client.go
+++ b/internal/gitea/client.go
@@ -105,8 +105,9 @@ type PullRequest struct {
Deletions int `json:"deletions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
- RepoOwner string `json:"-"` // populated after fetch
- RepoName string `json:"-"` // populated after fetch
+ RepoOwner string `json:"-"` // populated after fetch
+ RepoName string `json:"-"` // populated after fetch
+ ReviewState string `json:"-"` // aggregated review state: "approved", "changes_requested", "pending", or ""
}
// TriageItem represents an item in the triage queue.
@@ -944,6 +945,74 @@ func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string
return string(rendered), nil
}
+// Review represents a single review on a pull request.
+type Review struct {
+ ID int64 `json:"id"`
+ Body string `json:"body"`
+ State string `json:"state"` // "APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING"
+ User struct {
+ Login string `json:"login"`
+ } `json:"user"`
+}
+
+// GetPullReviewState fetches reviews for a PR and returns the aggregate state.
+// Priority: changes_requested > approved > pending > "" (no reviews).
+func (c *Client) GetPullReviewState(ctx context.Context, token, owner, repo string, index int64) string {
+ path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?limit=50", owner, repo, index)
+ resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+
+ var reviews []Review
+ if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil {
+ return ""
+ }
+
+ if len(reviews) == 0 {
+ return ""
+ }
+
+ // Aggregate: last non-comment review per user wins, then pick the "worst" state.
+ userState := make(map[string]string)
+ for _, r := range reviews {
+ switch r.State {
+ case "APPROVED", "REQUEST_CHANGES":
+ userState[r.User.Login] = r.State
+ }
+ }
+
+ if len(userState) == 0 {
+ return "pending"
+ }
+
+ for _, s := range userState {
+ if s == "REQUEST_CHANGES" {
+ return "changes_requested"
+ }
+ }
+ return "approved"
+}
+
+// EnrichPullsWithReviewState fetches review state for each PR concurrently.
+func (c *Client) EnrichPullsWithReviewState(ctx context.Context, token string, pulls []PullRequest) {
+ sem := make(chan struct{}, c.maxConcurrent)
+ var wg sync.WaitGroup
+
+ for i := range pulls {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+
+ pulls[idx].ReviewState = c.GetPullReviewState(ctx, token, pulls[idx].RepoOwner, pulls[idx].RepoName, pulls[idx].Number)
+ }(i)
+ }
+ wg.Wait()
+}
+
// priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int {
for _, l := range labels {
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 516d7a1..4e9ed34 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -412,6 +412,10 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
if result.HasMore {
data.NextPage = page + 1
}
+ // Enrich PRs with review state for status icons.
+ if len(data.Pulls) > 0 {
+ h.Client.EnrichPullsWithReviewState(r.Context(), token, data.Pulls)
+ }
}
}
diff --git a/internal/templates/pulls.html b/internal/templates/pulls.html
index 402b7c5..51dee24 100644
--- a/internal/templates/pulls.html
+++ b/internal/templates/pulls.html
@@ -12,7 +12,13 @@
{{end}}
+{{.Additions}}
-{{.Deletions}}
- {{if .Mergeable}}mergeable{{end}}
+ {{if eq .ReviewState "approved"}}✓
+ {{else if eq .ReviewState "changes_requested"}}✗
+ {{else if eq .ReviewState "pending"}}⏲
+ {{end}}
+ {{if .Mergeable}}▶ Ready
+ {{else}}Conflicts
+ {{end}}
{{end}}
diff --git a/static/style.css b/static/style.css
index d4bea3d..aa7af14 100644
--- a/static/style.css
+++ b/static/style.css
@@ -419,6 +419,29 @@ a:active {
vertical-align: middle;
}
+/* Review status badges */
+.review-badge {
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 1px 4px;
+ border-radius: var(--radius-pill);
+ vertical-align: middle;
+}
+.review-approved { color: var(--accent-green); }
+.review-changes { color: var(--accent-red); }
+.review-pending { color: var(--accent-yellow); }
+
+/* Merge status badges */
+.merge-badge {
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 1px 5px;
+ border-radius: var(--radius-pill);
+ vertical-align: middle;
+}
+.merge-ready { color: var(--accent-green); border: 1px solid var(--accent-green); }
+.merge-conflicts { color: var(--accent-red); border: 1px solid var(--accent-red); }
+
/* Empty state */
.empty {
text-align: center;