//! Page: the edit history of one issue or pull request body, comment or review comment, at //! `/{owner}/{repo}/issues/{n}/history/{kind}/{target_id}` (and `/pulls/...`). Versions are listed //! newest first; each shows who made the edit, when, and a line diff against the version that //! replaced it. `kind` is `issue`, `comment` or `review_comment`. The target must belong to the //! item in the URL, and a deleted target is 404. use std::collections::HashMap; use std::sync::Arc; use forge_core::{EditVersion, Issue, Thread}; use similar::{ChangeTag, TextDiff}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::error::not_found; use topcoat::router::{page, path_param}; use topcoat::view::{View, component, view}; use crate::i18n::issues::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::issue_parts::{ItemKind, When, item_for, parse_number}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::state::AppState; use crate::views::{display_name, now_unix, repo_href, shell}; path_param!(owner); path_param!(repo); path_param!(number); path_param!(kind); path_param!(target_id); /// Unchanged lines shown around each change. const CONTEXT_LINES: usize = 3; /// The target kinds that have edit history, as the URL spells them. const KINDS: [&str; 3] = ["issue", "comment", "review_comment"]; /// The history URL of one target of an item: `/{o}/{r}/{issues|pulls}/{n}/history/{kind}/{id}`. pub(crate) fn history_href( owner: &str, repo_name: &str, kind: ItemKind, number: i64, target_kind: &str, target: &str, ) -> String { repo_href( owner, repo_name, kind.segment(), &format!("{number}/history/{target_kind}/{target}"), ) } /// Edit counts for a batch of targets, one query per call. Targets never edited are absent. pub(crate) async fn edit_counts( state: &AppState, target_kind: &str, ids: &[&str], ) -> Result> { Ok(state.store.edit_count(target_kind, ids).await?) } /// Edit counts for the root comments and replies of `threads`, shared by every thread of a page. pub(crate) async fn thread_edit_counts( state: &AppState, threads: &[Thread], ) -> Result>> { let ids: Vec<&str> = threads .iter() .flat_map(|thread| std::iter::once(&thread.root).chain(thread.replies.iter())) .map(|comment| comment.id.as_str()) .collect(); Ok(Arc::new(edit_counts(state, "review_comment", &ids).await?)) } /// One line of a diff: its CSS class from the `diff-*` set and its text with its sign. struct DiffRow { class: &'static str, text: String, } /// Line diff of `old` against `new`, with `CONTEXT_LINES` of unchanged lines around each change. /// Groups of changes are separated by a `@@` row. fn diff_rows(old: &str, new: &str) -> Vec { let diff = TextDiff::from_lines(old, new); let mut rows = Vec::new(); for (index, group) in diff.grouped_ops(CONTEXT_LINES).iter().enumerate() { if index > 0 { rows.push(DiffRow { class: "diff-line hunk", text: "@@".to_owned(), }); } for op in group { for change in diff.iter_changes(op) { let (class, sign) = match change.tag() { ChangeTag::Delete => ("diff-line del", '-'), ChangeTag::Insert => ("diff-line add", '+'), ChangeTag::Equal => ("diff-line ctx", ' '), }; let line = change.value().trim_end_matches(['\n', '\r']); rows.push(DiffRow { class, text: format!("{sign}{line}"), }); } } } rows } /// One version as shown on the page. struct HistoryEntry { editor: String, when: When, /// `(from, to)` when the title changed in this edit. title_change: Option<(String, String)>, rows: Vec, raw: String, /// True when the body is unchanged by this edit (a title-only change). body_unchanged: bool, } /// Builds the entries, newest first. Version `k` is replaced by version `k - 1` (or by the /// current text for `k = 0`), so each diff runs from its own text to the text that replaced it. fn entries_for( lang: Lang, versions: &[EditVersion], current_title: Option<&str>, current_body: &str, tz: &str, now: i64, ) -> Vec { versions .iter() .enumerate() .map(|(k, version)| { let (newer_title, newer_body) = match k.checked_sub(1) { Some(prev) => ( versions[prev].old_title.as_deref(), versions[prev].old_body.as_str(), ), None => (current_title, current_body), }; let title_change = match (version.old_title.as_deref(), newer_title) { (Some(old), Some(new)) if old != new => Some((old.to_owned(), new.to_owned())), _ => None, }; let body_unchanged = version.old_body == newer_body; HistoryEntry { editor: display_name(lang, &version.editor_name), when: When::new(lang, &version.edited_at, tz, now), title_change, rows: diff_rows(&version.old_body, newer_body), raw: version.old_body.clone(), body_unchanged, } }) .collect() } /// The history page for one target of `item`. `kind_item` only picks the links back. async fn history_page( cx: &Cx, ctx: RepoCtx, kind_item: ItemKind, item: Issue, target_kind: &'static str, target: String, ) -> Result { let target = target.as_str(); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); let state = app_context::(cx); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let number = item.number; // The target must belong to this item. A deleted target is absent, so it is 404 too. let belongs = state.store.edit_target_issue(target_kind, target).await?; if belongs.as_deref() != Some(item.id.as_str()) { return Err(not_found().into()); } let (current_title, current_body): (Option, String) = match target_kind { "issue" => (Some(item.title.clone()), item.body.clone()), "comment" => { let Some(comment) = state.store.comment_by_id(&item.id, target).await? else { return Err(not_found().into()); }; (None, comment.body) } _ => { let Some(comment) = state.store.review_comment(target).await? else { return Err(not_found().into()); }; (None, comment.body) } }; let versions = state.store.edit_history(target_kind, target).await?; let tz = ctx.viewer_tz().to_owned(); let now = now_unix(); let entries = entries_for( lang, &versions, current_title.as_deref(), ¤t_body, &tz, now, ); let what = g::history_what(lang, kind_item == ItemKind::Pull, target_kind); let back = repo_href(&owner, &name, kind_item.segment(), &number.to_string()); let page_title = g::history_title(lang, what, &format!("{owner}/{name} #{number}")); let header = ctx .head( state, if kind_item == ItemKind::Pull { "pulls" } else { "issues" }, ) .await?; let heading = g::history_heading(lang, what); let back_label = g::back_to_item(lang, number); let count_label = g::earlier_versions(lang, entries.len()); Ok(view! { cx => shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(heading.as_str())

