forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
35.2 KiB
1//! Pages: comparing two branches and opening a pull request from the comparison.
2//!
3//! - `GET /{o}/{r}/compare`: a plain GET picker for base and head. With both parameters it
4//! redirects to the comparison URL.
5//! - `GET /{o}/{r}/compare/{*spec}`: `base...head`, where head is `branch` or `owner:branch`. The
6//! head repo is this repo, one of its forks, or its parent, and the viewer must be able to Read
7//! it. Head commits from another repo are read through git's alternate object directory for
8//! this one read-only command. Nothing is copied into this repo, so a GET writes nothing.
9//! - `POST /{o}/{r}/pulls`: opens the pull request. Everything is re-resolved on the server; the
10//! hidden fields are only a request, never trusted.
11
12use std::path::PathBuf;
13
14use forge_core::{Action, Repo as RepoRecord, Session, authorize, validate_body, validate_title};
15use forge_git::browse::{self, CommitInfo, RefKind};
16use forge_git::command::ObjectSource;
17use forge_git::pulls;
18use forge_git::repo::repo_path;
19use serde::Deserialize;
20use topcoat::Result;
21use topcoat::context::{Cx, app_context};
22use topcoat::router::content::Form;
23use topcoat::router::error::{bad_request, forbidden, not_found, see_other};
24use topcoat::router::{page, path_param, query_params};
25use topcoat::view::{View, view};
26
27use crate::auth::{check_csrf, require_user};
28use crate::i18n;
29use crate::i18n::pulls::{self as g, K};
30use crate::notify::{self, Activity};
31use crate::pages::commits::{CommitRow, blocking, now_secs, url_path};
32use crate::pages::repo::repo_head;
33use crate::pages::repo_ctx::{RepoCtx, load_repo};
34use crate::state::AppState;
35use crate::templates;
36use crate::views::diff::{DiffLang, LIMITS, diff_section};
37use crate::views::{alert, repo_href, shell, user_facing_message};
38use crate::webhooks::emit::{Sender, emit_pull};
39
40path_param!(owner);
41path_param!(repo);
42path_param!(*spec);
43
44/// Commits listed on a comparison. The newest ones are kept when there are more.
45const MAX_COMMITS: usize = 250;
46
47/// The head side of a comparison: the repo, its on-disk path and the branch.
48struct Head {
49 repo: RepoRecord,
50 path: PathBuf,
51 branch: String,
52}
53
54/// A resolved comparison. `commits` is oldest first and empty when the histories are unrelated.
55struct Comparison {
56 base: String,
57 base_oid: String,
58 head: Head,
59 head_oid: String,
60 merge_base: Option<String>,
61 commits: Vec<CommitInfo>,
62 /// The head repo's object directory when the head lives in another repo, else `None`.
63 head_objects: Option<PathBuf>,
64}
65
66impl Comparison {
67 /// Where the head commits can be read from, for read-only git calls on the base repo.
68 fn objects(&self) -> ObjectSource<'_> {
69 ObjectSource {
70 extra: self.head_objects.as_deref(),
71 }
72 }
73}
74
75/// The head as a form value: a bare branch for this repo, `owner:branch` otherwise.
76fn head_value(ctx: &RepoCtx, head: &Head) -> String {
77 if head.repo.id == ctx.repo.id {
78 head.branch.clone()
79 } else {
80 format!("{}:{}", head.repo.owner_name, head.branch)
81 }
82}
83
84/// The repo a head owner refers to: the parent of this repo, or one of its forks.
85async fn related_repo(state: &AppState, ctx: &RepoCtx, owner: &str) -> Result<Option<RepoRecord>> {
86 if let Some(parent_id) = ctx.repo.fork_of.as_deref()
87 && let Some(parent) = state.store.repo_by_id(parent_id).await?
88 && parent.owner_name == owner
89 {
90 return Ok(Some(parent));
91 }
92 let forks = state.store.list_forks(&ctx.repo.id).await?;
93 Ok(forks.into_iter().find(|fork| fork.owner_name == owner))
94}
95
96/// Resolves `branch` or `owner:branch` to a head the viewer can Read. `None` when the spec is
97/// malformed, the repo is unrelated, or the viewer may not read it. Hidden repos look the same as
98/// missing ones.
99async fn resolve_head(state: &AppState, ctx: &RepoCtx, spec: &str) -> Result<Option<Head>> {
100 let (owner, branch) = match spec.split_once(':') {
101 Some((owner, branch)) => (Some(owner), branch),
102 None => (None, spec),
103 };
104 if branch.is_empty() {
105 return Ok(None);
106 }
107 let repo = match owner {
108 None => ctx.repo.clone(),
109 Some(owner) if owner == ctx.repo.owner_name => ctx.repo.clone(),
110 Some(owner) => match related_repo(state, ctx, owner).await? {
111 Some(repo) => repo,
112 None => return Ok(None),
113 },
114 };
115 let (role, org_role) = match &ctx.viewer {
116 Some((user, _)) => (
117 state.store.collaborator_role(&repo.id, &user.id).await?,
118 state
119 .store
120 .org_repo_role(&repo.owner_id, &user.id, &repo.id)
121 .await?,
122 ),
123 None => (None, None),
124 };
125 let actor = ctx.viewer.as_ref().map(|(user, _)| user);
126 if authorize(actor, &repo, role, org_role, Action::Read).is_err() {
127 return Ok(None);
128 }
129 let path = repo_path(&state.config.data_dir, &repo.id)?;
130 Ok(Some(Head {
131 repo,
132 path,
133 branch: branch.to_owned(),
134 }))
135}
136
137/// Resolves both sides of a comparison. Head objects from a fork are read through an alternate
138/// object directory, so nothing is written to either repo. `None` when either branch does not exist.
139async fn resolve_comparison(
140 state: &AppState,
141 ctx: &RepoCtx,
142 base: &str,
143 head_spec: &str,
144) -> Result<Option<Comparison>> {
145 let base_path = ctx.path.clone();
146 let base_name = base.to_owned();
147 let Some(base_oid) =
148 blocking(move || browse::default_branch_commit(&base_path, &base_name)).await?
149 else {
150 return Ok(None);
151 };
152 let Some(head) = resolve_head(state, ctx, head_spec).await? else {
153 return Ok(None);
154 };
155 let head_path = head.path.clone();
156 let head_branch = head.branch.clone();
157 let Some(head_oid) =
158 blocking(move || browse::default_branch_commit(&head_path, &head_branch)).await?
159 else {
160 return Ok(None);
161 };
162 let head_objects = (head.repo.id != ctx.repo.id).then(|| head.path.join("objects"));
163 let src = ObjectSource {
164 extra: head_objects.as_deref(),
165 };
166 let merge_base =
167 pulls::merge_base_with(&state.git, &ctx.path, src, &base_oid, &head_oid).await?;
168 let commits = match merge_base {
169 Some(_) => {
170 pulls::range_log_with(
171 &state.git,
172 &ctx.path,
173 src,
174 &base_oid,
175 &head_oid,
176 MAX_COMMITS,
177 )
178 .await?
179 }
180 None => Vec::new(),
181 };
182 Ok(Some(Comparison {
183 base: base.to_owned(),
184 base_oid,
185 head,
186 head_oid,
187 merge_base,
188 commits,
189 head_objects,
190 }))
191}
192
193fn csrf_of(ctx: &RepoCtx) -> String {
194 ctx.viewer
195 .as_ref()
196 .map(|(_, session)| session.csrf_token.clone())
197 .unwrap_or_default()
198}
199
200/// Write requests need an unarchived repo and a matching CSRF token.
201fn check_write(ctx: &RepoCtx, session: &Session, csrf: &str) -> Result<()> {
202 if ctx.repo.archived || !check_csrf(session, csrf) {
203 return Err(forbidden().into());
204 }
205 Ok(())
206}
207
208fn pull_href(ctx: &RepoCtx, number: i64) -> String {
209 repo_href(
210 &ctx.repo.owner_name,
211 &ctx.repo.name,
212 "pulls",
213 &number.to_string(),
214 )
215}
216
217/// The body the pull request form starts with: the default branch's pull request template, or
218/// empty when there is none.
219async fn pull_template_body(ctx: &RepoCtx) -> Result<String> {
220 let template =
221 templates::load_pull_template(ctx.path.clone(), ctx.repo.default_branch.clone()).await?;
222 Ok(template.map(|t| t.body).unwrap_or_default())
223}
224
225/// The comparison page body: header, commits, the diff and the pull request box.
226/// `draft` holds the title and body typed into the form; `None` prefills the title.
227async fn compare_view(
228 cx: &Cx,
229 ctx: RepoCtx,
230 cmp: Comparison,
231 draft: Option<(String, String)>,
232 error: Option<String>,
233) -> Result<impl View> {
234 let state = app_context::<AppState>(cx);
235 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
236 let owner = ctx.repo.owner_name.clone();
237 let name = ctx.repo.name.clone();
238 let tz = ctx.viewer_tz().to_owned();
239 let now = now_secs();
240 let title = format!("{owner}/{name}");
241 let repo_href_root = repo_href(&owner, &name, "", "");
242 let base = cmp.base.clone();
243 let head = head_value(&ctx, &cmp.head);
244 let head_for_form = head.clone();
245
246 let rows: Vec<CommitRow> = cmp
247 .commits
248 .iter()
249 .map(|info| CommitRow::new(&owner, &name, info, now, &tz, lang))
250 .collect();
251 let related = cmp.merge_base.is_some();
252 let has_commits = !rows.is_empty();
253 let commits_label = g::commit_count(lang, rows.len());
254 let unrelated = !related;
255 let nothing = related && !has_commits;
256 let show_diff = related && has_commits;
257 let parent = cmp.merge_base.clone();
258
259 let diff = if show_diff {
260 Some(
261 pulls::range_diff_files_with(
262 &state.git,
263 &ctx.path,
264 cmp.objects(),
265 &cmp.base_oid,
266 &cmp.head_oid,
267 LIMITS,
268 )
269 .await?,
270 )
271 } else {
272 None
273 };
274
275 let signed_in = ctx.viewer.is_some();
276 let archived = ctx.repo.archived;
277 let existing = if show_diff && signed_in && !archived {
278 state
279 .store
280 .find_open_pull(&ctx.repo.id, &cmp.base, &cmp.head.repo.id, &cmp.head.branch)
281 .await?
282 } else {
283 None
284 };
285 let has_existing = existing.is_some();
286 let existing_href = existing
287 .map(|number| pull_href(&ctx, number))
288 .unwrap_or_default();
289 let show_form = show_diff && signed_in && !archived && !has_existing;
290 let archived_note = show_diff && signed_in && archived;
291
292 let default_title = match rows.len() {
293 1 => cmp
294 .commits
295 .first()
296 .map(|c| c.summary.clone())
297 .unwrap_or_default(),
298 _ => cmp.head.branch.clone(),
299 };
300 let (form_title, form_body) = match draft {
301 Some(draft) => draft,
302 None => (default_title, pull_template_body(&ctx).await?),
303 };
304 let has_error = error.is_some();
305 let error_text = error.unwrap_or_default();
306 let csrf = csrf_of(&ctx);
307 let pulls_action = repo_href(&owner, &name, "pulls", "");
308 let heading_base = base.clone();
309 let heading_head = head.clone();
310
311 let header = ctx.head(state, "commits").await?;
312 let __cx = cx;
313 Ok(view! {
314 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
315 repo_head(head: header)
316 <h2>(g::t(lang, K::ComparingPrefix)) <code>(heading_base.as_str())</code> "..." <code>(heading_head.as_str())</code></h2>
317 <p class="muted small"><a href=(repo_href_root.as_str())>(title.as_str())</a></p>
318 if unrelated {
319 <p class="muted">(g::t(lang, K::Unrelated))</p>
320 }
321 if nothing {
322 <p class="muted">(g::t(lang, K::NothingToCompare))</p>
323 }
324 if has_commits {
325 <h3>(commits_label.as_str())</h3>
326 <ul class="commit-list">
327 for row in rows.iter() {
328 <li class="commit-row">
329 <div class="commit-main">
330 <a class="commit-summary" href=(row.commit_href.as_str())>(row.summary.as_str())</a>
331 <div class="commit-meta muted small">
332 <span class="commit-author">(row.author.as_str())</span>
333 if lang == i18n::Lang::Tr {
334 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
335 <span>(g::t(lang, K::Committed))</span>
336 } else {
337 <span>(g::t(lang, K::Committed))</span>
338 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
339 }
340 </div>
341 </div>
342 <div class="commit-actions">
343 <a class="mono commit-oid" href=(row.commit_href.as_str())>(row.short.as_str())</a>
344 <a class="small muted" href=(row.tree_href.as_str())>(g::t(lang, K::BrowseFiles))</a>
345 </div>
346 </li>
347 }
348 </ul>
349 }
350 if show_form {
351 <section class="card">
352 <h3>(g::t(lang, K::OpenPullRequest))</h3>
353 if has_error {
354 alert(message: error_text.as_str())
355 }
356 <form method="post" action=(pulls_action.as_str())>
357 <input type="hidden" name="csrf" value=(csrf.as_str())>
358 <input type="hidden" name="base" value=(base.as_str())>
359 <input type="hidden" name="head" value=(head_for_form.as_str())>
360 <p><label>(g::t(lang, K::TitleLabel)) <input type="text" name="title" value=(form_title.as_str())></label></p>
361 <p><label>(g::t(lang, K::DescriptionLabel)) <textarea name="body" rows="8">(form_body.as_str())</textarea></label></p>
362 <p><button type="submit" class="primary">(g::t(lang, K::CreatePullRequest))</button></p>
363 </form>
364 </section>
365 }
366 if has_existing {
367 <p class="muted">(g::t(lang, K::AlreadyOpen)) <a href=(existing_href.as_str())>(g::t(lang, K::ViewIt))</a></p>
368 }
369 if archived_note {
370 <p class="muted">(g::t(lang, K::ArchivedNoPulls))</p>
371 }
372 if show_diff {
373 for d in diff.iter() {
374 diff_section(diff: d, owner: owner.as_str(), repo: name.as_str(), oid: cmp.head_oid.as_str(), parent: parent.as_deref(), subject: g::t(lang, K::ThisComparison), lang: DiffLang(lang))
375 }
376 }
377 )
378 })
379}
380
381/// The picker: base and head fields as a plain GET form, with the repo's branches suggested.
382async fn picker_view(cx: &Cx, ctx: RepoCtx, base: String, head: String) -> Result<impl View> {
383 let state = app_context::<AppState>(cx);
384 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
385 let path = ctx.path.clone();
386 let refs = blocking(move || browse::list_refs(&path)).await?;
387 let branches: Vec<String> = refs
388 .into_iter()
389 .filter(|r| r.kind == RefKind::Branch)
390 .map(|r| r.name)
391 .collect();
392 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
393 let action = repo_href(&ctx.repo.owner_name, &ctx.repo.name, "compare", "");
394 let header = ctx.head(state, "commits").await?;
395 let __cx = cx;
396 Ok(view! {
397 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
398 repo_head(head: header)
399 <h2>(g::t(lang, K::CompareBranches))</h2>
400 <form method="get" action=(action.as_str())>
401 <p><label>(g::t(lang, K::BasePrefix)) <input type="text" name="base" list="compare-branches" value=(base.as_str())></label></p>
402 <p><label>(g::t(lang, K::HeadPrefix)) <input type="text" name="head" list="compare-branches" value=(head.as_str()) placeholder=(g::t(lang, K::BranchOrOwner))></label></p>
403 <datalist id="compare-branches">
404 for name in branches.iter() {
405 <option value=(name.as_str())></option>
406 }
407 </datalist>
408 <p><button type="submit">(g::t(lang, K::Compare))</button></p>
409 </form>
410 )
411 })
412}
413
414#[query_params(error = bad_request)]
415struct PickerQuery {
416 base: Option<String>,
417 head: Option<String>,
418}
419
420/// Picker at `/{owner}/{repo}/compare`. Both fields filled in redirect to the comparison.
421#[page("/{owner}/{repo}/compare")]
422async fn compare_picker(cx: &Cx) -> Result<impl View> {
423 let owner = path_param::<Owner>(cx);
424 let name = path_param::<Repo>(cx);
425 let query = query_params::<PickerQuery>(cx)?;
426 let ctx = load_repo(cx, owner, name).await?;
427 let base = query.base.clone().unwrap_or_default();
428 let head = query.head.clone().unwrap_or_default();
429 if base.is_empty() && head.is_empty() {
430 let default_branch = ctx.repo.default_branch.clone();
431 return picker_view(cx, ctx, default_branch, String::new()).await;
432 }
433 if !base.is_empty() && !head.is_empty() {
434 let target = format!(
435 "/{}/{}/compare/{}",
436 ctx.repo.owner_name,
437 ctx.repo.name,
438 url_path(&format!("{base}...{head}"))
439 );
440 return Err(see_other(target).into());
441 }
442 picker_view(cx, ctx, base, head).await
443}
444
445/// Comparison at `/{owner}/{repo}/compare/{base}...{head}`.
446#[page("/{owner}/{repo}/compare/{*spec}")]
447async fn compare_page(cx: &Cx) -> Result<impl View> {
448 let owner = path_param::<Owner>(cx);
449 let name = path_param::<Repo>(cx);
450 let spec = path_param::<Spec>(cx).collect::<Vec<_>>().join("/");
451 let ctx = load_repo(cx, owner, name).await?;
452 let state = app_context::<AppState>(cx);
453 let Some((base, head)) = spec.split_once("...") else {
454 return Err(not_found().into());
455 };
456 if base.is_empty() {
457 return Err(not_found().into());
458 }
459 let Some(cmp) = resolve_comparison(state, &ctx, base, head).await? else {
460 return Err(not_found().into());
461 };
462 compare_view(cx, ctx, cmp, None, None).await
463}
464
465#[derive(Deserialize)]
466struct PullForm {
467 #[serde(default)]
468 csrf: String,
469 #[serde(default)]
470 title: String,
471 #[serde(default)]
472 body: String,
473 #[serde(default)]
474 base: String,
475 #[serde(default)]
476 head: String,
477}
478
479/// Opens a pull request from a comparison: `POST /{owner}/{repo}/pulls`.
480#[page(POST "/{owner}/{repo}/pulls")]
481async fn pull_create(cx: &Cx, Form(form): Form<PullForm>) -> Result<impl View> {
482 let owner = path_param::<Owner>(cx);
483 let name = path_param::<Repo>(cx);
484 let ctx = load_repo(cx, owner, name).await?;
485 let (user, session) = require_user(cx).await?;
486 check_write(&ctx, &session, &form.csrf)?;
487 let lang = i18n::lang(cx, Some(&user));
488 let state = app_context::<AppState>(cx);
489
490 let Some(cmp) = resolve_comparison(state, &ctx, &form.base, &form.head).await? else {
491 return Err(bad_request(g::t(lang, K::UnknownBranches)).into());
492 };
493 if cmp.merge_base.is_none() {
494 return Err(bad_request(g::t(lang, K::Unrelated)).into());
495 }
496 if let Some(number) = state
497 .store
498 .find_open_pull(&ctx.repo.id, &cmp.base, &cmp.head.repo.id, &cmp.head.branch)
499 .await?
500 {
501 return Err(see_other(pull_href(&ctx, number)).into());
502 }
503 if cmp.commits.is_empty() {
504 return Err(bad_request(g::t(lang, K::NothingToCompare)).into());
505 }
506
507 let title = match validate_title(&form.title) {
508 Ok(title) => title,
509 Err(e) => {
510 let message = user_facing_message(&e).unwrap_or_else(|| e.to_string());
511 return compare_view(cx, ctx, cmp, Some((form.title, form.body)), Some(message)).await;
512 }
513 };
514 if let Err(e) = validate_body(&form.body) {
515 let message = user_facing_message(&e).unwrap_or_else(|| e.to_string());
516 return compare_view(cx, ctx, cmp, Some((title, form.body)), Some(message)).await;
517 }
518
519 let created = state
520 .store
521 .create_pull(
522 &ctx.repo.id,
523 &user.id,
524 &title,
525 &form.body,
526 &cmp.base,
527 &cmp.head.repo.id,
528 &cmp.head.branch,
529 &cmp.head_oid,
530 &cmp.base_oid,
531 )
532 .await;
533 let pr = match created {
534 Ok(pr) => pr,
535 Err(e @ forge_core::Error::Conflict(_)) => {
536 // Another request opened the pull first (or the number was taken). Send the viewer to
537 // the open pull when there is one; otherwise report the original conflict.
538 let open = state
539 .store
540 .find_open_pull(&ctx.repo.id, &cmp.base, &cmp.head.repo.id, &cmp.head.branch)
541 .await?;
542 return match open {
543 Some(number) => Err(see_other(pull_href(&ctx, number)).into()),
544 None => Err(e.into()),
545 };
546 }
547 Err(e) => return Err(e.into()),
548 };
549 emit_pull(state, &ctx.repo, &Sender::of(&user), "opened", &pr).await;
550 notify::dispatch(
551 state,
552 &ctx.repo,
553 &pr.issue,
554 &user.id,
555 Activity::Opened,
556 Some(&pr.issue.body),
557 )
558 .await;
559 let number = pr.issue.number;
560 pulls::fetch_into_pull_ref(&state.git, &ctx.path, &cmp.head.path, &cmp.head_oid, number)
561 .await?;
562 // After the fetch: a fork's head commit is only in the base repo once refs/pull/N/head exists.
563 crate::ci::on_pull_event(state, &ctx.repo, &pr, &user.id).await;
564 Err(see_other(pull_href(&ctx, number)).into())
565}
566
567#[cfg(test)]
568mod tests {
569 use std::path::{Path, PathBuf};
570
571 use forge_core::{Store, Visibility};
572 use topcoat::router::request::Request;
573 use topcoat::router::{Body, Router, to_bytes};
574
575 use crate::auth::SESSION_COOKIE;
576 use crate::pages::commits::test_support::{get, git};
577 use crate::test_support::{app_router, test_state};
578
579 const OWNER: &str = "alice";
580 const REPO: &str = "demo";
581 const ALICE_CSRF: &str = "alice-csrf";
582
583 struct Fixture {
584 router: Router,
585 store: Store,
586 dir: PathBuf,
587 bare: PathBuf,
588 feature_oid: String,
589 _tmp: tempfile::TempDir,
590 }
591
592 impl Fixture {
593 /// Alice's repo with `main` (a.txt) and `feature` (adds feature.txt). Alice's session is
594 /// `alice-token` with CSRF `alice-csrf`.
595 async fn new(visibility: Visibility) -> Self {
596 let tmp = tempfile::tempdir().expect("tempdir");
597 let dir = tmp.path().to_path_buf();
598 let state = test_state(&dir).await;
599 let store = state.store.clone();
600 let router = app_router(state);
601
602 let alice = store
603 .create_user(OWNER, None, "not-a-real-hash", false)
604 .await
605 .unwrap();
606 let repo = store
607 .create_repo(&alice.id, REPO, "", visibility, "main")
608 .await
609 .unwrap();
610 let bare = forge_git::repo::repo_path(&dir, &repo.id).unwrap();
611 std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
612 git(
613 &dir,
614 &[
615 "init",
616 "-q",
617 "--bare",
618 "-b",
619 "main",
620 &bare.to_string_lossy(),
621 ],
622 );
623
624 let work = dir.join("work");
625 std::fs::create_dir_all(&work).unwrap();
626 git(&work, &["init", "-q", "-b", "main"]);
627 std::fs::write(work.join("a.txt"), "one\n").unwrap();
628 git(&work, &["add", "-A"]);
629 git(&work, &["commit", "-q", "-m", "root commit"]);
630 git(&work, &["checkout", "-q", "-b", "feature"]);
631 std::fs::write(work.join("feature.txt"), "feature\n").unwrap();
632 git(&work, &["add", "-A"]);
633 git(&work, &["commit", "-q", "-m", "Add feature file"]);
634 let feature_oid = git(&work, &["rev-parse", "HEAD"]);
635 git(&work, &["checkout", "-q", "main"]);
636 git(
637 &work,
638 &[
639 "push",
640 "-q",
641 &bare.to_string_lossy(),
642 "main:refs/heads/main",
643 "feature:refs/heads/feature",
644 ],
645 );
646
647 crate::test_support::create_session(&store, &alice.id, "alice-token", ALICE_CSRF).await;
648 Self {
649 router,
650 store,
651 dir,
652 bare,
653 feature_oid,
654 _tmp: tmp,
655 }
656 }
657
658 /// Gives bob a fork of alice's repo with a `bob-work` branch that alice's repo lacks.
659 /// Returns bob's commit.
660 async fn add_bob_fork(&self) -> String {
661 let bob = self
662 .store
663 .create_user("bob", None, "not-a-real-hash", false)
664 .await
665 .unwrap();
666 crate::test_support::create_session(&self.store, &bob.id, "bob-token", "bob-csrf")
667 .await;
668 let source = self
669 .store
670 .repo_by_owner_and_name(OWNER, REPO)
671 .await
672 .unwrap()
673 .unwrap();
674 let fork = self
675 .store
676 .create_fork(&source, &bob.id, REPO)
677 .await
678 .unwrap();
679 let fork_path = forge_git::repo::repo_path(&self.dir, &fork.id).unwrap();
680 std::fs::create_dir_all(fork_path.parent().unwrap()).unwrap();
681 git(
682 &self.dir,
683 &[
684 "clone",
685 "-q",
686 "--bare",
687 "--local",
688 "--no-checkout",
689 &self.bare.to_string_lossy(),
690 &fork_path.to_string_lossy(),
691 ],
692 );
693 let work = self.dir.join("work");
694 git(&work, &["checkout", "-q", "-b", "bob-work", "main"]);
695 std::fs::write(work.join("bob.txt"), "bob\n").unwrap();
696 git(&work, &["add", "-A"]);
697 git(&work, &["commit", "-q", "-m", "Add bob file"]);
698 let bob_oid = git(&work, &["rev-parse", "HEAD"]);
699 git(
700 &work,
701 &[
702 "push",
703 "-q",
704 &fork_path.to_string_lossy(),
705 "bob-work:refs/heads/bob-work",
706 ],
707 );
708 git(&work, &["checkout", "-q", "main"]);
709 bob_oid
710 }
711 }
712
713 /// A request with an optional session cookie and a form body. Returns
714 /// `(status, location header, body)`.
715 async fn send(
716 router: &Router,
717 method: &str,
718 uri: &str,
719 token: Option<&str>,
720 form: Option<&str>,
721 ) -> (u16, Option<String>, String) {
722 let mut builder = Request::builder().method(method).uri(uri);
723 if let Some(token) = token {
724 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}"));
725 }
726 let body = match form {
727 Some(form) => {
728 builder = builder.header("content-type", "application/x-www-form-urlencoded");
729 Body::from(form.to_owned())
730 }
731 None => Body::empty(),
732 };
733 let res = router.handle(builder.body(body).unwrap()).await;
734 let status = res.status().as_u16();
735 let location = res
736 .headers()
737 .get("location")
738 .and_then(|v| v.to_str().ok())
739 .map(str::to_owned);
740 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
741 (
742 status,
743 location,
744 String::from_utf8_lossy(&bytes).into_owned(),
745 )
746 }
747
748 fn rev(bare: &Path, name: &str) -> Option<String> {
749 let out = std::process::Command::new("git")
750 .args(["rev-parse", "--verify", "-q", name])
751 .current_dir(bare)
752 .output()
753 .expect("git runs");
754 out.status
755 .success()
756 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
757 }
758
759 fn has_object(bare: &Path, oid: &str) -> bool {
760 std::process::Command::new("git")
761 .args(["cat-file", "-e", oid])
762 .current_dir(bare)
763 .status()
764 .expect("git runs")
765 .success()
766 }
767
768 fn create_form(base: &str, head: &str, title: &str) -> String {
769 format!("csrf={ALICE_CSRF}&base={base}&head={head}&title={title}&body=")
770 }
771
772 #[tokio::test]
773 async fn same_repo_compare_lists_commit_and_file() {
774 let fx = Fixture::new(Visibility::Public).await;
775 let (status, _, body) = get(&fx.router, "/alice/demo/compare/main...feature").await;
776 assert_eq!(status, 200);
777 let html = String::from_utf8(body).unwrap();
778 assert!(html.contains("Comparing"));
779 assert!(html.contains("Add feature file"));
780 assert!(html.contains("feature.txt"));
781 }
782
783 #[tokio::test]
784 async fn picker_redirects_to_comparison() {
785 let fx = Fixture::new(Visibility::Public).await;
786 let (status, location, _) = send(
787 &fx.router,
788 "GET",
789 "/alice/demo/compare?base=main&head=feature",
790 None,
791 None,
792 )
793 .await;
794 assert_eq!(status, 303);
795 assert_eq!(
796 location.as_deref(),
797 Some("/alice/demo/compare/main...feature")
798 );
799 let (status, _, body) = send(&fx.router, "GET", "/alice/demo/compare", None, None).await;
800 assert_eq!(status, 200);
801 assert!(body.contains("Compare branches"));
802 }
803
804 #[tokio::test]
805 async fn anonymous_compare_has_no_form() {
806 let fx = Fixture::new(Visibility::Public).await;
807 let (status, _, body) = send(
808 &fx.router,
809 "GET",
810 "/alice/demo/compare/main...feature",
811 None,
812 None,
813 )
814 .await;
815 assert_eq!(status, 200);
816 assert!(!body.contains("action=\"/alice/demo/pulls\""));
817 assert!(!body.contains("Open a pull request"));
818 }
819
820 #[tokio::test]
821 async fn post_creates_pull_and_pull_ref() {
822 let fx = Fixture::new(Visibility::Public).await;
823 let form = create_form("main", "feature", "Add+feature+file");
824 let (status, location, _) = send(
825 &fx.router,
826 "POST",
827 "/alice/demo/pulls",
828 Some("alice-token"),
829 Some(form.as_str()),
830 )
831 .await;
832 assert_eq!(status, 303);
833 assert_eq!(location.as_deref(), Some("/alice/demo/pulls/1"));
834 assert_eq!(
835 rev(&fx.bare, "refs/pull/1/head").as_deref(),
836 Some(fx.feature_oid.as_str())
837 );
838 }
839
840 #[tokio::test]
841 async fn duplicate_post_redirects_to_existing_pull() {
842 let fx = Fixture::new(Visibility::Public).await;
843 let form = create_form("main", "feature", "Add+feature+file");
844 let (status, _, _) = send(
845 &fx.router,
846 "POST",
847 "/alice/demo/pulls",
848 Some("alice-token"),
849 Some(form.as_str()),
850 )
851 .await;
852 assert_eq!(status, 303);
853
854 let (status, location, _) = send(
855 &fx.router,
856 "POST",
857 "/alice/demo/pulls",
858 Some("alice-token"),
859 Some(form.as_str()),
860 )
861 .await;
862 assert_eq!(status, 303);
863 assert_eq!(location.as_deref(), Some("/alice/demo/pulls/1"));
864
865 let repo = fx
866 .store
867 .repo_by_owner_and_name(OWNER, REPO)
868 .await
869 .unwrap()
870 .unwrap();
871 assert!(
872 fx.store
873 .pull_by_number(&repo.id, 2)
874 .await
875 .unwrap()
876 .is_none()
877 );
878
879 // The comparison now links to the open pull instead of offering the form.
880 let (_, _, body) = send(
881 &fx.router,
882 "GET",
883 "/alice/demo/compare/main...feature",
884 Some("alice-token"),
885 None,
886 )
887 .await;
888 let html = body;
889 assert!(!html.contains("action=\"/alice/demo/pulls\""));
890 assert!(html.contains("/alice/demo/pulls/1"));
891 }
892
893 #[tokio::test]
894 async fn signed_in_compare_shows_form() {
895 let fx = Fixture::new(Visibility::Public).await;
896 let (_, _, body) = send(
897 &fx.router,
898 "GET",
899 "/alice/demo/compare/main...feature",
900 Some("alice-token"),
901 None,
902 )
903 .await;
904 let html = body;
905 assert!(html.contains("action=\"/alice/demo/pulls\""));
906 assert!(html.contains("value=\"Add feature file\""));
907 }
908
909 #[tokio::test]
910 async fn fork_compare_reads_objects_without_writing_to_base() {
911 let fx = Fixture::new(Visibility::Public).await;
912 let bob_oid = fx.add_bob_fork().await;
913 assert!(!has_object(&fx.bare, &bob_oid));
914
915 let (status, _, body) = send(
916 &fx.router,
917 "GET",
918 "/alice/demo/compare/main...bob:bob-work",
919 None,
920 None,
921 )
922 .await;
923 assert_eq!(status, 200);
924 let html = body;
925 assert!(html.contains("Add bob file"));
926 assert!(html.contains("bob.txt"));
927 // A GET leaves the base repo untouched: no copied object, no ref.
928 assert!(!has_object(&fx.bare, &bob_oid));
929 assert_eq!(rev(&fx.bare, "refs/pull/1/head"), None);
930
931 let form = "csrf=bob-csrf&base=main&head=bob:bob-work&title=Add+bob+file&body=";
932 let (status, location, _) = send(
933 &fx.router,
934 "POST",
935 "/alice/demo/pulls",
936 Some("bob-token"),
937 Some(form),
938 )
939 .await;
940 assert_eq!(status, 303);
941 assert_eq!(location.as_deref(), Some("/alice/demo/pulls/1"));
942 // Opening the pull publishes the head commits to the base and points the pull ref at them.
943 assert!(has_object(&fx.bare, &bob_oid));
944 assert_eq!(
945 rev(&fx.bare, "refs/pull/1/head").as_deref(),
946 Some(bob_oid.as_str())
947 );
948 }
949
950 #[tokio::test]
951 async fn private_base_is_404_for_anonymous() {
952 let fx = Fixture::new(Visibility::Private).await;
953 let (status, _, _) = send(
954 &fx.router,
955 "GET",
956 "/alice/demo/compare/main...feature",
957 None,
958 None,
959 )
960 .await;
961 assert_eq!(status, 404);
962 }
963
964 #[tokio::test]
965 async fn missing_or_wrong_csrf_is_rejected() {
966 let fx = Fixture::new(Visibility::Public).await;
967 let no_csrf = "base=main&head=feature&title=Add+feature+file&body=";
968 let (status, _, _) = send(
969 &fx.router,
970 "POST",
971 "/alice/demo/pulls",
972 Some("alice-token"),
973 Some(no_csrf),
974 )
975 .await;
976 assert_eq!(status, 403);
977
978 let wrong = "csrf=nope&base=main&head=feature&title=Add+feature+file&body=";
979 let (status, _, _) = send(
980 &fx.router,
981 "POST",
982 "/alice/demo/pulls",
983 Some("alice-token"),
984 Some(wrong),
985 )
986 .await;
987 assert_eq!(status, 403);
988 assert!(rev(&fx.bare, "refs/pull/1/head").is_none());
989 }
990
991 #[tokio::test]
992 async fn unrelated_head_is_404() {
993 let fx = Fixture::new(Visibility::Public).await;
994 let (status, _, _) = send(
995 &fx.router,
996 "GET",
997 "/alice/demo/compare/main...mallory:feature",
998 None,
999 None,
1000 )
1001 .await;
1002 assert_eq!(status, 404);
1003 let (status, _, _) = send(
1004 &fx.router,
1005 "GET",
1006 "/alice/demo/compare/main...missing",
1007 None,
1008 None,
1009 )
1010 .await;
1011 assert_eq!(status, 404);
1012 }
1013}