forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.3 KiB
1//! Page: the edit history of one issue or pull request body, comment or review comment, at
2//! `/{owner}/{repo}/issues/{n}/history/{kind}/{target_id}` (and `/pulls/...`). Versions are listed
3//! newest first; each shows who made the edit, when, and a line diff against the version that
4//! replaced it. `kind` is `issue`, `comment` or `review_comment`. The target must belong to the
5//! item in the URL, and a deleted target is 404.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use forge_core::{EditVersion, Issue, Thread};
11use similar::{ChangeTag, TextDiff};
12use topcoat::Result;
13use topcoat::context::{Cx, app_context};
14use topcoat::router::error::not_found;
15use topcoat::router::{page, path_param};
16use topcoat::view::{View, component, view};
17
18use crate::i18n::issues::{self as g, K};
19use crate::i18n::{self, Lang};
20use crate::pages::issue_parts::{ItemKind, When, item_for, parse_number};
21use crate::pages::repo::repo_head;
22use crate::pages::repo_ctx::{RepoCtx, load_repo};
23use crate::state::AppState;
24use crate::views::{display_name, now_unix, repo_href, shell};
25
26path_param!(owner);
27path_param!(repo);
28path_param!(number);
29path_param!(kind);
30path_param!(target_id);
31
32/// Unchanged lines shown around each change.
33const CONTEXT_LINES: usize = 3;
34
35/// The target kinds that have edit history, as the URL spells them.
36const KINDS: [&str; 3] = ["issue", "comment", "review_comment"];
37
38/// The history URL of one target of an item: `/{o}/{r}/{issues|pulls}/{n}/history/{kind}/{id}`.
39pub(crate) fn history_href(
40 owner: &str,
41 repo_name: &str,
42 kind: ItemKind,
43 number: i64,
44 target_kind: &str,
45 target: &str,
46) -> String {
47 repo_href(
48 owner,
49 repo_name,
50 kind.segment(),
51 &format!("{number}/history/{target_kind}/{target}"),
52 )
53}
54
55/// Edit counts for a batch of targets, one query per call. Targets never edited are absent.
56pub(crate) async fn edit_counts(
57 state: &AppState,
58 target_kind: &str,
59 ids: &[&str],
60) -> Result<HashMap<String, u64>> {
61 Ok(state.store.edit_count(target_kind, ids).await?)
62}
63
64/// Edit counts for the root comments and replies of `threads`, shared by every thread of a page.
65pub(crate) async fn thread_edit_counts(
66 state: &AppState,
67 threads: &[Thread],
68) -> Result<Arc<HashMap<String, u64>>> {
69 let ids: Vec<&str> = threads
70 .iter()
71 .flat_map(|thread| std::iter::once(&thread.root).chain(thread.replies.iter()))
72 .map(|comment| comment.id.as_str())
73 .collect();
74 Ok(Arc::new(edit_counts(state, "review_comment", &ids).await?))
75}
76
77/// One line of a diff: its CSS class from the `diff-*` set and its text with its sign.
78struct DiffRow {
79 class: &'static str,
80 text: String,
81}
82
83/// Line diff of `old` against `new`, with `CONTEXT_LINES` of unchanged lines around each change.
84/// Groups of changes are separated by a `@@` row.
85fn diff_rows(old: &str, new: &str) -> Vec<DiffRow> {
86 let diff = TextDiff::from_lines(old, new);
87 let mut rows = Vec::new();
88 for (index, group) in diff.grouped_ops(CONTEXT_LINES).iter().enumerate() {
89 if index > 0 {
90 rows.push(DiffRow {
91 class: "diff-line hunk",
92 text: "@@".to_owned(),
93 });
94 }
95 for op in group {
96 for change in diff.iter_changes(op) {
97 let (class, sign) = match change.tag() {
98 ChangeTag::Delete => ("diff-line del", '-'),
99 ChangeTag::Insert => ("diff-line add", '+'),
100 ChangeTag::Equal => ("diff-line ctx", ' '),
101 };
102 let line = change.value().trim_end_matches(['\n', '\r']);
103 rows.push(DiffRow {
104 class,
105 text: format!("{sign}{line}"),
106 });
107 }
108 }
109 }
110 rows
111}
112
113/// One version as shown on the page.
114struct HistoryEntry {
115 editor: String,
116 when: When,
117 /// `(from, to)` when the title changed in this edit.
118 title_change: Option<(String, String)>,
119 rows: Vec<DiffRow>,
120 raw: String,
121 /// True when the body is unchanged by this edit (a title-only change).
122 body_unchanged: bool,
123}
124
125/// Builds the entries, newest first. Version `k` is replaced by version `k - 1` (or by the
126/// current text for `k = 0`), so each diff runs from its own text to the text that replaced it.
127fn entries_for(
128 lang: Lang,
129 versions: &[EditVersion],
130 current_title: Option<&str>,
131 current_body: &str,
132 tz: &str,
133 now: i64,
134) -> Vec<HistoryEntry> {
135 versions
136 .iter()
137 .enumerate()
138 .map(|(k, version)| {
139 let (newer_title, newer_body) = match k.checked_sub(1) {
140 Some(prev) => (
141 versions[prev].old_title.as_deref(),
142 versions[prev].old_body.as_str(),
143 ),
144 None => (current_title, current_body),
145 };
146 let title_change = match (version.old_title.as_deref(), newer_title) {
147 (Some(old), Some(new)) if old != new => Some((old.to_owned(), new.to_owned())),
148 _ => None,
149 };
150 let body_unchanged = version.old_body == newer_body;
151 HistoryEntry {
152 editor: display_name(lang, &version.editor_name),
153 when: When::new(lang, &version.edited_at, tz, now),
154 title_change,
155 rows: diff_rows(&version.old_body, newer_body),
156 raw: version.old_body.clone(),
157 body_unchanged,
158 }
159 })
160 .collect()
161}
162
163/// The history page for one target of `item`. `kind_item` only picks the links back.
164async fn history_page(
165 cx: &Cx,
166 ctx: RepoCtx,
167 kind_item: ItemKind,
168 item: Issue,
169 target_kind: &'static str,
170 target: String,
171) -> Result<impl View> {
172 let target = target.as_str();
173 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
174 let state = app_context::<AppState>(cx);
175 let owner = ctx.repo.owner_name.clone();
176 let name = ctx.repo.name.clone();
177 let number = item.number;
178
179 // The target must belong to this item. A deleted target is absent, so it is 404 too.
180 let belongs = state.store.edit_target_issue(target_kind, target).await?;
181 if belongs.as_deref() != Some(item.id.as_str()) {
182 return Err(not_found().into());
183 }
184
185 let (current_title, current_body): (Option<String>, String) = match target_kind {
186 "issue" => (Some(item.title.clone()), item.body.clone()),
187 "comment" => {
188 let Some(comment) = state.store.comment_by_id(&item.id, target).await? else {
189 return Err(not_found().into());
190 };
191 (None, comment.body)
192 }
193 _ => {
194 let Some(comment) = state.store.review_comment(target).await? else {
195 return Err(not_found().into());
196 };
197 (None, comment.body)
198 }
199 };
200
201 let versions = state.store.edit_history(target_kind, target).await?;
202 let tz = ctx.viewer_tz().to_owned();
203 let now = now_unix();
204 let entries = entries_for(
205 lang,
206 &versions,
207 current_title.as_deref(),
208 &current_body,
209 &tz,
210 now,
211 );
212
213 let what = g::history_what(lang, kind_item == ItemKind::Pull, target_kind);
214 let back = repo_href(&owner, &name, kind_item.segment(), &number.to_string());
215 let page_title = g::history_title(lang, what, &format!("{owner}/{name} #{number}"));
216 let header = ctx
217 .head(
218 state,
219 if kind_item == ItemKind::Pull {
220 "pulls"
221 } else {
222 "issues"
223 },
224 )
225 .await?;
226 let heading = g::history_heading(lang, what);
227 let back_label = g::back_to_item(lang, number);
228 let count_label = g::earlier_versions(lang, entries.len());
229
230 Ok(view! { cx =>
231 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
232 repo_head(head: header)
233 <section class="issue-head">
234 <h1 class="issue-heading">(heading.as_str())</h1>
235 <p class="issue-byline">
236 <a href=(back.as_str())>(back_label.as_str())</a>
237 " · "
238 (count_label.as_str())
239 </p>
240 </section>
241 <div class="issue-timeline">
242 if entries.is_empty() {
243 <p class="muted">(g::t(lang, K::NoEarlierVersions))</p>
244 }
245 for entry in entries.into_iter() {
246 history_entry(lang: lang, entry: entry)
247 }
248 </div>
249 )
250 })
251}
252
253/// One version: the editor and time, the title change if any, the diff and the raw text.
254#[component]
255async fn history_entry(lang: Lang, entry: HistoryEntry) -> Result<impl View> {
256 let HistoryEntry {
257 editor,
258 when,
259 title_change,
260 rows,
261 raw,
262 body_unchanged,
263 } = entry;
264 let title_text = title_change.map(|(from, to)| g::title_change(lang, &from, &to));
265 Ok(view! {
266 <article class="issue-item">
267 <header class="issue-item-head">
268 <strong>(editor.as_str())</strong>
269 (g::t(lang, K::EditedBy))
270 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
271 (g::edited_after(lang))
272 </header>
273 if let Some(text) = title_text {
274 <p class="mono">(text.as_str())</p>
275 }
276 if body_unchanged {
277 <p class="muted">(g::t(lang, K::NoTextChange))</p>
278 } else {
279 <div class="diff-file">
280 <div class="diff-file-body">
281 <table class="diff-table">
282 <tbody>
283 for row in rows.into_iter() {
284 <tr class=(row.class)>
285 <td class="diff-code">(row.text.as_str())</td>
286 </tr>
287 }
288 </tbody>
289 </table>
290 </div>
291 </div>
292 }
293 <details>
294 <summary>(g::t(lang, K::Raw))</summary>
295 <pre class="review-hunk"><code>(raw.as_str())</code></pre>
296 </details>
297 </article>
298 })
299}
300
301/// Parses the `{kind}` segment, or 404 for anything without edit history.
302fn parse_kind(raw: &str) -> Result<&'static str> {
303 match KINDS.into_iter().find(|known| *known == raw) {
304 Some(known) => Ok(known),
305 None => Err(not_found().into()),
306 }
307}
308
309async fn history_route(cx: &Cx, item_kind: ItemKind) -> Result<impl View> {
310 let number = parse_number(path_param::<Number>(cx))?;
311 let target_kind = parse_kind(path_param::<Kind>(cx))?;
312 let target = path_param::<TargetId>(cx).to_owned();
313 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
314 let state = app_context::<AppState>(cx);
315 let item = item_for(state, &ctx, item_kind, number).await?;
316 history_page(cx, ctx, item_kind, item, target_kind, target).await
317}
318
319#[page("/{owner}/{repo}/issues/{number}/history/{kind}/{target_id}")]
320async fn issue_history(cx: &Cx) -> Result<impl View> {
321 history_route(cx, ItemKind::Issue).await
322}
323
324#[page("/{owner}/{repo}/pulls/{number}/history/{kind}/{target_id}")]
325async fn pull_history(cx: &Cx) -> Result<impl View> {
326 history_route(cx, ItemKind::Pull).await
327}
328
329#[cfg(test)]
330mod tests {
331 use forge_core::{Side, Visibility};
332 use topcoat::router::request::Request;
333 use topcoat::router::{Body, Router, to_bytes};
334
335 use crate::auth::SESSION_COOKIE;
336 use crate::pages::commits::test_support::{Fixture, OWNER, REPO};
337
338 /// Sends one request and returns `(status, body)`.
339 async fn send(router: &Router, uri: &str, token: Option<&str>) -> (u16, String) {
340 let mut req = Request::builder().method("GET").uri(uri);
341 if let Some(token) = token {
342 req = req.header("cookie", format!("__Host-{SESSION_COOKIE}={token}"));
343 }
344 let res = router.handle(req.body(Body::empty()).unwrap()).await;
345 let status = res.status().as_u16();
346 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
347 (status, String::from_utf8_lossy(&bytes).into_owned())
348 }
349
350 /// The repo owner with a session whose token is `alice`.
351 async fn alice(fx: &Fixture) -> forge_core::User {
352 let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap();
353 crate::test_support::create_session(&fx.store, &owner.id, "alice", "alice-csrf").await;
354 owner
355 }
356
357 async fn repo_id(fx: &Fixture) -> String {
358 fx.store
359 .repo_by_owner_and_name(OWNER, REPO)
360 .await
361 .unwrap()
362 .unwrap()
363 .id
364 }
365
366 fn hex_oid(c: char) -> String {
367 std::iter::repeat_n(c, 40).collect()
368 }
369
370 #[tokio::test]
371 async fn comment_edited_twice_shows_marker_and_diffs() {
372 let fx = Fixture::new(Visibility::Public).await;
373 let owner = alice(&fx).await;
374 let id = repo_id(&fx).await;
375 let issue = fx
376 .store
377 .create_issue(&id, &owner.id, "bug", "body")
378 .await
379 .unwrap();
380 let comment = fx
381 .store
382 .add_comment(&issue.id, &owner.id, "one\ntwo\nthree")
383 .await
384 .unwrap();
385 fx.store
386 .edit_comment(&comment.id, &owner.id, "one\nTWO\nthree")
387 .await
388 .unwrap();
389 fx.store
390 .edit_comment(&comment.id, &owner.id, "one\nTWO\nthree\nfour")
391 .await
392 .unwrap();
393
394 let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await;
395 assert_eq!(status, 200);
396 let link = format!("/alice/demo/issues/1/history/comment/{}", comment.id);
397 assert!(page.contains("issue-edited"), "{page}");
398 assert!(page.contains(&format!("href=\"{link}\"")), "{page}");
399
400 let (status, history) = send(&fx.router, &link, None).await;
401 assert_eq!(status, 200);
402 assert_eq!(history.matches("<article").count(), 2, "{history}");
403 assert!(history.contains("diff-line del"), "{history}");
404 assert!(history.contains("-two"), "{history}");
405 assert!(history.contains("+TWO"), "{history}");
406 assert!(history.contains("+four"), "{history}");
407 assert!(history.contains("alice"), "{history}");
408 }
409
410 #[tokio::test]
411 async fn unedited_comment_has_no_marker() {
412 let fx = Fixture::new(Visibility::Public).await;
413 let owner = alice(&fx).await;
414 let id = repo_id(&fx).await;
415 let issue = fx
416 .store
417 .create_issue(&id, &owner.id, "bug", "body")
418 .await
419 .unwrap();
420 fx.store
421 .add_comment(&issue.id, &owner.id, "plain")
422 .await
423 .unwrap();
424 let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await;
425 assert_eq!(status, 200);
426 assert!(!page.contains("issue-edited"), "{page}");
427 }
428
429 #[tokio::test]
430 async fn body_edit_history_is_listed_and_linked() {
431 let fx = Fixture::new(Visibility::Public).await;
432 let owner = alice(&fx).await;
433 let id = repo_id(&fx).await;
434 let issue = fx
435 .store
436 .create_issue(&id, &owner.id, "old title", "a\nb\nc")
437 .await
438 .unwrap();
439 fx.store
440 .edit_issue(&issue.id, &owner.id, "new title", "a\nB\nc")
441 .await
442 .unwrap();
443
444 let link = format!("/alice/demo/issues/1/history/issue/{}", issue.id);
445 let (status, page) = send(&fx.router, "/alice/demo/issues/1", None).await;
446 assert_eq!(status, 200);
447 assert!(page.contains(&format!("href=\"{link}\"")), "{page}");
448
449 let (status, history) = send(&fx.router, &link, None).await;
450 assert_eq!(status, 200);
451 assert!(history.contains("Title: old title"), "{history}");
452 assert!(history.contains("-b"), "{history}");
453 assert!(history.contains("+B"), "{history}");
454 }
455
456 #[tokio::test]
457 async fn target_from_another_issue_is_not_found() {
458 let fx = Fixture::new(Visibility::Public).await;
459 let owner = alice(&fx).await;
460 let id = repo_id(&fx).await;
461 fx.store
462 .create_issue(&id, &owner.id, "first", "body")
463 .await
464 .unwrap();
465 let other = fx
466 .store
467 .create_issue(&id, &owner.id, "second", "body")
468 .await
469 .unwrap();
470 let comment = fx
471 .store
472 .add_comment(&other.id, &owner.id, "x")
473 .await
474 .unwrap();
475 fx.store
476 .edit_comment(&comment.id, &owner.id, "y")
477 .await
478 .unwrap();
479
480 let (status, _) = send(
481 &fx.router,
482 &format!("/alice/demo/issues/1/history/comment/{}", comment.id),
483 None,
484 )
485 .await;
486 assert_eq!(status, 404);
487 let (status, _) = send(
488 &fx.router,
489 &format!("/alice/demo/issues/2/history/comment/{}", comment.id),
490 None,
491 )
492 .await;
493 assert_eq!(status, 200);
494 }
495
496 #[tokio::test]
497 async fn unknown_kind_and_missing_target_are_not_found() {
498 let fx = Fixture::new(Visibility::Public).await;
499 let owner = alice(&fx).await;
500 let id = repo_id(&fx).await;
501 let issue = fx
502 .store
503 .create_issue(&id, &owner.id, "bug", "body")
504 .await
505 .unwrap();
506 let (status, _) = send(
507 &fx.router,
508 &format!("/alice/demo/issues/1/history/label/{}", issue.id),
509 None,
510 )
511 .await;
512 assert_eq!(status, 404);
513 let (status, _) = send(
514 &fx.router,
515 "/alice/demo/issues/1/history/comment/missing",
516 None,
517 )
518 .await;
519 assert_eq!(status, 404);
520 }
521
522 #[tokio::test]
523 async fn private_repo_is_not_found_for_anonymous() {
524 let fx = Fixture::new(Visibility::Private).await;
525 let owner = alice(&fx).await;
526 let id = repo_id(&fx).await;
527 let issue = fx
528 .store
529 .create_issue(&id, &owner.id, "secret", "body")
530 .await
531 .unwrap();
532 fx.store
533 .edit_issue(&issue.id, &owner.id, "secret", "body 2")
534 .await
535 .unwrap();
536 let (status, _) = send(
537 &fx.router,
538 &format!("/alice/demo/issues/1/history/issue/{}", issue.id),
539 None,
540 )
541 .await;
542 assert_eq!(status, 404);
543 let (status, _) = send(
544 &fx.router,
545 &format!("/alice/demo/issues/1/history/issue/{}", issue.id),
546 Some("alice"),
547 )
548 .await;
549 assert_eq!(status, 200);
550 }
551
552 #[tokio::test]
553 async fn review_comment_history_on_a_pull_request() {
554 let fx = Fixture::new(Visibility::Public).await;
555 let owner = alice(&fx).await;
556 let id = repo_id(&fx).await;
557 let pr = fx
558 .store
559 .create_pull(
560 &id,
561 &owner.id,
562 "change",
563 "",
564 "main",
565 &id,
566 "feature",
567 &hex_oid('a'),
568 &hex_oid('b'),
569 )
570 .await
571 .unwrap();
572 let comment = fx
573 .store
574 .add_review_comment(
575 &pr.issue.id,
576 &owner.id,
577 false,
578 "a.txt",
579 Side::New,
580 2,
581 &hex_oid('a'),
582 "+x",
583 "first",
584 )
585 .await
586 .unwrap();
587 fx.store
588 .edit_review_comment(&comment.id, "second")
589 .await
590 .unwrap();
591
592 let link = format!("/alice/demo/pulls/1/history/review_comment/{}", comment.id);
593 let (status, history) = send(&fx.router, &link, None).await;
594 assert_eq!(status, 200);
595 assert!(history.contains("-first"), "{history}");
596 assert!(history.contains("+second"), "{history}");
597
598 // The same comment under the issue URL is not on that item.
599 let (status, _) = send(
600 &fx.router,
601 &format!("/alice/demo/issues/1/history/review_comment/{}", comment.id),
602 None,
603 )
604 .await;
605 assert_eq!(status, 404);
606 }
607}