🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The web interface for patch proposals.
//!
//! A patch proposal is created when someone pushes to `refs/patches` over SSH.
//! This module provides the web pages to view, list, and edit patch proposals.

use std::sync::Arc;

use actix_web::{HttpResponse, get, post, web};
use sea_orm::EntityTrait;
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use uuid::Uuid;

use super::readable_repository;
use crate::auth::{CurrentUser, viewer_name};
use crate::entities::patch_proposals::{MergePolicy, Status};
use crate::entities::repositories::Model as Repository;
use crate::entities::users;
use crate::patches::{self, merge, store};
use crate::repositories::git;
use crate::{AppState, comments, markdown, state_read, views};

/// What the list page is asked to show.
#[derive(Debug, Default, Deserialize)]
struct PatchesQuery {
    /// The state filter: "all", "draft", "open", "closed", "merged", or empty
    /// for default.
    #[serde(default)]
    state: String,
}

/// What a patch edit form posts.
#[derive(Debug)]
struct PatchFields {
    summary: String,
    description: String,
    target_branch: String,
    status: String,
    merge_policy: Option<String>,
}

/// Parses a patch edit form from URL-encoded data.
fn parse_patch_form(body: &str) -> Result<PatchFields, String> {
    let mut summary: Option<String> = None;
    let mut description: Option<String> = None;
    let mut target_branch: Option<String> = None;
    let mut status: Option<String> = None;
    let mut merge_policy: Option<String> = None;

    for pair in body.split('&') {
        if pair.is_empty() {
            continue;
        }
        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
        let key = urlencoding_decode(key)?;
        let value = urlencoding_decode(value)?;

        match key.as_str() {
            "summary" => summary = Some(value),
            "description" => description = Some(value),
            "target_branch" => target_branch = Some(value),
            "status" => status = Some(value),
            "merge_policy" => merge_policy = Some(value),
            _ => {}
        }
    }

    Ok(PatchFields {
        summary: summary.ok_or_else(|| "missing summary field".to_string())?,
        description: description.unwrap_or_default(),
        target_branch: target_branch.ok_or_else(|| "missing target_branch field".to_string())?,
        status: status.ok_or_else(|| "missing status field".to_string())?,
        merge_policy,
    })
}

/// Decodes a URL-encoded string, handling `+` as space.
fn urlencoding_decode(s: &str) -> Result<String, String> {
    let mut result = String::new();
    let mut chars = s.bytes();
    while let Some(b) = chars.next() {
        match b {
            b'+' => result.push(' '),
            b'%' => {
                let hi = chars.next().ok_or("incomplete percent encoding")?;
                let lo = chars.next().ok_or("incomplete percent encoding")?;
                let hex = format!("{}{}", hi as char, lo as char);
                let byte = u8::from_str_radix(&hex, 16)
                    .map_err(|_| "invalid percent encoding".to_string())?;
                result.push(byte as char);
            }
            _ => result.push(b as char),
        }
    }
    Ok(result)
}

/// The address of a repository's patches.
fn patches_address(owner: &users::Model, repository: &Repository) -> String {
    format!("/~{}/{}/patches", owner.name, repository.slug)
}

/// The answer for a patch this repository does not have.
fn no_such_patch(number: &str) -> HttpResponse {
    HttpResponse::NotFound().body(format!("This repository has no patch {number}"))
}

