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
20pub async fn handle_webhook(
28 State(state): State<AppState>,
29 VerifiedWebhookPayload(body): VerifiedWebhookPayload,
30) -> ApiResult<impl IntoResponse> {
31 let payload: Value = serde_json::from_slice(&body)?;
33
34 if let Some(action) = payload.get("action").and_then(|v| v.as_str()) {
36 if let Some(_pull_request) = payload.get("pull_request") {
38 if let Some(_review) = payload.get("review") {
40 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 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 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 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 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
86async 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 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 }
116 Err(e) => {
117 warn!(
119 "Failed to check collaborator role for {}: {}. Proceeding with credit check.",
120 username, e
121 );
122 }
123 }
124
125 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 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 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 Ok(());
158 }
159
160 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 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
205async 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 state
215 .github_client
216 .add_comment(repo_owner, repo_name, pr_number, message)
217 .await?;
218
219 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
229fn 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 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 tokio::time::sleep(delay).await;
253
254 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
279async 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 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 }
307 Err(e) => {
308 warn!(
309 "Failed to check collaborator role for {}: {}. Proceeding with credit grant.",
310 username, e
311 );
312 }
313 }
314
315 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 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 let delta = 5;
336 let credit_before = contributor.credit_score;
337 let credit_after = apply_credit(credit_before, delta);
338
339 update_credit_score(&state.db_pool, contributor.id, credit_after).await?;
341
342 insert_credit_event(
344 &state.db_pool,
345 contributor.id,
346 "review_submitted",
347 delta,
348 credit_before,
349 credit_after,
350 None, None,
352 )
353 .await?;
354
355 info!(
356 "Granted +{} credit to {} for review (new score: {})",
357 delta, username, credit_after
358 );
359
360 Ok(())
363}
364
365async 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 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 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 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 }
415 Err(e) => {
416 warn!(
417 "Failed to check collaborator role for {}: {}. Proceeding with credit evaluation.",
418 username, e
419 );
420 }
421 }
422
423 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 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 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
457async 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 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 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 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
513async 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 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 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 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 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 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
608async 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 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 let credit_before = contributor.credit_score;
652 let credit_after = apply_credit(credit_before, delta);
653
654 update_credit_score(&state.db_pool, contributor.id, credit_after).await?;
656
657 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 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 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
716async 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 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_blacklisted(&state.db_pool, contributor.id, true).await?;
758
759 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 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
786fn 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
819fn 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
852async 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 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 let context = EvalContext {
886 content_type,
887 title: title.clone(),
888 body: body.clone(),
889 diff_summary,
890 thread_context,
891 };
892
893 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 let delta = calculate_delta_with_config(
907 &state.repo_config,
908 event_type,
909 evaluation.classification,
910 );
911
912 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 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 if evaluation.confidence >= 0.85 {
925 let credit_after = apply_credit(credit_before, delta);
927
928 update_credit_score(&state.db_pool, contributor_id, credit_after).await?;
930
931 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 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_blacklisted(&state.db_pool, contributor_id, true).await?;
963
964 insert_credit_event(
966 &state.db_pool,
967 contributor_id,
968 "auto_blacklist",
969 0, 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 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 sqlx::any::install_default_drivers();
1028
1029 let pool = AnyPoolOptions::new()
1031 .max_connections(1)
1032 .connect("sqlite::memory:")
1033 .await
1034 .expect("Failed to create test database pool");
1035
1036 sqlx::query("PRAGMA foreign_keys = ON")
1038 .execute(&pool)
1039 .await
1040 .expect("Failed to enable foreign keys");
1041
1042 sqlx::query(include_str!("../../meritocrab-db/migrations/001_initial.sql"))
1044 .execute(&pool)
1045 .await
1046 .expect("Failed to run migrations");
1047
1048 let github_client = create_mock_github_client();
1050
1051 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 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1069
1070 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}