Skip to main content

meritocrab_api/
webhook_handler.rs

1use crate::{
2    error::ApiResult,
3    extractors::VerifiedWebhookPayload,
4    state::AppState,
5};
6use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
7use rand::Rng;
8use meritocrab_core::{check_blacklist, check_pr_gate, calculate_delta_with_config, apply_credit, EventType, GateResult};
9use meritocrab_db::{
10    contributors::{lookup_or_create_contributor, update_credit_score, set_blacklisted},
11    credit_events::insert_credit_event,
12    evaluations::insert_evaluation,
13};
14use meritocrab_github::{PullRequestEvent, IssueCommentEvent, PullRequestReviewEvent};
15use meritocrab_llm::{ContentType, EvalContext};
16use serde_json::Value;
17use std::time::Duration;
18use tracing::{info, warn, error};
19
20/// Webhook handler for GitHub events
21///
22/// This handler:
23/// 1. Verifies HMAC signature (handled by VerifiedWebhook extractor)
24/// 2. Parses the event payload
25/// 3. Processes pull_request, issue_comment, and pull_request_review events
26/// 4. Returns 200 OK immediately (async LLM processing happens in background)
27pub async fn handle_webhook(
28    State(state): State<AppState>,
29    VerifiedWebhookPayload(body): VerifiedWebhookPayload,
30) -> ApiResult<impl IntoResponse> {
31    // Parse the event payload
32    let payload: Value = serde_json::from_slice(&body)?;
33
34    // Check event type and action
35    if let Some(action) = payload.get("action").and_then(|v| v.as_str()) {
36        // Handle pull_request events
37        if let Some(_pull_request) = payload.get("pull_request") {
38            // Check if this is a review event
39            if let Some(_review) = payload.get("review") {
40                // This is a pull_request_review event
41                if action == "submitted" {
42                    let event: PullRequestReviewEvent = serde_json::from_slice(&body)?;
43                    process_pr_review_submitted(state, event).await?;
44                    return Ok((StatusCode::OK, Json(serde_json::json!({
45                        "status": "ok",
46                        "message": "Review processed successfully"
47                    }))));
48                }
49            } else if action == "opened" {
50                // This is a pull_request.opened event
51                let event: PullRequestEvent = serde_json::from_slice(&body)?;
52                process_pr_opened(state, event).await?;
53                return Ok((StatusCode::OK, Json(serde_json::json!({
54                    "status": "ok",
55                    "message": "PR processed successfully"
56                }))));
57            }
58        }
59
60        // Handle issue_comment events
61        if let Some(_issue) = payload.get("issue") {
62            if let Some(_comment) = payload.get("comment") {
63                if action == "created" {
64                    let event: IssueCommentEvent = serde_json::from_slice(&body)?;
65                    // Only process comments on pull requests
66                    if event.issue.pull_request.is_some() {
67                        process_comment_created(state, event).await?;
68                        return Ok((StatusCode::OK, Json(serde_json::json!({
69                            "status": "ok",
70                            "message": "Comment processed successfully"
71                        }))));
72                    }
73                }
74            }
75        }
76    }
77
78    // Return 200 OK for unhandled events
79    info!("Received webhook event (not handled), ignoring");
80    Ok((StatusCode::OK, Json(serde_json::json!({
81        "status": "ok",
82        "message": "Event type not processed"
83    }))))
84}
85
86/// Process a PR opened event
87async fn process_pr_opened(state: AppState, event: PullRequestEvent) -> ApiResult<()> {
88    let user_id = event.pull_request.user.id;
89    let username = &event.pull_request.user.login;
90    let repo_owner = &event.repository.owner.login;
91    let repo_name = &event.repository.name;
92    let pr_number = event.pull_request.number as u64;
93
94    info!(
95        "Processing PR #{} opened by {} in {}/{}",
96        pr_number, username, repo_owner, repo_name
97    );
98
99    // Step 1: Check if user is a maintainer/collaborator (bypass credit check)
100    // If role check fails (e.g., GitHub API unavailable), proceed with credit check
101    match state
102        .github_client
103        .check_collaborator_role(repo_owner, repo_name, username)
104        .await
105    {
106        Ok(role) if role.is_maintainer() => {
107            info!(
108                "User {} has maintainer role {:?}, bypassing credit check",
109                username, role
110            );
111            return Ok(());
112        }
113        Ok(_) => {
114            // User is not a maintainer, proceed with credit check
115        }
116        Err(e) => {
117            // GitHub API error - log and proceed with credit check
118            warn!(
119                "Failed to check collaborator role for {}: {}. Proceeding with credit check.",
120                username, e
121            );
122        }
123    }
124
125    // Step 2: Lookup or create contributor
126    let contributor = lookup_or_create_contributor(
127        &state.db_pool,
128        user_id,
129        repo_owner,
130        repo_name,
131        state.repo_config.starting_credit,
132    )
133    .await?;
134
135    info!(
136        "Contributor {} has credit score {}",
137        username, contributor.credit_score
138    );
139
140    // Step 3: Check if contributor is blacklisted (or check is_blacklisted field)
141    if contributor.is_blacklisted || check_blacklist(contributor.credit_score, state.repo_config.blacklist_threshold) {
142        warn!(
143            "Contributor {} is blacklisted (credit: {}, is_blacklisted: {}), scheduling delayed PR close for #{}",
144            username, contributor.credit_score, contributor.is_blacklisted, pr_number
145        );
146
147        // Shadow blacklist: schedule delayed PR close with randomized delay (30-120 seconds)
148        schedule_delayed_pr_close(
149            state.clone(),
150            repo_owner.to_string(),
151            repo_name.to_string(),
152            pr_number,
153            username.to_string(),
154        );
155
156        // Return 200 OK immediately (delay happens in background)
157        return Ok(());
158    }
159
160    // Step 4: Check PR gate (credit threshold)
161    let gate_result = check_pr_gate(contributor.credit_score, state.repo_config.pr_threshold);
162
163    match gate_result {
164        GateResult::Allow => {
165            info!(
166                "PR #{} allowed (credit: {} >= threshold: {}), spawning LLM evaluation",
167                pr_number, contributor.credit_score, state.repo_config.pr_threshold
168            );
169
170            // Step 5: Spawn async LLM evaluation
171            spawn_pr_evaluation(
172                state.clone(),
173                contributor.id,
174                user_id,
175                username.to_string(),
176                repo_owner.to_string(),
177                repo_name.to_string(),
178                event.pull_request.title,
179                event.pull_request.body.unwrap_or_default(),
180            );
181        }
182        GateResult::Deny => {
183            warn!(
184                "PR #{} denied (credit: {} < threshold: {}), closing",
185                pr_number, contributor.credit_score, state.repo_config.pr_threshold
186            );
187
188            close_pr_with_message(
189                &state,
190                repo_owner,
191                repo_name,
192                pr_number,
193                &format!(
194                    "Your contribution score ({}) is below the required threshold ({}). Please build your score through quality comments and reviews.",
195                    contributor.credit_score, state.repo_config.pr_threshold
196                ),
197            )
198            .await?;
199        }
200    }
201
202    Ok(())
203}
204
205/// Helper to close PR and add comment
206async fn close_pr_with_message(
207    state: &AppState,
208    repo_owner: &str,
209    repo_name: &str,
210    pr_number: u64,
211    message: &str,
212) -> ApiResult<()> {
213    // Add comment first
214    state
215        .github_client
216        .add_comment(repo_owner, repo_name, pr_number, message)
217        .await?;
218
219    // Then close the PR
220    state
221        .github_client
222        .close_pull_request(repo_owner, repo_name, pr_number)
223        .await?;
224
225    info!("Closed PR #{} with message", pr_number);
226    Ok(())
227}
228
229/// Schedule delayed PR close for shadow blacklist
230///
231/// This spawns a background task that waits a randomized delay (30-120 seconds)
232/// before closing the PR with a generic message. This makes the blacklist less
233/// obvious to bad actors.
234fn schedule_delayed_pr_close(
235    state: AppState,
236    repo_owner: String,
237    repo_name: String,
238    pr_number: u64,
239    username: String,
240) {
241    tokio::spawn(async move {
242        // Generate random delay between 30 and 120 seconds
243        let delay_secs = rand::rng().random_range(30..=120);
244        let delay = Duration::from_secs(delay_secs);
245
246        info!(
247            "Scheduled PR #{} close for blacklisted user {} with delay of {} seconds",
248            pr_number, username, delay_secs
249        );
250
251        // Wait for the randomized delay
252        tokio::time::sleep(delay).await;
253
254        // Close PR with generic message (no mention of blacklist/credit/spam)
255        let generic_message = "Thank you for your contribution. Unfortunately, we are unable to accept this pull request at this time.";
256
257        if let Err(e) = close_pr_with_message(
258            &state,
259            &repo_owner,
260            &repo_name,
261            pr_number,
262            generic_message,
263        )
264        .await
265        {
266            error!(
267                "Failed to close blacklisted PR #{} for {}: {}",
268                pr_number, username, e
269            );
270        } else {
271            info!(
272                "Successfully closed blacklisted PR #{} for {} after {} second delay",
273                pr_number, username, delay_secs
274            );
275        }
276    });
277}
278
279/// Process a pull request review submitted event
280async fn process_pr_review_submitted(state: AppState, event: PullRequestReviewEvent) -> ApiResult<()> {
281    let user_id = event.review.user.id;
282    let username = &event.review.user.login;
283    let repo_owner = &event.repository.owner.login;
284    let repo_name = &event.repository.name;
285
286    info!(
287        "Processing review submitted by {} in {}/{}",
288        username, repo_owner, repo_name
289    );
290
291    // Check if user is a maintainer/collaborator (skip credit for privileged roles)
292    match state
293        .github_client
294        .check_collaborator_role(repo_owner, repo_name, username)
295        .await
296    {
297        Ok(role) if role.is_maintainer() || role.has_write_access() => {
298            info!(
299                "User {} has privileged role {:?}, skipping credit for review",
300                username, role
301            );
302            return Ok(());
303        }
304        Ok(_) => {
305            // User is not privileged, proceed with credit grant
306        }
307        Err(e) => {
308            warn!(
309                "Failed to check collaborator role for {}: {}. Proceeding with credit grant.",
310                username, e
311            );
312        }
313    }
314
315    // Lookup or create contributor
316    let contributor = lookup_or_create_contributor(
317        &state.db_pool,
318        user_id,
319        repo_owner,
320        repo_name,
321        state.repo_config.starting_credit,
322    )
323    .await?;
324
325    // Check if blacklisted (skip credit for blacklisted users)
326    if check_blacklist(contributor.credit_score, state.repo_config.blacklist_threshold) {
327        info!(
328            "Contributor {} is blacklisted, skipping credit for review",
329            username
330        );
331        return Ok(());
332    }
333
334    // Reviews always grant +5 credit (no LLM evaluation needed)
335    let delta = 5;
336    let credit_before = contributor.credit_score;
337    let credit_after = apply_credit(credit_before, delta);
338
339    // Update contributor credit
340    update_credit_score(&state.db_pool, contributor.id, credit_after).await?;
341
342    // Log credit event
343    insert_credit_event(
344        &state.db_pool,
345        contributor.id,
346        "review_submitted",
347        delta,
348        credit_before,
349        credit_after,
350        None, // No LLM evaluation for reviews
351        None,
352    )
353    .await?;
354
355    info!(
356        "Granted +{} credit to {} for review (new score: {})",
357        delta, username, credit_after
358    );
359
360    // Note: Reviews always have positive delta, so no auto-blacklist check needed
361
362    Ok(())
363}
364
365/// Process an issue comment created event
366async fn process_comment_created(state: AppState, event: IssueCommentEvent) -> ApiResult<()> {
367    let user_id = event.comment.user.id;
368    let username = &event.comment.user.login;
369    let repo_owner = &event.repository.owner.login;
370    let repo_name = &event.repository.name;
371    let comment_body = &event.comment.body;
372    let issue_number = event.issue.number;
373
374    info!(
375        "Processing comment by {} in {}/{} on PR #{}",
376        username, repo_owner, repo_name, issue_number
377    );
378
379    // STEP 1: Check if comment contains /credit command
380    use crate::credit_commands::parse_credit_command;
381    if let Some(command) = parse_credit_command(comment_body) {
382        info!(
383            "Detected /credit command from {} in {}/{}: {:?}",
384            username, repo_owner, repo_name, command
385        );
386
387        // Process /credit command (requires maintainer role)
388        return process_credit_command(
389            state,
390            repo_owner.to_string(),
391            repo_name.to_string(),
392            username.to_string(),
393            issue_number as u64,
394            command,
395        )
396        .await;
397    }
398
399    // STEP 2: Check if user is a maintainer/collaborator (skip credit for privileged roles)
400    match state
401        .github_client
402        .check_collaborator_role(repo_owner, repo_name, username)
403        .await
404    {
405        Ok(role) if role.is_maintainer() || role.has_write_access() => {
406            info!(
407                "User {} has privileged role {:?}, skipping credit for comment",
408                username, role
409            );
410            return Ok(());
411        }
412        Ok(_) => {
413            // User is not privileged, proceed with credit evaluation
414        }
415        Err(e) => {
416            warn!(
417                "Failed to check collaborator role for {}: {}. Proceeding with credit evaluation.",
418                username, e
419            );
420        }
421    }
422
423    // STEP 3: Lookup or create contributor
424    let contributor = lookup_or_create_contributor(
425        &state.db_pool,
426        user_id,
427        repo_owner,
428        repo_name,
429        state.repo_config.starting_credit,
430    )
431    .await?;
432
433    // STEP 4: Check if blacklisted (comment stays but no credit earned)
434    if check_blacklist(contributor.credit_score, state.repo_config.blacklist_threshold) {
435        info!(
436            "Contributor {} is blacklisted, skipping credit for comment",
437            username
438        );
439        return Ok(());
440    }
441
442    // STEP 5: Spawn async LLM evaluation for the comment
443    spawn_comment_evaluation(
444        state.clone(),
445        contributor.id,
446        user_id,
447        username.to_string(),
448        repo_owner.to_string(),
449        repo_name.to_string(),
450        comment_body.clone(),
451        event.issue.title,
452    );
453
454    Ok(())
455}
456
457/// Process /credit command from maintainer
458async fn process_credit_command(
459    state: AppState,
460    repo_owner: String,
461    repo_name: String,
462    commenter_username: String,
463    issue_number: u64,
464    command: crate::credit_commands::CreditCommand,
465) -> ApiResult<()> {
466    use crate::credit_commands::CreditCommand;
467
468    // Check if commenter is a maintainer
469    let is_maintainer = match state
470        .github_client
471        .check_collaborator_role(&repo_owner, &repo_name, &commenter_username)
472        .await
473    {
474        Ok(role) if role.is_maintainer() => true,
475        Ok(_) => false,
476        Err(e) => {
477            warn!(
478                "Failed to check collaborator role for {}: {}. Treating as non-maintainer.",
479                commenter_username, e
480            );
481            false
482        }
483    };
484
485    // Non-maintainers: silently ignore command
486    if !is_maintainer {
487        info!(
488            "User {} is not a maintainer of {}/{}. Silently ignoring /credit command.",
489            commenter_username, repo_owner, repo_name
490        );
491        return Ok(());
492    }
493
494    info!(
495        "Processing /credit command from maintainer {} in {}/{}",
496        commenter_username, repo_owner, repo_name
497    );
498
499    // Process command based on type
500    match command {
501        CreditCommand::Check { username } => {
502            handle_credit_check(state, repo_owner, repo_name, issue_number, username).await
503        }
504        CreditCommand::Override { username, delta, reason } => {
505            handle_credit_override(state, repo_owner, repo_name, issue_number, username, delta, reason).await
506        }
507        CreditCommand::Blacklist { username } => {
508            handle_credit_blacklist(state, repo_owner, repo_name, issue_number, username).await
509        }
510    }
511}
512
513/// Handle /credit check @username command
514async fn handle_credit_check(
515    state: AppState,
516    repo_owner: String,
517    repo_name: String,
518    issue_number: u64,
519    target_username: String,
520) -> ApiResult<()> {
521    info!(
522        "Checking credit for {} in {}/{}",
523        target_username, repo_owner, repo_name
524    );
525
526    // Get GitHub user ID by username (we need to look up in database or use GitHub API)
527    // For simplicity, we'll try to find the contributor by username pattern
528    // This is a limitation - we should ideally query GitHub API to get user ID
529    // For now, we'll search contributors by repo and match username pattern
530
531    // Try to find contributor by matching github_user_id with expected username pattern
532    // Note: This is not ideal, but we don't have a username field in the database
533    // We'll need to query GitHub API to get the actual username for each user_id
534    // For now, we'll use a simplified approach: query by github_user_id if username is numeric
535
536    let contributor_opt = if let Ok(github_user_id) = target_username.parse::<i64>() {
537        meritocrab_db::contributors::get_contributor(&state.db_pool, github_user_id, &repo_owner, &repo_name).await?
538    } else {
539        // We don't have username stored, so we can't look up by username
540        // Return error message
541        let response = format!(
542            "Unable to find contributor @{}. Note: Use GitHub user ID instead of username for now.",
543            target_username
544        );
545        state
546            .github_client
547            .add_comment(&repo_owner, &repo_name, issue_number, &response)
548            .await?;
549        return Ok(());
550    };
551
552    let contributor = match contributor_opt {
553        Some(c) => c,
554        None => {
555            let response = format!("Contributor @{} not found in {}/{}.", target_username, repo_owner, repo_name);
556            state
557                .github_client
558                .add_comment(&repo_owner, &repo_name, issue_number, &response)
559                .await?;
560            return Ok(());
561        }
562    };
563
564    // Get last 5 credit events
565    let events = meritocrab_db::credit_events::list_events_by_contributor(
566        &state.db_pool,
567        contributor.id,
568        5,
569        0,
570    )
571    .await?;
572
573    // Format response
574    let mut response = format!(
575        "**Credit Report for @{}**\n\n",
576        target_username
577    );
578    response.push_str(&format!("- Credit Score: **{}**\n", contributor.credit_score));
579    response.push_str(&format!("- Role: {}\n", contributor.role.as_deref().unwrap_or("contributor")));
580    response.push_str(&format!("- Blacklisted: {}\n", if contributor.is_blacklisted { "Yes" } else { "No" }));
581    response.push_str("\n**Recent Credit History (last 5 events):**\n\n");
582
583    if events.is_empty() {
584        response.push_str("_No credit events recorded._\n");
585    } else {
586        for event in events {
587            response.push_str(&format!(
588                "- `{}`: {} ({} -> {}) — {}\n",
589                event.event_type,
590                if event.delta >= 0 { format!("+{}", event.delta) } else { event.delta.to_string() },
591                event.credit_before,
592                event.credit_after,
593                event.created_at.format("%Y-%m-%d %H:%M UTC")
594            ));
595        }
596    }
597
598    // Reply with credit report
599    state
600        .github_client
601        .add_comment(&repo_owner, &repo_name, issue_number, &response)
602        .await?;
603
604    info!("Replied with credit report for {} in {}/{}", target_username, repo_owner, repo_name);
605    Ok(())
606}
607
608/// Handle /credit override @username +10 "reason" command
609async fn handle_credit_override(
610    state: AppState,
611    repo_owner: String,
612    repo_name: String,
613    issue_number: u64,
614    target_username: String,
615    delta: i32,
616    reason: String,
617) -> ApiResult<()> {
618    info!(
619        "Overriding credit for {} in {}/{}: delta={}, reason={}",
620        target_username, repo_owner, repo_name, delta, reason
621    );
622
623    // Look up contributor (same limitation as credit check)
624    let contributor_opt = if let Ok(github_user_id) = target_username.parse::<i64>() {
625        meritocrab_db::contributors::get_contributor(&state.db_pool, github_user_id, &repo_owner, &repo_name).await?
626    } else {
627        let response = format!(
628            "Unable to find contributor @{}. Note: Use GitHub user ID instead of username for now.",
629            target_username
630        );
631        state
632            .github_client
633            .add_comment(&repo_owner, &repo_name, issue_number, &response)
634            .await?;
635        return Ok(());
636    };
637
638    let contributor = match contributor_opt {
639        Some(c) => c,
640        None => {
641            let response = format!("Contributor @{} not found in {}/{}.", target_username, repo_owner, repo_name);
642            state
643                .github_client
644                .add_comment(&repo_owner, &repo_name, issue_number, &response)
645                .await?;
646            return Ok(());
647        }
648    };
649
650    // Apply credit adjustment
651    let credit_before = contributor.credit_score;
652    let credit_after = apply_credit(credit_before, delta);
653
654    // Update credit score
655    update_credit_score(&state.db_pool, contributor.id, credit_after).await?;
656
657    // Log credit event with maintainer override
658    insert_credit_event(
659        &state.db_pool,
660        contributor.id,
661        "manual_adjustment",
662        delta,
663        credit_before,
664        credit_after,
665        None,
666        Some(reason.clone()),
667    )
668    .await?;
669
670    info!(
671        "Applied credit override for {}: {} -> {} (delta: {})",
672        target_username, credit_before, credit_after, delta
673    );
674
675    // Check if auto-blacklist should trigger
676    if credit_after <= state.repo_config.blacklist_threshold && credit_before > state.repo_config.blacklist_threshold {
677        warn!(
678            "Auto-blacklisting user {} due to credit override (credit dropped to {})",
679            target_username, credit_after
680        );
681
682        set_blacklisted(&state.db_pool, contributor.id, true).await?;
683
684        insert_credit_event(
685            &state.db_pool,
686            contributor.id,
687            "auto_blacklist",
688            0,
689            credit_after,
690            credit_after,
691            None,
692            Some(format!("Auto-blacklisted due to credit dropping to {}", credit_after)),
693        )
694        .await?;
695    }
696
697    // Reply with confirmation
698    let response = format!(
699        "Credit adjusted for @{}: **{} → {}** (delta: {})\n\nReason: {}",
700        target_username,
701        credit_before,
702        credit_after,
703        if delta >= 0 { format!("+{}", delta) } else { delta.to_string() },
704        reason
705    );
706
707    state
708        .github_client
709        .add_comment(&repo_owner, &repo_name, issue_number, &response)
710        .await?;
711
712    info!("Replied with credit override confirmation for {} in {}/{}", target_username, repo_owner, repo_name);
713    Ok(())
714}
715
716/// Handle /credit blacklist @username command
717async fn handle_credit_blacklist(
718    state: AppState,
719    repo_owner: String,
720    repo_name: String,
721    issue_number: u64,
722    target_username: String,
723) -> ApiResult<()> {
724    info!(
725        "Blacklisting user {} in {}/{}",
726        target_username, repo_owner, repo_name
727    );
728
729    // Look up contributor (same limitation as credit check)
730    let contributor_opt = if let Ok(github_user_id) = target_username.parse::<i64>() {
731        meritocrab_db::contributors::get_contributor(&state.db_pool, github_user_id, &repo_owner, &repo_name).await?
732    } else {
733        let response = format!(
734            "Unable to find contributor @{}. Note: Use GitHub user ID instead of username for now.",
735            target_username
736        );
737        state
738            .github_client
739            .add_comment(&repo_owner, &repo_name, issue_number, &response)
740            .await?;
741        return Ok(());
742    };
743
744    let contributor = match contributor_opt {
745        Some(c) => c,
746        None => {
747            let response = format!("Contributor @{} not found in {}/{}.", target_username, repo_owner, repo_name);
748            state
749                .github_client
750                .add_comment(&repo_owner, &repo_name, issue_number, &response)
751                .await?;
752            return Ok(());
753        }
754    };
755
756    // Set blacklist flag
757    set_blacklisted(&state.db_pool, contributor.id, true).await?;
758
759    // Log blacklist event
760    insert_credit_event(
761        &state.db_pool,
762        contributor.id,
763        "blacklist_added",
764        0,
765        contributor.credit_score,
766        contributor.credit_score,
767        None,
768        Some("Manually blacklisted by maintainer".to_string()),
769    )
770    .await?;
771
772    info!("Blacklisted user {} in {}/{}", target_username, repo_owner, repo_name);
773
774    // Reply with vague confirmation (as per spec)
775    let response = "User status updated.";
776
777    state
778        .github_client
779        .add_comment(&repo_owner, &repo_name, issue_number, &response)
780        .await?;
781
782    info!("Replied with blacklist confirmation for {} in {}/{}", target_username, repo_owner, repo_name);
783    Ok(())
784}
785
786/// Spawn async PR evaluation task
787fn spawn_pr_evaluation(
788    state: AppState,
789    contributor_id: i64,
790    user_id: i64,
791    username: String,
792    repo_owner: String,
793    repo_name: String,
794    pr_title: String,
795    pr_body: String,
796) {
797    tokio::spawn(async move {
798        if let Err(e) = evaluate_and_apply_credit(
799            state,
800            contributor_id,
801            user_id,
802            username,
803            repo_owner,
804            repo_name,
805            EventType::PrOpened,
806            ContentType::PullRequest,
807            Some(pr_title.clone()),
808            pr_body.clone(),
809            None,
810            None,
811        )
812        .await
813        {
814            error!("Failed to evaluate PR: {}", e);
815        }
816    });
817}
818
819/// Spawn async comment evaluation task
820fn spawn_comment_evaluation(
821    state: AppState,
822    contributor_id: i64,
823    user_id: i64,
824    username: String,
825    repo_owner: String,
826    repo_name: String,
827    comment_body: String,
828    thread_context: String,
829) {
830    tokio::spawn(async move {
831        if let Err(e) = evaluate_and_apply_credit(
832            state,
833            contributor_id,
834            user_id,
835            username,
836            repo_owner,
837            repo_name,
838            EventType::Comment,
839            ContentType::Comment,
840            None,
841            comment_body.clone(),
842            None,
843            Some(thread_context),
844        )
845        .await
846        {
847            error!("Failed to evaluate comment: {}", e);
848        }
849    });
850}
851
852/// Evaluate content and apply credit based on confidence
853async fn evaluate_and_apply_credit(
854    state: AppState,
855    contributor_id: i64,
856    user_id: i64,
857    username: String,
858    repo_owner: String,
859    repo_name: String,
860    event_type: EventType,
861    content_type: ContentType,
862    title: Option<String>,
863    body: String,
864    diff_summary: Option<String>,
865    thread_context: Option<String>,
866) -> ApiResult<()> {
867    // Acquire semaphore permit to limit concurrent evaluations
868    let _permit = state.llm_semaphore.acquire().await.map_err(|e| {
869        crate::error::ApiError::Internal(format!("Failed to acquire semaphore: {}", e))
870    })?;
871
872    info!(
873        "Evaluating {} for user {} in {}/{}",
874        match content_type {
875            ContentType::PullRequest => "PR",
876            ContentType::Comment => "comment",
877            ContentType::Review => "review",
878        },
879        username,
880        repo_owner,
881        repo_name
882    );
883
884    // Create evaluation context
885    let context = EvalContext {
886        content_type,
887        title: title.clone(),
888        body: body.clone(),
889        diff_summary,
890        thread_context,
891    };
892
893    // Perform LLM evaluation
894    let evaluation = state
895        .llm_evaluator
896        .evaluate(&body, &context)
897        .await
898        .map_err(|e| crate::error::ApiError::Internal(format!("LLM evaluation failed: {}", e)))?;
899
900    info!(
901        "LLM evaluation for {}: {:?} (confidence: {})",
902        username, evaluation.classification, evaluation.confidence
903    );
904
905    // Calculate credit delta
906    let delta = calculate_delta_with_config(
907        &state.repo_config,
908        event_type,
909        evaluation.classification,
910    );
911
912    // Serialize LLM evaluation to JSON string
913    let llm_eval_json_str = serde_json::to_string(&evaluation)
914        .map_err(|e| crate::error::ApiError::Internal(format!("Failed to serialize LLM evaluation: {}", e)))?;
915
916    // Get current contributor state
917    let contributor = meritocrab_db::contributors::get_contributor(&state.db_pool, user_id, &repo_owner, &repo_name)
918        .await?
919        .ok_or_else(|| crate::error::ApiError::Internal("Contributor not found".to_string()))?;
920
921    let credit_before = contributor.credit_score;
922
923    // Check confidence threshold
924    if evaluation.confidence >= 0.85 {
925        // High confidence: apply credit automatically
926        let credit_after = apply_credit(credit_before, delta);
927
928        // Update contributor credit
929        update_credit_score(&state.db_pool, contributor_id, credit_after).await?;
930
931        // Log credit event with LLM evaluation
932        insert_credit_event(
933            &state.db_pool,
934            contributor_id,
935            match event_type {
936                EventType::PrOpened => "pr_opened",
937                EventType::Comment => "comment",
938                EventType::PrMerged => "pr_merged",
939                EventType::ReviewSubmitted => "review_submitted",
940            },
941            delta,
942            credit_before,
943            credit_after,
944            Some(llm_eval_json_str),
945            None,
946        )
947        .await?;
948
949        info!(
950            "Applied {} credit to {} (confidence {:.2}, new score: {})",
951            delta, username, evaluation.confidence, credit_after
952        );
953
954        // Auto-blacklist if credit drops to 0 or below
955        if credit_after <= state.repo_config.blacklist_threshold && credit_before > state.repo_config.blacklist_threshold {
956            warn!(
957                "Auto-blacklisting user {} (credit dropped to {})",
958                username, credit_after
959            );
960
961            // Set blacklist flag
962            set_blacklisted(&state.db_pool, contributor_id, true).await?;
963
964            // Log auto-blacklist event
965            insert_credit_event(
966                &state.db_pool,
967                contributor_id,
968                "auto_blacklist",
969                0, // No delta for blacklist event
970                credit_after,
971                credit_after,
972                None,
973                Some(format!("Auto-blacklisted due to credit dropping to {}", credit_after)),
974            )
975            .await?;
976
977            info!(
978                "Successfully auto-blacklisted user {} (credit: {})",
979                username, credit_after
980            );
981        }
982    } else {
983        // Low confidence: create pending evaluation
984        let eval_id = format!(
985            "eval-{}-{}-{}",
986            user_id,
987            repo_name,
988            chrono::Utc::now().timestamp()
989        );
990
991        insert_evaluation(
992            &state.db_pool,
993            eval_id.clone(),
994            contributor_id,
995            &repo_owner,
996            &repo_name,
997            format!("{:?}", evaluation.classification),
998            evaluation.confidence,
999            delta,
1000        )
1001        .await?;
1002
1003        info!(
1004            "Created pending evaluation {} for {} (confidence {:.2}, proposed delta: {})",
1005            eval_id, username, evaluation.confidence, delta
1006        );
1007    }
1008
1009    Ok(())
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015    use crate::{error::ApiError, state::AppState};
1016    use hmac::{Hmac, Mac};
1017    use meritocrab_core::RepoConfig;
1018    use meritocrab_github::{GithubApiClient, WebhookSecret};
1019    use sha2::Sha256;
1020    use sqlx::any::AnyPoolOptions;
1021    use std::sync::Arc;
1022
1023    type HmacSha256 = Hmac<Sha256>;
1024
1025    async fn setup_test_state() -> AppState {
1026        // Install SQLite driver
1027        sqlx::any::install_default_drivers();
1028
1029        // Create in-memory database
1030        let pool = AnyPoolOptions::new()
1031            .max_connections(1)
1032            .connect("sqlite::memory:")
1033            .await
1034            .expect("Failed to create test database pool");
1035
1036        // Enable foreign keys
1037        sqlx::query("PRAGMA foreign_keys = ON")
1038            .execute(&pool)
1039            .await
1040            .expect("Failed to enable foreign keys");
1041
1042        // Run migrations
1043        sqlx::query(include_str!("../../meritocrab-db/migrations/001_initial.sql"))
1044            .execute(&pool)
1045            .await
1046            .expect("Failed to run migrations");
1047
1048        // Create mock GitHub client (will need to be updated with actual mock)
1049        let github_client = create_mock_github_client();
1050
1051        // Create mock LLM evaluator
1052        let llm_evaluator = Arc::new(meritocrab_llm::MockEvaluator::new());
1053
1054        let webhook_secret = WebhookSecret::new("test-secret".to_string());
1055        let repo_config = RepoConfig::default();
1056
1057        let oauth_config = crate::OAuthConfig {
1058            client_id: "test-client-id".to_string(),
1059            client_secret: "test-client-secret".to_string(),
1060            redirect_url: "http://localhost:8080/auth/callback".to_string(),
1061        };
1062
1063        AppState::new(pool, github_client, repo_config, webhook_secret, llm_evaluator, 10, oauth_config, 300)
1064    }
1065
1066    fn create_mock_github_client() -> GithubApiClient {
1067        // Initialize rustls crypto provider for tests
1068        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1069
1070        // For now, create a client that will fail if called
1071        // In a real test, we'd use wiremock or similar
1072        GithubApiClient::new("test-token".to_string()).expect("Failed to create mock client")
1073    }
1074
1075    fn compute_signature(body: &[u8], secret: &str) -> String {
1076        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
1077        mac.update(body);
1078        let result = mac.finalize();
1079        format!("sha256={}", hex::encode(result.into_bytes()))
1080    }
1081
1082    #[tokio::test]
1083    async fn test_parse_pull_request_event() {
1084        let payload = serde_json::json!({
1085            "action": "opened",
1086            "number": 123,
1087            "pull_request": {
1088                "number": 123,
1089                "title": "Test PR",
1090                "body": "Test body",
1091                "user": {
1092                    "id": 12345,
1093                    "login": "testuser"
1094                },
1095                "state": "open",
1096                "html_url": "https://github.com/owner/repo/pull/123"
1097            },
1098            "repository": {
1099                "id": 1,
1100                "name": "repo",
1101                "full_name": "owner/repo",
1102                "owner": {
1103                    "id": 1,
1104                    "login": "owner"
1105                }
1106            },
1107            "sender": {
1108                "id": 12345,
1109                "login": "testuser"
1110            }
1111        });
1112
1113        let event: Result<PullRequestEvent, _> = serde_json::from_value(payload);
1114        assert!(event.is_ok());
1115    }
1116
1117    #[tokio::test]
1118    async fn test_webhook_handler_invalid_json() {
1119        let state = setup_test_state().await;
1120        let body = b"{invalid json}";
1121
1122        let webhook_payload = VerifiedWebhookPayload(body.to_vec());
1123        let result = handle_webhook(State(state), webhook_payload).await;
1124
1125        assert!(result.is_err());
1126    }
1127
1128    #[test]
1129    fn test_error_conversion() {
1130        let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
1131        let api_err: ApiError = json_err.into();
1132
1133        match api_err {
1134            ApiError::InvalidPayload(_) => {},
1135            _ => panic!("Expected InvalidPayload error"),
1136        }
1137    }
1138}