#[get("/~{user_or_team}/{repository_slug}/patches")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        state = tracing::field::Empty,
    )
)]
async fn get_patches(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
    query: web::Query<PatchesQuery>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("state", query.state.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    list_page(&data, viewer.as_ref(), &owner, &repository, &query).await
}

/// The list page: the patches the filter leaves.
async fn list_page(
    data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<&CurrentUser>,
    owner: &users::Model,
    repository: &Repository,
    query: &PatchesQuery,
) -> HttpResponse {
    let db = { state_read(data).await.db.clone() };

    // Parse the state filter
    let state_filter = query.state.trim().to_lowercase();
    let status_filter = match state_filter.as_str() {
        "draft" => Some(Status::Draft),
        "open" => Some(Status::Open),
        "closed" => Some(Status::Closed),
        "merged" => Some(Status::Merged),
        "all" | "" => None,
        // Default to showing only open patch proposals
        _ => Some(Status::Open),
    };

    let listed = match store::list(&db, repository.id, status_filter).await {
        Ok(listed) => listed,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's patches failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Filter out drafts that the viewer shouldn't see
    let viewer_id = viewer.map(|v| v.id);
    let is_owner = viewer_id == Some(repository.owner_id);
    let listed: Vec<_> = listed
        .into_iter()
        .filter(|pp| {
            // Drafts are only visible to their creator and the repo owner
            if pp.status == Status::Draft {
                is_owner || viewer_id == pp.creator_id
            } else {
                true
            }
        })
        .collect();

    let creator_ids: Vec<i64> = listed.iter().filter_map(|pp| pp.creator_id).collect();
    let remote_actor_ids: Vec<i64> = listed.iter().filter_map(|pp| pp.remote_actor_id).collect();
    let creators = match crate::tickets::store::creator_authors(
        &db,
        &creator_ids,
        &remote_actor_ids,
    )
    .await
    {
        Ok(creators) => creators,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "looking up who created patches failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let patches: Vec<views::PatchRow> = listed
        .into_iter()
        .map(|pp| {
            let creator = pp
                .creator_id
                .or(pp.remote_actor_id)
                .and_then(|id| creators.get(&id))
                .cloned()
                .unwrap_or_else(|| crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: false,
                });
            views::PatchRow {
                number: pp.number,
                summary: pp.summary,
                creator,
                status: pp_status_label(&pp.status),
            }
        })
        .collect();

    views::patches(
        viewer_name(viewer),
        &owner.name,
        &views::PatchesDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            patches,
            can_open: viewer.is_some(),
            state_filter,
        },
    )
}

/// Converts a patch status to a human-readable label.
fn pp_status_label(status: &Status) -> String {
    match status {
        Status::Draft => "draft".to_string(),
        Status::Open => "open".to_string(),
        Status::Closed => "closed".to_string(),
        Status::Merged => "merged".to_string(),
    }
}

#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
pub async fn get_patch(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Check visibility: drafts are only visible to creator and owner
    let viewer_id = viewer.as_ref().map(|v| v.id);
    let is_owner = viewer_id == Some(repository.owner_id);
    let is_creator = viewer_id == patch.creator_id;

    if patch.status == Status::Draft && !is_owner && !is_creator {
        return no_such_patch(&number);
    }

    let creator = if let Some(creator_id) = patch.creator_id {
        match crate::tickets::store::creator_authors(&db, &[creator_id], &[]).await {
            Ok(creators) => creators.get(&creator_id).cloned().unwrap_or_else(|| {
                crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: false,
                }
            }),
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up who created a patch failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else if let Some(remote_actor_id) = patch.remote_actor_id {
        match crate::tickets::store::creator_authors(&db, &[], &[remote_actor_id]).await {
            Ok(creators) => creators.get(&remote_actor_id).cloned().unwrap_or_else(|| {
                crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: true,
                }
            }),
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up who created a patch failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else {
        crate::comments::CommentAuthor {
            name: String::from("someone"),
            actor_url: None,
            is_remote: false,
        }
    };

    let may_change = viewer.as_ref().is_some_and(|viewer| {
        patches::may_revise(repository.owner_id, patch.creator_id, viewer.id)
    });

    // Fetch the bare repository path for git operations
    let repo_root = { state_read(&data).await.repo_root.clone() };
    let bare = super::RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    // Check if the diff is unavailable (merged/closed before snapshots were
    // implemented)
    let diff_unavailable_reason = if matches!(patch.status, Status::Merged | Status::Closed)
        && patch.snapshot_base_oid.is_none()
        && patch.snapshot_head_oid.is_none()
    {
        Some("Diff not available: this patch was merged/closed before diff snapshots were implemented".to_string())
    } else {
        None
    };

    // Fetch commits and diff for the patch
    let (commits, files) = if diff_unavailable_reason.is_some() {
        (Vec::new(), Vec::new())
    } else {
        let range = patches::compute_diff_range(&patch);
        let commits = match git::history(&bare, &range).await {
            Ok(Some(commits)) => commits,
            Ok(None) => Vec::new(),
            Err(e) => {
                tracing::error!(%e, "failed to fetch patch commits");
                Vec::new()
            }
        };

        let files = match git::compare_diff(&bare, &range).await {
            Ok(Some(files)) => files,
            Ok(None) => Vec::new(),
            Err(e) => {
                tracing::error!(%e, "failed to fetch patch diff");
                Vec::new()
            }
        };
        (commits, files)
    };

    // Convert commits to views format
    let commit_summaries: Vec<views::CommitSummary> = commits
        .into_iter()
        .map(|c| views::CommitSummary {
            hash: c.hash,
            full_hash: c.full_hash,
            author: c.author,
            date: c.date,
            subject: c.subject,
            files_changed: c.files_changed,
            insertions: c.insertions,
            deletions: c.deletions,
        })
        .collect();

    // Fetch comments for this patch
    let comments = match comments::store::list_for_patch(&db, patch.id).await {
        Ok(comments) => {
            let author_ids: Vec<i64> = comments.iter().filter_map(|c| c.author_id).collect();
            let remote_actor_ids: Vec<i64> =
                comments.iter().filter_map(|c| c.remote_actor_id).collect();
            let authors = match comments::store::comment_authors(
                &db,
                &author_ids,
                &remote_actor_ids,
            )
            .await
            {
                Ok(authors) => authors,
                Err(error) => {
                    error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up comment authors failed");
                    std::collections::HashMap::new()
                }
            };
            let viewer_id = viewer.as_ref().map(|v| v.id);
            comments
                .into_iter()
                .map(|c| {
                    let author = c
                        .author_id
                        .and_then(|id| authors.get(&id))
                        .or_else(|| c.remote_actor_id.and_then(|id| authors.get(&id)))
                        .cloned()
                        .unwrap_or_else(|| comments::CommentAuthor {
                            name: String::from("someone"),
                            actor_url: None,
                            is_remote: false,
                        });
                    views::CommentView {
                        base_url: "", /* filled later on in
                                       * [`crate::views::comments::CommentListView`] */
                        uuid: c.uuid.to_string(),
                        author,
                        content: c.content.clone(),
                        content_html: markdown::render(&c.content),
                        created_at: c.created_at,
                        updated_at: c.updated_at,
                        may_change: viewer_id.is_some_and(|vid| {
                            comments::may_change(c.author_id, repository.owner_id, vid)
                        }),
                    }
                })
                .collect()
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "listing a patch's comments failed");
            Vec::new()
        }
    };

    let may_comment = viewer.is_some();

    views::patch(
        viewer_name(viewer.as_ref()),
        &owner.name,
        &views::PatchDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            number: patch.number,
            summary: patch.summary,
            description_html: markdown::render(patch.description.as_deref().unwrap_or("")),
            creator,
            created_at: patch.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
            status: pp_status_label(&patch.status),
            source_ref: patch.source_ref,
            target_branch: patch.target_branch,
            merge_policy: pp_merge_policy_label(&patch.merge_policy),
            may_change,
            is_owner,
            commits: commit_summaries,
            files,
            diff_unavailable_reason,
            comments,
            may_comment,
            comment_error: None,
            comment_draft: None,
            change_id: patch.change_id,
        },
    )
}

/// Converts a patch merge policy to a human-readable label.
fn pp_merge_policy_label(policy: &MergePolicy) -> String {
    match policy {
        MergePolicy::PreferFastForward => "Prefer fast-forward".to_string(),
        MergePolicy::FastForwardOnly => "Fast-forward only".to_string(),
        MergePolicy::MergeCommitOnly => "Merge commit only".to_string(),
        MergePolicy::RebaseThenFastForward => "Rebase then fast-forward".to_string(),
    }
}

#[get("/~{user_or_team}/{repository_slug}/patches/{number}/edit")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
async fn get_edit_patch(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Only creator or owner may edit
    if !patches::may_revise(repository.owner_id, patch.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Fetch available branches for the target branch dropdown
    let bare_path = super::RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&_repo_root);
    let branches = match git::branches(&bare_path, None).await {
        Ok(refs) => refs.into_iter().map(|r| r.name).collect(),
        Err(e) => {
            tracing::error!(%e, "failed to list branches");
            vec![repository.default_branch.clone()]
        }
    };

    views::patch_form(
        Some(&current.name),
        &owner.name,
        &views::PatchEditForm {
            action: format!("{}/{number}", patches_address(&owner, &repository)),
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            number: patch.number,
            summary: Some(patch.summary),
            description: patch.description,
            target_branch: patch.target_branch,
            branches,
            status: pp_status_label(&patch.status),
            merge_policy: pp_merge_policy_label(&patch.merge_policy),
            error: None,
            submit: "Save",
            is_owner: current.id == repository.owner_id,
        },
    )
}

#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        summary = tracing::field::Empty,
    )
)]
pub async fn post_edit_patch(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
    body: String,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let form = match parse_patch_form(&body) {
        Ok(f) => f,
        Err(e) => return HttpResponse::BadRequest().body(format!("Invalid form data: {e}")),
    };

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("summary", form.summary.trim().chars().count());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if !patches::may_revise(repository.owner_id, patch.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Fetch available branches for the target branch dropdown
    let bare_path = super::RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&_repo_root);
    let branches = match git::branches(&bare_path, None).await {
        Ok(refs) => refs.into_iter().map(|r| r.name).collect(),
        Err(e) => {
            tracing::error!(%e, "failed to list branches");
            vec![repository.default_branch.clone()]
        }
    };

    let refused = |error: String| {
        views::patch_form(
            Some(&current.name),
            &owner.name,
            &views::PatchEditForm {
                action: format!("{}/{number}", patches_address(&owner, &repository)),
                slug: repository.slug.clone(),
                visibility: repository.visibility.label(),
                default_branch: repository.default_branch.clone(),
                number: patch.number,
                summary: Some(form.summary.clone()),
                description: Some(form.description.clone()),
                target_branch: form.target_branch.clone(),
                branches: branches.clone(),
                status: form.status.clone(),
                merge_policy: form.merge_policy.clone().unwrap_or_default(),
                error: Some(error),
                submit: "Save",
                is_owner: current.id == repository.owner_id,
            },
        )
    };

    let summary = match patches::checked_summary(&form.summary) {
        Ok(summary) => summary.to_string(),
        Err(error) => return refused(error),
    };

    let target_branch = form.target_branch.trim();
    if target_branch.is_empty() {
        return refused("a target branch is required".to_string());
    }

    let status = match patches::parse_status(&form.status) {
        Some(s) => s,
        None => return refused(format!("`{}` is not a valid status", form.status)),
    };

    // Only the owner may change the merge policy
    let merge_policy = if patches::may_change_merge_policy(repository.owner_id, current.id) {
        match form.merge_policy.as_deref() {
            Some(s) => match patches::parse_merge_policy(s) {
                Some(p) => Some(p),
                None => return refused(format!("`{s}` is not a valid merge policy")),
            },
            None => None,
        }
    } else {
        None
    };

    let description = (!form.description.is_empty()).then_some(form.description.as_str());

    // Store the old state before updating to detect transitions
    let old_status = patch.status;
    let old_resolve_url = patch.resolve_activity_url.clone();

    // Determine what the new resolve_activity_url should be
    let new_resolve_url = if old_status != status
        && status == crate::entities::patch_proposals::Status::Closed
    {
        // Open→Closed: will emit Resolve, need to generate and store the URL
        let state_guard = state_read(&data).await;
        let federation_enabled = crate::activitypub::enabled(&state_guard);
        let is_public = repository.visibility.label() == "public";
        drop(state_guard);

        if federation_enabled && is_public {
            let state_guard = state_read(&data).await;
            let urls = crate::activitypub::ApUrls::new(state_guard.public_url());
            Some(urls.activity(&owner.name, Uuid::new_v4()))
        } else {
            None
        }
    } else if old_status != status && status == crate::entities::patch_proposals::Status::Open {
        // Closed→Open: will emit Undo(Resolve), clear the URL
        None
    } else {
        // No status change or same status: keep existing value
        old_resolve_url.clone()
    };

    // Compute snapshot OIDs if closing the patch
    let snapshot_oids =
        if old_status != status && status == crate::entities::patch_proposals::Status::Closed {
            // Compute merge-base and head of source ref for the snapshot
            let source_ref_full = if patch.source_ref.starts_with("refs/") {
                patch.source_ref.clone()
            } else {
                format!("refs/{}", patch.source_ref)
            };

            let target_head = match crate::repositories::git::resolves_to_commit(
                &bare_path,
                &format!("refs/heads/{}", patch.target_branch),
            )
            .await
            {
                Ok(true) => Some(format!("refs/heads/{}", patch.target_branch)),
                _ => None,
            };

            let source_head =
                match crate::repositories::git::resolves_to_commit(&bare_path, &source_ref_full)
                    .await
                {
                    Ok(true) => Some(source_ref_full.clone()),
                    _ => None,
                };

            if let (Some(target), Some(source)) = (target_head, source_head) {
                // Compute merge-base using git command
                let output = tokio::process::Command::new("git")
                    .arg("-C")
                    .arg(&bare_path)
                    .arg("merge-base")
                    .arg(&target)
                    .arg(&source)
                    .output()
                    .await;

                match output {
                    Ok(out) if out.status.success() => {
                        let merge_base = String::from_utf8_lossy(&out.stdout).trim().to_string();

                        // Get the actual OID for source
                        let source_oid_output = tokio::process::Command::new("git")
                            .arg("-C")
                            .arg(&bare_path)
                            .arg("rev-parse")
                            .arg(&source_ref_full)
                            .output()
                            .await;

                        if let Ok(out) = source_oid_output {
                            if out.status.success() {
                                let source_oid =
                                    String::from_utf8_lossy(&out.stdout).trim().to_string();
                                Some((merge_base, source_oid))
                            } else {
                                None
                            }
                        } else {
                            None
                        }
                    }
                    _ => None,
                }
            } else {
                None
            }
        } else {
            None
        };

    // Update the patch with appropriate function based on whether we're closing
    let update_result = if let Some((base_oid, head_oid)) = &snapshot_oids {
        // Closing with snapshot - use update_with_merge_snapshot
        store::update_with_merge_snapshot(
            &db,
            patch.id,
            Some(&summary),
            description,
            Some(target_branch),
            Some(status),
            merge_policy,
            Some(base_oid),
            Some(head_oid),
            new_resolve_url.clone(),
        )
        .await
    } else {
        // Regular update - use update_with_resolve_url
        store::update_with_resolve_url(
            &db,
            patch.id,
            Some(&summary),
            description,
            Some(target_branch),
            Some(status),
            merge_policy,
            new_resolve_url.clone(),
        )
        .await
    };

    if let Err(error) = update_result {
        error!(%error, owner = %owner.name, slug = %repository.slug, number, "editing a patch failed");
        return HttpResponse::InternalServerError().finish();
    }

    // If reopening, clear the snapshot OIDs to go back to live diff
    if old_status != status
        && old_status == crate::entities::patch_proposals::Status::Closed
        && status == crate::entities::patch_proposals::Status::Open
        && let Err(error) = store::clear_snapshot_oids(&db, patch.id).await
    {
        error!(%error, owner = %owner.name, slug = %repository.slug, number, "clearing snapshot OIDs on reopen failed");
        return HttpResponse::InternalServerError().finish();
    }

    // Emit federation activities
    let state_guard = state_read(&data).await;
    let federation_enabled = crate::activitypub::enabled(&state_guard);
    let is_public = repository.visibility.label() == "public";
    drop(state_guard);

    if federation_enabled && is_public {
        let state_guard = state_read(&data).await;
        let urls = crate::activitypub::ApUrls::new(state_guard.public_url());

        let repo_actor =
            match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                Ok(actor) => actor,
                Err(e) => {
                    error!(%e, "failed to get repo actor for federation");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", patches_address(&owner, &repository)),
                        ))
                        .finish();
                }
            };

        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
        let parsed_number: i64 = number.parse().unwrap_or(0);
        let patch_url = urls.repo_patch(&owner.name, &repository.slug, parsed_number);

        // Emit Resolve if patch was closed
        if old_status != status
            && status == crate::entities::patch_proposals::Status::Closed
            && let Some(resolve_url) = &new_resolve_url
        {
            let resolve = crate::activitypub::types::Resolve {
                context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                id: resolve_url.clone(),
                kind: "Resolve",
                actor: repo_actor_url.clone(),
                object: patch_url.clone(),
                to: vec![repo_actor_url.clone()],
                cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                published: chrono::Utc::now().to_rfc3339(),
            };

            let resolve_json = match serde_json::to_string(&resolve) {
                Ok(json) => json,
                Err(e) => {
                    error!(%e, "failed to serialize Resolve activity");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", patches_address(&owner, &repository)),
                        ))
                        .finish();
                }
            };

            if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                &db,
                repo_actor.id,
                &resolve_json,
                &patch_url,
            )
            .await
            {
                error!(%e, "failed to publish Resolve activity");
            }
        }

        // Emit Undo(Resolve) if patch was reopened
        if old_status != status
            && status == crate::entities::patch_proposals::Status::Open
            && old_status == crate::entities::patch_proposals::Status::Closed
            && let Some(resolve_url) = &old_resolve_url
        {
            let resolve_to_undo = crate::activitypub::types::Resolve {
                context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                id: resolve_url.clone(),
                kind: "Resolve",
                actor: repo_actor_url.clone(),
                object: patch_url.clone(),
                to: vec![repo_actor_url.clone()],
                cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                published: chrono::Utc::now().to_rfc3339(),
            };

            let undo = crate::activitypub::types::Undo {
                context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                id: urls.activity(&owner.name, Uuid::new_v4()),
                kind: "Undo",
                actor: repo_actor_url.clone(),
                object: resolve_to_undo,
                to: vec![repo_actor_url.clone()],
                cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                published: chrono::Utc::now().to_rfc3339(),
            };

            let undo_json = match serde_json::to_string(&undo) {
                Ok(json) => json,
                Err(e) => {
                    error!(%e, "failed to serialize Undo activity");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", patches_address(&owner, &repository)),
                        ))
                        .finish();
                }
            };

            if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                &db,
                repo_actor.id,
                &undo_json,
                &patch_url,
            )
            .await
            {
                error!(%e, "failed to publish Undo activity");
            }
        }

        // Emit Offer+Accept if patch was opened from draft
        if old_status != status
            && old_status == crate::entities::patch_proposals::Status::Draft
            && status == crate::entities::patch_proposals::Status::Open
        {
            // Get the patch creator (user actor)
            if let Some(creator_id) = patch.creator_id {
                // Fetch the creator user entity
                let creator = match users::Entity::find_by_id(creator_id).one(&db).await {
                    Ok(Some(user)) => user,
                    Ok(None) => {
                        error!("patch creator not found");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", patches_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                    Err(e) => {
                        error!(%e, "failed to fetch patch creator");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", patches_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                };

                let user_actor =
                    match crate::activitypub::actor::get_or_create(&db, creator_id).await {
                        Ok(actor) => actor,
                        Err(e) => {
                            error!(%e, "failed to get user actor for federation");
                            return HttpResponse::SeeOther()
                                .append_header((
                                    actix_web::http::header::LOCATION,
                                    format!("{}/{number}", patches_address(&owner, &repository)),
                                ))
                                .finish();
                        }
                    };

                let user_actor_url = urls.actor(&creator.name);
                let offer_id = urls.activity(&creator.name, Uuid::new_v4());

                // Build patch document for the Offer
                let bare_path = std::path::Path::new(&_repo_root);
                let patch_doc = match crate::activitypub::forge::builders::build_patch_document(
                    &patch,
                    &repository,
                    &owner.name,
                    bare_path,
                    &creator,
                    &urls,
                )
                .await
                {
                    Ok(doc) => doc,
                    Err(e) => {
                        error!(%e, "failed to build patch document for Offer");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", patches_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                };

                // Build Offer activity
                let offer = crate::activitypub::types::Offer {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: offer_id.clone(),
                    kind: "Offer",
                    actor: user_actor_url.clone(),
                    object: serde_json::to_value(&patch_doc).expect("patch doc should serialize"),
                    target: repo_actor_url.clone(),
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                    published: chrono::Utc::now().to_rfc3339(),
                };

                let offer_json = match serde_json::to_string(&offer) {
                    Ok(json) => json,
                    Err(e) => {
                        error!(%e, "failed to serialize Offer activity");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", patches_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                };

                // Insert Offer into user's activities (no delivery - tracker is
                // local)
                if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                    &db,
                    user_actor.id,
                    &offer_json,
                    &patch_url,
                )
                .await
                {
                    error!(%e, "failed to publish Offer activity");
                }

                // Announce Accept from repo actor to followers
                if let Err(e) = crate::activitypub::forge::activity::announce_accept(
                    &db,
                    repo_actor.id,
                    &repo_actor_url,
                    &offer_id,
                    &patch_url,
                    None,
                )
                .await
                {
                    error!(%e, "failed to announce Accept activity");
                }
            }
        }
    }

    HttpResponse::SeeOther()
        .append_header((
            actix_web::http::header::LOCATION,
            format!("{}/{number}", patches_address(&owner, &repository)),
        ))
        .finish()
}

/// POST endpoint to merge a patch proposal.
///
/// Only the repository owner may merge a patch. This endpoint attempts to merge
/// the patch according to its merge policy, updates the status to merged on
/// success, and redirects to the patch detail page.
#[post("/~{user_or_team}/{repository_slug}/patches/{number}/merge")]
pub async fn post_merge_patch(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let viewer = match viewer {
        Some(v) => v,
        None => return HttpResponse::Unauthorized().finish(),
    };

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&viewer), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    // Only the owner may merge
    if !patches::may_change_merge_policy(repository.owner_id, viewer.id) {
        return HttpResponse::Forbidden().finish();
    }

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Cannot merge a patch that is already merged or closed
    if patch.status == Status::Merged || patch.status == Status::Closed {
        return HttpResponse::SeeOther()
            .append_header((
                actix_web::http::header::LOCATION,
                format!("{}/{number}", patches_address(&owner, &repository)),
            ))
            .finish();
    }

    // Get the bare repository path
    let bare_path = super::RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&_repo_root);

    // Get the current target branch HEAD before merge (for Push activity)
    let old_head = match crate::repositories::git::commit(&bare_path, &patch.target_branch).await {
        Ok(Some(commit)) => Some(commit.full_hash),
        Ok(None) => None, // Target branch doesn't exist yet
        Err(error) => {
            error!(%error, "failed to resolve target branch for Push activity");
            None // Continue without Push if we can't get old HEAD
        }
    };

    // Attempt the merge
    let merge_result = match merge::merge(
        &bare_path,
        &patch.source_ref,
        &patch.target_branch,
        patch.merge_policy,
    )
    .await
    {
        Ok(result) => result,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "merge failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    match merge_result {
        merge::MergeResult::Success {
            new_target_oid,
            snapshot_base_oid,
            snapshot_head_oid,
        } => {
            // Update the patch status to merged and store the snapshot OIDs
            if let Err(error) = store::update_with_merge_snapshot(
                &db,
                patch.id,
                None,
                None,
                None,
                Some(Status::Merged),
                None,
                Some(&snapshot_base_oid),
                Some(&snapshot_head_oid),
                None,
            )
            .await
            {
                error!(%error, owner = %owner.name, slug = %repository.slug, number, "updating patch status to merged failed");
                return HttpResponse::InternalServerError().finish();
            }

            tracing::info!(
                %new_target_oid,
                owner = %owner.name,
                slug = %repository.slug,
                number,
                "patch merged successfully"
            );

            // Emit federation activities
            let state_guard = state_read(&data).await;
            let federation_enabled = crate::activitypub::enabled(&state_guard);
            let is_public = repository.visibility.label() == "public";
            drop(state_guard);

            if federation_enabled && is_public {
                let state_guard = state_read(&data).await;
                let urls = crate::activitypub::ApUrls::new(state_guard.public_url());
                drop(state_guard);

                let repo_actor =
                    match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id)
                        .await
                    {
                        Ok(actor) => actor,
                        Err(e) => {
                            error!(%e, "failed to get repo actor for federation");
                            return HttpResponse::SeeOther()
                                .append_header((
                                    actix_web::http::header::LOCATION,
                                    format!("{}/{number}", patches_address(&owner, &repository)),
                                ))
                                .finish();
                        }
                    };

                let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
                let parsed_number: i64 = number.parse().unwrap_or(0);
                let patch_url = urls.repo_patch(&owner.name, &repository.slug, parsed_number);
                let resolve_url = urls.activity(&owner.name, Uuid::new_v4());

                // Update patch with resolve_activity_url
                if let Err(error) = store::update_with_resolve_url(
                    &db,
                    patch.id,
                    None,
                    None,
                    None,
                    Some(Status::Merged),
                    None,
                    Some(resolve_url.clone()),
                )
                .await
                {
                    error!(%error, "updating patch resolve_activity_url failed");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", patches_address(&owner, &repository)),
                        ))
                        .finish();
                }

                // Emit Resolve activity
                let resolve = crate::activitypub::types::Resolve {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: resolve_url.clone(),
                    kind: "Resolve",
                    actor: repo_actor_url.clone(),
                    object: patch_url.clone(),
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                    published: chrono::Utc::now().to_rfc3339(),
                };

                let resolve_json = match serde_json::to_string(&resolve) {
                    Ok(json) => json,
                    Err(e) => {
                        error!(%e, "failed to serialize Resolve activity");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", patches_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                };

                if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                    &db,
                    repo_actor.id,
                    &resolve_json,
                    &patch_url,
                )
                .await
                {
                    error!(%e, "failed to publish Resolve activity");
                }

                // Emit Push activity if we have the old HEAD
                if let Some(old_head) = old_head {
                    // Get commits in range (old_head, new_target_oid]
                    let commits = match crate::repositories::git::history(
                        &bare_path,
                        &format!("{}..{}", old_head, new_target_oid),
                    )
                    .await
                    {
                        Ok(Some(commits)) => commits,
                        Ok(None) => vec![],
                        Err(error) => {
                            error!(%error, "failed to get commit history for Push");
                            vec![]
                        }
                    };

                    // Build commit objects
                    let commit_objects: Vec<crate::activitypub::types::Commit> = commits
                        .iter()
                        .map(|c| crate::activitypub::types::Commit {
                            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                            id: format!("{}/commits/{}", patch_url, c.full_hash),
                            kind: "Commit",
                            attributed_to: None,
                            hash: c.full_hash.clone(),
                            created: c.date.clone(),
                            summary: c.subject.clone(),
                        })
                        .collect();

                    let push_url = urls.activity(&owner.name, Uuid::new_v4());
                    let push = crate::activitypub::types::Push {
                        context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                        id: push_url.clone(),
                        kind: "Push",
                        actor: repo_actor_url.clone(),
                        attributed_to: urls.actor(&viewer.name),
                        target: urls.repo_actor(&owner.name, &repository.slug),
                        hash_before: Some(old_head),
                        hash_after: new_target_oid.clone(),
                        object: crate::activitypub::types::PushCommitCollection {
                            kind: "OrderedCollection",
                            total_items: commit_objects.len() as u64,
                            ordered_items: commit_objects,
                        },
                        published: chrono::Utc::now().to_rfc3339(),
                        to: vec![repo_actor_url.clone()],
                    };

                    let push_json = match serde_json::to_string(&push) {
                        Ok(json) => json,
                        Err(e) => {
                            error!(%e, "failed to serialize Push activity");
                            return HttpResponse::SeeOther()
                                .append_header((
                                    actix_web::http::header::LOCATION,
                                    format!("{}/{number}", patches_address(&owner, &repository)),
                                ))
                                .finish();
                        }
                    };

                    if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                        &db,
                        repo_actor.id,
                        &push_json,
                        &patch_url,
                    )
                    .await
                    {
                        error!(%e, "failed to publish Push activity");
                    }
                }
            }

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", patches_address(&owner, &repository)),
                ))
                .finish()
        }
        merge::MergeResult::Conflict { reason } => {
            // Redirect back to the patch page with an error message
            // For now, we'll just log and redirect. A better UX would pass the
            // error through flash messages or query parameters.
            tracing::warn!(
                %reason,
                owner = %owner.name,
                slug = %repository.slug,
                number,
                "merge conflict"
            );

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", patches_address(&owner, &repository)),
                ))
                .finish()
        }
        merge::MergeResult::Refused { reason } => {
            // Redirect back to the patch page with an error message
            tracing::warn!(
                %reason,
                owner = %owner.name,
                slug = %repository.slug,
                number,
                "merge refused"
            );

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", patches_address(&owner, &repository)),
                ))
                .finish()
        }
    }
}

