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
26 changes: 26 additions & 0 deletions internal/automations/prompts_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
PromptProjectIssue = "project.issue"
PromptPRVerify = "pr.verify"
PromptPRMerge = "pr.merge"
PromptPRReview = "pr.review"
PromptAnalyzeBlock = "analyze.block"
PromptAnalyzeSummary = "analyze.summary"
PromptRisk = "pr.risk"
Expand Down Expand Up @@ -178,6 +179,31 @@ func PromptCatalog() []PromptDef {
"the ROOT CAUSE before proposing a fix — don't patch the symptom. The PR is {{pr_url}}.",
Slots: []string{"pr_number", "pr_title", "pr_url"},
},
{
Key: PromptPRReview,
Name: "Full PR review",
UsedWhen: "Run when you click \"Full Review\" on a PR. Corral first runs the risk + heuristics analysis, then feeds those findings + the PR description + the diff into this prompt (host Claude, read-only). The result can be posted as a PR comment.",
Default: "You are doing a thorough review of pull request #{{pr_number}} (\"{{pr_title}}\") in {{repo}}.\n\n" +
"You already have prior analysis to build on — use it as INPUT, not as the answer:\n\n" +
"PR description:\n{{pr_description}}\n\n" +
"Risk verdict (from the risk analysis):\n{{risk}}\n\n" +
"File heuristics / hot spots (historical signals):\n{{heuristics}}\n\n" +
"Per-block AI findings:\n{{findings}}\n\n" +
"The diff:\n```\n{{diff}}\n```\n\n" +
"While using the heuristics to find hot spots, don't treat these as binary. As in, if it's hot " +
"don't just look there, evaluate the entire PR. Look for things that are commonly missed in PRs, " +
"non handled edge cases, concurrency issues, idempotency issues, issues in regards to at least once, " +
"at most once, and trying to understand how that impacts this PR. Try and understand if this PR is " +
"purely additive verse subtractive. If additive, just look at common issues that would occur with " +
"the PR as stated above. If subtractive, or we are fixing something, understand the root cause of " +
"why something has happened and do your research. Do not try and just solve the symptom. Make a " +
"note of what the root cause probably is and justify it when reviewing the PR. If implementing these " +
"in a hot path, try and think about potential performance issues and note them.\n\n" +
"Write the review as clear markdown a human can paste into the PR: a short summary line, then the " +
"specific findings grouped sensibly (each with the file/area, what's wrong or risky, and why it " +
"matters). Call out the likely root cause where relevant. Be concrete and skip praise-filler.",
Slots: []string{"repo", "pr_number", "pr_title", "pr_description", "risk", "heuristics", "findings", "diff"},
},
{
Key: PromptPRMerge,
Name: "Rebase & merge PR in sandbox",
Expand Down
71 changes: 71 additions & 0 deletions internal/dashboard/repos_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,10 @@ 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" && r.Method == http.MethodGet:
d.handlePRReviewGet(w, r, prID)
case action == "review" && r.Method == http.MethodPost:
d.handlePRReviewRun(w, r, prID)
case action == "issues" && r.Method == http.MethodGet:
d.handlePRIssues(w, r, prID)
case action == "notes" && r.Method == http.MethodGet:
Expand Down Expand Up @@ -949,6 +953,73 @@ func (d *dashboardServer) handlePRAnalyze(w http.ResponseWriter, r *http.Request
writeFilesJSON(w, map[string]any{"risk": v})
}

// 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()
if err != nil {
http.Error(w, "database unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
rev, err := prreview.New(s).StoredReview(prID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeFilesJSON(w, map[string]any{"review": rev})
}

// handlePRReviewRun is the FULL-REVIEW WORKFLOW: it ensures the prior analysis
// exists (extract blocks + risk verdict if missing — auto-run so one click does
// everything), then feeds those findings + the PR description + diff into the
// editable pr.review prompt (host claude, read-only) and stores the markdown.
//
// POST /prs/<prId>/review
func (d *dashboardServer) handlePRReviewRun(w http.ResponseWriter, r *http.Request, prID int64) {
claudeBin, err := resolveClaudeBin()
if err != nil {
http.Error(w, "the `claude` CLI could not be located — install Claude Code and restart the dashboard", http.StatusBadGateway)
return
}
s, err := d.getStore()
if err != nil {
http.Error(w, "database unavailable: "+err.Error(), http.StatusInternalServerError)
return
}
svc := prreview.New(s).WithPromptResolver(d.promptResolver())
repoID, _ := svc.RepoIDForPR(prID)
num, _, _, _, _ := svc.PRHookContext(prID)

ctx, endSpan := d.applog().StartSpan(r.Context(), applog.Entry{
Category: applog.CatAI, Event: "ai.review",
Message: applog.Fmt("Full review PR #%d", num),
RepoID: repoID, Meta: map[string]any{"pr": num},
})
runner := func(kind string) aiRunner {
return d.capturingRunner(ctx, convOrigin{
Kind: "analysis", OriginID: fmt.Sprintf("%s-%d", kind, prID), RepoID: repoID, PRNumber: num,
}, prreview.NewClaudeRunner(claudeBin))
}

// Auto-run the prerequisites if missing/stale, so a single click does the whole
// workflow. Best-effort: if a prereq step errors, RunFullReview still proceeds
// with whatever context exists (it degrades to "not available" notes).
if blocks, _ := svc.Blocks(prID); len(blocks) == 0 {
_, _ = svc.ExtractBlocks(ctx, prID, runner("enrich"))
}
if risk, _ := svc.StoredRisk(prID); risk == nil {
_, _ = svc.AnalyzeRisk(ctx, prID, runner("risk"))
}

rev, err := svc.RunFullReview(ctx, prID, runner("review"))
if err != nil {
endSpan(err)
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
endSpan(nil)
writeFilesJSON(w, map[string]any{"review": rev})
}

// handleRepoOpenPRs: GET /repos/<id>/prs/open — the repo's live open PRs via
// `gh pr list`. A live GitHub read (no DB); returns {available:false} if the
// repo isn't a GitHub remote or gh fails, so the UI can degrade to manual fetch.
Expand Down
149 changes: 147 additions & 2 deletions internal/dashboard/webui/app-src/src/pages/PRReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,37 @@ import {
// panel (body / merge-method) and confirms before submitting, since all of
// these write to GitHub.
type ActionKind = "approve" | "comment" | "request-changes" | null;
function PRActions({ prId, repoId, pr, repoName }: { prId: number; repoId: string; pr: PrItem; repoName?: string }) {
function PRActions({
prId,
repoId,
pr,
repoName,
commentDraft,
}: {
prId: number;
repoId: string;
pr: PrItem;
repoName?: string;
// When set (from the Full Review panel's "Comment on PR"), open the comment
// box pre-filled with this text for the user to edit before posting. `seq`
// changes each request so re-sending the same text re-opens the box.
commentDraft?: { text: string; seq: number } | null;
}) {
const [open, setOpen] = useState<ActionKind>(null);
const [body, setBody] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ text: string; err: boolean } | null>(null);

// Pre-fill + open the comment box when the review panel requests it.
useEffect(() => {
if (commentDraft && commentDraft.text) {
setBody(commentDraft.text);
setOpen("comment");
setMsg(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentDraft?.seq]);

const submit = (kind: Exclude<ActionKind, null>) => {
setBusy(true);
setMsg(null);
Expand Down Expand Up @@ -825,6 +850,99 @@ function PRNotes({ prId }: { prId: number }) {
);
}

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

// FullReviewPanel is the right-side drawer for the "Full Review" workflow. It
// shows the last stored review (if any) and a Run button that POSTs /review —
// the backend first runs risk + heuristics if missing, then feeds those findings
// + the PR description + diff into the editable pr.review prompt (host Claude,
// read-only) and returns markdown. "Comment on PR" hands the markdown up to the
// action bar's comment box (pre-filled, for edit-then-post — nothing is posted
// to GitHub without the user confirming there).
function FullReviewPanel({
prId,
number,
onClose,
onComment,
}: {
prId: number;
number: number;
onClose: () => void;
onComment: (markdown: string) => void;
}) {
const [review, setReview] = useState<FullReview | null>(null);
const [running, setRunning] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);

// Restore the last stored review on open.
useEffect(() => {
getJSON<{ review: FullReview | null }>(`/prs/${prId}/review`)
.then((d) => setReview(d.review))
.catch(() => {})
.finally(() => setLoaded(true));
}, [prId]);

const run = () => {
setRunning(true);
setErr(null);
postJSON<{ review: FullReview }>(`/prs/${prId}/review`, {})
.then((d) => setReview(d.review))
.catch((e) => setErr((e as Error).message))
.finally(() => setRunning(false));
};

return (
<div className="chat-panel pr-review-panel" id="pr-review-panel">
<div className="chat-panel-bar">
<span>
<i className="screen-dot" />
full review · PR #{number}
</span>
<span className="chat-panel-actions">
<button type="button" title="Close" onClick={onClose}>
✕
</button>
</span>
</div>
<div className="pr-review-panel-body">
<div className="pr-review-panel-actions">
<button type="button" className="btn primary" disabled={running} onClick={run}>
{running ? "Reviewing…" : review ? "Re-run review" : "Run full review"}
</button>
{review && (
<button type="button" className="btn" disabled={running} onClick={() => onComment(review.markdown)}>
💬 Comment on PR
</button>
)}
</div>
{running && (
<p className="pr-review-panel-note">
Running risk + heuristics, then the full review — this can take a minute.
</p>
)}
{err && <p className="tab-note err">{err}</p>}
{!running && loaded && !review && !err && (
<p className="pr-review-panel-note">
No review yet. “Run full review” runs the risk + heuristics analysis, then reviews the whole PR
(edge cases, concurrency, idempotency, root cause) using the editable review prompt.
</p>
)}
{review && (
<>
<div
className="pr-review-panel-md markdown"
dangerouslySetInnerHTML={{ __html: renderMarkdown(review.markdown) }}
/>
{review.reviewedAt && <p className="pr-review-panel-when">Last run: {review.reviewedAt} UTC</p>}
</>
)}
</div>
</div>
);
}

export function PRReviewPage({ repoId, number }: { repoId: string; number: number }) {
useBodyClass("console");
const [repo, setRepo] = useState<CachedRepo | null>(null);
Expand All @@ -836,6 +954,10 @@ export function PRReviewPage({ repoId, number }: { repoId: string; number: numbe
const [linksOpen, setLinksOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
// A comment body requested by the Full Review panel ("Comment on PR"). Bumping
// `seq` re-opens the comment box in PRActions even if the same text is sent twice.
const [commentDraft, setCommentDraft] = useState<{ text: string; seq: number } | null>(null);

// Force a re-pull from GitHub: re-fetches the PR (body + diff) and re-extracts
// blocks. Use when the stored copy is stale (e.g. the PR was updated, or was
Expand Down Expand Up @@ -924,6 +1046,14 @@ export function PRReviewPage({ repoId, number }: { repoId: string; number: numbe
>
🗒 Notes
</button>
<button
type="button"
className={`pr-review-cta${reviewOpen ? " on" : ""}`}
title="Run a full AI review: risk + heuristics + block findings fed into the review prompt, then post as a comment"
onClick={() => setReviewOpen((v) => !v)}
>
★ Full Review
</button>
<VerifyLaunch repoId={repoId} pr={pr} repoName={repo?.name} />
<button
type="button"
Expand Down Expand Up @@ -959,7 +1089,7 @@ export function PRReviewPage({ repoId, number }: { repoId: string; number: numbe
</div>
)}
{pr && notesOpen && <PRNotes prId={pr.id} />}
{pr && <PRActions prId={pr.id} repoId={repoId} pr={pr} repoName={repo?.name} />}
{pr && <PRActions prId={pr.id} repoId={repoId} pr={pr} repoName={repo?.name} commentDraft={commentDraft} />}
{err ? (
<p className="tab-note err">Failed to load PR #{number}: {err}</p>
) : !pr ? (
Expand Down Expand Up @@ -1010,6 +1140,21 @@ export function PRReviewPage({ repoId, number }: { repoId: string; number: numbe
</div>
)}

{/* Full-review drawer (right side). Runs the review workflow (risk +
heuristics + findings → the pr.review prompt) and renders the markdown,
with a button to post it as a PR comment (pre-fills the comment box). */}
{pr && reviewOpen && (
<FullReviewPanel
prId={pr.id}
number={number}
onClose={() => setReviewOpen(false)}
onComment={(text) => {
setCommentDraft({ text, seq: Date.now() });
setReviewOpen(false);
}}
/>
)}

{pr && startOpen && (
<NewProjectModal
presetRepoId={repoId}
Expand Down
25 changes: 25 additions & 0 deletions internal/dashboard/webui/app-src/src/styles/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,31 @@ table.projects th, table.projects td {
}
.dock-toggle.on { border-color: var(--con-working); color: var(--con-working); }

/* Full Review CTA — the high-prominence action on the PR page. Solid accent fill
so it reads as the primary thing to do here, distinct from the muted toolbar
buttons around it. */
.pr-review-cta {
background: var(--con-working, #c15f3c); color: #fff;
border: 1px solid var(--con-working, #c15f3c); border-radius: 6px;
padding: 0.32rem 0.8rem; font-size: 0.82rem; font-weight: 650; cursor: pointer;
box-shadow: 0 1px 6px -1px rgba(0,0,0,0.4);
}
.pr-review-cta:hover { filter: brightness(1.07); }
.pr-review-cta.on { outline: 2px solid rgba(255,255,255,0.35); }

/* Full-review drawer: reuses .chat-panel's fixed right-drawer chrome, a touch
wider (prose), with its own scrolling body. */
.pr-review-panel { width: 480px; }
.pr-review-panel-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0.9rem 1rem; }
.pr-review-panel-actions { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; }
.pr-review-panel-note { color: var(--con-muted, #9a958c); font-size: 0.82rem; line-height: 1.5; }
.pr-review-panel-md { color: var(--con-text, #ddd); font-size: 0.86rem; line-height: 1.6; }
.pr-review-panel-md h1, .pr-review-panel-md h2, .pr-review-panel-md h3 { font-size: 0.95rem; margin: 0.8rem 0 0.4rem; }
.pr-review-panel-md ul { padding-left: 1.1rem; }
.pr-review-panel-md code { background: rgba(255,255,255,0.08); padding: 0 0.2rem; border-radius: 3px; }
.pr-review-panel-md pre { background: rgba(0,0,0,0.3); padding: 0.6rem; border-radius: 5px; overflow-x: auto; }
.pr-review-panel-when { color: var(--con-muted, #9a958c); font-size: 0.72rem; margin-top: 0.8rem; }

/* Split-button: a primary action plus a ▾ that opens a prompt picker. The two
buttons read as one control (shared border, only the seam between them). */
.split-btn { position: relative; margin-left: auto; display: inline-flex; }
Expand Down
32 changes: 32 additions & 0 deletions internal/dashboard/webui/static/app/assets/index-CY_b9603.css

Large diffs are not rendered by default.

Loading
Loading