Skip to main content

meritocrab_api/
admin_handlers.rs

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/// 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 = 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    // Count total evaluations
157    let total = evaluations.len() as i64; // For simplicity, we're not implementing count separately
158
159    // Convert to response format
160    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), // TODO: fetch from GitHub API
166            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
187/// POST /api/repos/{owner}/{repo}/evaluations/{id}/approve
188/// Approve a pending evaluation
189pub 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    // Fetch evaluation
195    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    // Verify evaluation belongs to this repo
204    if evaluation.repo_owner != owner || evaluation.repo_name != repo {
205        return Err(ApiError::NotFound("Evaluation not found".to_string()));
206    }
207
208    // Verify evaluation is pending
209    if evaluation.status != "pending" {
210        return Err(ApiError::BadRequest(format!(
211            "Evaluation is not pending: {}",
212            evaluation.status
213        )));
214    }
215
216    // Get contributor
217    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    // Apply credit delta
231    let credit_before = contributor.credit_score;
232    let credit_after = apply_credit(credit_before, evaluation.proposed_delta);
233
234    // Update credit score
235    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    // Log credit event
243    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()), // maintainer_override = false
255    )
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
263    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
278/// POST /api/repos/{owner}/{repo}/evaluations/{id}/override
279/// Override a pending evaluation with custom delta
280pub 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    // Fetch evaluation
287    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    // Verify evaluation belongs to this repo
296    if evaluation.repo_owner != owner || evaluation.repo_name != repo {
297        return Err(ApiError::NotFound("Evaluation not found".to_string()));
298    }
299
300    // Verify evaluation is pending
301    if evaluation.status != "pending" {
302        return Err(ApiError::BadRequest(format!(
303            "Evaluation is not pending: {}",
304            evaluation.status
305        )));
306    }
307
308    // Get contributor
309    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    // Apply custom delta
323    let credit_before = contributor.credit_score;
324    let credit_after = apply_credit(credit_before, req.delta);
325
326    // Update credit score
327    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    // Log credit event with maintainer override
335    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
355    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
370/// GET /api/repos/{owner}/{repo}/contributors
371/// List contributors with pagination
372pub 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    // Fetch contributors from database
381    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    // Count total contributors
390    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    // Convert to response format
398    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), // TODO: fetch from GitHub API
404            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
422/// POST /api/repos/{owner}/{repo}/contributors/{user_id}/adjust
423/// Manually adjust contributor credit
424pub 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    // Get contributor
431    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    // Verify contributor belongs to this repo
440    if contributor.repo_owner != owner || contributor.repo_name != repo {
441        return Err(ApiError::NotFound("Contributor not found".to_string()));
442    }
443
444    // Apply credit delta
445    let credit_before = contributor.credit_score;
446    let credit_after = apply_credit(credit_before, req.delta);
447
448    // Update credit score
449    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    // Log credit event
457    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
481/// POST /api/repos/{owner}/{repo}/contributors/{user_id}/blacklist
482/// Toggle contributor blacklist status
483pub 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    // Get contributor
489    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    // Verify contributor belongs to this repo
498    if contributor.repo_owner != owner || contributor.repo_name != repo {
499        return Err(ApiError::NotFound("Contributor not found".to_string()));
500    }
501
502    // Toggle blacklist status
503    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    // Log credit event
512    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
548/// GET /api/repos/{owner}/{repo}/events
549/// List credit events with pagination and filters
550pub 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    // Fetch events from database
559    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    // Count total events
575    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    // Convert to response format
589    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}