#[post("/~{user_or_team}/{repository_slug}/patches/{number}/comments")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
pub async fn post_comment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
    body: String,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Check visibility: drafts are only visible to creator and owner
    let viewer_id = current.id;
    let is_owner = viewer_id == repository.owner_id;
    let is_creator = Some(viewer_id) == patch.creator_id;

    if patch.status == Status::Draft && !is_owner && !is_creator {
        return no_such_patch(&number);
    }

    // Parse the form body to extract content
    let mut content: Option<String> = None;
    for pair in body.split('&') {
        if pair.is_empty() {
            continue;
        }
        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
        if key == "content" {
            content = Some(urlencoding_decode(value).unwrap_or_default());
        }
    }

    let content = content.unwrap_or_default();
    let content = match comments::checked_content(&content) {
        Ok(c) => c,
        Err(error) => {
            // Re-render the patch page with the error
            return patch_with_comment_error(
                &data,
                Some(&current),
                &owner,
                &repository,
                &patch,
                Some(error),
                Some(content),
            )
            .await;
        }
    };

    match comments::store::create_on_patch(&db, current.id, patch.id, content).await {
        Ok(comment) => {
            // Emit Create activity for federation
            let state = state_read(&data).await;
            if crate::activitypub::enabled(&state) {
                let urls = crate::activitypub::ApUrls::new(state.public_url());

                // Get or create the repo actor
                match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                    Ok(repo_actor) => {
                        let user_actor_url = urls.actor(&owner.name);
                        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

                        // Build the Note document
                        let note_doc =
                            crate::activitypub::forge::builders::build_comment_note_document(
                                &comment,
                                &owner.name,
                                &repository.slug,
                                patch.number,
                                false, // is_ticket
                                &owner,
                                &urls,
                            );

                        // Build the Create activity
                        let activity_id =
                            format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
                        let create_activity = crate::activitypub::types::Create {
                            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                            id: activity_id,
                            kind: "Create",
                            actor: user_actor_url.clone(),
                            object: note_doc,
                            published: chrono::Utc::now().to_rfc3339(),
                            to: vec![repo_actor_url.clone()],
                            cc: vec![urls.repo_followers(&owner.name, &repository.slug)],
                        };

                        let create_json =
                            serde_json::to_string(&create_activity).unwrap_or_default();

                        // Publish with user as signer, repo followers as
                        // delivery target
                        let comment_url = urls.repo_patch_comment(
                            &owner.name,
                            &repository.slug,
                            patch.number,
                            comment.uuid,
                        );
                        if let Err(e) = crate::activitypub::forge::activity::publish_activity_with_separate_signer(
                            &db,
                            current.id,           // signer = user actor
                            repo_actor.id,        // fan-out to repo followers
                            &create_json,
                            &comment_url,
                        ).await {
                            tracing::error!(%e, "failed to publish Create comment activity");
                        }
                    }
                    Err(e) => {
                        tracing::error!(%e, "failed to get repo actor for comment emission");
                    }
                }
            }

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", patches_address(&owner, &repository)),
                ))
                .finish()
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "creating a comment failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
pub async fn post_edit_comment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String, String)>,
    body: String,
) -> HttpResponse {
    let (owner_name, repository_slug, number, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(uuid) = uuid_str.parse::<uuid::Uuid>() else {
        return HttpResponse::NotFound().body("No such comment");
    };

    let db = { state_read(&data).await.db.clone() };
    let comment = match comments::store::find_by_uuid(&db, uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().body("No such comment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if comment.patch_proposal_id != Some(patch.id) {
        return HttpResponse::NotFound().body("No such comment");
    }

    if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Parse the form body to extract content
    let mut content: Option<String> = None;
    for pair in body.split('&') {
        if pair.is_empty() {
            continue;
        }
        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
        if key == "content" {
            content = Some(urlencoding_decode(value).unwrap_or_default());
        }
    }

    let content = content.unwrap_or_default();
    let content = match comments::checked_content(&content) {
        Ok(c) => c,
        Err(error) => {
            // Re-render the patch page with the error
            return patch_with_comment_error(
                &data,
                Some(&current),
                &owner,
                &repository,
                &patch,
                Some(error),
                Some(content),
            )
            .await;
        }
    };

    match comments::store::update(&db, comment.id, content).await {
        Ok(()) => {
            // Re-fetch the updated comment for federation
            let updated_comment = match comments::store::find_by_uuid(&db, uuid).await {
                Ok(Some(comment)) => comment,
                Ok(None) => {
                    tracing::error!("comment disappeared after update");
                    return HttpResponse::InternalServerError().finish();
                }
                Err(error) => {
                    tracing::error!(%error, "failed to re-fetch comment after update");
                    return HttpResponse::InternalServerError().finish();
                }
            };

            // Emit Edit activity for federation
            let state = state_read(&data).await;
            if crate::activitypub::enabled(&state) {
                let urls = crate::activitypub::ApUrls::new(state.public_url());

                // Get or create the repo actor
                match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                    Ok(repo_actor) => {
                        let user_actor_url = urls.actor(&owner.name);
                        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

                        // Build the updated Note document
                        let note_doc =
                            crate::activitypub::forge::builders::build_comment_note_document(
                                &updated_comment,
                                &owner.name,
                                &repository.slug,
                                patch.number,
                                false, // is_ticket
                                &owner,
                                &urls,
                            );
                        let note_doc_id = note_doc.id.clone();

                        // Build the Edit activity
                        let activity_id =
                            format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
                        let edit_activity = crate::activitypub::types::Edit {
                            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                            id: activity_id,
                            kind: "Update",
                            actor: user_actor_url.clone(),
                            object: serde_json::to_value(&note_doc).unwrap_or_default(),
                            published: chrono::Utc::now().to_rfc3339(),
                            to: vec![repo_actor_url.clone()],
                            cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                        };

                        let edit_json = serde_json::to_string(&edit_activity).unwrap_or_default();

                        // Publish with user as signer, repo followers as
                        // delivery target
                        if let Err(e) = crate::activitypub::forge::activity::publish_activity_with_separate_signer(
                            &db,
                            current.id,           // signer = user actor
                            repo_actor.id,        // fan-out to repo followers
                            &edit_json,
                            &note_doc_id,
                        ).await {
                            tracing::error!(%e, "failed to publish Edit comment activity");
                        }
                    }
                    Err(e) => {
                        tracing::error!(%e, "failed to get repo actor for comment edit emission");
                    }
                }
            }

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", patches_address(&owner, &repository)),
                ))
                .finish()
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "editing a comment failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/{repository_slug}/patches/{number}/comments/{uuid}/delete")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
pub async fn post_delete_comment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(uuid) = uuid_str.parse::<uuid::Uuid>() else {
        return HttpResponse::NotFound().body("No such comment");
    };

    let db = { state_read(&data).await.db.clone() };
    let comment = match comments::store::find_by_uuid(&db, uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().body("No such comment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_patch(&number);
    };

    let patch = match store::find_by_number(&db, repository.id, parsed).await {
        Ok(Some(patch)) => patch,
        Ok(None) => return no_such_patch(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if comment.patch_proposal_id != Some(patch.id) {
        return HttpResponse::NotFound().body("No such comment");
    }

    if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Emit Delete activity before deleting
    let state = state_read(&data).await;
    if crate::activitypub::enabled(&state) {
        let urls = crate::activitypub::ApUrls::new(state.public_url());

        // Get or create the repo actor
        match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
            Ok(repo_actor) => {
                let user_actor_url = urls.actor(&owner.name);
                let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

                // Build the comment URL
                let comment_url = urls.repo_patch_comment(
                    &owner.name,
                    &repository.slug,
                    patch.number,
                    comment.uuid,
                );

                // Build the Delete activity
                let activity_id = format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
                let delete_activity = crate::activitypub::types::Delete {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: activity_id,
                    kind: "Delete",
                    actor: user_actor_url.clone(),
                    object: comment_url.clone(),
                    published: chrono::Utc::now().to_rfc3339(),
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                };

                let delete_json = serde_json::to_string(&delete_activity).unwrap_or_default();

                // Publish with user as signer, repo followers as delivery
                // target
                if let Err(e) =
                    crate::activitypub::forge::activity::publish_activity_with_separate_signer(
                        &db,
                        current.id,    // signer = user actor
                        repo_actor.id, // fan-out to repo followers
                        &delete_json,
                        &comment_url,
                    )
                    .await
                {
                    tracing::error!(%e, "failed to publish Delete comment activity");
                }
            }
            Err(e) => {
                tracing::error!(%e, "failed to get repo actor for comment delete emission");
            }
        }
    }

    match comments::store::delete(&db, comment.id).await {
        Ok(_) => HttpResponse::SeeOther()
            .append_header((
                actix_web::http::header::LOCATION,
                format!("{}/{number}", patches_address(&owner, &repository)),
            ))
            .finish(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "deleting a comment failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// Re-renders the patch page with a comment error and draft preserved
async fn patch_with_comment_error(
    data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<&CurrentUser>,
    owner: &users::Model,
    repository: &Repository,
    patch: &crate::entities::patch_proposals::Model,
    error: Option<String>,
    draft: Option<String>,
) -> HttpResponse {
    let db = { state_read(data).await.db.clone() };

    let creator = if let Some(creator_id) = patch.creator_id {
        // Local creator
        match crate::tickets::store::creator_authors(&db, &[creator_id], &[]).await {
            Ok(creators) => creators.get(&creator_id).cloned().unwrap_or_else(|| {
                crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: false,
                }
            }),
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number = patch.number, "looking up who created a patch failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else if let Some(remote_id) = patch.remote_actor_id {
        // Remote creator
        match crate::tickets::store::creator_authors(&db, &[], &[remote_id]).await {
            Ok(creators) => creators.get(&remote_id).cloned().unwrap_or_else(|| {
                crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: true,
                }
            }),
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number = patch.number, "looking up who created a patch failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else {
        crate::comments::CommentAuthor {
            name: String::from("someone"),
            actor_url: None,
            is_remote: false,
        }
    };

    let viewer_id = viewer.map(|v| v.id);
    let is_owner = viewer_id == Some(repository.owner_id);

    let may_change = viewer.is_some_and(|viewer| {
        crate::patches::may_revise(repository.owner_id, patch.creator_id, viewer.id)
    });

    // Fetch the bare repository path for git operations
    let repo_root = { state_read(data).await.repo_root.clone() };
    let bare = super::RepositoryPath {
        owner: owner.name.clone(),
        slug: repository.slug.clone(),
    }
    .bare_path(&repo_root);

    // Check if the diff is unavailable (merged/closed before snapshots were
    // implemented)
    let diff_unavailable_reason = if matches!(patch.status, Status::Merged | Status::Closed)
        && patch.snapshot_base_oid.is_none()
        && patch.snapshot_head_oid.is_none()
    {
        Some("Diff not available: this patch was merged/closed before diff snapshots were implemented".to_string())
    } else {
        None
    };

    // Fetch commits and diff for the patch
    let (commits, files) = if diff_unavailable_reason.is_some() {
        (Vec::new(), Vec::new())
    } else {
        let range = patches::compute_diff_range(patch);
        let commits = match crate::repositories::git::history(&bare, &range).await {
            Ok(Some(commits)) => commits,
            Ok(None) => Vec::new(),
            Err(e) => {
                tracing::error!(%e, "failed to fetch patch commits");
                Vec::new()
            }
        };

        let files = match crate::repositories::git::compare_diff(&bare, &range).await {
            Ok(Some(files)) => files,
            Ok(None) => Vec::new(),
            Err(e) => {
                tracing::error!(%e, "failed to fetch patch diff");
                Vec::new()
            }
        };
        (commits, files)
    };

    // Convert commits to views format
    let commit_summaries: Vec<views::CommitSummary> = commits
        .into_iter()
        .map(|c| views::CommitSummary {
            hash: c.hash,
            full_hash: c.full_hash,
            author: c.author,
            date: c.date,
            subject: c.subject,
            files_changed: c.files_changed,
            insertions: c.insertions,
            deletions: c.deletions,
        })
        .collect();

    let comments = match comments::store::list_for_patch(&db, patch.id).await {
        Ok(comments) => {
            let author_ids: Vec<i64> = comments.iter().filter_map(|c| c.author_id).collect();
            let remote_actor_ids: Vec<i64> =
                comments.iter().filter_map(|c| c.remote_actor_id).collect();
            let authors = match comments::store::comment_authors(
                &db,
                &author_ids,
                &remote_actor_ids,
            )
            .await
            {
                Ok(authors) => authors,
                Err(error) => {
                    error!(%error, owner = %owner.name, slug = %repository.slug, number = patch.number, "looking up comment authors failed");
                    std::collections::HashMap::new()
                }
            };
            comments
                .into_iter()
                .map(|c| {
                    let author = c
                        .author_id
                        .and_then(|id| authors.get(&id))
                        .or_else(|| c.remote_actor_id.and_then(|id| authors.get(&id)))
                        .cloned()
                        .unwrap_or_else(|| comments::CommentAuthor {
                            name: String::from("someone"),
                            actor_url: None,
                            is_remote: false,
                        });
                    views::CommentView {
                        base_url: "", /* filled later on in
                                       * [`crate::views::comments::CommentListView`] */
                        uuid: c.uuid.to_string(),
                        author,
                        content: c.content.clone(),
                        content_html: crate::markdown::render(&c.content),
                        created_at: c.created_at,
                        updated_at: c.updated_at,
                        may_change: viewer_id.is_some_and(|vid| {
                            comments::may_change(c.author_id, repository.owner_id, vid)
                        }),
                    }
                })
                .collect()
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number = patch.number, "listing a patch's comments failed");
            Vec::new()
        }
    };

    let may_comment = viewer.is_some();

    views::patch(
        viewer_name(viewer),
        &owner.name,
        &views::PatchDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            number: patch.number,
            summary: patch.summary.clone(),
            description_html: crate::markdown::render(patch.description.as_deref().unwrap_or("")),
            creator,
            created_at: patch.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
            status: pp_status_label(&patch.status),
            source_ref: patch.source_ref.clone(),
            target_branch: patch.target_branch.clone(),
            merge_policy: pp_merge_policy_label(&patch.merge_policy),
            may_change,
            is_owner,
            commits: commit_summaries,
            files,
            diff_unavailable_reason,
            comments,
            may_comment,
            comment_error: error,
            comment_draft: draft,
            change_id: patch.change_id.clone(),
        },
    )
}