555287fbe2da54538fc82f014941f11804f9e7fe / frogue / src / webui / repositories / patches.rs
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 crate::AppState;
use crate::auth::{CurrentUser, viewer_name};
use crate::comments;
use crate::entities::patch_proposals::{MergePolicy, Status};
use crate::entities::repositories::Model as Repository;
use crate::entities::users;
use crate::markdown;
use crate::patches::{self, merge, store};
use crate::repositories::git;
use crate::state_read;
use crate::views;
use super::readable_repository;
/// 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);
// Fetch commits and diff for the patch
let range = format!("{}...{}", patch.target_branch, patch.source_ref);
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()
}
};
// 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,
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(¤t), &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(¤t.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(¤t), &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(¤t.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()
};
if let Err(error) = store::update_with_resolve_url(
&db,
patch.id,
Some(&summary),
description,
Some(target_branch),
Some(status),
merge_policy,
new_resolve_url.clone(),
)
.await
{
error!(%error, owner = %owner.name, slug = %repository.slug, number, "editing a patch 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 } => {
// Update the patch status to merged
if let Err(error) =
store::update(&db, patch.id, None, None, None, Some(Status::Merged), 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(¤t), &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(¤t),
&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(¤t), &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(¤t),
&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(¬e_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,
¬e_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(¤t), &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);
// Fetch commits and diff for the patch
let range = format!("{}...{}", patch.target_branch, patch.source_ref);
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()
}
};
// 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,
comments,
may_comment,
comment_error: error,
comment_draft: draft,
change_id: patch.change_id.clone(),
},
)
}