if entries.is_empty() {

(g::t(lang, K::NoEarlierVersions))

} for entry in entries.into_iter() { history_entry(lang: lang, entry: entry) }
) }) } /// One version: the editor and time, the title change if any, the diff and the raw text. #[component] async fn history_entry(lang: Lang, entry: HistoryEntry) -> Result { let HistoryEntry { editor, when, title_change, rows, raw, body_unchanged, } = entry; let title_text = title_change.map(|(from, to)| g::title_change(lang, &from, &to)); Ok(view! {
(editor.as_str()) (g::t(lang, K::EditedBy)) (g::edited_after(lang))
if let Some(text) = title_text {

(text.as_str())

} if body_unchanged {

(g::t(lang, K::NoTextChange))

} else {
for row in rows.into_iter() { }
(row.text.as_str())
}
(g::t(lang, K::Raw))
(raw.as_str())
}) } /// Parses the `{kind}` segment, or 404 for anything without edit history. fn parse_kind(raw: &str) -> Result<&'static str> { match KINDS.into_iter().find(|known| *known == raw) { Some(known) => Ok(known), None => Err(not_found().into()), } } async fn history_route(cx: &Cx, item_kind: ItemKind) -> Result { let number = parse_number(path_param::(cx))?; let target_kind = parse_kind(path_param::(cx))?; let target = path_param::(cx).to_owned(); let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let state = app_context::(cx); let item = item_for(state, &ctx, item_kind, number).await?; history_page(cx, ctx, item_kind, item, target_kind, target).await } #[page("/{owner}/{repo}/issues/{number}/history/{kind}/{target_id}")] async fn issue_history(cx: &Cx) -> Result { history_route(cx, ItemKind::Issue).await } #[page("/{owner}/{repo}/pulls/{number}/history/{kind}/{target_id}")] async fn pull_history(cx: &Cx) -> Result { history_route(cx, ItemKind::Pull).await } #[cfg(test)] mod tests { use forge_core::{Side, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::pages::commits::test_support::{Fixture, OWNER, REPO}; /// Sends one request and returns `(status, body)`. async fn send(router: &Router, uri: &str, token: Option<&str>) -> (u16, String) { let mut req = Request::builder().method("GET").uri(uri); if let Some(token) = token { req = req.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); } let res = router.handle(req.body(Body::empty()).unwrap()).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } /// The repo owner with a session whose token is `alice`. async fn alice(fx: &Fixture) -> forge_core::User { let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await; owner } async fn repo_id(fx: &Fixture) -> String { fx.store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap() .id } fn hex_oid(c: char) -> String { std::iter::repeat_n(c, 40).collect() } #[tokio::test] async fn comment_edited_twice_shows_marker_and_diffs() { let fx = Fixture::new(Visibility::Public).await; let owner = alice(&fx).await; let id = repo_id(&fx).await; let issue = fx .store .create_issue(&id, &owner.id, "bug", "body") .await .unwrap(); let comment = fx .store .add_comment(&issue.id, &owner.id, "one\ntwo\nthree") .await .unwrap(); fx.store .edit_comment(&comment.id, &owner.id, "one\nTWO\nthree") .await .unwrap(); fx.store .edit_comment(&comment.id, &owner.id, "one\nTWO\nthree\nfour") .await .unwrap(); let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await; assert_eq!(status, 200); let link = format!("/alice/demo/issues/1/history/comment/{}", comment.id); assert!(page.contains("issue-edited"), "{page}"); assert!(page.contains(&format!("href=\"{link}\"")), "{page}"); let (status, history) = send(&fx.router, &link, None).await; assert_eq!(status, 200); assert_eq!(history.matches("