Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: one commit at `/{owner}/{repo}/commit/{oid}`: metadata, file list and unified diff. |
| 2 | |
| 3 | use forge_core::tz::format_in_tz; |
| 4 | use forge_git::browse; |
| 5 | use forge_git::history; |
| 6 | use topcoat::Result; |
| 7 | use topcoat::context::{Cx, app_context}; |
| 8 | use topcoat::router::error::not_found; |
| 9 | use topcoat::router::{page, path_param, route}; |
| 10 | use topcoat::view::{View, view}; |
| 11 | |
| 12 | use crate::i18n::code::{self as g, K}; |
| 13 | use crate::i18n::{self}; |
| 14 | use crate::pages::commits::{SigBadgeView, blocking, iso_time, now_secs, short_oid}; |
| 15 | use crate::pages::repo::repo_head; |
| 16 | use crate::pages::repo_ctx::load_repo; |
| 17 | use crate::render::emoji::emojify; |
| 18 | use crate::signatures; |
| 19 | use crate::state::AppState; |
| 20 | use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary}; |
| 21 | use crate::views::diff::{DiffLang, LIMITS, diff_section}; |
| 22 | use crate::views::relative_time; |
| 23 | use crate::views::shell; |
| 24 | |
| 25 | path_param!(owner); |
| 26 | path_param!(repo); |
| 27 | path_param!(oid); |
| 28 | |
| 29 | /// Author, committer or parent line data. |
| 30 | struct Person { |
| 31 | name: String, |
| 32 | relative: String, |
| 33 | absolute: String, |
| 34 | iso: String, |
| 35 | } |
| 36 | |
| 37 | struct ParentLink { |
| 38 | href: String, |
| 39 | short: String, |
| 40 | } |
| 41 | |
| 42 | fn is_hex_prefix(s: &str) -> bool { |
| 43 | (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit()) |
| 44 | } |
| 45 | |
| 46 | #[page("/{owner}/{repo}/commit/{oid}")] |
| 47 | async fn commit_page(cx: &Cx) -> Result<impl View> { |
| 48 | let owner = path_param::<Owner>(cx); |
| 49 | let name = path_param::<Repo>(cx); |
| 50 | let rev = path_param::<Oid>(cx); |
| 51 | if !is_hex_prefix(rev) { |
| 52 | return Err(not_found().into()); |
| 53 | } |
| 54 | |
| 55 | let ctx = load_repo(cx, owner, name).await?; |
| 56 | let state = app_context::<AppState>(cx); |
| 57 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); |
| 58 | |
| 59 | let repo_dir = ctx.path.clone(); |
| 60 | let rev = rev.to_owned(); |
| 61 | let Some(oid) = blocking(move || browse::resolve_commit(&repo_dir, &rev)).await? else { |
| 62 | return Err(not_found().into()); |
| 63 | }; |
| 64 | let repo_dir = ctx.path.clone(); |
| 65 | let lookup = oid.clone(); |
| 66 | let Some(info) = blocking(move || browse::read_commit(&repo_dir, &lookup)).await? else { |
| 67 | return Err(not_found().into()); |
| 68 | }; |
| 69 | let diff = history::commit_diff(&state.git, &ctx.path, &info.oid, LIMITS).await?; |
| 70 | |
| 71 | let owner_name = ctx.repo.owner_name.clone(); |
| 72 | let repo_name = ctx.repo.name.clone(); |
| 73 | let tz = ctx.viewer_tz().to_owned(); |
| 74 | let parent = info.parents.first().cloned(); |
| 75 | |
| 76 | let now = now_secs(); |
| 77 | let author = Person { |
| 78 | name: info.author_name.clone(), |
| 79 | relative: relative_time(lang, info.author_time, now), |
| 80 | absolute: format_in_tz(info.author_time, &tz), |
| 81 | iso: iso_time(info.author_time), |
| 82 | }; |
| 83 | let committer_differs = |
| 84 | info.committer_name != info.author_name || info.committer_email != info.author_email; |
| 85 | let committer = Person { |
| 86 | name: info.committer_name.clone(), |
| 87 | relative: relative_time(lang, info.committer_time, now), |
| 88 | absolute: format_in_tz(info.committer_time, &tz), |
| 89 | iso: iso_time(info.committer_time), |
| 90 | }; |
| 91 | let parents: Vec<ParentLink> = info |
| 92 | .parents |
| 93 | .iter() |
| 94 | .map(|p| ParentLink { |
| 95 | href: format!("/{owner_name}/{repo_name}/commit/{p}"), |
| 96 | short: short_oid(p).to_owned(), |
| 97 | }) |
| 98 | .collect(); |
| 99 | let is_root = parents.is_empty(); |
| 100 | let has_body = !info.message.trim().is_empty(); |
| 101 | let title = format!("{owner_name}/{repo_name}"); |
| 102 | let repo_href = format!("/{owner_name}/{repo_name}"); |
| 103 | let tree_href = format!("/{owner_name}/{repo_name}/tree/{}", info.oid); |
| 104 | let summary = emojify(&info.summary).into_owned(); |
| 105 | let full_oid = info.oid.clone(); |
| 106 | let message = emojify(&info.message).into_owned(); |
| 107 | let statuses = state.store.statuses_for(&ctx.repo.id, &full_oid).await?; |
| 108 | let checks = (!statuses.is_empty()).then(|| StatusSummary::from_rows(&statuses)); |
| 109 | let sig = signatures::badges(state, &ctx.repo, std::slice::from_ref(&full_oid)) |
| 110 | .await |
| 111 | .get(&full_oid) |
| 112 | .map(|badge| SigBadgeView::from_badge(badge, lang)); |
| 113 | |
| 114 | let header = ctx.head(state, "commits").await?; |
| 115 | Ok(view! { |
| 116 | shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), |
| 117 | repo_head(head: header) |
| 118 | <h2 class="commit-summary-title">(summary.as_str())</h2> |
| 119 | <p class="muted small"><a href=(repo_href.as_str())>(title.as_str())</a></p> |
| 120 | <section class="card commit-info"> |
| 121 | <p> |
| 122 | <code>(full_oid.as_str())</code> |
| 123 | if let Some(badge) = sig { |
| 124 | <span class=(badge.class) title=(badge.title.as_str())>(badge.label)</span> |
| 125 | } |
| 126 | </p> |
| 127 | if let Some(summary) = checks { |
| 128 | checks_summary(summary: summary, lang: StatusLang(lang)) |
| 129 | } |
| 130 | <p class="muted">(g::t(lang, K::AuthorLabel)) <strong>(author.name.as_str())</strong> " · " <time datetime=(author.iso.as_str()) title=(author.absolute.as_str())>(author.relative.as_str())</time></p> |
| 131 | if committer_differs { |
| 132 | <p class="muted">(g::t(lang, K::CommitterLabel)) <strong>(committer.name.as_str())</strong> " · " <time datetime=(committer.iso.as_str()) title=(committer.absolute.as_str())>(committer.relative.as_str())</time></p> |
| 133 | } |
| 134 | if is_root { |
| 135 | <p class="muted">(g::t(lang, K::RootCommit))</p> |
| 136 | } else { |
| 137 | <p class="muted">(g::t(lang, K::ParentLabel)) |
| 138 | for p in parents.iter() { |
| 139 | <a class="mono" href=(p.href.as_str())>(p.short.as_str())</a> " " |
| 140 | } |
| 141 | </p> |
| 142 | } |
| 143 | <p><a href=(tree_href.as_str())>(g::t(lang, K::BrowseFiles))</a></p> |
| 144 | </section> |
| 145 | if has_body { |
| 146 | <pre class="code commit-message"><code>(message.as_str())</code></pre> |
| 147 | } |
| 148 | diff_section(diff: &diff, owner: owner_name.as_str(), repo: repo_name.as_str(), oid: info.oid.as_str(), parent: parent.as_deref(), subject: g::t(lang, K::ThisCommit), lang: DiffLang(lang)) |
| 149 | ) |
| 150 | }) |
| 151 | } |
| 152 | |
| 153 | /// Other methods on a commit path get a plain 404. |
| 154 | #[route(* "/{owner}/{repo}/commit/{oid}")] |
| 155 | async fn commit_other_methods() -> Result<&'static str> { |
| 156 | Err(not_found().into()) |
| 157 | } |
| 158 | |
| 159 | #[cfg(test)] |
| 160 | mod tests { |
| 161 | use forge_core::Visibility; |
| 162 | use scraper::{Html, Selector}; |
| 163 | |
| 164 | use super::is_hex_prefix; |
| 165 | use crate::pages::commits::test_support::{Fixture, OWNER, REPO, get}; |
| 166 | |
| 167 | fn doc(body: &[u8]) -> Html { |
| 168 | Html::parse_document(std::str::from_utf8(body).unwrap()) |
| 169 | } |
| 170 | |
| 171 | /// Text of each cell in every `tr` matching `row_selector`, as `[old, new, code]`. |
| 172 | fn rows(html: &Html, row_selector: &str) -> Vec<Vec<String>> { |
| 173 | let tr = Selector::parse(row_selector).unwrap(); |
| 174 | let td = Selector::parse("td").unwrap(); |
| 175 | html.select(&tr) |
| 176 | .map(|row| { |
| 177 | row.select(&td) |
| 178 | .map(|c| c.text().collect::<String>().trim().to_owned()) |
| 179 | .collect() |
| 180 | }) |
| 181 | .collect() |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn hex_prefix_bounds() { |
| 186 | assert!(is_hex_prefix("abcd")); |
| 187 | assert!(is_hex_prefix(&"a".repeat(64))); |
| 188 | assert!(!is_hex_prefix("abc")); |
| 189 | assert!(!is_hex_prefix(&"a".repeat(65))); |
| 190 | assert!(!is_hex_prefix("zzzz")); |
| 191 | } |
| 192 | |
| 193 | #[tokio::test] |
| 194 | async fn modified_and_added_files_have_line_numbers() { |
| 195 | let fx = Fixture::new(Visibility::Public).await; |
| 196 | let (status, _, body) = get( |
| 197 | &fx.router, |
| 198 | &format!("/{OWNER}/{REPO}/commit/{}", fx.modified), |
| 199 | ) |
| 200 | .await; |
| 201 | assert_eq!(status, 200); |
| 202 | let html = doc(&body); |
| 203 | |
| 204 | let rows = rows(&html, "tr.diff-line"); |
| 205 | let find = |code: &str| { |
| 206 | rows.iter() |
| 207 | .find(|r| r.get(2).map(String::as_str) == Some(code)) |
| 208 | .unwrap_or_else(|| panic!("no row {code:?} in {rows:?}")) |
| 209 | .clone() |
| 210 | }; |
| 211 | assert_eq!(find("-two"), vec!["2", "", "-two"]); |
| 212 | assert_eq!(find("+TWO"), vec!["", "2", "+TWO"]); |
| 213 | assert_eq!(find("three"), vec!["3", "3", "three"]); |
| 214 | assert_eq!(find("+four"), vec!["", "4", "+four"]); |
| 215 | assert_eq!(find("+new file"), vec!["", "1", "+new file"]); |
| 216 | |
| 217 | let added = Selector::parse(".diff-file-header").unwrap(); |
| 218 | let headers: Vec<String> = html |
| 219 | .select(&added) |
| 220 | .map(|h| h.text().collect::<String>()) |
| 221 | .collect(); |
| 222 | assert!( |
| 223 | headers |
| 224 | .iter() |
| 225 | .any(|h| h.contains("new.txt") && h.contains("added")) |
| 226 | ); |
| 227 | assert!( |
| 228 | headers |
| 229 | .iter() |
| 230 | .any(|h| h.contains("a.txt") && h.contains("modified")) |
| 231 | ); |
| 232 | let text = String::from_utf8_lossy(&body).into_owned(); |
| 233 | assert!(text.contains("Body line one.")); |
| 234 | assert!(text.contains("class=\"mono\"")); |
| 235 | assert!(text.contains("diff-file-body")); |
| 236 | } |
| 237 | |
| 238 | #[tokio::test] |
| 239 | async fn root_commit_page_renders() { |
| 240 | let fx = Fixture::new(Visibility::Public).await; |
| 241 | let (status, _, body) = |
| 242 | get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.root)).await; |
| 243 | assert_eq!(status, 200); |
| 244 | let html = String::from_utf8(body.clone()).unwrap(); |
| 245 | assert!(html.contains("Root commit (no parents)")); |
| 246 | let rows = rows(&doc(&body), "tr.diff-line"); |
| 247 | assert!( |
| 248 | rows.iter() |
| 249 | .any(|r| r.get(2).map(String::as_str) == Some("+one")) |
| 250 | ); |
| 251 | } |
| 252 | |
| 253 | #[tokio::test] |
| 254 | async fn commit_title_uses_viewer_timezone() { |
| 255 | use topcoat::router::request::Request; |
| 256 | use topcoat::router::{Body, to_bytes}; |
| 257 | |
| 258 | use crate::auth::SESSION_COOKIE; |
| 259 | |
| 260 | let fx = Fixture::new(Visibility::Public).await; |
| 261 | let tokyo = fx |
| 262 | .store |
| 263 | .create_user("tokyo", None, "not-a-real-hash", false) |
| 264 | .await |
| 265 | .unwrap(); |
| 266 | fx.store |
| 267 | .set_profile(&tokyo.id, "", "Asia/Tokyo") |
| 268 | .await |
| 269 | .unwrap(); |
| 270 | crate::test_support::create_session(&fx.store, &tokyo.id, "tokyo-token", "tokyo-csrf") |
| 271 | .await; |
| 272 | let uri = format!("/{OWNER}/{REPO}/commit/{}", fx.root); |
| 273 | |
| 274 | let req = Request::builder() |
| 275 | .method("GET") |
| 276 | .uri(&uri) |
| 277 | .header("cookie", format!("__Host-{SESSION_COOKIE}=tokyo-token")) |
| 278 | .body(Body::empty()) |
| 279 | .unwrap(); |
| 280 | let res = fx.router.handle(req).await; |
| 281 | assert_eq!(res.status(), 200); |
| 282 | let html = String::from_utf8( |
| 283 | to_bytes(res.into_body(), usize::MAX) |
| 284 | .await |
| 285 | .unwrap() |
| 286 | .to_vec(), |
| 287 | ) |
| 288 | .unwrap(); |
| 289 | // Scope to the page's own `<main>`: the shell's theme button has a `title` with ':'. |
| 290 | let main = html |
| 291 | .split_once("<main") |
| 292 | .and_then(|(_, rest)| rest.split_once("</main>")) |
| 293 | .map(|(main, _)| main) |
| 294 | .expect("page has a <main> element"); |
| 295 | let titles: Vec<&str> = main |
| 296 | .split("title=\"") |
| 297 | .skip(1) |
| 298 | .filter_map(|rest| rest.split('"').next()) |
| 299 | .filter(|t| t.contains(':')) |
| 300 | .collect(); |
| 301 | assert!(!titles.is_empty(), "absolute time in title: {html}"); |
| 302 | assert!( |
| 303 | titles.iter().all(|t| t.ends_with("+09")), |
| 304 | "titles in Tokyo time: {titles:?}" |
| 305 | ); |
| 306 | |
| 307 | // Anonymous viewers see UTC, so no +09 offset. |
| 308 | let (status, _, body) = get(&fx.router, &uri).await; |
| 309 | assert_eq!(status, 200); |
| 310 | let anon = String::from_utf8(body).unwrap(); |
| 311 | assert!(!anon.contains("+09\""), "anonymous view is not Tokyo time"); |
| 312 | } |
| 313 | |
| 314 | #[tokio::test] |
| 315 | async fn bad_oid_is_404() { |
| 316 | let fx = Fixture::new(Visibility::Public).await; |
| 317 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/zzzz")).await; |
| 318 | assert_eq!(status, 404); |
| 319 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/abc")).await; |
| 320 | assert_eq!(status, 404); |
| 321 | let unknown = "0123456789abcdef0123456789abcdef01234567"; |
| 322 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{unknown}")).await; |
| 323 | assert_eq!(status, 404); |
| 324 | } |
| 325 | |
| 326 | #[tokio::test] |
| 327 | async fn unsigned_commit_page_has_no_signature_badge() { |
| 328 | let fx = Fixture::new(Visibility::Public).await; |
| 329 | let (status, _, body) = get( |
| 330 | &fx.router, |
| 331 | &format!("/{OWNER}/{REPO}/commit/{}", fx.modified), |
| 332 | ) |
| 333 | .await; |
| 334 | assert_eq!(status, 200); |
| 335 | let html = String::from_utf8(body).unwrap(); |
| 336 | assert!(!html.contains("sig-badge")); |
| 337 | } |
| 338 | |
| 339 | #[tokio::test] |
| 340 | async fn private_repo_is_404_for_anonymous() { |
| 341 | let fx = Fixture::new(Visibility::Private).await; |
| 342 | let (status, _, _) = get(&fx.router, &format!("/{OWNER}/{REPO}/commit/{}", fx.root)).await; |
| 343 | assert_eq!(status, 404); |
| 344 | } |
| 345 | } |