Skip to main content

meritocrab_api/
admin_handlers.rs

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/// Pagination query parameters
27#[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/// Events filter query parameters
46#[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/// Paginated response wrapper
59#[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/// Evaluation response with contributor info
69#[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/// Contributor response with last activity
84#[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/// Credit event response
96#[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/// Override evaluation request
110#[derive(Debug, Deserialize)]
111pub struct OverrideRequest {
112    pub delta: i32,
113    pub reason: String,
114}
115
116/// Adjust credit request
117#[derive(Debug, Deserialize)]
118pub struct AdjustCreditRequest {
119    pub delta: i32,
120    pub reason: String,
121}
122
123/// GET /api/repos/{owner}/{repo}/evaluations
124/// List pending evaluations with pagination
125pub 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    // Fetch evaluations from database
142    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    // Count total evaluations
151    let total = evaluations.len() as i64; // For simplicity, we're not implementing count separately
152
153    // Convert to response format
154    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), // TODO: fetch from GitHub API
160            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
181/// POST /api/repos/{owner}/{repo}/evaluations/{id}/approve
182/// Approve a pending evaluation
183pub 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    // Fetch evaluation
189    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    // Verify evaluation belongs to this repo
198    if evaluation.repo_owner != owner || evaluation.repo_name != repo {
199        return Err(ApiError::NotFound("Evaluation not found".to_string()));
200    }
201
202    // Verify evaluation is pending
203    if evaluation.status != "pending" {
204        return Err(ApiError::BadRequest(format!(
205            "Evaluation is not pending: {}",
206            evaluation.status
207        )));
208    }
209
210    // Get contributor
211    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    // Apply credit delta
222    let credit_before = contributor.credit_score;
223    let credit_after = apply_credit(credit_before, evaluation.proposed_delta);
224
225    // Update credit score
226    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    // Log credit event
234    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()), // maintainer_override = false
246    )
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
254    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
269/// POST /api/repos/{owner}/{repo}/evaluations/{id}/override
270/// Override a pending evaluation with custom delta
271pub 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    // Fetch evaluation
278    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    // Verify evaluation belongs to this repo
287    if evaluation.repo_owner != owner || evaluation.repo_name != repo {
288        return Err(ApiError::NotFound("Evaluation not found".to_string()));
289    }
290
291    // Verify evaluation is pending
292    if evaluation.status != "pending" {
293        return Err(ApiError::BadRequest(format!(
294            "Evaluation is not pending: {}",
295            evaluation.status
296        )));
297    }
298
299    // Get contributor
300    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    // Apply custom delta
311    let credit_before = contributor.credit_score;
312    let credit_after = apply_credit(credit_before, req.delta);
313
314    // Update credit score
315    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    // Log credit event with maintainer override
323    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
343    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
358/// GET /api/repos/{owner}/{repo}/contributors
359/// List contributors with pagination
360pub 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    // Fetch contributors from database
369    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    // Count total contributors
378    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    // Convert to response format
386    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), // TODO: fetch from GitHub API
392            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
410/// POST /api/repos/{owner}/{repo}/contributors/{user_id}/adjust
411/// Manually adjust contributor credit
412pub 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    // Get contributor
419    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    // Verify contributor belongs to this repo
428    if contributor.repo_owner != owner || contributor.repo_name != repo {
429        return Err(ApiError::NotFound("Contributor not found".to_string()));
430    }
431
432    // Apply credit delta
433    let credit_before = contributor.credit_score;
434    let credit_after = apply_credit(credit_before, req.delta);
435
436    // Update credit score
437    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    // Log credit event
445    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
469/// POST /api/repos/{owner}/{repo}/contributors/{user_id}/blacklist
470/// Toggle contributor blacklist status
471pub 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    // Get contributor
477    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    // Verify contributor belongs to this repo
486    if contributor.repo_owner != owner || contributor.repo_name != repo {
487        return Err(ApiError::NotFound("Contributor not found".to_string()));
488    }
489
490    // Toggle blacklist status
491    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    // Log credit event
500    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
533/// GET /api/repos/{owner}/{repo}/events
534/// List credit events with pagination and filters
535pub 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    // Fetch events from database
544    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    // Count total events
560    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    // Convert to response format
574    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}