feat: trigger code review via !reviewfast PR comment (Vibe Kanban) (#1604)
* All tasks are complete. Here's a summary of the implementation: ## Implementation Complete I've implemented the `!reviewfast` comment trigger feature. Here's what was changed: ### Files Modified 1. **`crates/remote/src/db/reviews.rs`** (lines 253-279) - Added `has_pending_review_for_pr()` method to check if a review is already in progress for a given PR 2. **`crates/remote/src/routes/github_app.rs`** - Added import for `ReviewRepository` (line 21) - Added `TriggerReviewContext` and `PrMetadata` structs (lines 800-816) - Added `try_trigger_pr_review()` shared helper function (lines 818-928) - Refactored `handle_pull_request_event` to use the shared helper (lines 930-982) - Added `handle_issue_comment_event` handler (lines 984-1039) - Wired up `"issue_comment"` event in `handle_webhook` match (line 657) 3. **`crates/remote/.sqlx/query-7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990.json`** - Added sqlx query cache for the new `has_pending_review_for_pr` query ### How It Works When a user comments exactly `!reviewfast` on a PR: 1. The `issue_comment` webhook is received 2. The handler validates: - It's a new comment (action = "created") - It's on a PR (not an issue) - The body is exactly `!reviewfast` - The commenter is not a bot 3. The shared `try_trigger_pr_review()` function then: - Checks the installation exists and isn't suspended - Checks the repository has reviews enabled - Checks no review is already pending for this PR - Checks R2 and review worker are configured - Fetches PR details from GitHub API - Spawns an async task to process the review ### Deployment Note The GitHub App must be configured to receive `issue_comment` webhook events in the GitHub App settings. * Cleanup script changes for task attempt fc1ea8e0-fa6d-435d-b569-a6bbbff3c4d2 * update review comment * Yes, you need to update your GitHub App to receive `issue_comment` webhook events. **Steps:** 1. Go to your GitHub App settings: `https://github.com/settings/apps/YOUR-APP-NAME` 2. Navigate to **Permissions & events** → **Subscribe to events** 3. Check the **Issue comments** checkbox 4. Save changes Without this, GitHub won't send the `issue_comment` webhook when someone comments `!reviewfast` on a PR. * Fixed. The tip is now always shown when the GitHub App is installed, regardless of whether repositories have finished loading. * Update copy
This commit is contained in:
committed by
GitHub
parent
5810c7cccf
commit
8a689ae4cb
24
crates/remote/.sqlx/query-7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990.json
generated
Normal file
24
crates/remote/.sqlx/query-7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1 FROM reviews\n WHERE pr_owner = $1\n AND pr_repo = $2\n AND pr_number = $3\n AND status = 'pending'\n AND deleted_at IS NULL\n ) as \"exists!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7373b3a43a7dd6c5d77c13b5094bb01a63e2902a89dec683659644dd80eb6990"
|
||||
}
|
||||
@@ -249,4 +249,32 @@ impl<'a> ReviewRepository<'a> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if there's a pending review for a specific PR
|
||||
pub async fn has_pending_review_for_pr(
|
||||
&self,
|
||||
pr_owner: &str,
|
||||
pr_repo: &str,
|
||||
pr_number: i32,
|
||||
) -> Result<bool, ReviewError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM reviews
|
||||
WHERE pr_owner = $1
|
||||
AND pr_repo = $2
|
||||
AND pr_number = $3
|
||||
AND status = 'pending'
|
||||
AND deleted_at IS NULL
|
||||
) as "exists!"
|
||||
"#,
|
||||
pr_owner,
|
||||
pr_repo,
|
||||
pr_number
|
||||
)
|
||||
.fetch_one(self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.exists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
auth::RequestContext,
|
||||
db::{
|
||||
github_app::GitHubAppRepository2, identity_errors::IdentityError,
|
||||
organizations::OrganizationRepository,
|
||||
organizations::OrganizationRepository, reviews::ReviewRepository,
|
||||
},
|
||||
github_app::{PrReviewParams, PrReviewService, verify_webhook_signature},
|
||||
};
|
||||
@@ -654,6 +654,7 @@ pub async fn handle_webhook(
|
||||
"installation" => handle_installation_event(&state, &payload).await,
|
||||
"installation_repositories" => handle_installation_repos_event(&state, &payload).await,
|
||||
"pull_request" => handle_pull_request_event(&state, github_app, &payload).await,
|
||||
"issue_comment" => handle_issue_comment_event(&state, github_app, &payload).await,
|
||||
_ => {
|
||||
info!(event_type, "Ignoring unhandled webhook event");
|
||||
StatusCode::OK.into_response()
|
||||
@@ -795,96 +796,99 @@ async fn handle_installation_repos_event(
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn handle_pull_request_event(
|
||||
// ========== Shared PR Review Trigger Logic ==========
|
||||
|
||||
/// Parameters for triggering a PR review from webhook events
|
||||
struct TriggerReviewContext<'a> {
|
||||
installation_id: i64,
|
||||
github_repo_id: i64,
|
||||
repo_owner: &'a str,
|
||||
repo_name: &'a str,
|
||||
pr_number: u64,
|
||||
/// PR metadata - if None, will be fetched from GitHub API
|
||||
pr_metadata: Option<PrMetadata>,
|
||||
}
|
||||
|
||||
struct PrMetadata {
|
||||
title: String,
|
||||
body: String,
|
||||
head_sha: String,
|
||||
base_ref: String,
|
||||
}
|
||||
|
||||
/// Shared logic to validate and trigger a PR review.
|
||||
/// Returns Ok(()) if review was triggered, Err with reason if skipped.
|
||||
async fn try_trigger_pr_review(
|
||||
state: &AppState,
|
||||
github_app: &crate::github_app::GitHubAppService,
|
||||
payload: &serde_json::Value,
|
||||
) -> Response {
|
||||
use crate::github_app::{PrReviewParams, PrReviewService};
|
||||
|
||||
let action = payload["action"].as_str().unwrap_or("");
|
||||
|
||||
// Only handle opened PRs
|
||||
if action != "opened" {
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
let installation_id = payload["installation"]["id"].as_i64().unwrap_or(0);
|
||||
let pr_number = payload["pull_request"]["number"].as_u64().unwrap_or(0);
|
||||
let repo_owner = payload["repository"]["owner"]["login"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let repo_name = payload["repository"]["name"].as_str().unwrap_or("");
|
||||
|
||||
info!(
|
||||
installation_id,
|
||||
pr_number, repo_owner, repo_name, "Processing pull_request.opened event"
|
||||
);
|
||||
|
||||
ctx: TriggerReviewContext<'_>,
|
||||
check_pending: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
// Check if we have this installation
|
||||
let gh_repo = GitHubAppRepository2::new(state.pool());
|
||||
let installation = match gh_repo.get_by_github_id(installation_id).await {
|
||||
Ok(Some(inst)) => inst,
|
||||
Ok(None) => {
|
||||
info!(installation_id, "Installation not found, ignoring PR");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(?e, "Failed to get installation");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
};
|
||||
let installation = gh_repo
|
||||
.get_by_github_id(ctx.installation_id)
|
||||
.await
|
||||
.map_err(|_| "Failed to get installation")?
|
||||
.ok_or("Installation not found")?;
|
||||
|
||||
// Check if installation is suspended
|
||||
if installation.suspended_at.is_some() {
|
||||
info!(installation_id, "Installation is suspended, ignoring PR");
|
||||
return StatusCode::OK.into_response();
|
||||
return Err("Installation is suspended");
|
||||
}
|
||||
|
||||
// Check if this repository has reviews enabled
|
||||
let github_repo_id = payload["repository"]["id"].as_i64().unwrap_or(0);
|
||||
// Check if repository has reviews enabled
|
||||
let is_review_enabled = gh_repo
|
||||
.is_repository_review_enabled(installation.id, github_repo_id)
|
||||
.is_repository_review_enabled(installation.id, ctx.github_repo_id)
|
||||
.await
|
||||
.unwrap_or(true); // Default to true if lookup fails
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_review_enabled {
|
||||
info!(
|
||||
installation_id,
|
||||
github_repo_id, repo_owner, repo_name, "Repository has reviews disabled, ignoring PR"
|
||||
);
|
||||
return StatusCode::OK.into_response();
|
||||
return Err("Repository has reviews disabled");
|
||||
}
|
||||
|
||||
// Optionally check for pending review
|
||||
if check_pending {
|
||||
let review_repo = ReviewRepository::new(state.pool());
|
||||
if review_repo
|
||||
.has_pending_review_for_pr(ctx.repo_owner, ctx.repo_name, ctx.pr_number as i32)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err("Review already pending");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if R2 and review worker are configured
|
||||
let Some(r2) = state.r2() else {
|
||||
info!("R2 not configured, skipping PR review");
|
||||
return StatusCode::OK.into_response();
|
||||
};
|
||||
let r2 = state.r2().ok_or("R2 not configured")?;
|
||||
let worker_base_url = state
|
||||
.config
|
||||
.review_worker_base_url
|
||||
.as_ref()
|
||||
.ok_or("Review worker not configured")?;
|
||||
|
||||
let Some(worker_base_url) = state.config.review_worker_base_url.as_ref() else {
|
||||
info!("Review worker not configured, skipping PR review");
|
||||
return StatusCode::OK.into_response();
|
||||
// Get PR metadata (from payload or fetch from API)
|
||||
let (pr_title, pr_body, head_sha, base_ref) = match ctx.pr_metadata {
|
||||
Some(meta) => (meta.title, meta.body, meta.head_sha, meta.base_ref),
|
||||
None => {
|
||||
let pr_details = github_app
|
||||
.get_pr_details(
|
||||
ctx.installation_id,
|
||||
ctx.repo_owner,
|
||||
ctx.repo_name,
|
||||
ctx.pr_number,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Failed to fetch PR details")?;
|
||||
(
|
||||
pr_details.title,
|
||||
pr_details.body.unwrap_or_default(),
|
||||
pr_details.head.sha,
|
||||
pr_details.base.ref_name,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Extract PR metadata from payload
|
||||
let pr_title = payload["pull_request"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or("Untitled PR")
|
||||
.to_string();
|
||||
let pr_body = payload["pull_request"]["body"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let head_sha = payload["pull_request"]["head"]["sha"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let base_ref = payload["pull_request"]["base"]["ref"]
|
||||
.as_str()
|
||||
.unwrap_or("main")
|
||||
.to_string();
|
||||
|
||||
// Spawn async task to process PR review
|
||||
let github_app_clone = github_app.clone();
|
||||
let r2_clone = r2.clone();
|
||||
@@ -892,8 +896,10 @@ async fn handle_pull_request_event(
|
||||
let worker_url = worker_base_url.clone();
|
||||
let server_url = state.server_public_base_url.clone();
|
||||
let pool = state.pool.clone();
|
||||
let repo_owner = repo_owner.to_string();
|
||||
let repo_name = repo_name.to_string();
|
||||
let installation_id = ctx.installation_id;
|
||||
let pr_number = ctx.pr_number;
|
||||
let repo_owner = ctx.repo_owner.to_string();
|
||||
let repo_name = ctx.repo_name.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let service = PrReviewService::new(
|
||||
@@ -923,6 +929,117 @@ async fn handle_pull_request_event(
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_pull_request_event(
|
||||
state: &AppState,
|
||||
github_app: &crate::github_app::GitHubAppService,
|
||||
payload: &serde_json::Value,
|
||||
) -> Response {
|
||||
let action = payload["action"].as_str().unwrap_or("");
|
||||
|
||||
if action != "opened" {
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
let ctx = TriggerReviewContext {
|
||||
installation_id: payload["installation"]["id"].as_i64().unwrap_or(0),
|
||||
github_repo_id: payload["repository"]["id"].as_i64().unwrap_or(0),
|
||||
repo_owner: payload["repository"]["owner"]["login"]
|
||||
.as_str()
|
||||
.unwrap_or(""),
|
||||
repo_name: payload["repository"]["name"].as_str().unwrap_or(""),
|
||||
pr_number: payload["pull_request"]["number"].as_u64().unwrap_or(0),
|
||||
pr_metadata: Some(PrMetadata {
|
||||
title: payload["pull_request"]["title"]
|
||||
.as_str()
|
||||
.unwrap_or("Untitled PR")
|
||||
.to_string(),
|
||||
body: payload["pull_request"]["body"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
head_sha: payload["pull_request"]["head"]["sha"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
base_ref: payload["pull_request"]["base"]["ref"]
|
||||
.as_str()
|
||||
.unwrap_or("main")
|
||||
.to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
info!(
|
||||
installation_id = ctx.installation_id,
|
||||
pr_number = ctx.pr_number,
|
||||
repo_owner = ctx.repo_owner,
|
||||
repo_name = ctx.repo_name,
|
||||
"Processing pull_request.opened event"
|
||||
);
|
||||
|
||||
if let Err(reason) = try_trigger_pr_review(state, github_app, ctx, false).await {
|
||||
info!(reason, "Skipping PR review");
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn handle_issue_comment_event(
|
||||
state: &AppState,
|
||||
github_app: &crate::github_app::GitHubAppService,
|
||||
payload: &serde_json::Value,
|
||||
) -> Response {
|
||||
let action = payload["action"].as_str().unwrap_or("");
|
||||
|
||||
// Only handle new comments
|
||||
if action != "created" {
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
// Check if comment is on a PR (issues don't have pull_request field)
|
||||
if payload["issue"]["pull_request"].is_null() {
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
// Check for exact "!reviewfast" trigger
|
||||
let comment_body = payload["comment"]["body"].as_str().unwrap_or("").trim();
|
||||
if comment_body != "!reviewfast" {
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
// Ignore bot comments to prevent loops
|
||||
let user_type = payload["comment"]["user"]["type"].as_str().unwrap_or("");
|
||||
if user_type == "Bot" {
|
||||
info!("Ignoring !reviewfast from bot user");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
let ctx = TriggerReviewContext {
|
||||
installation_id: payload["installation"]["id"].as_i64().unwrap_or(0),
|
||||
github_repo_id: payload["repository"]["id"].as_i64().unwrap_or(0),
|
||||
repo_owner: payload["repository"]["owner"]["login"]
|
||||
.as_str()
|
||||
.unwrap_or(""),
|
||||
repo_name: payload["repository"]["name"].as_str().unwrap_or(""),
|
||||
pr_number: payload["issue"]["number"].as_u64().unwrap_or(0),
|
||||
pr_metadata: None, // Will fetch from GitHub API
|
||||
};
|
||||
|
||||
info!(
|
||||
installation_id = ctx.installation_id,
|
||||
pr_number = ctx.pr_number,
|
||||
repo_owner = ctx.repo_owner,
|
||||
repo_name = ctx.repo_name,
|
||||
"Processing !reviewfast comment"
|
||||
);
|
||||
|
||||
// Pass check_pending=true to skip if review already in progress
|
||||
if let Err(reason) = try_trigger_pr_review(state, github_app, ctx, true).await {
|
||||
info!(reason, "Skipping PR review from !reviewfast");
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
|
||||
@@ -401,9 +401,10 @@ pub async fn review_success(
|
||||
// Post PR comment instead of sending email
|
||||
if let Some(github_app) = state.github_app() {
|
||||
let comment = format!(
|
||||
"## Vibe Kanban Review Complete\n\n\
|
||||
Your code review is ready!\n\n\
|
||||
**[View Review]({})**",
|
||||
"## Review Complete\n\n\
|
||||
Your review story is ready!\n\n\
|
||||
**[View Story]({})**\n\n\
|
||||
Comment **!reviewfast** on this PR to re-generate the story.",
|
||||
review_url
|
||||
);
|
||||
|
||||
|
||||
@@ -872,6 +872,16 @@ export default function OrganizationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* !reviewfast tip */}
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800 font-medium mb-1">
|
||||
Tip: Trigger reviews on-demand
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
Comment <code className="px-1 py-0.5 bg-blue-100 rounded text-xs font-mono">!reviewfast</code> on any pull request to trigger an AI code review instantly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Disconnect section */}
|
||||
{showGithubDisconnectConfirm ? (
|
||||
<div className="bg-red-50 rounded-lg p-4">
|
||||
|
||||
@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3002
|
||||
port: 3002,
|
||||
allowedHosts: [
|
||||
".trycloudflare.com", // allow all cloudflared tunnels
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user