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;