Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/dashboard/api_pr_review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,19 @@ func TestAPIPRReviewRoutes(t *testing.T) {
t.Errorf("started review response should name the kind: %s", body)
}
}

// TestAPIPRReviewStatusRoute wires GET review-status. The seeded PR's repo isn't
// a resolvable GitHub remote in the test, so it returns {status:null} (200) — the
// graceful-degrade path — proving the route is reachable without needing network.
func TestAPIPRReviewStatusRoute(t *testing.T) {
srv, _, prID := apiTestServer(t)
defer srv.Close()

code, body := apiReq(t, srv, http.MethodGet, "/api/prs/"+itoa(prID)+"/review-status", "")
if code != http.StatusOK {
t.Fatalf("GET review-status = %d, want 200 (%s)", code, body)
}
if !strings.Contains(body, `"status"`) {
t.Errorf("review-status response missing status field: %s", body)
}
}
2 changes: 2 additions & 0 deletions internal/dashboard/automations_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ func (d *dashboardServer) handleAPIPRItem(w http.ResponseWriter, r *http.Request
d.handleAPIPRReviewStart(w, r, prID)
case action == "review" && r.Method == http.MethodGet:
d.handlePRReviewGet(w, r, prID)
case action == "review-status" && r.Method == http.MethodGet:
d.handlePRReviewStatus(w, r, prID)
case action == "comment" && r.Method == http.MethodPost:
// Post a comment on the PR (posts to GitHub via gh). Used to publish a
// full-review result: read GET .../review, then POST it here as {body}.
Expand Down
34 changes: 34 additions & 0 deletions internal/dashboard/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2495,6 +2495,40 @@
}
}
},
"/api/prs/{id}/review-status": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"summary": "A PR's human-review rollup (approvals, comments)",
"description": "Read live from GitHub via gh: the review decision, approval count vs the branch rule's required count, per-reviewer state, and discussion comment count — so you can tell whether someone has already reviewed/approved the PR. Read-only and cheap. requiredApprovals is 0 when branch protection can't be read (no protection, or the token lacks access). Returns {status:null} if the repo isn't a GitHub remote or gh fails.",
"responses": {
"200": {
"description": "Review status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "object",
"nullable": true
}
}
}
}
}
}
}
}
},
"/api/prs/{id}/comment": {
"parameters": [
{
Expand Down
27 changes: 27 additions & 0 deletions internal/dashboard/repos_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ func (d *dashboardServer) handlePRItem(w http.ResponseWriter, r *http.Request, r
d.handlePRRiskGet(w, r, prID)
case action == "analyze" && r.Method == http.MethodPost:
d.handlePRAnalyze(w, r, prID)
case action == "review-status" && r.Method == http.MethodGet:
d.handlePRReviewStatus(w, r, prID)
case action == "review" && r.Method == http.MethodGet:
d.handlePRReviewGet(w, r, prID)
case action == "review" && r.Method == http.MethodPost:
Expand Down Expand Up @@ -953,6 +955,31 @@ func (d *dashboardServer) handlePRAnalyze(w http.ResponseWriter, r *http.Request
writeFilesJSON(w, map[string]any{"risk": v})
}

// handlePRReviewStatus returns the PR's human-review rollup (approvals, required
// count, reviewers, comment count) — read live from GitHub at load so you can see
// whether someone already reviewed/approved it. Best-effort: {status:null} if the
// repo isn't a GitHub remote or gh fails, so the UI degrades quietly.
func (d *dashboardServer) handlePRReviewStatus(w http.ResponseWriter, r *http.Request, prID int64) {
s, err := d.getStore()
if err != nil {
http.Error(w, "database unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
svc := prreview.New(s)
ownerName := d.ownerNameForPR(svc, prID)
if ownerName == "" {
writeFilesJSON(w, map[string]any{"status": nil})
return
}
st, err := svc.ReviewStatus(prID, ownerName)
if err != nil {
// A gh hiccup (rate limit, offline) shouldn't error the page — return null.
writeFilesJSON(w, map[string]any{"status": nil})
return
}
writeFilesJSON(w, map[string]any{"status": st})
}

// handlePRReviewGet returns the last stored full-review markdown (null if never run).
func (d *dashboardServer) handlePRReviewGet(w http.ResponseWriter, r *http.Request, prID int64) {
s, err := d.getStore()
Expand Down
78 changes: 78 additions & 0 deletions internal/dashboard/webui/app-src/src/pages/PRReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,83 @@ function PRNotes({ prId }: { prId: number }) {
);
}

// ReviewStatus mirrors the backend rollup (GET /prs/<id>/review-status): who has
// reviewed/approved a PR and whether it has enough approvals to merge.
type ReviewStatus = {
decision: string; // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED | ""
approvals: number;
requiredApprovals: number; // 0 when branch protection can't be read
changesRequested: boolean;
reviewers: { login: string; state: string }[];
comments: number;
};

// ReviewStatusStrip shows, at PR load, whether someone has already reviewed the
// PR: the approval tally (X / N when the branch rule is known), any change
// requests, who reviewed, and the discussion comment count. Read live from
// GitHub; renders nothing until loaded and stays quiet if unavailable (private
// repo / gh offline), so it never blocks the page.
function ReviewStatusStrip({ prId }: { prId: number }) {
const [st, setSt] = useState<ReviewStatus | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let live = true;
getJSON<{ status: ReviewStatus | null }>(`/prs/${prId}/review-status`)
.then((d) => {
if (live) setSt(d.status);
})
.catch(() => {})
.finally(() => {
if (live) setLoaded(true);
});
return () => {
live = false;
};
}, [prId]);

if (!loaded || !st) return null;

// The approval chip: "✓ Approved 2/1", "1/2 approvals", or "No approvals yet".
const approvalText =
st.requiredApprovals > 0
? `${st.approvals}/${st.requiredApprovals} approval${st.requiredApprovals === 1 ? "" : "s"}`
: st.approvals > 0
? `${st.approvals} approval${st.approvals === 1 ? "" : "s"}`
: "No approvals yet";
const enough = st.requiredApprovals > 0 && st.approvals >= st.requiredApprovals;

return (
<div className="pr-reviewstatus">
{st.changesRequested ? (
<span className="pr-rs-chip changes" title="A reviewer requested changes">
✗ Changes requested
</span>
) : (
<span className={`pr-rs-chip${enough ? " approved" : st.approvals > 0 ? " partial" : ""}`}>
{enough ? "✓ " : ""}
{approvalText}
</span>
)}
{st.reviewers.length > 0 && (
<span className="pr-rs-reviewers">
{st.reviewers.map((rv) => (
<span
key={rv.login}
className={`pr-rs-reviewer ${rv.state.toLowerCase()}`}
title={`${rv.login} — ${rv.state.replace("_", " ").toLowerCase()}`}
>
{rv.state === "APPROVED" ? "✓" : rv.state === "CHANGES_REQUESTED" ? "✗" : "💬"} {rv.login}
</span>
))}
</span>
)}
<span className="pr-rs-comments" title="Discussion comments on this PR">
💬 {st.comments} comment{st.comments === 1 ? "" : "s"}
</span>
</div>
);
}

// FullReview is the stored result from GET/POST /prs/<id>/review.
type FullReview = { markdown: string; reviewedAt?: string };

Expand Down Expand Up @@ -1090,6 +1167,7 @@ export function PRReviewPage({ repoId, number }: { repoId: string; number: numbe
)}
{pr && notesOpen && <PRNotes prId={pr.id} />}
{pr && <PRActions prId={pr.id} repoId={repoId} pr={pr} repoName={repo?.name} commentDraft={commentDraft} />}
{pr && <ReviewStatusStrip prId={pr.id} />}
{err ? (
<p className="tab-note err">Failed to load PR #{number}: {err}</p>
) : !pr ? (
Expand Down
19 changes: 19 additions & 0 deletions internal/dashboard/webui/app-src/src/styles/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,25 @@ table.projects th, table.projects td {
.pr-actions { margin-bottom: 0.9rem; }
.pr-actions-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.pr-actions-msg { font-size: 0.8rem; color: var(--con-working); }

/* Review-status strip — shown at PR load so you can see whether someone has
already reviewed/approved it, without opening the diff. */
.pr-reviewstatus {
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
margin: -0.3rem 0 0.9rem; font-size: 0.8rem;
}
.pr-rs-chip {
padding: 0.15rem 0.5rem; border-radius: 999px; font-weight: 600;
border: 1px solid var(--con-line); color: var(--con-muted, #9a958c);
}
.pr-rs-chip.approved { color: #2e7d4f; border-color: #2e7d4f; background: rgba(46,125,79,0.10); }
.pr-rs-chip.partial { color: #b8860b; border-color: #b8860b; background: rgba(184,134,11,0.10); }
.pr-rs-chip.changes { color: #b23; border-color: #b23; background: rgba(187,51,51,0.10); }
.pr-rs-reviewers { display: inline-flex; gap: 0.35rem; flex-wrap: wrap; }
.pr-rs-reviewer { color: var(--con-muted, #9a958c); }
.pr-rs-reviewer.approved { color: #2e7d4f; }
.pr-rs-reviewer.changes_requested { color: #b23; }
.pr-rs-comments { color: var(--con-muted, #9a958c); margin-left: auto; }
.pr-actions-msg.err { color: var(--con-waiting); }
.pr-actions-link { font-size: 0.8rem; margin-left: 0.4rem; color: var(--con-working); }

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/dashboard/webui/static/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/svg+xml" href="/static/app/favicon.svg">
<title>corral — control</title>
<script type="module" crossorigin src="/static/app/assets/index-DlLGMHhc.js"></script>
<link rel="stylesheet" crossorigin href="/static/app/assets/index-CY_b9603.css">
<script type="module" crossorigin src="/static/app/assets/index-Bw-yMQFk.js"></script>
<link rel="stylesheet" crossorigin href="/static/app/assets/index-BaLRZ4d4.css">
</head>
<body>
<div id="root"></div>
Expand Down
112 changes: 112 additions & 0 deletions internal/prreview/review_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package prreview

import (
"encoding/json"
"fmt"
"strings"
)

// ReviewStatus is a lightweight snapshot of a PR's human-review state, shown on
// the PR page at load so you can see at a glance whether someone else has already
// reviewed/approved it and whether it has enough approvals to merge.
type ReviewStatus struct {
// Decision is GitHub's rollup: "APPROVED", "CHANGES_REQUESTED",
// "REVIEW_REQUIRED", or "" (no review policy / not determinable).
Decision string `json:"decision"`
// Approvals is the count of DISTINCT users whose latest review is APPROVED.
Approvals int `json:"approvals"`
// RequiredApprovals is the branch rule's required_approving_review_count, or 0
// when it couldn't be read (no protection, or the token lacks access). When 0,
// the UI shows just the approval count, not "X/N".
RequiredApprovals int `json:"requiredApprovals"`
// ChangesRequested is true if any current reviewer is blocking with changes.
ChangesRequested bool `json:"changesRequested"`
// Reviewers is the per-user latest review state (login → APPROVED /
// CHANGES_REQUESTED / COMMENTED), newest-wins.
Reviewers []Reviewer `json:"reviewers"`
// Comments is the number of discussion comments on the PR (issue comments).
Comments int `json:"comments"`
}

// Reviewer is one user's latest review verdict on the PR.
type Reviewer struct {
Login string `json:"login"`
State string `json:"state"` // APPROVED | CHANGES_REQUESTED | COMMENTED
}

// ReviewStatus fetches the PR's review rollup from GitHub in one `gh pr view`
// call, plus a best-effort branch-protection lookup for the required approval
// count. All of it is read-only and cheap; branch protection degrades to 0 (not
// an error) when the token can't read it, so the feature still works on repos
// where you're not an admin.
func (s *Service) ReviewStatus(prID int64, ownerName string) (*ReviewStatus, error) {
ref, err := s.prRef(prID)
if err != nil {
return nil, err
}
out, err := runGh("pr", "view", fmt.Sprint(ref.Number), "--repo", ownerName,
"--json", "reviewDecision,reviews,comments,baseRefName")
if err != nil {
return nil, err
}
var raw struct {
ReviewDecision string `json:"reviewDecision"`
BaseRefName string `json:"baseRefName"`
Reviews []struct {
Author struct {
Login string `json:"login"`
} `json:"author"`
State string `json:"state"`
SubmittedAt string `json:"submittedAt"`
} `json:"reviews"`
Comments []struct {
ID string `json:"id"`
} `json:"comments"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil, fmt.Errorf("prreview: parse review status: %w", err)
}

// Collapse to the LATEST review per user (GitHub returns them chronologically),
// ignoring pure COMMENTED/DISMISSED noise for the approval tally.
latest := map[string]string{}
for _, rv := range raw.Reviews {
login := rv.Author.Login
if login == "" {
continue
}
switch rv.State {
case "APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED":
latest[login] = rv.State
}
}
st := &ReviewStatus{
Decision: raw.ReviewDecision,
Comments: len(raw.Comments),
}
for login, state := range latest {
if state == "DISMISSED" {
continue
}
st.Reviewers = append(st.Reviewers, Reviewer{Login: login, State: state})
if state == "APPROVED" {
st.Approvals++
}
if state == "CHANGES_REQUESTED" {
st.ChangesRequested = true
}
}

// Required approvals from branch protection — best-effort. A 404/403 (no
// protection, or no access) just leaves it 0.
if raw.BaseRefName != "" {
if pOut, perr := runGh("api", fmt.Sprintf("repos/%s/branches/%s/protection", ownerName, raw.BaseRefName),
"--jq", ".required_pull_request_reviews.required_approving_review_count"); perr == nil {
var n int
if _, e := fmt.Sscanf(strings.TrimSpace(pOut), "%d", &n); e == nil {
st.RequiredApprovals = n
}
}
}
return st, nil
}
Loading