//! 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(¤t.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(¤t.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(target_repo_url, public_url, &actor, ¤t.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(&tracker_url, public_url, &actor, ¤t.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>,
}