1use axum::{
2 Extension,
3 extract::{Path, Query, State},
4 http::StatusCode,
5 response::{IntoResponse, Json, Response},
6};
7use meritocrab_core::{EvaluationStatus, credit::apply_credit};
8use meritocrab_db::{
9 contributors::{
10 count_contributors_by_repo, get_contributor_by_id, list_contributors_by_repo,
11 set_blacklisted, update_credit_score,
12 },
13 credit_events::{count_events_by_repo, insert_credit_event, list_events_by_repo},
14 evaluations::{
15 approve_evaluation, get_evaluation, list_evaluations_by_repo_and_status,
16 override_evaluation,
17 },
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 = list_evaluations_by_repo_and_status(
143 &state.db_pool,
144 &owner,
145 &repo,
146 &status,
147 pagination.per_page,
148 offset,
149 )
150 .await
151 .map_err(|e| {
152 error!("Failed to list evaluations: {}", e);
153 ApiError::InternalError(format!("Database error: {}", e))
154 })?;
155
156 let total = evaluations.len() as i64; let data: Vec<EvaluationResponse> = evaluations
161 .into_iter()
162 .map(|eval| EvaluationResponse {
163 id: eval.id,
164 contributor_id: eval.contributor_id,
165 contributor_login: format!("user-{}", eval.contributor_id), repo_owner: eval.repo_owner,
167 repo_name: eval.repo_name,
168 llm_classification: eval.llm_classification,
169 confidence: eval.confidence,
170 proposed_delta: eval.proposed_delta,
171 status: eval.status,
172 created_at: eval.created_at.to_rfc3339(),
173 })
174 .collect();
175
176 let total_pages = (total + pagination.per_page - 1) / pagination.per_page;
177
178 Ok(Json(PaginatedResponse {
179 data,
180 page: pagination.page,
181 per_page: pagination.per_page,
182 total,
183 total_pages,
184 }))
185}
186
187pub async fn approve_evaluation_handler(
190 State(state): State<AppState>,
191 Path((owner, repo, eval_id)): Path<(String, String, String)>,
192 Extension(_user): Extension<GithubUser>,
193) -> ApiResult<Response> {
194 let evaluation = get_evaluation(&state.db_pool, &eval_id)
196 .await
197 .map_err(|e| {
198 error!("Failed to get evaluation: {}", e);
199 ApiError::InternalError(format!("Database error: {}", e))
200 })?
201 .ok_or_else(|| ApiError::NotFound(format!("Evaluation not found: {}", eval_id)))?;
202
203 if evaluation.repo_owner != owner || evaluation.repo_name != repo {
205 return Err(ApiError::NotFound("Evaluation not found".to_string()));
206 }
207
208 if evaluation.status != "pending" {
210 return Err(ApiError::BadRequest(format!(
211 "Evaluation is not pending: {}",
212 evaluation.status
213 )));
214 }
215
216 let contributor = get_contributor_by_id(&state.db_pool, evaluation.contributor_id)
218 .await
219 .map_err(|e| {
220 error!("Failed to get contributor: {}", e);
221 ApiError::InternalError(format!("Database error: {}", e))
222 })?
223 .ok_or_else(|| {
224 ApiError::NotFound(format!(
225 "Contributor not found: {}",
226 evaluation.contributor_id
227 ))
228 })?;
229
230 let credit_before = contributor.credit_score;
232 let credit_after = apply_credit(credit_before, evaluation.proposed_delta);
233
234 update_credit_score(&state.db_pool, contributor.id, credit_after)
236 .await
237 .map_err(|e| {
238 error!("Failed to update credit score: {}", e);
239 ApiError::InternalError(format!("Database error: {}", e))
240 })?;
241
242 insert_credit_event(
244 &state.db_pool,
245 contributor.id,
246 "evaluation_approved",
247 evaluation.proposed_delta,
248 credit_before,
249 credit_after,
250 Some(format!(
251 r#"{{"evaluation_id": "{}", "classification": "{}"}}"#,
252 evaluation.id, evaluation.llm_classification
253 )),
254 Some("false".to_string()), )
256 .await
257 .map_err(|e| {
258 error!("Failed to insert credit event: {}", e);
259 ApiError::InternalError(format!("Database error: {}", e))
260 })?;
261
262 approve_evaluation(&state.db_pool, &eval_id, None)
264 .await
265 .map_err(|e| {
266 error!("Failed to approve evaluation: {}", e);
267 ApiError::InternalError(format!("Database error: {}", e))
268 })?;
269
270 info!(
271 "Evaluation {} approved by maintainer for contributor {}",
272 eval_id, contributor.id
273 );
274
275 Ok((StatusCode::OK, "Evaluation approved").into_response())
276}
277
278pub async fn override_evaluation_handler(
281 State(state): State<AppState>,
282 Path((owner, repo, eval_id)): Path<(String, String, String)>,
283 Extension(_user): Extension<GithubUser>,
284 Json(req): Json<OverrideRequest>,
285) -> ApiResult<Response> {
286 let evaluation = get_evaluation(&state.db_pool, &eval_id)
288 .await
289 .map_err(|e| {
290 error!("Failed to get evaluation: {}", e);
291 ApiError::InternalError(format!("Database error: {}", e))
292 })?
293 .ok_or_else(|| ApiError::NotFound(format!("Evaluation not found: {}", eval_id)))?;
294
295 if evaluation.repo_owner != owner || evaluation.repo_name != repo {
297 return Err(ApiError::NotFound("Evaluation not found".to_string()));
298 }
299
300 if evaluation.status != "pending" {
302 return Err(ApiError::BadRequest(format!(
303 "Evaluation is not pending: {}",
304 evaluation.status
305 )));
306 }
307
308 let contributor = get_contributor_by_id(&state.db_pool, evaluation.contributor_id)
310 .await
311 .map_err(|e| {
312 error!("Failed to get contributor: {}", e);
313 ApiError::InternalError(format!("Database error: {}", e))
314 })?
315 .ok_or_else(|| {
316 ApiError::NotFound(format!(
317 "Contributor not found: {}",
318 evaluation.contributor_id
319 ))
320 })?;
321
322 let credit_before = contributor.credit_score;
324 let credit_after = apply_credit(credit_before, req.delta);
325
326 update_credit_score(&state.db_pool, contributor.id, credit_after)
328 .await
329 .map_err(|e| {
330 error!("Failed to update credit score: {}", e);
331 ApiError::InternalError(format!("Database error: {}", e))
332 })?;
333
334 insert_credit_event(
336 &state.db_pool,
337 contributor.id,
338 "evaluation_overridden",
339 req.delta,
340 credit_before,
341 credit_after,
342 Some(format!(
343 r#"{{"evaluation_id": "{}", "classification": "{}"}}"#,
344 evaluation.id, evaluation.llm_classification
345 )),
346 Some(req.reason.clone()),
347 )
348 .await
349 .map_err(|e| {
350 error!("Failed to insert credit event: {}", e);
351 ApiError::InternalError(format!("Database error: {}", e))
352 })?;
353
354 override_evaluation(&state.db_pool, &eval_id, req.delta, req.reason.clone())
356 .await
357 .map_err(|e| {
358 error!("Failed to override evaluation: {}", e);
359 ApiError::InternalError(format!("Database error: {}", e))
360 })?;
361
362 info!(
363 "Evaluation {} overridden by maintainer for contributor {} with delta {} (reason: {})",
364 eval_id, contributor.id, req.delta, req.reason
365 );
366
367 Ok((StatusCode::OK, "Evaluation overridden").into_response())
368}
369
370pub async fn list_contributors(
373 State(state): State<AppState>,
374 Path((owner, repo)): Path<(String, String)>,
375 Query(pagination): Query<PaginationQuery>,
376 Extension(_user): Extension<GithubUser>,
377) -> ApiResult<Json<PaginatedResponse<ContributorResponse>>> {
378 let offset = (pagination.page - 1) * pagination.per_page;
379
380 let contributors =
382 list_contributors_by_repo(&state.db_pool, &owner, &repo, pagination.per_page, offset)
383 .await
384 .map_err(|e| {
385 error!("Failed to list contributors: {}", e);
386 ApiError::InternalError(format!("Database error: {}", e))
387 })?;
388
389 let total = count_contributors_by_repo(&state.db_pool, &owner, &repo)
391 .await
392 .map_err(|e| {
393 error!("Failed to count contributors: {}", e);
394 ApiError::InternalError(format!("Database error: {}", e))
395 })?;
396
397 let data: Vec<ContributorResponse> = contributors
399 .into_iter()
400 .map(|contrib| ContributorResponse {
401 id: contrib.id,
402 github_user_id: contrib.github_user_id,
403 username: format!("user-{}", contrib.github_user_id), credit_score: contrib.credit_score,
405 role: contrib.role,
406 is_blacklisted: contrib.is_blacklisted,
407 last_activity: contrib.updated_at.to_rfc3339(),
408 })
409 .collect();
410
411 let total_pages = (total + pagination.per_page - 1) / pagination.per_page;
412
413 Ok(Json(PaginatedResponse {
414 data,
415 page: pagination.page,
416 per_page: pagination.per_page,
417 total,
418 total_pages,
419 }))
420}
421
422pub async fn adjust_contributor_credit(
425 State(state): State<AppState>,
426 Path((owner, repo, user_id)): Path<(String, String, i64)>,
427 Extension(_user): Extension<GithubUser>,
428 Json(req): Json<AdjustCreditRequest>,
429) -> ApiResult<Response> {
430 let contributor = get_contributor_by_id(&state.db_pool, user_id)
432 .await
433 .map_err(|e| {
434 error!("Failed to get contributor: {}", e);
435 ApiError::InternalError(format!("Database error: {}", e))
436 })?
437 .ok_or_else(|| ApiError::NotFound(format!("Contributor not found: {}", user_id)))?;
438
439 if contributor.repo_owner != owner || contributor.repo_name != repo {
441 return Err(ApiError::NotFound("Contributor not found".to_string()));
442 }
443
444 let credit_before = contributor.credit_score;
446 let credit_after = apply_credit(credit_before, req.delta);
447
448 update_credit_score(&state.db_pool, contributor.id, credit_after)
450 .await
451 .map_err(|e| {
452 error!("Failed to update credit score: {}", e);
453 ApiError::InternalError(format!("Database error: {}", e))
454 })?;
455
456 insert_credit_event(
458 &state.db_pool,
459 contributor.id,
460 "manual_adjustment",
461 req.delta,
462 credit_before,
463 credit_after,
464 None,
465 Some(req.reason.clone()),
466 )
467 .await
468 .map_err(|e| {
469 error!("Failed to insert credit event: {}", e);
470 ApiError::InternalError(format!("Database error: {}", e))
471 })?;
472
473 info!(
474 "Credit manually adjusted for contributor {} by maintainer: delta {} (reason: {})",
475 contributor.id, req.delta, req.reason
476 );
477
478 Ok((StatusCode::OK, "Credit adjusted").into_response())
479}
480
481pub async fn toggle_contributor_blacklist(
484 State(state): State<AppState>,
485 Path((owner, repo, user_id)): Path<(String, String, i64)>,
486 Extension(_user): Extension<GithubUser>,
487) -> ApiResult<Response> {
488 let contributor = get_contributor_by_id(&state.db_pool, user_id)
490 .await
491 .map_err(|e| {
492 error!("Failed to get contributor: {}", e);
493 ApiError::InternalError(format!("Database error: {}", e))
494 })?
495 .ok_or_else(|| ApiError::NotFound(format!("Contributor not found: {}", user_id)))?;
496
497 if contributor.repo_owner != owner || contributor.repo_name != repo {
499 return Err(ApiError::NotFound("Contributor not found".to_string()));
500 }
501
502 let new_status = !contributor.is_blacklisted;
504 set_blacklisted(&state.db_pool, contributor.id, new_status)
505 .await
506 .map_err(|e| {
507 error!("Failed to set blacklist status: {}", e);
508 ApiError::InternalError(format!("Database error: {}", e))
509 })?;
510
511 let event_type = if new_status {
513 "blacklist_added"
514 } else {
515 "blacklist_removed"
516 };
517 insert_credit_event(
518 &state.db_pool,
519 contributor.id,
520 event_type,
521 0,
522 contributor.credit_score,
523 contributor.credit_score,
524 None,
525 Some(format!(
526 "Blacklist toggled by maintainer to: {}",
527 new_status
528 )),
529 )
530 .await
531 .map_err(|e| {
532 error!("Failed to insert credit event: {}", e);
533 ApiError::InternalError(format!("Database error: {}", e))
534 })?;
535
536 info!(
537 "Blacklist status toggled for contributor {}: {}",
538 contributor.id, new_status
539 );
540
541 Ok((
542 StatusCode::OK,
543 format!("Blacklist status set to: {}", new_status),
544 )
545 .into_response())
546}
547
548pub async fn list_credit_events(
551 State(state): State<AppState>,
552 Path((owner, repo)): Path<(String, String)>,
553 Query(filter): Query<EventsFilterQuery>,
554 Extension(_user): Extension<GithubUser>,
555) -> ApiResult<Json<PaginatedResponse<CreditEventResponse>>> {
556 let offset = (filter.page - 1) * filter.per_page;
557
558 let events = list_events_by_repo(
560 &state.db_pool,
561 &owner,
562 &repo,
563 filter.contributor_id,
564 filter.event_type.as_deref(),
565 filter.per_page,
566 offset,
567 )
568 .await
569 .map_err(|e| {
570 error!("Failed to list events: {}", e);
571 ApiError::InternalError(format!("Database error: {}", e))
572 })?;
573
574 let total = count_events_by_repo(
576 &state.db_pool,
577 &owner,
578 &repo,
579 filter.contributor_id,
580 filter.event_type.as_deref(),
581 )
582 .await
583 .map_err(|e| {
584 error!("Failed to count events: {}", e);
585 ApiError::InternalError(format!("Database error: {}", e))
586 })?;
587
588 let data: Vec<CreditEventResponse> = events
590 .into_iter()
591 .map(|event| CreditEventResponse {
592 id: event.id,
593 contributor_id: event.contributor_id,
594 event_type: event.event_type,
595 delta: event.delta,
596 credit_before: event.credit_before,
597 credit_after: event.credit_after,
598 llm_evaluation: event.llm_evaluation,
599 maintainer_override: event.maintainer_override,
600 created_at: event.created_at.to_rfc3339(),
601 })
602 .collect();
603
604 let total_pages = (total + filter.per_page - 1) / filter.per_page;
605
606 Ok(Json(PaginatedResponse {
607 data,
608 page: filter.page,
609 per_page: filter.per_page,
610 total,
611 total_pages,
612 }))
613}