🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Handlers for opening tickets and merge requests on remote trackers.
//!
//! This module implements the outbound side of ForgeFed federation: local users
//! can open tickets or submit merge requests to remote repositories. The flow:
//! 1. User fills out the form at `/~{user}/remote-tickets/new`
//! 2. POST handler validates the target repository and its tracker
//! 3. Builds and signs an Offer activity
//! 4. Records it in ap_remote_tickets (status: pending)
//! 5. Delivers to the tracker's inbox
//! 6. Later, Accept/Reject correlation updates the status

use actix_web::{HttpResponse, get, post, web};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{error, info, instrument};
use uuid::Uuid;

use crate::AppState;
use crate::activitypub::actor::{actor_url_for, get_or_create};
use crate::activitypub::delivery::enqueue_delivery;
use crate::activitypub::remote;
use crate::auth::CurrentUser;
use crate::entities::ap_remote_tickets;
use crate::repositories::RepositoryPath;
use crate::repositories::store::find_repository_by_path;
use crate::state_read;
use crate::views::{
    NewRemoteTicket, RemoteTicketRow, RemoteTicketsList, new_remote_ticket, remote_tickets,
};

/// Parses a repository URL like `http://host/~owner/slug` into (owner, slug).
fn parse_repository_url(url: &str, public_url: &str) -> Option<(String, String)> {
    // Strip the public URL prefix
    let path = url.strip_prefix(public_url)?;

    // Strip leading slash and ~ prefix
    let path = path.strip_prefix('/')?.strip_prefix('~')?;

    // Split into owner and slug
    let (owner, slug) = path.split_once('/')?;

    // Ensure no further slashes in slug
    if slug.contains('/') {
        return None;
    }

    Some((owner.to_string(), slug.to_string()))
}

/// Generates patch files using git format-patch for the range default_branch..branch
async fn generate_patches(
    bare_path: &std::path::Path,
    branch: &str,
) -> std::io::Result<Vec<String>> {
    use tokio::process::Command;

    // Get the default branch (we'll use "main" as the default)
    let default_branch = "main";

    // Generate patches using git format-patch
    let output = Command::new("git")
        .arg("-C")
        .arg(bare_path)
        .arg("format-patch")
        .arg("--stdout")
        .arg(format!("{default_branch}..{branch}"))
        .output()
        .await?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(std::io::Error::other(format!(
            "git format-patch failed: {}",
            stderr
        )));
    }

    // Split the output into individual patches (separated by blank lines)
    let patches_str = String::from_utf8_lossy(&output.stdout);
    let patches: Vec<String> = patches_str
        .split("\n\n")
        .filter(|s| !s.trim().is_empty())
        .map(|s| s.to_string())
        .collect();

    Ok(patches)
}

