1use axum::{
2 extract::{Path, Query, State},
3 http::StatusCode,
4 response::{IntoResponse, Json, Response},
5 Extension,
6};
7use meritocrab_core::{
8 credit::apply_credit,
9 EvaluationStatus,
10};
11use meritocrab_db::{
12 contributors::{
13 count_contributors_by_repo, get_contributor_by_id, list_contributors_by_repo,
14 set_blacklisted, update_credit_score,
15 },
16 credit_events::{count_events_by_repo, insert_credit_event, list_events_by_repo},
17 evaluations::{approve_evaluation, get_evaluation, list_evaluations_by_repo_and_status, override_evaluation},
18};
19use serde::{Deserialize, Serialize};
20use tracing::{error, info};
21
22use crate::error::{ApiError, ApiResult};
23use crate::oauth::GithubUser;
24use crate::state::AppState;
25
26#[derive(Debug, Deserialize)]
28pub struct PaginationQuery {
29 #[serde(default = "default_page")]
30 page: i64,
31 #[serde(default = "default_per_page")]
32 per_page: i64,
33 #[serde(default)]
34 status: Option<String>,
35}
36
37fn default_page() -> i64 {
38 1
39}
40
41fn default_per_page() -> i64 {
42 20
43}
44
45#[derive(Debug, Deserialize)]
47pub struct EventsFilterQuery {
48 #[serde(default = "default_page")]
49 page: i64,
50 #[serde(default = "default_per_page")]
51 per_page: i64,
52 #[serde(default)]
53 contributor_id: Option<i64>,
54 #[serde(default)]
55 event_type: Option<String>,
56}
57
58#[derive(Debug, Serialize)]
60pub struct PaginatedResponse<T> {
61 data: Vec<T>,
62 page: i64,
63 per_page: i64,
64 total: i64,
65 total_pages: i64,
66}
67
68#[derive(Debug, Serialize)]
70pub struct EvaluationResponse {
71 pub id: String,
72 pub contributor_id: i64,
73 pub contributor_login: String,
74 pub repo_owner: String,
75 pub repo_name: String,
76 pub llm_classification: String,
77 pub confidence: f64,
78 pub proposed_delta: i32,
79 pub status: String,
80 pub created_at: String,
81}
82
83#[derive(Debug, Serialize)]
85pub struct ContributorResponse {
86 pub id: i64,
87 pub github_user_id: i64,
88 pub username: String,
89 pub credit_score: i32,
90 pub role: Option<String>,
91 pub is_blacklisted: bool,
92 pub last_activity: String,
93}
94
95#[derive(Debug, Serialize)]
97pub struct CreditEventResponse {
98 pub id: i64,
99 pub contributor_id: i64,
100 pub event_type: String,
101 pub delta: i32,
102 pub credit_before: i32,
103 pub credit_after: i32,
104 pub llm_evaluation: Option<String>,
105 pub maintainer_override: Option<String>,
106 pub created_at: String,
107}
108
109#[derive(Debug, Deserialize)]
111pub struct OverrideRequest {
112 pub delta: i32,
113 pub reason: String,
114}
115
116#[derive(Debug, Deserialize)]
118pub struct AdjustCreditRequest {
119 pub delta: i32,
120 pub reason: String,
121}
122
123pub async fn list_evaluations(
126 State(state): State<AppState>,
127 Path((owner, repo)): Path<(String, String)>,
128 Query(pagination): Query<PaginationQuery>,
129 Extension(_user): Extension<GithubUser>,
130) -> ApiResult<Json<PaginatedResponse<EvaluationResponse>>> {
131 let status_str = pagination.status.as_deref().unwrap_or("pending");
132 let status = match status_str {
133 "pending" => EvaluationStatus::Pending,
134 "approved" => EvaluationStatus::Approved,
135 "overridden" => EvaluationStatus::Overridden,
136 "auto_applied" => EvaluationStatus::AutoApplied,
137 _ => EvaluationStatus::Pending,
138 };
139 let offset = (pagination.page - 1) * pagination.per_page;
140
141 let evaluations =
143 list_evaluations_by_repo_and_status(&state.db_pool, &owner, &repo, &status, pagination.per_page, offset)
144 .await
145 .map_err(|e| {
146 error!("Failed to list evaluations: {}", e);
147 ApiError::InternalError(format!("Database error: {}", e))
148 })?;
149
150 let total = evaluations.len() as i64; let data: Vec<EvaluationResponse> = evaluations
155 .into_iter()
156 .map(|eval| EvaluationResponse {
157 id: eval.id,
158 contributor_id: eval.contributor_id,
159 contributor_login: format!("user-{}", eval.contributor_id), repo_owner: eval.repo_owner,
161 repo_name: eval.repo_name,
162 llm_classification: eval.llm_classification,
163 confidence: eval.confidence,
164 proposed_delta: eval.proposed_delta,
165 status: eval.status,
166 created_at: eval.created_at.to_rfc3339(),
167 })
168 .collect();
169
170 let total_pages = (total + pagination.per_page - 1) / pagination.per_page;
171
172 Ok(Json(PaginatedResponse {
173 data,
174 page: pagination.page,
175 per_page: pagination.per_page,
176 total,
177 total_pages,
178 }))
179}
180
181pub async fn approve_evaluation_handler(
184 State(state): State<AppState>,
185 Path((owner, repo, eval_id)): Path<(String, String, String)>,
186 Extension(_user): Extension<GithubUser>,
187) -> ApiResult<Response> {
188 let evaluation = get_evaluation(&state.db_pool, &eval_id)
190 .await
191 .map_err(|e| {
192 error!("Failed to get evaluation: {}", e);
193 ApiError::InternalError(format!("Database error: {}", e))
194 })?
195 .ok_or_else(|| ApiError::NotFound(format!("Evaluation not found: {}", eval_id)))?;
196
197 if evaluation.repo_owner != owner || evaluation.repo_name != repo {
199 return Err(ApiError::NotFound("Evaluation not found".to_string()));
200 }
201
202 if evaluation.status != "pending" {
204 return Err(ApiError::BadRequest(format!(
205 "Evaluation is not pending: {}",
206 evaluation.status
207 )));
208 }
209
210 let contributor = get_contributor_by_id(&state.db_pool, evaluation.contributor_id)
212 .await
213 .map_err(|e| {
214 error!("Failed to get contributor: {}", e);
215 ApiError::InternalError(format!("Database error: {}", e))
216 })?
217 .ok_or_else(|| {
218 ApiError::NotFound(format!("Contributor not found: {}", evaluation.contributor_id))
219 })?;
220
221 let credit_before = contributor.credit_score;
223 let credit_after = apply_credit(credit_before, evaluation.proposed_delta);
224
225 update_credit_score(&state.db_pool, contributor.id, credit_after)
227 .await
228 .map_err(|e| {
229 error!("Failed to update credit score: {}", e);
230 ApiError::InternalError(format!("Database error: {}", e))
231 })?;
232
233 insert_credit_event(
235 &state.db_pool,
236 contributor.id,
237 "evaluation_approved",
238 evaluation.proposed_delta,
239 credit_before,
240 credit_after,
241 Some(format!(
242 r#"{{"evaluation_id": "{}", "classification": "{}"}}"#,
243 evaluation.id, evaluation.llm_classification
244 )),
245 Some("false".to_string()), )
247 .await
248 .map_err(|e| {
249 error!("Failed to insert credit event: {}", e);
250 ApiError::InternalError(format!("Database error: {}", e))
251 })?;
252
253 approve_evaluation(&state.db_pool, &eval_id, None)
255 .await
256 .map_err(|e| {
257 error!("Failed to approve evaluation: {}", e);
258 ApiError::InternalError(format!("Database error: {}", e))
259 })?;
260
261 info!(
262 "Evaluation {} approved by maintainer for contributor {}",
263 eval_id, contributor.id
264 );
265
266 Ok((StatusCode::OK, "Evaluation approved").into_response())
267}
268
269pub async fn override_evaluation_handler(
272 State(state): State<AppState>,
273 Path((owner, repo, eval_id)): Path<(String, String, String)>,
274 Extension(_user): Extension<GithubUser>,
275 Json(req): Json<OverrideRequest>,
276) -> ApiResult<Response> {
277 let evaluation = get_evaluation(&state.db_pool, &eval_id)
279 .await
280 .map_err(|e| {
281 error!("Failed to get evaluation: {}", e);
282 ApiError::InternalError(format!("Database error: {}", e))
283 })?
284 .ok_or_else(|| ApiError::NotFound(format!("Evaluation not found: {}", eval_id)))?;
285
286 if evaluation.repo_owner != owner || evaluation.repo_name != repo {
288 return Err(ApiError::NotFound("Evaluation not found".to_string()));
289 }
290
291 if evaluation.status != "pending" {
293 return Err(ApiError::BadRequest(format!(
294 "Evaluation is not pending: {}",
295 evaluation.status
296 )));
297 }
298
299 let contributor = get_contributor_by_id(&state.db_pool, evaluation.contributor_id)
301 .await
302 .map_err(|e| {
303 error!("Failed to get contributor: {}", e);
304 ApiError::InternalError(format!("Database error: {}", e))
305 })?
306 .ok_or_else(|| {
307 ApiError::NotFound(format!("Contributor not found: {}", evaluation.contributor_id))
308 })?;
309
310 let credit_before = contributor.credit_score;
312 let credit_after = apply_credit(credit_before, req.delta);
313
314 update_credit_score(&state.db_pool, contributor.id, credit_after)
316 .await
317 .map_err(|e| {
318 error!("Failed to update credit score: {}", e);
319 ApiError::InternalError(format!("Database error: {}", e))
320 })?;
321
322 insert_credit_event(
324 &state.db_pool,
325 contributor.id,
326 "evaluation_overridden",
327 req.delta,
328 credit_before,
329 credit_after,
330 Some(format!(
331 r#"{{"evaluation_id": "{}", "classification": "{}"}}"#,
332 evaluation.id, evaluation.llm_classification
333 )),
334 Some(req.reason.clone()),
335 )
336 .await
337 .map_err(|e| {
338 error!("Failed to insert credit event: {}", e);
339 ApiError::InternalError(format!("Database error: {}", e))
340 })?;
341
342 override_evaluation(&state.db_pool, &eval_id, req.delta, req.reason.clone())
344 .await
345 .map_err(|e| {
346 error!("Failed to override evaluation: {}", e);
347 ApiError::InternalError(format!("Database error: {}", e))
348 })?;
349
350 info!(
351 "Evaluation {} overridden by maintainer for contributor {} with delta {} (reason: {})",
352 eval_id, contributor.id, req.delta, req.reason
353 );
354
355 Ok((StatusCode::OK, "Evaluation overridden").into_response())
356}
357
358pub async fn list_contributors(
361 State(state): State<AppState>,
362 Path((owner, repo)): Path<(String, String)>,
363 Query(pagination): Query<PaginationQuery>,
364 Extension(_user): Extension<GithubUser>,
365) -> ApiResult<Json<PaginatedResponse<ContributorResponse>>> {
366 let offset = (pagination.page - 1) * pagination.per_page;
367
368 let contributors =
370 list_contributors_by_repo(&state.db_pool, &owner, &repo, pagination.per_page, offset)
371 .await
372 .map_err(|e| {
373 error!("Failed to list contributors: {}", e);
374 ApiError::InternalError(format!("Database error: {}", e))
375 })?;
376
377 let total = count_contributors_by_repo(&state.db_pool, &owner, &repo)
379 .await
380 .map_err(|e| {
381 error!("Failed to count contributors: {}", e);
382 ApiError::InternalError(format!("Database error: {}", e))
383 })?;
384
385 let data: Vec<ContributorResponse> = contributors
387 .into_iter()
388 .map(|contrib| ContributorResponse {
389 id: contrib.id,
390 github_user_id: contrib.github_user_id,
391 username: format!("user-{}", contrib.github_user_id), credit_score: contrib.credit_score,
393 role: contrib.role,
394 is_blacklisted: contrib.is_blacklisted,
395 last_activity: contrib.updated_at.to_rfc3339(),
396 })
397 .collect();
398
399 let total_pages = (total + pagination.per_page - 1) / pagination.per_page;
400
401 Ok(Json(PaginatedResponse {
402 data,
403 page: pagination.page,
404 per_page: pagination.per_page,
405 total,
406 total_pages,
407 }))
408}
409
410pub async fn adjust_contributor_credit(
413 State(state): State<AppState>,
414 Path((owner, repo, user_id)): Path<(String, String, i64)>,
415 Extension(_user): Extension<GithubUser>,
416 Json(req): Json<AdjustCreditRequest>,
417) -> ApiResult<Response> {
418 let contributor = get_contributor_by_id(&state.db_pool, user_id)
420 .await
421 .map_err(|e| {
422 error!("Failed to get contributor: {}", e);
423 ApiError::InternalError(format!("Database error: {}", e))
424 })?
425 .ok_or_else(|| ApiError::NotFound(format!("Contributor not found: {}", user_id)))?;
426
427 if contributor.repo_owner != owner || contributor.repo_name != repo {
429 return Err(ApiError::NotFound("Contributor not found".to_string()));
430 }
431
432 let credit_before = contributor.credit_score;
434 let credit_after = apply_credit(credit_before, req.delta);
435
436 update_credit_score(&state.db_pool, contributor.id, credit_after)
438 .await
439 .map_err(|e| {
440 error!("Failed to update credit score: {}", e);
441 ApiError::InternalError(format!("Database error: {}", e))
442 })?;
443
444 insert_credit_event(
446 &state.db_pool,
447 contributor.id,
448 "manual_adjustment",
449 req.delta,
450 credit_before,
451 credit_after,
452 None,
453 Some(req.reason.clone()),
454 )
455 .await
456 .map_err(|e| {
457 error!("Failed to insert credit event: {}", e);
458 ApiError::InternalError(format!("Database error: {}", e))
459 })?;
460
461 info!(
462 "Credit manually adjusted for contributor {} by maintainer: delta {} (reason: {})",
463 contributor.id, req.delta, req.reason
464 );
465
466 Ok((StatusCode::OK, "Credit adjusted").into_response())
467}
468
469pub async fn toggle_contributor_blacklist(
472 State(state): State<AppState>,
473 Path((owner, repo, user_id)): Path<(String, String, i64)>,
474 Extension(_user): Extension<GithubUser>,
475) -> ApiResult<Response> {
476 let contributor = get_contributor_by_id(&state.db_pool, user_id)
478 .await
479 .map_err(|e| {
480 error!("Failed to get contributor: {}", e);
481 ApiError::InternalError(format!("Database error: {}", e))
482 })?
483 .ok_or_else(|| ApiError::NotFound(format!("Contributor not found: {}", user_id)))?;
484
485 if contributor.repo_owner != owner || contributor.repo_name != repo {
487 return Err(ApiError::NotFound("Contributor not found".to_string()));
488 }
489
490 let new_status = !contributor.is_blacklisted;
492 set_blacklisted(&state.db_pool, contributor.id, new_status)
493 .await
494 .map_err(|e| {
495 error!("Failed to set blacklist status: {}", e);
496 ApiError::InternalError(format!("Database error: {}", e))
497 })?;
498
499 let event_type = if new_status {
501 "blacklist_added"
502 } else {
503 "blacklist_removed"
504 };
505 insert_credit_event(
506 &state.db_pool,
507 contributor.id,
508 event_type,
509 0,
510 contributor.credit_score,
511 contributor.credit_score,
512 None,
513 Some(format!("Blacklist toggled by maintainer to: {}", new_status)),
514 )
515 .await
516 .map_err(|e| {
517 error!("Failed to insert credit event: {}", e);
518 ApiError::InternalError(format!("Database error: {}", e))
519 })?;
520
521 info!(
522 "Blacklist status toggled for contributor {}: {}",
523 contributor.id, new_status
524 );
525
526 Ok((
527 StatusCode::OK,
528 format!("Blacklist status set to: {}", new_status),
529 )
530 .into_response())
531}
532
533pub async fn list_credit_events(
536 State(state): State<AppState>,
537 Path((owner, repo)): Path<(String, String)>,
538 Query(filter): Query<EventsFilterQuery>,
539 Extension(_user): Extension<GithubUser>,
540) -> ApiResult<Json<PaginatedResponse<CreditEventResponse>>> {
541 let offset = (filter.page - 1) * filter.per_page;
542
543 let events = list_events_by_repo(
545 &state.db_pool,
546 &owner,
547 &repo,
548 filter.contributor_id,
549 filter.event_type.as_deref(),
550 filter.per_page,
551 offset,
552 )
553 .await
554 .map_err(|e| {
555 error!("Failed to list events: {}", e);
556 ApiError::InternalError(format!("Database error: {}", e))
557 })?;
558
559 let total = count_events_by_repo(
561 &state.db_pool,
562 &owner,
563 &repo,
564 filter.contributor_id,
565 filter.event_type.as_deref(),
566 )
567 .await
568 .map_err(|e| {
569 error!("Failed to count events: {}", e);
570 ApiError::InternalError(format!("Database error: {}", e))
571 })?;
572
573 let data: Vec<CreditEventResponse> = events
575 .into_iter()
576 .map(|event| CreditEventResponse {
577 id: event.id,
578 contributor_id: event.contributor_id,
579 event_type: event.event_type,
580 delta: event.delta,
581 credit_before: event.credit_before,
582 credit_after: event.credit_after,
583 llm_evaluation: event.llm_evaluation,
584 maintainer_override: event.maintainer_override,
585 created_at: event.created_at.to_rfc3339(),
586 })
587 .collect();
588
589 let total_pages = (total + filter.per_page - 1) / filter.per_page;
590
591 Ok(Json(PaginatedResponse {
592 data,
593 page: filter.page,
594 per_page: filter.per_page,
595 total,
596 total_pages,
597 }))
598}