guts_node/
ci_api.rs

1//! # CI/CD API
2//!
3//! This module provides the CI/CD API endpoints for managing workflows, runs,
4//! artifacts, and status checks.
5//!
6//! ## Endpoint Overview
7//!
8//! | Method | Path | Description |
9//! |--------|------|-------------|
10//! | GET | `/api/repos/{owner}/{name}/workflows` | List workflows |
11//! | POST | `/api/repos/{owner}/{name}/workflows` | Create/update workflow |
12//! | GET | `/api/repos/{owner}/{name}/workflows/{id}` | Get workflow |
13//! | DELETE | `/api/repos/{owner}/{name}/workflows/{id}` | Delete workflow |
14//! | GET | `/api/repos/{owner}/{name}/runs` | List runs |
15//! | POST | `/api/repos/{owner}/{name}/runs` | Trigger manual run |
16//! | GET | `/api/repos/{owner}/{name}/runs/{id}` | Get run details |
17//! | POST | `/api/repos/{owner}/{name}/runs/{id}/cancel` | Cancel run |
18//! | GET | `/api/repos/{owner}/{name}/runs/{id}/jobs` | List jobs in run |
19//! | GET | `/api/repos/{owner}/{name}/runs/{id}/jobs/{job}/logs` | Get job logs |
20//! | GET | `/api/repos/{owner}/{name}/runs/{id}/artifacts` | List artifacts |
21//! | POST | `/api/repos/{owner}/{name}/runs/{id}/artifacts` | Upload artifact |
22//! | GET | `/api/repos/{owner}/{name}/runs/{id}/artifacts/{name}` | Download artifact |
23//! | GET | `/api/repos/{owner}/{name}/commits/{sha}/status` | Get combined status |
24//! | GET | `/api/repos/{owner}/{name}/commits/{sha}/statuses` | List all statuses |
25//! | POST | `/api/repos/{owner}/{name}/commits/{sha}/statuses` | Create status check |
26
27use axum::{
28    body::Body,
29    extract::{Path, State},
30    http::{header, StatusCode},
31    response::{IntoResponse, Response},
32    routing::{get, post},
33    Json, Router,
34};
35use guts_ci::{
36    Artifact, CheckState, CiStore, StatusCheck, TriggerContext, TriggerType, Workflow, WorkflowRun,
37};
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40use std::sync::Arc;
41
42/// CI/CD error type.
43#[derive(Debug, thiserror::Error)]
44pub enum CiApiError {
45    #[error("workflow not found: {0}")]
46    WorkflowNotFound(String),
47    #[error("run not found: {0}")]
48    RunNotFound(String),
49    #[error("artifact not found: {0}")]
50    ArtifactNotFound(String),
51    #[error("invalid workflow: {0}")]
52    InvalidWorkflow(String),
53    #[error("bad request: {0}")]
54    BadRequest(String),
55    #[error("ci error: {0}")]
56    CiError(#[from] guts_ci::CiError),
57}
58
59impl IntoResponse for CiApiError {
60    fn into_response(self) -> Response {
61        let (status, message) = match &self {
62            CiApiError::WorkflowNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
63            CiApiError::RunNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
64            CiApiError::ArtifactNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
65            CiApiError::InvalidWorkflow(_) => (StatusCode::BAD_REQUEST, self.to_string()),
66            CiApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
67            CiApiError::CiError(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
68        };
69
70        (status, Json(serde_json::json!({ "error": message }))).into_response()
71    }
72}
73
74/// CI/CD state shared across handlers.
75#[derive(Clone)]
76pub struct CiState {
77    /// CI/CD store.
78    pub ci: Arc<CiStore>,
79}
80
81// ==================== Request/Response Types ====================
82
83/// Request to create/update a workflow.
84#[derive(Debug, Deserialize)]
85pub struct CreateWorkflowRequest {
86    /// Path to the workflow file (e.g., ".guts/workflows/ci.yml")
87    pub path: String,
88    /// YAML content of the workflow
89    pub content: String,
90}
91
92/// Response for workflow info.
93#[derive(Debug, Serialize)]
94pub struct WorkflowResponse {
95    pub id: String,
96    pub name: String,
97    pub path: String,
98    pub created_at: u64,
99    pub updated_at: u64,
100}
101
102impl From<&Workflow> for WorkflowResponse {
103    fn from(w: &Workflow) -> Self {
104        Self {
105            id: w.id.clone(),
106            name: w.name.clone(),
107            path: w.path.clone(),
108            created_at: w.created_at,
109            updated_at: w.updated_at,
110        }
111    }
112}
113
114/// Request to trigger a manual workflow run.
115#[derive(Debug, Deserialize)]
116pub struct TriggerRunRequest {
117    /// Workflow ID to run
118    pub workflow_id: String,
119    /// Branch or ref to run on
120    #[serde(default)]
121    pub ref_name: Option<String>,
122    /// Input parameters for workflow_dispatch
123    #[serde(default)]
124    pub inputs: HashMap<String, String>,
125}
126
127/// Response for workflow run info.
128#[derive(Debug, Serialize)]
129pub struct RunResponse {
130    pub id: String,
131    pub workflow_id: String,
132    pub workflow_name: String,
133    pub number: u32,
134    pub status: String,
135    pub conclusion: Option<String>,
136    pub head_sha: String,
137    pub head_branch: Option<String>,
138    pub created_at: u64,
139    pub started_at: Option<u64>,
140    pub completed_at: Option<u64>,
141}
142
143impl From<&WorkflowRun> for RunResponse {
144    fn from(r: &WorkflowRun) -> Self {
145        Self {
146            id: r.id.clone(),
147            workflow_id: r.workflow_id.clone(),
148            workflow_name: r.workflow_name.clone(),
149            number: r.number,
150            status: format!("{:?}", r.status).to_lowercase(),
151            conclusion: r.conclusion.map(|c| format!("{:?}", c).to_lowercase()),
152            head_sha: r.head_sha.clone(),
153            head_branch: r.head_branch.clone(),
154            created_at: r.created_at,
155            started_at: r.started_at,
156            completed_at: r.completed_at,
157        }
158    }
159}
160
161/// Response for job info within a run.
162#[derive(Debug, Serialize)]
163pub struct JobResponse {
164    pub id: String,
165    pub name: String,
166    pub status: String,
167    pub conclusion: Option<String>,
168    pub started_at: Option<u64>,
169    pub completed_at: Option<u64>,
170    pub steps: Vec<StepResponse>,
171}
172
173/// Response for step info within a job.
174#[derive(Debug, Serialize)]
175pub struct StepResponse {
176    pub number: u32,
177    pub name: String,
178    pub status: String,
179    pub conclusion: Option<String>,
180}
181
182/// Response for artifact info.
183#[derive(Debug, Serialize)]
184pub struct ArtifactResponse {
185    pub id: String,
186    pub name: String,
187    pub size_bytes: u64,
188    pub content_type: String,
189    pub created_at: u64,
190    pub expires_at: Option<u64>,
191}
192
193impl From<&Artifact> for ArtifactResponse {
194    fn from(a: &Artifact) -> Self {
195        Self {
196            id: a.id.clone(),
197            name: a.name.clone(),
198            size_bytes: a.size_bytes,
199            content_type: a.content_type.clone(),
200            created_at: a.created_at,
201            expires_at: a.expires_at,
202        }
203    }
204}
205
206/// Request to create a status check.
207#[derive(Debug, Deserialize)]
208pub struct CreateStatusRequest {
209    /// Context name (e.g., "CI / Build")
210    pub context: String,
211    /// State: pending, success, failure, error
212    pub state: String,
213    /// Optional description
214    #[serde(default)]
215    pub description: Option<String>,
216    /// Optional target URL
217    #[serde(default)]
218    pub target_url: Option<String>,
219}
220
221/// Response for status check info.
222#[derive(Debug, Serialize)]
223pub struct StatusResponse {
224    pub id: String,
225    pub context: String,
226    pub state: String,
227    pub description: Option<String>,
228    pub target_url: Option<String>,
229    pub created_at: u64,
230    pub updated_at: u64,
231}
232
233impl From<&StatusCheck> for StatusResponse {
234    fn from(s: &StatusCheck) -> Self {
235        Self {
236            id: s.id.clone(),
237            context: s.context.clone(),
238            state: format!("{:?}", s.state).to_lowercase(),
239            description: s.description.clone(),
240            target_url: s.target_url.clone(),
241            created_at: s.created_at,
242            updated_at: s.updated_at,
243        }
244    }
245}
246
247/// Response for combined status.
248#[derive(Debug, Serialize)]
249pub struct CombinedStatusResponse {
250    pub state: String,
251    pub total_count: usize,
252    pub statuses: Vec<StatusResponse>,
253}
254
255// ==================== Routes ====================
256
257/// Creates the CI/CD routes.
258pub fn ci_routes() -> Router<crate::api::AppState> {
259    Router::new()
260        // Workflows
261        .route(
262            "/api/repos/{owner}/{name}/workflows",
263            get(list_workflows).post(create_workflow),
264        )
265        .route(
266            "/api/repos/{owner}/{name}/workflows/{workflow_id}",
267            get(get_workflow).delete(delete_workflow),
268        )
269        // Runs
270        .route(
271            "/api/repos/{owner}/{name}/runs",
272            get(list_runs).post(trigger_run),
273        )
274        .route("/api/repos/{owner}/{name}/runs/{run_id}", get(get_run))
275        .route(
276            "/api/repos/{owner}/{name}/runs/{run_id}/cancel",
277            post(cancel_run),
278        )
279        .route(
280            "/api/repos/{owner}/{name}/runs/{run_id}/jobs",
281            get(list_jobs),
282        )
283        .route(
284            "/api/repos/{owner}/{name}/runs/{run_id}/jobs/{job_id}/logs",
285            get(get_job_logs),
286        )
287        // Artifacts
288        .route(
289            "/api/repos/{owner}/{name}/runs/{run_id}/artifacts",
290            get(list_artifacts).post(upload_artifact),
291        )
292        .route(
293            "/api/repos/{owner}/{name}/runs/{run_id}/artifacts/{artifact_name}",
294            get(download_artifact).delete(delete_artifact),
295        )
296        // Status checks
297        .route(
298            "/api/repos/{owner}/{name}/commits/{sha}/status",
299            get(get_combined_status),
300        )
301        .route(
302            "/api/repos/{owner}/{name}/commits/{sha}/statuses",
303            get(list_statuses).post(create_status),
304        )
305        // CI stats
306        .route("/api/ci/stats", get(get_ci_stats))
307}
308
309// ==================== Workflow Handlers ====================
310
311/// List workflows for a repository.
312async fn list_workflows(
313    State(state): State<crate::api::AppState>,
314    Path((owner, name)): Path<(String, String)>,
315) -> impl IntoResponse {
316    let repo_key = format!("{}/{}", owner, name);
317    let workflows = state.ci.workflows.list(&repo_key);
318    let response: Vec<WorkflowResponse> = workflows.iter().map(WorkflowResponse::from).collect();
319    Json(response)
320}
321
322/// Create or update a workflow.
323async fn create_workflow(
324    State(state): State<crate::api::AppState>,
325    Path((owner, name)): Path<(String, String)>,
326    Json(req): Json<CreateWorkflowRequest>,
327) -> Result<impl IntoResponse, CiApiError> {
328    let repo_key = format!("{}/{}", owner, name);
329
330    let workflow = Workflow::parse(&req.content, &repo_key, &req.path)
331        .map_err(|e| CiApiError::InvalidWorkflow(e.to_string()))?;
332
333    state.ci.workflows.store(workflow.clone());
334
335    Ok((StatusCode::CREATED, Json(WorkflowResponse::from(&workflow))))
336}
337
338/// Get a specific workflow.
339async fn get_workflow(
340    State(state): State<crate::api::AppState>,
341    Path((owner, name, workflow_id)): Path<(String, String, String)>,
342) -> Result<impl IntoResponse, CiApiError> {
343    let repo_key = format!("{}/{}", owner, name);
344
345    let workflow = state
346        .ci
347        .workflows
348        .get(&repo_key, &workflow_id)
349        .ok_or(CiApiError::WorkflowNotFound(workflow_id))?;
350
351    Ok(Json(WorkflowResponse::from(&workflow)))
352}
353
354/// Delete a workflow.
355async fn delete_workflow(
356    State(state): State<crate::api::AppState>,
357    Path((owner, name, workflow_id)): Path<(String, String, String)>,
358) -> Result<impl IntoResponse, CiApiError> {
359    let repo_key = format!("{}/{}", owner, name);
360
361    state
362        .ci
363        .workflows
364        .delete(&repo_key, &workflow_id)
365        .ok_or(CiApiError::WorkflowNotFound(workflow_id))?;
366
367    Ok(StatusCode::NO_CONTENT)
368}
369
370// ==================== Run Handlers ====================
371
372/// List workflow runs.
373async fn list_runs(
374    State(state): State<crate::api::AppState>,
375    Path((owner, name)): Path<(String, String)>,
376) -> impl IntoResponse {
377    let repo_key = format!("{}/{}", owner, name);
378    let runs = state.ci.runs.list_by_repo(&repo_key, Some(50));
379    let response: Vec<RunResponse> = runs.iter().map(RunResponse::from).collect();
380    Json(response)
381}
382
383/// Trigger a manual workflow run.
384async fn trigger_run(
385    State(state): State<crate::api::AppState>,
386    Path((owner, name)): Path<(String, String)>,
387    Json(req): Json<TriggerRunRequest>,
388) -> Result<impl IntoResponse, CiApiError> {
389    let repo_key = format!("{}/{}", owner, name);
390
391    let workflow = state
392        .ci
393        .workflows
394        .get(&repo_key, &req.workflow_id)
395        .ok_or_else(|| CiApiError::WorkflowNotFound(req.workflow_id.clone()))?;
396
397    if !workflow.allows_manual_trigger() {
398        return Err(CiApiError::BadRequest(
399            "Workflow does not allow manual trigger".into(),
400        ));
401    }
402
403    let run_number = state.ci.runs.next_run_number(&repo_key, &workflow.id);
404
405    // Get the head SHA from the ref or use a placeholder
406    let head_sha = format!("manual-{}", uuid::Uuid::new_v4());
407    let ref_name = req.ref_name.unwrap_or_else(|| "main".to_string());
408
409    let trigger = TriggerContext {
410        trigger_type: TriggerType::WorkflowDispatch,
411        actor: "api".to_string(),
412        ref_name: Some(format!("refs/heads/{}", ref_name)),
413        sha: head_sha.clone(),
414        base_sha: None,
415        pr_number: None,
416        inputs: req.inputs,
417        event: serde_json::Value::Null,
418    };
419
420    let run = WorkflowRun::new(
421        uuid::Uuid::new_v4().to_string(),
422        workflow.id.clone(),
423        workflow.name.clone(),
424        repo_key,
425        run_number,
426        trigger,
427        head_sha,
428        Some(ref_name),
429    );
430
431    state.ci.runs.store(run.clone());
432
433    Ok((StatusCode::CREATED, Json(RunResponse::from(&run))))
434}
435
436/// Get a specific run.
437async fn get_run(
438    State(state): State<crate::api::AppState>,
439    Path((_owner, _name, run_id)): Path<(String, String, String)>,
440) -> Result<impl IntoResponse, CiApiError> {
441    let run = state
442        .ci
443        .runs
444        .get(&run_id)
445        .ok_or(CiApiError::RunNotFound(run_id))?;
446
447    Ok(Json(RunResponse::from(&run)))
448}
449
450/// Cancel a run.
451async fn cancel_run(
452    State(state): State<crate::api::AppState>,
453    Path((_owner, _name, run_id)): Path<(String, String, String)>,
454) -> Result<impl IntoResponse, CiApiError> {
455    let mut run = state
456        .ci
457        .runs
458        .get(&run_id)
459        .ok_or_else(|| CiApiError::RunNotFound(run_id.clone()))?;
460
461    if !run.status.is_active() {
462        return Err(CiApiError::BadRequest("Run is not active".into()));
463    }
464
465    run.cancel();
466    state.ci.runs.update(run.clone())?;
467
468    Ok(Json(RunResponse::from(&run)))
469}
470
471/// List jobs in a run.
472async fn list_jobs(
473    State(state): State<crate::api::AppState>,
474    Path((_owner, _name, run_id)): Path<(String, String, String)>,
475) -> Result<impl IntoResponse, CiApiError> {
476    let run = state
477        .ci
478        .runs
479        .get(&run_id)
480        .ok_or(CiApiError::RunNotFound(run_id))?;
481
482    let jobs: Vec<JobResponse> = run
483        .jobs
484        .values()
485        .map(|j| JobResponse {
486            id: j.id.clone(),
487            name: j.name.clone(),
488            status: format!("{:?}", j.status).to_lowercase(),
489            conclusion: j.conclusion.map(|c| format!("{:?}", c).to_lowercase()),
490            started_at: j.started_at,
491            completed_at: j.completed_at,
492            steps: j
493                .steps
494                .iter()
495                .map(|s| StepResponse {
496                    number: s.number,
497                    name: s.name.clone(),
498                    status: format!("{:?}", s.status).to_lowercase(),
499                    conclusion: s.conclusion.map(|c| format!("{:?}", c).to_lowercase()),
500                })
501                .collect(),
502        })
503        .collect();
504
505    Ok(Json(jobs))
506}
507
508/// Get job logs.
509async fn get_job_logs(
510    State(state): State<crate::api::AppState>,
511    Path((_owner, _name, run_id, job_id)): Path<(String, String, String, String)>,
512) -> Result<impl IntoResponse, CiApiError> {
513    let run = state
514        .ci
515        .runs
516        .get(&run_id)
517        .ok_or(CiApiError::RunNotFound(run_id))?;
518
519    let job = run
520        .jobs
521        .values()
522        .find(|j| j.id == job_id || j.job_id == job_id)
523        .ok_or_else(|| CiApiError::BadRequest(format!("Job not found: {}", job_id)))?;
524
525    // Return logs as JSON array (clone to avoid lifetime issues)
526    Ok(Json(job.logs.clone()))
527}
528
529// ==================== Artifact Handlers ====================
530
531/// List artifacts for a run.
532async fn list_artifacts(
533    State(state): State<crate::api::AppState>,
534    Path((_owner, _name, run_id)): Path<(String, String, String)>,
535) -> impl IntoResponse {
536    let artifacts = state.ci.artifacts.list_by_run(&run_id);
537    let response: Vec<ArtifactResponse> = artifacts.iter().map(ArtifactResponse::from).collect();
538    Json(response)
539}
540
541/// Upload an artifact.
542async fn upload_artifact(
543    State(state): State<crate::api::AppState>,
544    Path((owner, name, run_id)): Path<(String, String, String)>,
545    body: axum::body::Bytes,
546) -> Result<impl IntoResponse, CiApiError> {
547    let repo_key = format!("{}/{}", owner, name);
548
549    // Extract artifact name from Content-Disposition header or generate one
550    let artifact_name = format!("artifact-{}.bin", uuid::Uuid::new_v4());
551
552    let artifact =
553        state
554            .ci
555            .artifacts
556            .upload(run_id, repo_key, artifact_name, body.to_vec(), Some(30))?;
557
558    Ok((StatusCode::CREATED, Json(ArtifactResponse::from(&artifact))))
559}
560
561/// Download an artifact.
562async fn download_artifact(
563    State(state): State<crate::api::AppState>,
564    Path((_owner, _name, run_id, artifact_name)): Path<(String, String, String, String)>,
565) -> Result<impl IntoResponse, CiApiError> {
566    let artifact = state
567        .ci
568        .artifacts
569        .get_by_name(&run_id, &artifact_name)
570        .map_err(|_| CiApiError::ArtifactNotFound(artifact_name.clone()))?;
571
572    let (_, content) = state
573        .ci
574        .artifacts
575        .download(&artifact.id)
576        .map_err(|_| CiApiError::ArtifactNotFound(artifact_name))?;
577
578    Ok(Response::builder()
579        .status(StatusCode::OK)
580        .header(header::CONTENT_TYPE, artifact.content_type)
581        .header(
582            header::CONTENT_DISPOSITION,
583            format!("attachment; filename=\"{}\"", artifact.name),
584        )
585        .body(Body::from(content.as_ref().clone()))
586        .unwrap())
587}
588
589/// Delete an artifact.
590async fn delete_artifact(
591    State(state): State<crate::api::AppState>,
592    Path((_owner, _name, run_id, artifact_name)): Path<(String, String, String, String)>,
593) -> Result<impl IntoResponse, CiApiError> {
594    let artifact = state
595        .ci
596        .artifacts
597        .get_by_name(&run_id, &artifact_name)
598        .map_err(|_| CiApiError::ArtifactNotFound(artifact_name))?;
599
600    state.ci.artifacts.delete(&artifact.id)?;
601
602    Ok(StatusCode::NO_CONTENT)
603}
604
605// ==================== Status Check Handlers ====================
606
607/// Get combined status for a commit.
608async fn get_combined_status(
609    State(state): State<crate::api::AppState>,
610    Path((owner, name, sha)): Path<(String, String, String)>,
611) -> impl IntoResponse {
612    let repo_key = format!("{}/{}", owner, name);
613    let combined = state.ci.statuses.get_combined_status(&repo_key, &sha);
614
615    Json(CombinedStatusResponse {
616        state: format!("{:?}", combined.state).to_lowercase(),
617        total_count: combined.total_count,
618        statuses: combined.statuses.iter().map(StatusResponse::from).collect(),
619    })
620}
621
622/// List all statuses for a commit.
623async fn list_statuses(
624    State(state): State<crate::api::AppState>,
625    Path((owner, name, sha)): Path<(String, String, String)>,
626) -> impl IntoResponse {
627    let repo_key = format!("{}/{}", owner, name);
628    let statuses = state.ci.statuses.list_for_commit(&repo_key, &sha);
629    let response: Vec<StatusResponse> = statuses.iter().map(StatusResponse::from).collect();
630    Json(response)
631}
632
633/// Create a status check.
634async fn create_status(
635    State(state): State<crate::api::AppState>,
636    Path((owner, name, sha)): Path<(String, String, String)>,
637    Json(req): Json<CreateStatusRequest>,
638) -> Result<impl IntoResponse, CiApiError> {
639    let repo_key = format!("{}/{}", owner, name);
640
641    let check_state = match req.state.to_lowercase().as_str() {
642        "pending" => CheckState::Pending,
643        "success" => CheckState::Success,
644        "failure" => CheckState::Failure,
645        "error" => CheckState::Error,
646        _ => {
647            return Err(CiApiError::BadRequest(format!(
648                "Invalid state: {}",
649                req.state
650            )))
651        }
652    };
653
654    let mut check = StatusCheck::new(repo_key, sha, req.context, check_state);
655    if let Some(desc) = req.description {
656        check = check.with_description(desc);
657    }
658    if let Some(url) = req.target_url {
659        check = check.with_target_url(url);
660    }
661
662    let check = state.ci.statuses.create_or_update(check);
663
664    Ok((StatusCode::CREATED, Json(StatusResponse::from(&check))))
665}
666
667/// Get CI stats.
668async fn get_ci_stats(State(state): State<crate::api::AppState>) -> impl IntoResponse {
669    let stats = state.ci.stats();
670    Json(stats)
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn test_workflow_response_conversion() {
679        let yaml = r#"
680name: CI
681on: push
682jobs:
683  test:
684    steps:
685      - run: echo test
686"#;
687        let workflow = Workflow::parse(yaml, "alice/repo", ".guts/workflows/ci.yml").unwrap();
688        let response = WorkflowResponse::from(&workflow);
689
690        assert_eq!(response.id, "ci");
691        assert_eq!(response.name, "CI");
692    }
693
694    #[test]
695    fn test_status_state_parsing() {
696        assert!(matches!(
697            match "pending".to_lowercase().as_str() {
698                "pending" => CheckState::Pending,
699                _ => CheckState::Error,
700            },
701            CheckState::Pending
702        ));
703    }
704}