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
19pub async fn handle_webhook(
27 State(state): State<AppState>,
28 VerifiedWebhookPayload(body): VerifiedWebhookPayload,
29) -> ApiResult<impl IntoResponse> {
30 let payload: Value = serde_json::from_slice(&body)?;
32
33 if let Some(action) = payload.get("action").and_then(|v| v.as_str()) {
35 if let Some(_pull_request) = payload.get("pull_request") {
37 if let Some(_review) = payload.get("review") {
39 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 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 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 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 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
97async 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 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 }
127 Err(e) => {
128 warn!(
130 "Failed to check collaborator role for {}: {}. Proceeding with credit check.",
131 username, e
132 );
133 }
134 }
135
136 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 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 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 Ok(());
174 }
175
176 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 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
221async 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 state
231 .github_client
232 .add_comment(repo_owner, repo_name, pr_number, message)
233 .await?;
234
235 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
245fn 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 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 tokio::time::sleep(delay).await;
269
270 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
289async 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 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 }
320 Err(e) => {
321 warn!(
322 "Failed to check collaborator role for {}: {}. Proceeding with credit grant.",
323 username, e
324 );
325 }
326 }
327
328 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 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 let delta = 5;
352 let credit_before = contributor.credit_score;
353 let credit_after = apply_credit(credit_before, delta);
354
355 update_credit_score(&state.db_pool, contributor.id, credit_after).await?;
357
358 insert_credit_event(
360 &state.db_pool,
361 contributor.id,
362 "review_submitted",
363 delta,
364 credit_before,
365 credit_after,
366 None, None,
368 )
369 .await?;
370
371 info!(
372 "Granted +{} credit to {} for review (new score: {})",
373 delta, username, credit_after
374 );
375
376 Ok(())
379}
380
381async 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 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 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 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 }
431 Err(e) => {
432 warn!(
433 "Failed to check collaborator role for {}: {}. Proceeding with credit evaluation.",
434 username, e
435 );
436 }
437 }
438
439 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 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 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
476async 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 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 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 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
545async 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 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 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 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 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 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
666async 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 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 let credit_before = contributor.credit_score;
719 let credit_after = apply_credit(credit_before, delta);
720
721 update_credit_score(&state.db_pool, contributor.id, credit_after).await?;
723
724 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 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 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
795async 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 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_blacklisted(&state.db_pool, contributor.id, true).await?;
846
847 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 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#[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#[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#[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 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 let context = EvalContext {
983 content_type,
984 title: title.clone(),
985 body: body.clone(),
986 diff_summary,
987 thread_context,
988 };
989
990 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 let delta =
1004 calculate_delta_with_config(&state.repo_config, event_type, evaluation.classification);
1005
1006 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 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 if evaluation.confidence >= 0.85 {
1025 let credit_after = apply_credit(credit_before, delta);
1027
1028 update_credit_score(&state.db_pool, contributor_id, credit_after).await?;
1030
1031 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 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_blacklisted(&state.db_pool, contributor_id, true).await?;
1065
1066 insert_credit_event(
1068 &state.db_pool,
1069 contributor_id,
1070 "auto_blacklist",
1071 0, 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 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 sqlx::any::install_default_drivers();
1129
1130 let pool = AnyPoolOptions::new()
1132 .max_connections(1)
1133 .connect("sqlite::memory:")
1134 .await
1135 .expect("Failed to create test database pool");
1136
1137 sqlx::query("PRAGMA foreign_keys = ON")
1139 .execute(&pool)
1140 .await
1141 .expect("Failed to enable foreign keys");
1142
1143 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 let github_client = create_mock_github_client();
1153
1154 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 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1181
1182 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}