//! GitLab Integration API //! //! Enables linking communities to GitLab projects for governance workflows //! including issue tracking and merge request voting. use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use chrono::{DateTime, Utc}; use crate::auth::AuthUser; // ============================================================================ // Types // ============================================================================ #[derive(Debug, Serialize)] pub struct GitLabConnection { pub id: Uuid, pub community_id: Uuid, pub gitlab_url: String, pub project_path: String, pub is_active: bool, pub sync_issues: bool, pub sync_merge_requests: bool, pub auto_create_proposals: bool, pub last_synced_at: Option>, } #[derive(Debug, Deserialize)] pub struct CreateConnectionRequest { pub gitlab_url: String, pub project_path: String, #[allow(dead_code)] pub access_token: Option, pub sync_issues: Option, pub sync_merge_requests: Option, pub auto_create_proposals: Option, } /// Request to update a GitLab connection. Designed for PUT endpoint. #[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct UpdateConnectionRequest { pub is_active: Option, pub sync_issues: Option, pub sync_merge_requests: Option, pub auto_create_proposals: Option, } #[derive(Debug, Serialize)] pub struct GitLabIssue { pub id: Uuid, pub gitlab_iid: i32, pub title: String, pub description: Option, pub state: String, pub author_username: Option, pub labels: Vec, pub proposal_id: Option, pub gitlab_created_at: Option>, } #[derive(Debug, Serialize)] pub struct GitLabMergeRequest { pub id: Uuid, pub gitlab_iid: i32, pub title: String, pub description: Option, pub state: String, pub author_username: Option, pub source_branch: Option, pub target_branch: Option, pub labels: Vec, pub proposal_id: Option, pub gitlab_created_at: Option>, } // ============================================================================ // Handlers // ============================================================================ /// Get GitLab connection for a community async fn get_connection( auth: AuthUser, Path(community_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { // Check membership let membership = sqlx::query!( "SELECT role FROM community_members WHERE community_id = $1 AND user_id = $2", community_id, auth.user_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if membership.is_none() { return Err((StatusCode::FORBIDDEN, "Not a community member".to_string())); } let connection = sqlx::query!( r#"SELECT id, community_id, gitlab_url, project_path, is_active, sync_issues, sync_merge_requests, auto_create_proposals, last_synced_at FROM gitlab_connections WHERE community_id = $1"#, community_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(connection.map(|c| GitLabConnection { id: c.id, community_id: c.community_id, gitlab_url: c.gitlab_url, project_path: c.project_path, is_active: c.is_active, sync_issues: c.sync_issues, sync_merge_requests: c.sync_merge_requests, auto_create_proposals: c.auto_create_proposals, last_synced_at: c.last_synced_at, }))) } /// Create or update GitLab connection async fn create_connection( auth: AuthUser, Path(community_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { // Check admin role let membership = sqlx::query!( "SELECT role FROM community_members WHERE community_id = $1 AND user_id = $2", community_id, auth.user_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?; if membership.role != "admin" { return Err((StatusCode::FORBIDDEN, "Only admins can configure GitLab".to_string())); } // Validate GitLab URL if !req.gitlab_url.starts_with("https://") { return Err((StatusCode::BAD_REQUEST, "GitLab URL must use HTTPS".to_string())); } let connection = sqlx::query!( r#"INSERT INTO gitlab_connections (community_id, gitlab_url, project_path, sync_issues, sync_merge_requests, auto_create_proposals) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (community_id) DO UPDATE SET gitlab_url = $2, project_path = $3, sync_issues = COALESCE($4, gitlab_connections.sync_issues), sync_merge_requests = COALESCE($5, gitlab_connections.sync_merge_requests), auto_create_proposals = COALESCE($6, gitlab_connections.auto_create_proposals), updated_at = NOW() RETURNING id, community_id, gitlab_url, project_path, is_active, sync_issues, sync_merge_requests, auto_create_proposals, last_synced_at"#, community_id, req.gitlab_url, req.project_path, req.sync_issues, req.sync_merge_requests, req.auto_create_proposals ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(GitLabConnection { id: connection.id, community_id: connection.community_id, gitlab_url: connection.gitlab_url, project_path: connection.project_path, is_active: connection.is_active, sync_issues: connection.sync_issues, sync_merge_requests: connection.sync_merge_requests, auto_create_proposals: connection.auto_create_proposals, last_synced_at: connection.last_synced_at, })) } /// List GitLab issues for a community async fn list_issues( Path(community_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let issues = sqlx::query!( r#"SELECT gi.id, gi.gitlab_iid, gi.title, gi.description, gi.state, gi.author_username, gi.labels, gi.proposal_id, gi.gitlab_created_at FROM gitlab_issues gi JOIN gitlab_connections gc ON gi.connection_id = gc.id WHERE gc.community_id = $1 ORDER BY gi.gitlab_iid DESC LIMIT 100"#, community_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(issues.into_iter().map(|i| GitLabIssue { id: i.id, gitlab_iid: i.gitlab_iid, title: i.title, description: i.description, state: i.state, author_username: i.author_username, labels: i.labels.unwrap_or_default(), proposal_id: i.proposal_id, gitlab_created_at: i.gitlab_created_at, }).collect())) } /// List GitLab merge requests for a community async fn list_merge_requests( Path(community_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let mrs = sqlx::query!( r#"SELECT gmr.id, gmr.gitlab_iid, gmr.title, gmr.description, gmr.state, gmr.author_username, gmr.source_branch, gmr.target_branch, gmr.labels, gmr.proposal_id, gmr.gitlab_created_at FROM gitlab_merge_requests gmr JOIN gitlab_connections gc ON gmr.connection_id = gc.id WHERE gc.community_id = $1 ORDER BY gmr.gitlab_iid DESC LIMIT 100"#, community_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(mrs.into_iter().map(|m| GitLabMergeRequest { id: m.id, gitlab_iid: m.gitlab_iid, title: m.title, description: m.description, state: m.state, author_username: m.author_username, source_branch: m.source_branch, target_branch: m.target_branch, labels: m.labels.unwrap_or_default(), proposal_id: m.proposal_id, gitlab_created_at: m.gitlab_created_at, }).collect())) } /// Create proposal from GitLab issue async fn create_proposal_from_issue( auth: AuthUser, Path((community_id, issue_id)): Path<(Uuid, Uuid)>, State(pool): State, ) -> Result, (StatusCode, String)> { // Check membership let _membership = sqlx::query!( "SELECT role FROM community_members WHERE community_id = $1 AND user_id = $2", community_id, auth.user_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?; // Get the issue let issue = sqlx::query!( r#"SELECT gi.id, gi.title, gi.description, gi.proposal_id FROM gitlab_issues gi JOIN gitlab_connections gc ON gi.connection_id = gc.id WHERE gi.id = $1 AND gc.community_id = $2"#, issue_id, community_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?; if issue.proposal_id.is_some() { return Err((StatusCode::CONFLICT, "Issue already linked to a proposal".to_string())); } // Create proposal let proposal = sqlx::query!( r#"INSERT INTO proposals (community_id, author_id, title, description, status, voting_method) VALUES ($1, $2, $3, $4, 'draft', 'approval') RETURNING id"#, community_id, auth.user_id, issue.title, issue.description.unwrap_or_default() ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Link issue to proposal sqlx::query!( "UPDATE gitlab_issues SET proposal_id = $1 WHERE id = $2", proposal.id, issue_id ) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Create default options sqlx::query!( "INSERT INTO proposal_options (proposal_id, label, sort_order) VALUES ($1, 'Approve', 1), ($1, 'Reject', 2)", proposal.id ) .execute(&pool) .await .ok(); Ok(Json(serde_json::json!({ "proposal_id": proposal.id, "message": "Proposal created from GitLab issue" }))) } // ============================================================================ // Router // ============================================================================ pub fn router(pool: PgPool) -> Router { Router::new() .route("/api/communities/{community_id}/gitlab", get(get_connection).post(create_connection)) .route("/api/communities/{community_id}/gitlab/issues", get(list_issues)) .route("/api/communities/{community_id}/gitlab/merge-requests", get(list_merge_requests)) .route("/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", post(create_proposal_from_issue)) .with_state(pool) }