Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: repository home at `/{owner}/{repo}`. Shows the default branch's root tree, a Clone |
| 2 | //! popover and the README, with a branch aside. An empty repo shows the clone popover and push |
| 3 | //! instructions instead. |
| 4 | |
| 5 | use std::collections::{HashMap, HashSet}; |
| 6 | use std::path::PathBuf; |
| 7 | use std::time::Duration; |
| 8 | |
| 9 | use forge_core::Config; |
| 10 | use forge_core::Repo as RepoRecord; |
| 11 | use forge_core::WatchLevel; |
| 12 | use forge_core::time::stamp_before; |
| 13 | use forge_git::browse; |
| 14 | use forge_git::repo::repo_path; |
| 15 | use topcoat::Result; |
| 16 | use topcoat::context::{Cx, app_context}; |
| 17 | use topcoat::router::error::not_found; |
| 18 | use topcoat::router::{page, path_param, route}; |
| 19 | use topcoat::view::{View, component, view}; |
| 20 | |
| 21 | use crate::i18n::code::{self as g, K}; |
| 22 | use crate::i18n::{self, Lang}; |
| 23 | use crate::pages::dashboard::badge_class; |
| 24 | use crate::pages::issue_parts::stamp_unix; |
| 25 | use crate::pages::notifications::watch_form; |
| 26 | use crate::pages::refs::load_refs; |
| 27 | use crate::pages::releases::latest_card; |
| 28 | use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; |
| 29 | use crate::pages::tree::{ |
| 30 | entry_rows, last_commits, load_readme, read_dir, readme_card, tree_table, |
| 31 | }; |
| 32 | use crate::state::AppState; |
| 33 | use crate::views::{now_unix, relative_time, repo_href, repo_tabs, shell}; |
| 34 | |
| 35 | path_param!(owner); |
| 36 | path_param!(repo); |
| 37 | |
| 38 | /// HTTPS clone URL: `{base_url}/{owner}/{repo}.git`. |
| 39 | pub fn https_clone_url(config: &Config, owner: &str, name: &str) -> String { |
| 40 | format!( |
| 41 | "{}/{owner}/{name}.git", |
| 42 | config.base_url.trim_end_matches('/') |
| 43 | ) |
| 44 | } |
| 45 | |
| 46 | /// SSH clone URL. Port 22 uses the scp-like form `git@host:owner/repo.git`. |
| 47 | pub fn ssh_clone_url(config: &Config, owner: &str, name: &str) -> String { |
| 48 | let host = &config.ssh_public_host; |
| 49 | if config.ssh_public_port == 22 { |
| 50 | format!("git@{host}:{owner}/{name}.git") |
| 51 | } else { |
| 52 | format!( |
| 53 | "ssh://git@{host}:{}/{owner}/{name}.git", |
| 54 | config.ssh_public_port |
| 55 | ) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /// Header shared by the repo pages: title, visibility, the fork line, the fork and star controls, |
| 60 | /// the archived notice, description and the header tabs. Built by `RepoCtx::head`; every repo page |
| 61 | /// passes one. |
| 62 | #[component] |
| 63 | pub async fn repo_head(head: RepoHead) -> Result<impl View> { |
| 64 | let RepoHead { |
| 65 | owner, |
| 66 | name, |
| 67 | default_branch, |
| 68 | visibility, |
| 69 | description, |
| 70 | archived, |
| 71 | active, |
| 72 | can_admin, |
| 73 | open_issues, |
| 74 | open_pulls, |
| 75 | forked_from, |
| 76 | can_fork, |
| 77 | stars, |
| 78 | star, |
| 79 | watchers, |
| 80 | watch, |
| 81 | mirror, |
| 82 | discussions, |
| 83 | wiki, |
| 84 | security, |
| 85 | lang, |
| 86 | } = head; |
| 87 | let title = format!("{owner}/{name}"); |
| 88 | let badge = badge_class(visibility); |
| 89 | let vis = i18n::visibility_label(lang, visibility); |
| 90 | let active = active.to_owned(); |
| 91 | // The fork button posts with the same session CSRF token as the star form. |
| 92 | let fork_control = match (can_fork, star.as_ref()) { |
| 93 | (true, Some(star)) => Some((repo_href(&owner, &name, "fork", ""), star.csrf.clone())), |
| 94 | _ => None, |
| 95 | }; |
| 96 | let fork_line = forked_from.map(|(source_owner, source_name)| { |
| 97 | ( |
| 98 | repo_href(&source_owner, &source_name, "", ""), |
| 99 | format!("{source_owner}/{source_name}"), |
| 100 | ) |
| 101 | }); |
| 102 | // Signed-in viewers get a button that stars or unstars; anonymous viewers see the count only. |
| 103 | let star_control = match star { |
| 104 | Some(star) => { |
| 105 | let (path, label) = if star.starred { |
| 106 | ("unstar", g::star_label(lang, true, stars)) |
| 107 | } else { |
| 108 | ("star", g::star_label(lang, false, stars)) |
| 109 | }; |
| 110 | Some((repo_href(&owner, &name, path, ""), label, star.csrf)) |
| 111 | } |
| 112 | None => None, |
| 113 | }; |
| 114 | let anon_count = format!("\u{2605} {stars}"); |
| 115 | // Signed-in viewers get the watch level select; everyone gets the watcher count. |
| 116 | let watch_control = watch.map(|watch| { |
| 117 | let level = match watch.level { |
| 118 | WatchLevel::Participating => "participating", |
| 119 | WatchLevel::Watching => "watching", |
| 120 | WatchLevel::Ignoring => "ignoring", |
| 121 | }; |
| 122 | (repo_href(&owner, &name, "watch", ""), watch.csrf, level) |
| 123 | }); |
| 124 | let watch_count = g::watching_count(lang, watchers); |
| 125 | let search_action = repo_href(&owner, &name, "search", ""); |
| 126 | let mirror_line = mirror.as_ref().map(|source| source.url.clone()); |
| 127 | let mirror_status = match mirror { |
| 128 | Some(source) => match (source.last_sync, source.last_ok) { |
| 129 | (Some(at), Some(true)) => g::mirror_synced(lang, &at), |
| 130 | (Some(at), _) => g::mirror_sync_failed(lang, &at), |
| 131 | (None, _) => g::t(lang, K::NotSyncedYet).to_owned(), |
| 132 | }, |
| 133 | None => String::new(), |
| 134 | }; |
| 135 | Ok(view! { |
| 136 | <section class="page-head"> |
| 137 | <div> |
| 138 | <div class="repo-title-row"> |
| 139 | <h1 class="repo-title">(title.as_str())</h1> |
| 140 | <span class=(badge)>(vis)</span> |
| 141 | </div> |
| 142 | if let Some((href, label)) = fork_line { |
| 143 | <p class="muted small">(g::t(lang, K::ForkedFrom)) <a href=(href.as_str())>(label.as_str())</a></p> |
| 144 | } |
| 145 | if let Some(source) = mirror_line { |
| 146 | <p class="muted small mirror-line">(g::t(lang, K::MirrorOf)) <code>(source.as_str())</code> ", " (mirror_status.as_str())</p> |
| 147 | } |
| 148 | </div> |
| 149 | <div class="header-actions"> |
| 150 | if let Some((action, csrf)) = fork_control { |
| 151 | <form method="post" action=(action.as_str()) class="fork-form"> |
| 152 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 153 | <button type="submit" class="fork-button">(g::t(lang, K::ForkButton))</button> |
| 154 | </form> |
| 155 | } |
| 156 | if let Some((action, csrf, level)) = watch_control { |
| 157 | watch_form(lang: lang, action: action, csrf: csrf, level: level, watchers: watchers) |
| 158 | } else { |
| 159 | <span class="muted small">(watch_count.as_str())</span> |
| 160 | } |
| 161 | <div class="star-control"> |
| 162 | if let Some((action, label, csrf)) = star_control { |
| 163 | <form method="post" action=(action.as_str()) class="star-form"> |
| 164 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 165 | <button type="submit" class="star-button">(label.as_str())</button> |
| 166 | </form> |
| 167 | } else { |
| 168 | <span class="muted" aria-label=(g::t(lang, K::StarsAria))>(anon_count.as_str())</span> |
| 169 | } |
| 170 | </div> |
| 171 | </div> |
| 172 | </section> |
| 173 | if archived { |
| 174 | <section class="card notice" role="status"> |
| 175 | (g::t(lang, K::ArchivedNotice)) |
| 176 | </section> |
| 177 | } |
| 178 | // The description and code search belong to the Code tab only; sub-pages keep the compact head. |
| 179 | if active == "code" && !description.is_empty() { |
| 180 | <p class="lead">(description.as_str())</p> |
| 181 | } |
| 182 | if active == "code" { |
| 183 | <form method="get" action=(search_action.as_str()) class="repo-search" role="search"> |
| 184 | <input type="search" name="q" placeholder=(g::t(lang, K::SearchCodePlaceholder)) aria-label=(g::t(lang, K::SearchCodePlaceholder))> |
| 185 | </form> |
| 186 | } |
| 187 | repo_tabs(owner: owner, repo: name, default_branch: default_branch, active: active, can_admin: can_admin, lang: lang, open_issues: Some(open_issues), open_pulls: Some(open_pulls), discussions: discussions, wiki: wiki, security: security) |
| 188 | }) |
| 189 | } |
| 190 | |
| 191 | /// Clone button that opens a popover with both clone URLs as selectable read-only fields. |
| 192 | #[component] |
| 193 | async fn clone_popover(https: String, ssh: String, lang: Lang) -> Result<impl View> { |
| 194 | Ok(view! { |
| 195 | <details class="clone pop"> |
| 196 | <summary class="button primary"> |
| 197 | <svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg> |
| 198 | (g::t(lang, K::CloneTitle)) |
| 199 | </summary> |
| 200 | <div class="pop-panel"> |
| 201 | <label for="clone-https">"HTTPS"</label> |
| 202 | <input id="clone-https" class="mono" type="text" readonly=(true) value=(https.as_str())> |
| 203 | <label for="clone-ssh">"SSH"</label> |
| 204 | <input id="clone-ssh" class="mono" type="text" readonly=(true) value=(ssh.as_str())> |
| 205 | <p class="muted small">(g::t(lang, K::SshRequiresKey))</p> |
| 206 | </div> |
| 207 | </details> |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | /// Shown for an empty repo (or one whose default branch is missing): the branches that exist, |
| 212 | /// or push instructions when there are none. |
| 213 | #[component] |
| 214 | async fn branches_card( |
| 215 | branches: Vec<String>, |
| 216 | push_https: String, |
| 217 | push_ssh: String, |
| 218 | lang: Lang, |
| 219 | ) -> Result<impl View> { |
| 220 | let empty = branches.is_empty(); |
| 221 | Ok(view! { |
| 222 | <section class="card"> |
| 223 | <h2>(g::t(lang, K::BranchesTitle))</h2> |
| 224 | if empty { |
| 225 | <p class="muted">(g::t(lang, K::EmptyRepoPush))</p> |
| 226 | <p class="muted small">(g::t(lang, K::OverHttps))</p> |
| 227 | <pre class="code"><code>(push_https)</code></pre> |
| 228 | <p class="muted small">(g::t(lang, K::OverSsh))</p> |
| 229 | <pre class="code"><code>(push_ssh)</code></pre> |
| 230 | } else { |
| 231 | <ul class="branch-list"> |
| 232 | for branch in branches.into_iter() { |
| 233 | <li><code>(branch)</code></li> |
| 234 | } |
| 235 | </ul> |
| 236 | } |
| 237 | </section> |
| 238 | }) |
| 239 | } |
| 240 | |
| 241 | /// Download links for the archive of `ref_name`. |
| 242 | #[component] |
| 243 | async fn archive_links( |
| 244 | owner: String, |
| 245 | repo: String, |
| 246 | ref_name: String, |
| 247 | lang: Lang, |
| 248 | ) -> Result<impl View> { |
| 249 | let tar = repo_href(&owner, &repo, "archive", &format!("{ref_name}.tar.gz")); |
| 250 | let zip = repo_href(&owner, &repo, "archive", &format!("{ref_name}.zip")); |
| 251 | Ok(view! { |
| 252 | <nav class="archive-links" aria-label=(g::t(lang, K::DownloadNav))> |
| 253 | <a href=(zip)>(g::t(lang, K::DownloadZip))</a> |
| 254 | <a href=(tar)>(g::t(lang, K::DownloadTarGz))</a> |
| 255 | </nav> |
| 256 | }) |
| 257 | } |
| 258 | |
| 259 | async fn default_commit(path: PathBuf, branch: String) -> Result<Option<String>> { |
| 260 | Ok( |
| 261 | tokio::task::spawn_blocking(move || browse::default_branch_commit(&path, &branch)) |
| 262 | .await??, |
| 263 | ) |
| 264 | } |
| 265 | |
| 266 | /// Most recent-push banners the repo home shows. |
| 267 | const MAX_PUSH_BANNERS: usize = 3; |
| 268 | /// How far back a push still counts as recent. |
| 269 | const RECENT_PUSH_WINDOW: Duration = Duration::from_secs(60 * 60); |
| 270 | |
| 271 | /// One "had recent pushes" banner: a branch the viewer pushed to, with its compare link. |
| 272 | struct PushBanner { |
| 273 | branch: String, |
| 274 | at_unix: i64, |
| 275 | href: String, |
| 276 | } |
| 277 | |
| 278 | /// Recent-push banners for the signed-in viewer. Candidates are the branches the viewer pushed to |
| 279 | /// in this repo and in their own forks of it, pushed within the last hour, newest first. A branch |
| 280 | /// is skipped when it is the default branch, is gone, or already has an open pull request. A fork |
| 281 | /// links to its parent's compare page when the parent is visible (`forked_from`); otherwise the |
| 282 | /// compare page is this repo's, with the head as `owner:branch` for a fork. |
| 283 | async fn recent_push_banners( |
| 284 | state: &AppState, |
| 285 | ctx: &RepoCtx, |
| 286 | forked_from: Option<(String, String)>, |
| 287 | ) -> Result<Vec<PushBanner>> { |
| 288 | let Some((user, _)) = &ctx.viewer else { |
| 289 | return Ok(Vec::new()); |
| 290 | }; |
| 291 | let repo = &ctx.repo; |
| 292 | let base = match forked_from { |
| 293 | Some((owner, name)) => state |
| 294 | .store |
| 295 | .repo_by_owner_and_name(&owner, &name) |
| 296 | .await? |
| 297 | .unwrap_or_else(|| repo.clone()), |
| 298 | None => repo.clone(), |
| 299 | }; |
| 300 | |
| 301 | // Forks the viewer owns, or that an organization they hold a role in owns. |
| 302 | let mut forks: Vec<RepoRecord> = Vec::new(); |
| 303 | for fork in state.store.list_forks(&repo.id).await? { |
| 304 | let mine = fork.owner_id == user.id |
| 305 | || state |
| 306 | .store |
| 307 | .org_repo_role(&fork.owner_id, &user.id, &fork.id) |
| 308 | .await? |
| 309 | .is_some(); |
| 310 | if mine { |
| 311 | forks.push(fork); |
| 312 | } |
| 313 | } |
| 314 | let mut candidates: Vec<&RepoRecord> = vec![repo]; |
| 315 | candidates.extend(forks.iter()); |
| 316 | let ids: Vec<&str> = candidates.iter().map(|r| r.id.as_str()).collect(); |
| 317 | let since = stamp_before(RECENT_PUSH_WINDOW); |
| 318 | let pushes = state |
| 319 | .store |
| 320 | .recent_branch_pushes(&ids, &user.id, &since) |
| 321 | .await?; |
| 322 | |
| 323 | let mut existing: HashMap<String, HashSet<String>> = HashMap::new(); |
| 324 | let mut banners = Vec::new(); |
| 325 | for (repo_id, branch, at) in pushes { |
| 326 | if banners.len() >= MAX_PUSH_BANNERS { |
| 327 | break; |
| 328 | } |
| 329 | let Some(pushed) = candidates.iter().find(|r| r.id == repo_id) else { |
| 330 | continue; |
| 331 | }; |
| 332 | if branch == pushed.default_branch { |
| 333 | continue; |
| 334 | } |
| 335 | if !existing.contains_key(&pushed.id) { |
| 336 | let path = repo_path(&state.config.data_dir, &pushed.id)?; |
| 337 | let names: HashSet<String> = load_refs(path) |
| 338 | .await? |
| 339 | .into_iter() |
| 340 | .filter(|r| r.kind == browse::RefKind::Branch) |
| 341 | .map(|r| r.name) |
| 342 | .collect(); |
| 343 | existing.insert(pushed.id.clone(), names); |
| 344 | } |
| 345 | if !existing[&pushed.id].contains(&branch) { |
| 346 | continue; |
| 347 | } |
| 348 | if state |
| 349 | .store |
| 350 | .find_open_pull(&base.id, &base.default_branch, &pushed.id, &branch) |
| 351 | .await? |
| 352 | .is_some() |
| 353 | { |
| 354 | continue; |
| 355 | } |
| 356 | let head = if pushed.id == base.id { |
| 357 | branch.clone() |
| 358 | } else { |
| 359 | format!("{}:{branch}", pushed.owner_name) |
| 360 | }; |
| 361 | let href = repo_href( |
| 362 | &base.owner_name, |
| 363 | &base.name, |
| 364 | "compare", |
| 365 | &format!("{}...{head}", base.default_branch), |
| 366 | ); |
| 367 | banners.push(PushBanner { |
| 368 | branch, |
| 369 | at_unix: stamp_unix(&at), |
| 370 | href, |
| 371 | }); |
| 372 | } |
| 373 | Ok(banners) |
| 374 | } |
| 375 | |
| 376 | /// The "had recent pushes" banners above the code tab. |
| 377 | #[component] |
| 378 | async fn recent_pushes(banners: Vec<PushBanner>, now: i64, lang: Lang) -> Result<impl View> { |
| 379 | Ok(view! { |
| 380 | <section class="recent-pushes" aria-label=(g::t(lang, K::RecentPushes))> |
| 381 | for banner in banners.into_iter() { |
| 382 | <p class="recent-push"> |
| 383 | <code>(banner.branch)</code> |
| 384 | (g::t(lang, K::HadRecentPushes)) |
| 385 | (relative_time(lang, banner.at_unix, now)) |
| 386 | " — " |
| 387 | <a href=(banner.href)>(g::t(lang, K::CompareAndPr))</a> |
| 388 | </p> |
| 389 | } |
| 390 | </section> |
| 391 | }) |
| 392 | } |
| 393 | |
| 394 | #[page("/{owner}/{repo}")] |
| 395 | async fn repo_page(cx: &Cx) -> Result<impl View> { |
| 396 | let owner = path_param::<Owner>(cx); |
| 397 | let name = path_param::<Repo>(cx); |
| 398 | let ctx = load_repo(cx, owner, name).await?; |
| 399 | let state = app_context::<AppState>(cx); |
| 400 | let lang = ctx.lang; |
| 401 | let repo = &ctx.repo; |
| 402 | let branch = repo.default_branch.clone(); |
| 403 | |
| 404 | let refs = load_refs(ctx.path.clone()).await?; |
| 405 | let head = default_commit(ctx.path.clone(), branch.clone()).await?; |
| 406 | |
| 407 | let https = https_clone_url(&state.config, &repo.owner_name, &repo.name); |
| 408 | let ssh = ssh_clone_url(&state.config, &repo.owner_name, &repo.name); |
| 409 | let push_https = format!("git remote add origin {https}\ngit push -u origin {branch}"); |
| 410 | let push_ssh = format!("git remote add origin {ssh}\ngit push -u origin {branch}"); |
| 411 | let title = format!("{}/{}", repo.owner_name, repo.name); |
| 412 | |
| 413 | // The tree and README exist only when the default branch has a commit. |
| 414 | let mut tree = None; |
| 415 | let mut readme = None; |
| 416 | if let Some(commit) = &head |
| 417 | && let Some(entries) = read_dir(ctx.path.clone(), commit.clone(), String::new()).await? |
| 418 | { |
| 419 | let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect(); |
| 420 | let last = last_commits(state, &ctx.path, commit, "", &names).await; |
| 421 | readme = load_readme(state, repo, &ctx.path, &branch, commit, "", &entries).await?; |
| 422 | tree = Some(entry_rows( |
| 423 | lang, |
| 424 | repo, |
| 425 | &branch, |
| 426 | "", |
| 427 | entries, |
| 428 | &last, |
| 429 | now_unix(), |
| 430 | ctx.viewer_tz(), |
| 431 | )); |
| 432 | } |
| 433 | let branch_names: Vec<String> = refs |
| 434 | .into_iter() |
| 435 | .filter(|r| r.kind == browse::RefKind::Branch) |
| 436 | .map(|r| r.name) |
| 437 | .collect(); |
| 438 | |
| 439 | let owner_name = repo.owner_name.clone(); |
| 440 | let repo_name = repo.name.clone(); |
| 441 | let header = ctx.head(state, "code").await?; |
| 442 | let latest = crate::pages::releases::latest_release_for_home(state, &ctx).await?; |
| 443 | let banners = recent_push_banners(state, &ctx, header.forked_from.clone()).await?; |
| 444 | let now = now_unix(); |
| 445 | Ok(view! { |
| 446 | shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), |
| 447 | repo_head(head: header) |
| 448 | if let Some(latest) = latest { |
| 449 | latest_card(release: latest, lang: lang) |
| 450 | } |
| 451 | if !banners.is_empty() { |
| 452 | recent_pushes(banners: banners, now: now, lang: lang) |
| 453 | } |
| 454 | if let Some(rows) = tree { |
| 455 | <div class="browse-bar"> |
| 456 | <span class="muted small">(g::t(lang, K::BranchLabel))</span> |
| 457 | <code>(branch.clone())</code> |
| 458 | archive_links(owner: owner_name, repo: repo_name, |
| 459 | ref_name: branch.clone(), lang: lang) |
| 460 | clone_popover(https: https, ssh: ssh, lang: lang) |
| 461 | </div> |
| 462 | <div class="repo-grid"> |
| 463 | <div class="repo-main"> |
| 464 | tree_table(rows: rows, lang: lang) |
| 465 | if let Some(readme) = readme { |
| 466 | readme_card(name: readme.name, markdown: readme.markdown, body: readme.body) |
| 467 | } |
| 468 | </div> |
| 469 | <aside class="repo-meta" aria-label=(g::t(lang, K::BranchesTitle))> |
| 470 | <section> |
| 471 | <h2>(g::t(lang, K::BranchesTitle))</h2> |
| 472 | <ul class="branch-list"> |
| 473 | for name in branch_names.into_iter() { |
| 474 | <li><code>(name)</code></li> |
| 475 | } |
| 476 | </ul> |
| 477 | </section> |
| 478 | </aside> |
| 479 | </div> |
| 480 | } else { |
| 481 | <div class="browse-bar"> |
| 482 | clone_popover(https: https, ssh: ssh, lang: lang) |
| 483 | </div> |
| 484 | branches_card(branches: branch_names, push_https: push_https, push_ssh: push_ssh, lang: lang) |
| 485 | } |
| 486 | ) |
| 487 | }) |
| 488 | } |
| 489 | |
| 490 | /// Other methods on a repo path get a plain 404, not a 405 that would reveal the route shape. |
| 491 | #[route(* "/{owner}/{repo}")] |
| 492 | async fn repo_other_methods() -> Result<&'static str> { |
| 493 | Err(not_found().into()) |
| 494 | } |
| 495 | |
| 496 | #[cfg(test)] |
| 497 | mod tests { |
| 498 | use std::path::Path; |
| 499 | use std::process::Command; |
| 500 | use std::time::Duration; |
| 501 | |
| 502 | use forge_core::Visibility; |
| 503 | use forge_core::time::stamp_before; |
| 504 | |
| 505 | use forge_git::repo::{init_bare, repo_path}; |
| 506 | |
| 507 | use topcoat::router::request::Request; |
| 508 | use topcoat::router::{Body, Router, to_bytes}; |
| 509 | |
| 510 | use crate::auth::SESSION_COOKIE; |
| 511 | use crate::state::AppState; |
| 512 | |
| 513 | async fn app(dir: &Path) -> (Router, AppState) { |
| 514 | let state = crate::test_support::test_state(dir).await; |
| 515 | (crate::test_support::app_router(state.clone()), state) |
| 516 | } |
| 517 | |
| 518 | fn git(dir: &Path, args: &[&str]) { |
| 519 | let status = Command::new("git") |
| 520 | .current_dir(dir) |
| 521 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 522 | .args([ |
| 523 | "-c", |
| 524 | "user.name=Test", |
| 525 | "-c", |
| 526 | "user.email=test@example.com", |
| 527 | "-c", |
| 528 | "commit.gpgsign=false", |
| 529 | ]) |
| 530 | .args(args) |
| 531 | .status() |
| 532 | .expect("git runs"); |
| 533 | assert!(status.success(), "git {args:?} failed"); |
| 534 | } |
| 535 | |
| 536 | /// `alice/demo` (public, default branch `main` with content) and `alice/secret` (private, empty). |
| 537 | async fn fixture(dir: &Path) -> (Router, AppState) { |
| 538 | let (router, state) = app(dir).await; |
| 539 | let alice = state |
| 540 | .store |
| 541 | .create_user("alice", None, "x", false) |
| 542 | .await |
| 543 | .unwrap(); |
| 544 | let demo = state |
| 545 | .store |
| 546 | .create_repo(&alice.id, "demo", "A demo repo", Visibility::Public, "main") |
| 547 | .await |
| 548 | .unwrap(); |
| 549 | let secret = state |
| 550 | .store |
| 551 | .create_repo(&alice.id, "secret", "", Visibility::Private, "main") |
| 552 | .await |
| 553 | .unwrap(); |
| 554 | for repo in [&demo, &secret] { |
| 555 | let path = repo_path(&state.config.data_dir, &repo.id).unwrap(); |
| 556 | init_bare(&state.git, &path, "main").await.unwrap(); |
| 557 | } |
| 558 | |
| 559 | let work = dir.join("work"); |
| 560 | std::fs::create_dir_all(work.join("src")).unwrap(); |
| 561 | std::fs::create_dir_all(work.join("docs")).unwrap(); |
| 562 | std::fs::write(work.join("README.md"), "# Demo\n\nHello from the README.\n").unwrap(); |
| 563 | std::fs::write( |
| 564 | work.join("src/main.rs"), |
| 565 | "fn main() {\n println!(\"hi\");\n}\n", |
| 566 | ) |
| 567 | .unwrap(); |
| 568 | std::fs::write( |
| 569 | work.join("docs/guide.md"), |
| 570 | "# Guide\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", |
| 571 | ) |
| 572 | .unwrap(); |
| 573 | std::fs::write(work.join("data.bin"), [0u8, 1, 2]).unwrap(); |
| 574 | git(&work, &["init", "-q", "-b", "main"]); |
| 575 | git(&work, &["add", "-A"]); |
| 576 | git(&work, &["commit", "-q", "-m", "first commit"]); |
| 577 | let demo_path = repo_path(&state.config.data_dir, &demo.id).unwrap(); |
| 578 | git( |
| 579 | &work, |
| 580 | &["push", "-q", demo_path.to_str().unwrap(), "main:main"], |
| 581 | ); |
| 582 | (router, state) |
| 583 | } |
| 584 | |
| 585 | async fn get(router: &Router, uri: &str) -> (u16, String) { |
| 586 | let req = Request::builder() |
| 587 | .method("GET") |
| 588 | .uri(uri) |
| 589 | .body(Body::empty()) |
| 590 | .unwrap(); |
| 591 | let res = router.handle(req).await; |
| 592 | let status = res.status().as_u16(); |
| 593 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 594 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 595 | } |
| 596 | |
| 597 | #[tokio::test] |
| 598 | async fn home_renders_tree_clone_popover_and_readme() { |
| 599 | let dir = tempfile::tempdir().unwrap(); |
| 600 | let (router, _) = fixture(dir.path()).await; |
| 601 | let (status, body) = get(&router, "/alice/demo").await; |
| 602 | assert_eq!(status, 200); |
| 603 | assert!(body.contains("Hello from the README"), "README rendered"); |
| 604 | assert!( |
| 605 | body.contains("/alice/demo/tree/main/src"), |
| 606 | "root tree listed" |
| 607 | ); |
| 608 | assert!( |
| 609 | body.contains("class=\"clone pop\""), |
| 610 | "clone popover rendered" |
| 611 | ); |
| 612 | assert!(body.contains("value=\"https://forge.test/alice/demo.git\"")); |
| 613 | assert!(body.contains("git@forge.test:alice/demo.git")); |
| 614 | assert!( |
| 615 | body.contains("readonly"), |
| 616 | "clone URLs are selectable, read-only fields" |
| 617 | ); |
| 618 | assert!(body.contains("/alice/demo/archive/main.tar.gz")); |
| 619 | assert!(body.contains("/alice/demo/branches")); |
| 620 | } |
| 621 | |
| 622 | #[tokio::test] |
| 623 | async fn tree_lists_entries() { |
| 624 | let dir = tempfile::tempdir().unwrap(); |
| 625 | let (router, _) = fixture(dir.path()).await; |
| 626 | let (status, body) = get(&router, "/alice/demo/tree/main/src").await; |
| 627 | assert_eq!(status, 200); |
| 628 | assert!(body.contains("main.rs")); |
| 629 | assert!(body.contains("/alice/demo/blob/main/src/main.rs")); |
| 630 | let (status, body) = get(&router, "/alice/demo/tree/main").await; |
| 631 | assert_eq!(status, 200); |
| 632 | assert!(body.contains("/alice/demo/tree/main/docs")); |
| 633 | } |
| 634 | |
| 635 | #[tokio::test] |
| 636 | async fn blob_highlights_rust_source() { |
| 637 | let dir = tempfile::tempdir().unwrap(); |
| 638 | let (router, _) = fixture(dir.path()).await; |
| 639 | let (status, body) = get(&router, "/alice/demo/blob/main/src/main.rs").await; |
| 640 | assert_eq!(status, 200); |
| 641 | assert!(body.contains("id=\"L1\"")); |
| 642 | assert!(body.contains("href=\"#L2\"")); |
| 643 | assert!(body.contains("<span class="), "syntax classes applied"); |
| 644 | assert!(body.contains("/assets/highlight.css?v=")); |
| 645 | } |
| 646 | |
| 647 | #[tokio::test] |
| 648 | async fn markdown_blob_renders_unless_plain() { |
| 649 | let dir = tempfile::tempdir().unwrap(); |
| 650 | let (router, _) = fixture(dir.path()).await; |
| 651 | let (status, body) = get(&router, "/alice/demo/blob/main/docs/guide.md").await; |
| 652 | assert_eq!(status, 200); |
| 653 | assert!( |
| 654 | body.contains("markdown-body") && body.contains("<table>"), |
| 655 | "GFM table rendered" |
| 656 | ); |
| 657 | let (status, body) = get(&router, "/alice/demo/blob/main/docs/guide.md?plain=1").await; |
| 658 | assert_eq!(status, 200); |
| 659 | assert!(body.contains("file-table"), "source shown as lines"); |
| 660 | assert!(!body.contains("markdown-body"), "not rendered"); |
| 661 | } |
| 662 | |
| 663 | #[tokio::test] |
| 664 | async fn binary_blob_shows_size() { |
| 665 | let dir = tempfile::tempdir().unwrap(); |
| 666 | let (router, _) = fixture(dir.path()).await; |
| 667 | let (status, body) = get(&router, "/alice/demo/blob/main/data.bin").await; |
| 668 | assert_eq!(status, 200); |
| 669 | assert!(body.contains("Binary file, 3 bytes")); |
| 670 | } |
| 671 | |
| 672 | #[tokio::test] |
| 673 | async fn unknown_refs_and_paths_are_404() { |
| 674 | let dir = tempfile::tempdir().unwrap(); |
| 675 | let (router, _) = fixture(dir.path()).await; |
| 676 | for uri in [ |
| 677 | "/alice/demo/tree/main/nope", |
| 678 | "/alice/demo/tree/nosuchref", |
| 679 | "/alice/demo/blob/main/nope.rs", |
| 680 | "/alice/demo/blob/main/src", |
| 681 | "/alice/demo/blob/main", |
| 682 | ] { |
| 683 | let (status, _) = get(&router, uri).await; |
| 684 | assert_eq!(status, 404, "{uri}"); |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | #[tokio::test] |
| 689 | async fn private_repo_is_404_for_anonymous() { |
| 690 | let dir = tempfile::tempdir().unwrap(); |
| 691 | let (router, _) = fixture(dir.path()).await; |
| 692 | for uri in [ |
| 693 | "/alice/secret", |
| 694 | "/alice/secret/tree/main", |
| 695 | "/alice/secret/blob/main/x", |
| 696 | "/alice/secret/branches", |
| 697 | "/alice/secret/tags", |
| 698 | ] { |
| 699 | let (status, _) = get(&router, uri).await; |
| 700 | assert_eq!(status, 404, "{uri}"); |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | #[tokio::test] |
| 705 | async fn fixed_segment_routes_win_over_the_repo_catch_all() { |
| 706 | let dir = tempfile::tempdir().unwrap(); |
| 707 | let (router, _) = fixture(dir.path()).await; |
| 708 | let (status, body) = get(&router, "/alice/demo/branches").await; |
| 709 | assert_eq!(status, 200); |
| 710 | assert!(body.contains("default"), "default branch marked"); |
| 711 | let (status, body) = get(&router, "/alice/demo/tags").await; |
| 712 | assert_eq!(status, 200); |
| 713 | assert!(body.contains("Nothing here yet")); |
| 714 | let (status, body) = get(&router, "/alice/demo/tree/main").await; |
| 715 | assert_eq!(status, 200); |
| 716 | assert!(body.contains("README.md")); |
| 717 | let (status, body) = get(&router, "/alice/demo").await; |
| 718 | assert_eq!(status, 200); |
| 719 | assert!(body.contains("Hello from the README")); |
| 720 | } |
| 721 | |
| 722 | const ZERO_OID: &str = "0000000000000000000000000000000000000000"; |
| 723 | const OID: &str = "1111111111111111111111111111111111111111"; |
| 724 | const BANNER: &str = "had recent pushes"; |
| 725 | |
| 726 | /// Creates a session for `user_id` and returns the cookie header value. |
| 727 | async fn sign_in(state: &AppState, user_id: &str, token: &str) -> String { |
| 728 | crate::test_support::create_session(&state.store, user_id, token, "csrf").await; |
| 729 | format!("__Host-{SESSION_COOKIE}={token}") |
| 730 | } |
| 731 | |
| 732 | async fn get_as(router: &Router, uri: &str, cookie: Option<&str>) -> (u16, String) { |
| 733 | let mut builder = Request::builder().method("GET").uri(uri); |
| 734 | if let Some(cookie) = cookie { |
| 735 | builder = builder.header("cookie", cookie); |
| 736 | } |
| 737 | let res = router.handle(builder.body(Body::empty()).unwrap()).await; |
| 738 | let status = res.status().as_u16(); |
| 739 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 740 | (status, String::from_utf8_lossy(&bytes).into_owned()) |
| 741 | } |
| 742 | |
| 743 | /// Points `branch` at the bare repo's `main`, so the branch exists. |
| 744 | fn make_branch(bare: &Path, branch: &str) { |
| 745 | let full = format!("refs/heads/{branch}"); |
| 746 | git(bare, &["update-ref", &full, "refs/heads/main"]); |
| 747 | } |
| 748 | |
| 749 | /// Records a push of `branch` to `repo_id` by `actor_id`, `age` ago. |
| 750 | async fn record_push( |
| 751 | state: &AppState, |
| 752 | repo_id: &str, |
| 753 | actor_id: &str, |
| 754 | branch: &str, |
| 755 | age: Duration, |
| 756 | ) { |
| 757 | let at = stamp_before(age); |
| 758 | let full = format!("refs/heads/{branch}"); |
| 759 | state |
| 760 | .store |
| 761 | .record_push_event_at(repo_id, actor_id, &full, ZERO_OID, OID, &at) |
| 762 | .await |
| 763 | .unwrap(); |
| 764 | } |
| 765 | |
| 766 | async fn demo_repo(state: &AppState) -> forge_core::Repo { |
| 767 | state |
| 768 | .store |
| 769 | .repo_by_owner_and_name("alice", "demo") |
| 770 | .await |
| 771 | .unwrap() |
| 772 | .unwrap() |
| 773 | } |
| 774 | |
| 775 | #[tokio::test] |
| 776 | async fn recent_push_banner_links_to_compare() { |
| 777 | let dir = tempfile::tempdir().unwrap(); |
| 778 | let (router, state) = fixture(dir.path()).await; |
| 779 | let demo = demo_repo(&state).await; |
| 780 | make_branch( |
| 781 | &repo_path(&state.config.data_dir, &demo.id).unwrap(), |
| 782 | "feature", |
| 783 | ); |
| 784 | record_push( |
| 785 | &state, |
| 786 | &demo.id, |
| 787 | &demo.owner_id, |
| 788 | "feature", |
| 789 | Duration::from_secs(300), |
| 790 | ) |
| 791 | .await; |
| 792 | let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; |
| 793 | |
| 794 | let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; |
| 795 | assert_eq!(status, 200); |
| 796 | assert!(body.contains("had recent pushes 5 minutes ago"), "{body}"); |
| 797 | assert!(body.contains("compare/main...feature"), "{body}"); |
| 798 | assert!(body.contains("Compare & pull request"), "{body}"); |
| 799 | } |
| 800 | |
| 801 | #[tokio::test] |
| 802 | async fn push_older_than_an_hour_shows_no_banner() { |
| 803 | let dir = tempfile::tempdir().unwrap(); |
| 804 | let (router, state) = fixture(dir.path()).await; |
| 805 | let demo = demo_repo(&state).await; |
| 806 | make_branch( |
| 807 | &repo_path(&state.config.data_dir, &demo.id).unwrap(), |
| 808 | "feature", |
| 809 | ); |
| 810 | record_push( |
| 811 | &state, |
| 812 | &demo.id, |
| 813 | &demo.owner_id, |
| 814 | "feature", |
| 815 | Duration::from_secs(7200), |
| 816 | ) |
| 817 | .await; |
| 818 | let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; |
| 819 | |
| 820 | let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; |
| 821 | assert_eq!(status, 200); |
| 822 | assert!(!body.contains(BANNER), "{body}"); |
| 823 | } |
| 824 | |
| 825 | #[tokio::test] |
| 826 | async fn default_branch_push_shows_no_banner() { |
| 827 | let dir = tempfile::tempdir().unwrap(); |
| 828 | let (router, state) = fixture(dir.path()).await; |
| 829 | let demo = demo_repo(&state).await; |
| 830 | record_push( |
| 831 | &state, |
| 832 | &demo.id, |
| 833 | &demo.owner_id, |
| 834 | "main", |
| 835 | Duration::from_secs(300), |
| 836 | ) |
| 837 | .await; |
| 838 | let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; |
| 839 | |
| 840 | let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; |
| 841 | assert_eq!(status, 200); |
| 842 | assert!(!body.contains(BANNER), "{body}"); |
| 843 | } |
| 844 | |
| 845 | #[tokio::test] |
| 846 | async fn open_pull_request_suppresses_banner() { |
| 847 | let dir = tempfile::tempdir().unwrap(); |
| 848 | let (router, state) = fixture(dir.path()).await; |
| 849 | let demo = demo_repo(&state).await; |
| 850 | make_branch( |
| 851 | &repo_path(&state.config.data_dir, &demo.id).unwrap(), |
| 852 | "feature", |
| 853 | ); |
| 854 | record_push( |
| 855 | &state, |
| 856 | &demo.id, |
| 857 | &demo.owner_id, |
| 858 | "feature", |
| 859 | Duration::from_secs(300), |
| 860 | ) |
| 861 | .await; |
| 862 | state |
| 863 | .store |
| 864 | .create_pull( |
| 865 | &demo.id, |
| 866 | &demo.owner_id, |
| 867 | "Add feature", |
| 868 | "", |
| 869 | "main", |
| 870 | &demo.id, |
| 871 | "feature", |
| 872 | OID, |
| 873 | OID, |
| 874 | ) |
| 875 | .await |
| 876 | .unwrap(); |
| 877 | let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; |
| 878 | |
| 879 | let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; |
| 880 | assert_eq!(status, 200); |
| 881 | assert!(!body.contains(BANNER), "{body}"); |
| 882 | } |
| 883 | |
| 884 | #[tokio::test] |
| 885 | async fn other_users_push_shows_no_banner() { |
| 886 | let dir = tempfile::tempdir().unwrap(); |
| 887 | let (router, state) = fixture(dir.path()).await; |
| 888 | let demo = demo_repo(&state).await; |
| 889 | let bob = state |
| 890 | .store |
| 891 | .create_user("bob", None, "x", false) |
| 892 | .await |
| 893 | .unwrap(); |
| 894 | make_branch( |
| 895 | &repo_path(&state.config.data_dir, &demo.id).unwrap(), |
| 896 | "feature", |
| 897 | ); |
| 898 | record_push( |
| 899 | &state, |
| 900 | &demo.id, |
| 901 | &bob.id, |
| 902 | "feature", |
| 903 | Duration::from_secs(300), |
| 904 | ) |
| 905 | .await; |
| 906 | let cookie = sign_in(&state, &demo.owner_id, "alice-token").await; |
| 907 | |
| 908 | let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; |
| 909 | assert_eq!(status, 200); |
| 910 | assert!(!body.contains(BANNER), "{body}"); |
| 911 | } |
| 912 | |
| 913 | #[tokio::test] |
| 914 | async fn anonymous_viewer_sees_no_banner() { |
| 915 | let dir = tempfile::tempdir().unwrap(); |
| 916 | let (router, state) = fixture(dir.path()).await; |
| 917 | let demo = demo_repo(&state).await; |
| 918 | make_branch( |
| 919 | &repo_path(&state.config.data_dir, &demo.id).unwrap(), |
| 920 | "feature", |
| 921 | ); |
| 922 | record_push( |
| 923 | &state, |
| 924 | &demo.id, |
| 925 | &demo.owner_id, |
| 926 | "feature", |
| 927 | Duration::from_secs(300), |
| 928 | ) |
| 929 | .await; |
| 930 | |
| 931 | let (status, body) = get_as(&router, "/alice/demo", None).await; |
| 932 | assert_eq!(status, 200); |
| 933 | assert!(!body.contains(BANNER), "{body}"); |
| 934 | } |
| 935 | |
| 936 | #[tokio::test] |
| 937 | async fn fork_push_links_to_parent_compare() { |
| 938 | let dir = tempfile::tempdir().unwrap(); |
| 939 | let (router, state) = fixture(dir.path()).await; |
| 940 | let demo = demo_repo(&state).await; |
| 941 | let bob = state |
| 942 | .store |
| 943 | .create_user("bob", None, "x", false) |
| 944 | .await |
| 945 | .unwrap(); |
| 946 | let fork = state |
| 947 | .store |
| 948 | .create_fork(&demo, &bob.id, "demo") |
| 949 | .await |
| 950 | .unwrap(); |
| 951 | let fork_path = repo_path(&state.config.data_dir, &fork.id).unwrap(); |
| 952 | init_bare(&state.git, &fork_path, "main").await.unwrap(); |
| 953 | let demo_path = repo_path(&state.config.data_dir, &demo.id).unwrap(); |
| 954 | git( |
| 955 | &fork_path, |
| 956 | &[ |
| 957 | "fetch", |
| 958 | "-q", |
| 959 | demo_path.to_str().unwrap(), |
| 960 | "refs/heads/main:refs/heads/main", |
| 961 | ], |
| 962 | ); |
| 963 | make_branch(&fork_path, "feature"); |
| 964 | record_push( |
| 965 | &state, |
| 966 | &fork.id, |
| 967 | &bob.id, |
| 968 | "feature", |
| 969 | Duration::from_secs(300), |
| 970 | ) |
| 971 | .await; |
| 972 | let cookie = sign_in(&state, &bob.id, "bob-token").await; |
| 973 | |
| 974 | // On the parent, the viewer's fork branch compares as owner:branch. |
| 975 | let (status, body) = get_as(&router, "/alice/demo", Some(&cookie)).await; |
| 976 | assert_eq!(status, 200); |
| 977 | assert!(body.contains("compare/main...bob%3Afeature"), "{body}"); |
| 978 | |
| 979 | // On the fork itself, the link goes to the parent's compare page. |
| 980 | let (status, body) = get_as(&router, "/bob/demo", Some(&cookie)).await; |
| 981 | assert_eq!(status, 200); |
| 982 | assert!( |
| 983 | body.contains("/alice/demo/compare/main...bob%3Afeature"), |
| 984 | "{body}" |
| 985 | ); |
| 986 | } |
| 987 | } |