Skip to main content

meritocrab_api/
webhook_handler.rs

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