forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.8 KiB
1//! Page: one commit at `/{owner}/{repo}/commit/{oid}`: metadata, file list and unified diff.
2
3use forge_core::tz::format_in_tz;
4use forge_git::browse;
5use forge_git::history;
6use topcoat::Result;
7use topcoat::context::{Cx, app_context};
8use topcoat::router::error::not_found;
9use topcoat::router::{page, path_param, route};
10use topcoat::view::{View, view};
11
12use crate::i18n::code::{self as g, K};
13use crate::i18n::{self};
14use crate::pages::commits::{SigBadgeView, blocking, iso_time, now_secs, short_oid};
15use crate::pages::repo::repo_head;
16use crate::pages::repo_ctx::load_repo;
17use crate::render::emoji::emojify;
18use crate::signatures;
19use crate::state::AppState;
20use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary};
21use crate::views::diff::{DiffLang, LIMITS, diff_section};
22use crate::views::relative_time;
23use crate::views::shell;
24
25path_param!(owner);
26path_param!(repo);
27path_param!(oid);
28
29/// Author, committer or parent line data.
30struct Person {
31 name: String,
32 relative: String,
33 absolute: String,
34 iso: String,
35}
36
37struct ParentLink {
38 href: String,
39 short: String,
40}
41
42fn 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}")]
47async 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}")]
155async fn commit_other_methods() -> Result<&'static str> {
156 Err(not_found().into())
157}
158
159#[cfg(test)]
160mod 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}