426a121f23b76660d4e726c990a4ce183c1c0384 / frogue / src / activitypub / forge / builders.rs
Raw
//! ForgeFed document builders for tickets, patches, and comments.
use std::path::Path;
use sea_orm::DatabaseConnection;
use crate::activitypub::ApUrls;
use crate::activitypub::types::{
FORGEFED_CONTEXT, OrderedCollection, Patch, PatchAttachment, PatchCollection, PatchObject,
Ticket, TicketSource,
};
use crate::entities::patch_proposals::Model as PatchProposal;
use crate::entities::repositories::Model as Repository;
use crate::entities::tickets::Model as TicketEntity;
use crate::entities::users::Model as User;
use crate::repositories;
/// Build a ForgeFed Ticket document from a ticket entity.
pub fn build_ticket_document(
ticket: &TicketEntity,
owner: &str,
slug: &str,
creator_url: &str,
urls: &ApUrls,
) -> Ticket {
let ticket_url = urls.repo_ticket(owner, slug, ticket.number);
let tracker_url = urls.repo_actor(owner, slug);
let replies_url = urls.repo_ticket_replies(owner, slug, ticket.number);
let content_html = crate::markdown::render(&ticket.description.clone().unwrap_or_default());
let published = ticket.created_at.to_rfc3339();
let updated = ticket.updated_at.to_rfc3339();
Ticket {
context: FORGEFED_CONTEXT.to_vec(),
id: Some(ticket_url),
kind: "Ticket",
attributed_to: creator_url.to_string(),
summary: ticket.summary.clone(),
content: content_html,
media_type: "text/html".to_string(),
source: TicketSource {
media_type: "text/markdown".to_string(),
content: ticket.description.clone().unwrap_or_default(),
},
tracker: tracker_url,
is_resolved: ticket.state == crate::tickets::State::Closed,
resolved_by: if ticket.state == crate::tickets::State::Closed {
// Set to repo owner as we don't track who specifically closed it
Some(urls.repo_actor(owner, slug))
} else {
None
},
replies: replies_url,
published,
updated: Some(updated),
}
}
/// Build a ForgeFed Patch (MergeRequest) document from a patch proposal entity.
pub async fn build_patch_document(
patch: &PatchProposal,
repo: &Repository,
owner: &str,
bare_path: &Path,
creator: &User,
urls: &ApUrls,
) -> Result<Patch, String> {
let patch_url = urls.repo_patch(owner, &repo.slug, patch.number);
let tracker_url = urls.repo_actor(owner, &repo.slug);
let replies_url = urls.repo_patch_replies(owner, &repo.slug, patch.number);
let creator_url = urls.actor(&creator.name);
let content_html = crate::markdown::render(&patch.description.clone().unwrap_or_default());
let published = patch.created_at.to_rfc3339();
let updated = patch.updated_at.to_rfc3339();
// Get the diff between target and source branches
let source_ref = format!("refs/patches/{}", patch.number);
let range = crate::patches::compute_diff_range(patch);
let diff_files = repositories::git::compare_diff(bare_path, &range)
.await
.map_err(|e| format!("Failed to get diff: {}", e))?
.unwrap_or_default();
// Convert diff files to patch text
let patch_text = format_diff_as_patch(&diff_files);
let patch_object = PatchObject {
kind: "Patch",
media_type: "text/x-diff".to_string(),
attributed_to: creator_url.clone(),
content: patch_text,
};
// Build origin and target branch URLs
let origin_url = format!("{}/branch/{}", tracker_url, source_ref);
let target_url = format!("{}/branch/{}", tracker_url, patch.target_branch);
Ok(Patch {
context: FORGEFED_CONTEXT.to_vec(),
id: patch_url,
kind: "Patch",
attributed_to: creator_url,
summary: patch.summary.clone(),
content: content_html,
media_type: "text/html".to_string(),
source: TicketSource {
media_type: "text/markdown".to_string(),
content: patch.description.clone().unwrap_or_default(),
},
tracker: tracker_url,
is_resolved: matches!(
patch.status,
crate::entities::patch_proposals::Status::Merged
| crate::entities::patch_proposals::Status::Closed
),
resolved_by: None, // TODO: track who merged/closed
replies: replies_url,
published,
updated: Some(updated),
attachment: PatchAttachment {
kind: "Offer",
origin: origin_url,
target: target_url,
object: PatchCollection {
kind: "OrderedCollection",
total_items: 1,
ordered_items: vec![patch_object],
},
},
})
}
/// Format diff files as unified diff patch text.
fn format_diff_as_patch(diff_files: &[repositories::git::DiffFile]) -> String {
let mut output = String::new();
for file in diff_files {
output.push_str(&format!(
"diff --git a/{} b/{}\n",
file.old_path, file.new_path
));
output.push_str("index 0000000..0000000 100644\n");
output.push_str(&format!("--- a/{}\n", file.old_path));
output.push_str(&format!("+++ b/{}\n", file.new_path));
for hunk in &file.hunks {
output.push_str(&hunk.header);
output.push('\n');
for line in &hunk.lines {
let prefix = match line.kind {
repositories::git::DiffLineKind::Context => " ",
repositories::git::DiffLineKind::Added => "+",
repositories::git::DiffLineKind::Removed => "-",
};
output.push_str(prefix);
output.push_str(&line.text);
output.push('\n');
}
}
}
output
}
/// Build an OrderedCollection for ticket replies (comments).
pub async fn build_ticket_replies(
db: &DatabaseConnection,
ticket: &TicketEntity,
owner: &str,
slug: &str,
urls: &ApUrls,
) -> Result<OrderedCollection, String> {
let replies_url = urls.repo_ticket_replies(owner, slug, ticket.number);
let comments = crate::comments::store::list_for_ticket(db, ticket.id)
.await
.map_err(|e| format!("Failed to list comments: {}", e))?;
let comment_urls: Vec<serde_json::Value> = comments
.iter()
.map(|c| {
let comment_url = urls.repo_ticket_comment(owner, slug, ticket.number, c.uuid);
serde_json::Value::String(comment_url)
})
.collect();
Ok(OrderedCollection {
context: FORGEFED_CONTEXT.to_vec(),
id: replies_url,
kind: "OrderedCollection",
total_items: comment_urls.len() as u64,
first: None,
ordered_items: if comment_urls.is_empty() {
None
} else {
Some(comment_urls)
},
})
}
/// Build an OrderedCollection for patch replies (comments).
pub async fn build_patch_replies(
db: &DatabaseConnection,
patch: &PatchProposal,
owner: &str,
slug: &str,
urls: &ApUrls,
) -> Result<OrderedCollection, String> {
let replies_url = urls.repo_patch_replies(owner, slug, patch.number);
let comments = crate::comments::store::list_for_patch(db, patch.id)
.await
.map_err(|e| format!("Failed to list comments: {}", e))?;
let comment_urls: Vec<serde_json::Value> = comments
.iter()
.map(|c| {
let comment_url = urls.repo_patch_comment(owner, slug, patch.number, c.uuid);
serde_json::Value::String(comment_url)
})
.collect();
Ok(OrderedCollection {
context: FORGEFED_CONTEXT.to_vec(),
id: replies_url,
kind: "OrderedCollection",
total_items: comment_urls.len() as u64,
first: None,
ordered_items: if comment_urls.is_empty() {
None
} else {
Some(comment_urls)
},
})
}
/// Build a Note document for a comment on a ticket or patch.
pub fn build_comment_note_document(
comment: &crate::entities::comments::Model,
owner: &str,
slug: &str,
ticket_or_patch_number: i64,
is_ticket: bool,
author: &User,
urls: &ApUrls,
) -> crate::activitypub::types::Note {
let comment_url = if is_ticket {
urls.repo_ticket_comment(owner, slug, ticket_or_patch_number, comment.uuid)
} else {
urls.repo_patch_comment(owner, slug, ticket_or_patch_number, comment.uuid)
};
let parent_url = if is_ticket {
urls.repo_ticket(owner, slug, ticket_or_patch_number)
} else {
urls.repo_patch(owner, slug, ticket_or_patch_number)
};
let tracker_url = urls.repo_actor(owner, slug);
let followers_url = urls.repo_followers(owner, slug);
let author_url = urls.actor(&author.name);
let content_html = crate::markdown::render(&comment.content);
let published = comment.created_at.to_rfc3339();
crate::activitypub::types::Note {
context: FORGEFED_CONTEXT.to_vec(),
id: comment_url.clone(),
kind: "Note",
attributed_to: author_url,
content: content_html,
media_type: Some("text/html".to_string()),
url: comment_url.clone(),
in_reply_to: Some(parent_url),
source: Some(crate::activitypub::types::NoteSource {
media_type: "text/markdown".to_string(),
content: comment.content.clone(),
}),
published,
to: vec![tracker_url],
cc: vec![followers_url],
}
}