/// GET `/~{user}/remote-tickets` - list of remote tickets/MRs this user has opened
#[get("/~{user}/remote-tickets")]
#[instrument(skip(state, current))]
pub async fn get_remote_tickets(
    state: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<String>,
) -> HttpResponse {
    let username = path.into_inner();

    // Only the owner can see their own remote tickets
    if current.name != username {
        return HttpResponse::NotFound().finish();
    }

    let state = state_read(&state).await;
    let db = &state.db;

    // Fetch all remote tickets for this user
    let tickets = match ap_remote_tickets::Entity::find()
        .filter(ap_remote_tickets::Column::AuthorId.eq(current.id))
        .all(db)
        .await
    {
        Ok(t) => t,
        Err(e) => {
            error!(error = %e, "failed to fetch remote tickets");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let view = RemoteTicketsList {
        username: username.clone(),
        tickets: tickets
            .into_iter()
            .map(|t| RemoteTicketRow {
                id: t.id,
                kind: t.kind,
                status: t.status,
                tracker_url: t.tracker_url,
                summary: t.summary,
                assigned_url: t.assigned_url,
                created_at: t.created_at,
            })
            .collect(),
    };

    remote_tickets(Some(&current.name), &view)
}

/// GET `/~{user}/remote-tickets/new` - form to open a ticket/MR on a remote tracker
#[get("/~{user}/remote-tickets/new")]
#[instrument(skip(_state, current))]
pub async fn get_new_remote_ticket(
    _state: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<String>,
) -> HttpResponse {
    let username = path.into_inner();

    // Only logged-in users can open remote tickets
    if current.name != username {
        return HttpResponse::NotFound().finish();
    }

    let view = NewRemoteTicket {
        username: username.clone(),
        error: None,
    };

    new_remote_ticket(Some(&current.name), &view)
}

/// POST `/~{user}/remote-tickets` - submit a ticket/MR to a remote tracker
#[post("/~{user}/remote-tickets")]
#[instrument(skip(state, current, form))]
pub async fn post_remote_ticket(
    state: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<String>,
    form: web::Form<RemoteTicketForm>,
) -> HttpResponse {
    let username = path.into_inner();

    // Only the owner can submit remote tickets
    if current.name != username {
        return HttpResponse::NotFound().finish();
    }

    let state = state_read(&state).await;
    let db = &state.db;
    let public_url = state.public_url();

    // Validate the target repository URL
    let target_repo_url = form.target.trim();
    if target_repo_url.is_empty() {
        return show_form_error(&username, "Target repository URL is required");
    }

    // Validate patch-specific fields
    if form.kind == "mr" {
        if form.origin_repo.as_deref().unwrap_or("").trim().is_empty() {
            return show_form_error(&username, "Origin repository URL is required for patches");
        }
        if form
            .origin_branch
            .as_deref()
            .unwrap_or("")
            .trim()
            .is_empty()
        {
            return show_form_error(&username, "Origin branch is required for patches");
        }
        if form
            .target_branch
            .as_deref()
            .unwrap_or("")
            .trim()
            .is_empty()
        {
            return show_form_error(&username, "Target branch is required for patches");
        }
    }

    // Fetch the user's ap_actor to sign requests
    let actor = match get_or_create(db, current.id).await {
        Ok(a) => a,
        Err(e) => {
            error!(error = %e, "failed to fetch user actor");
            return show_form_error(&username, "Failed to fetch your ActivityPub actor");
        }
    };

    let user_actor_url = match actor_url_for(db, public_url, &actor).await {
        Ok(Some(url)) => url,
        Ok(None) => {
            return show_form_error(&username, "User has no actor URL");
        }
        Err(e) => {
            error!(error = %e, "failed to compute actor URL");
            return show_form_error(&username, "Failed to compute your actor URL");
        }
    };

    // Fetch the remote repository actor document
    let repo_doc = match remote::signed_get(
        state.http_client(),
        target_repo_url,
        public_url,
        &actor,
        &current.name,
    )
    .await
    {
        Ok(doc) => doc,
        Err(e) => {
            error!(error = %e, url = %target_repo_url, "failed to fetch remote repository");
            return show_form_error(&username, "Failed to fetch remote repository");
        }
    };

    // Validate it's a Repository
    if repo_doc.get("type").and_then(|t| t.as_str()) != Some("Repository") {
        return show_form_error(&username, "Target is not a Repository");
    }

    // Get the tracker URL from ticketsTrackedBy or sendPatchesTo
    let tracker_url = match form.kind.as_str() {
        "ticket" => repo_doc
            .get("ticketsTrackedBy")
            .and_then(|t| t.as_str())
            .map(String::from),
        "mr" => repo_doc
            .get("sendPatchesTo")
            .and_then(|t| t.as_str())
            .map(String::from),
        _ => {
            return show_form_error(&username, "Invalid kind (must be 'ticket' or 'mr')");
        }
    };

    let tracker_url = match tracker_url {
        Some(url) => url,
        None => {
            return show_form_error(
                &username,
                "Remote repository does not accept remote tickets or merge requests",
            );
        }
    };

    // Fetch the tracker actor document to get its inbox
    let tracker_doc = match remote::signed_get(
        state.http_client(),
        &tracker_url,
        public_url,
        &actor,
        &current.name,
    )
    .await
    {
        Ok(doc) => doc,
        Err(e) => {
            error!(error = %e, url = %tracker_url, "failed to fetch tracker");
            return show_form_error(&username, "Failed to fetch tracker");
        }
    };

    // Verify the bidirectional reference: tracker must list the repo in tracksTicketsFor/tracksPatchesFor
    // Exception: be lenient when tracker == repo actor (self-tracking case)
    if tracker_url != target_repo_url {
        let back_ref_field = match form.kind.as_str() {
            "ticket" => "tracksTicketsFor",
            "mr" => "tracksPatchesFor",
            _ => unreachable!(),
        };

        let has_back_ref = tracker_doc
            .get(back_ref_field)
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str())
                    .any(|url| url == target_repo_url)
            })
            .unwrap_or(false);

        if !has_back_ref {
            return show_form_error(
                &username,
                &format!(
                    "Tracker does not advertise {} for this repository (missing bidirectional reference)",
                    back_ref_field
                ),
            );
        }
    }

    let tracker_inbox = match tracker_doc.get("inbox").and_then(|i| i.as_str()) {
        Some(inbox) => inbox.to_string(),
        None => {
            return show_form_error(&username, "Tracker has no inbox");
        }
    };

    // Build the Offer activity
    let offer_uuid = Uuid::new_v4();
    let offer_url = format!("{}/activities/{}", public_url, offer_uuid);

    // Build the appropriate object based on kind
    let object = if form.kind == "mr" {
        // For patches, we need to build a Patch object with attachment and collection
        let origin_repo = form.origin_repo.as_deref().unwrap_or("");
        let origin_branch = form.origin_branch.as_deref().unwrap_or("");

        // Parse the origin repository URL
        let Some((origin_owner, origin_slug)) = parse_repository_url(origin_repo, public_url)
        else {
            return show_form_error(&username, "Invalid origin repository URL");
        };

        // Look up the repository to verify ownership and get bare path
        let lookup_result = match find_repository_by_path(db, &origin_owner, &origin_slug).await {
            Ok(result) => result,
            Err(e) => {
                error!(error = %e, "failed to lookup origin repository");
                return show_form_error(&username, "Failed to lookup origin repository");
            }
        };

        let Some((origin_user, _origin_repo_model)) = lookup_result else {
            return show_form_error(&username, "Origin repository not found");
        };

        // Verify the current user owns the origin repository
        if origin_user.id != current.id {
            return show_form_error(&username, "You do not own the origin repository");
        }

        // Get the bare repository path
        let repo_path = RepositoryPath {
            owner: origin_owner.clone(),
            slug: origin_slug.clone(),
        };
        let bare_path = repo_path.bare_path(&state.repo_root);

        // Generate patches using git format-patch
        let patches = match generate_patches(&bare_path, origin_branch).await {
            Ok(patches) => patches,
            Err(e) => {
                error!(error = %e, branch = %origin_branch, "failed to generate patches");
                return show_form_error(&username, "Failed to generate patches");
            }
        };

        // Build the Branch attachment
        let branch_attachment = serde_json::json!({
            "type": "Branch",
            "name": origin_branch,
            "url": origin_repo,
        });

        // Build the Collection of patches
        let patch_items: Vec<serde_json::Value> = patches
            .into_iter()
            .map(|patch| {
                serde_json::json!({
                    "type": "Article",
                    "content": patch,
                })
            })
            .collect();

        let patch_collection = serde_json::json!({
            "type": "Collection",
            "items": patch_items,
        });

        // Build the Patch object
        serde_json::json!({
            "type": "Patch",
            "attributedTo": user_actor_url,
            "summary": form.summary,
            "content": form.description,
            "attachment": branch_attachment,
            "object": patch_collection,
        })
    } else {
        // For tickets, build a simple Ticket object
        serde_json::json!({
            "type": "Ticket",
            "attributedTo": user_actor_url,
            "summary": form.summary,
            "content": form.description,
        })
    };

    let offer = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            "https://forgefed.org/ns"
        ],
        "type": "Offer",
        "id": offer_url,
        "actor": user_actor_url,
        "to": [target_repo_url],
        "target": target_repo_url,
        "object": object,
    });

    let offer_json = serde_json::to_string(&offer).unwrap();

    // Record in ap_remote_tickets (status: pending)
    let remote_ticket = ap_remote_tickets::ActiveModel {
        uuid: Set(offer_uuid),
        author_id: Set(current.id),
        kind: Set(form.kind.clone()),
        tracker_url: Set(tracker_url.clone()),
        offer_activity_url: Set(offer_url.clone()),
        status: Set("pending".to_string()),
        assigned_url: Set(None),
        summary: Set(form.summary.clone()),
        created_at: Set(chrono::Utc::now().into()),
        ..Default::default()
    };

    if let Err(e) = remote_ticket.insert(db).await {
        error!(error = %e, "failed to insert remote ticket");
        return show_form_error(&username, "Failed to save remote ticket");
    }

    // Enqueue delivery to tracker inbox - this also inserts into ap_activities
    if let Err(e) = enqueue_delivery(
        db,
        current.id,
        offer_uuid,
        offer_json,
        offer_url,
        tracker_inbox,
    )
    .await
    {
        error!(error = %e, "failed to enqueue delivery");
        return show_form_error(&username, "Failed to enqueue delivery");
    }

    info!(
        user = %current.name,
        tracker = %tracker_url,
        kind = %form.kind,
        "remote ticket submitted"
    );

    // Redirect to the list page
    HttpResponse::SeeOther()
        .append_header(("Location", format!("/~{}/remote-tickets", username)))
        .finish()
}

fn show_form_error(username: &str, message: &str) -> HttpResponse {
    let view = NewRemoteTicket {
        username: username.to_string(),
        error: Some(message.to_string()),
    };

    new_remote_ticket(None, &view)
}

/// Form data for submitting a remote ticket/MR
#[derive(serde::Deserialize)]
pub struct RemoteTicketForm {
    pub target: String,
    pub kind: String,
    pub summary: String,
    pub description: String,
    // MR-specific fields (optional)
    pub origin_repo: Option<String>,
    pub origin_branch: Option<String>,
    pub target_branch: Option<String>,
}