Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Change detail, files, revisions, conflicts, comments, reviews, stacks
Matt W2//! (M3, M4).
Matt W3//!
Matt W4//! Split from `routes::change` — which owns the *list* — because the review
Matt W5//! surface is where most of the product's behaviour lives and the two have very
Matt W6//! little in common beyond resolving a change.
Matt W7//!
Matt W8//! Two rules every handler here follows:
Matt W9//!
Matt W10//! * `RepoContext::load` first, so an unauthorised private repository 404s
Matt W11//! before anything else runs.
Matt W12//! * Permission checks name the capability, not the role: `can_comment`,
Matt W13//! `can_manage_changes`. The mapping from role to capability lives in
Matt W14//! `df_auth::permissions` and nowhere else (spec §6).
Matt W15
Matt W16use axum::extract::{Path as UrlPath, Query, State};
Matt W17use axum::response::{IntoResponse, Redirect, Response};
Matt W18use axum::Form;
Matt W19use df_db::ids::new_id;
Matt W20use df_store::{DiffOpts, RevId};
Matt W21use serde::Deserialize;
Matt W22use uuid::Uuid;
Matt W23
Matt W24use crate::error::{AppError, AppResult};
Matt W25use crate::repo_ctx::RepoContext;
Matt W26use crate::routes::change::{resolve_change, ChangeRecord, Resolution};
Matt W27use crate::routes::settings::urlencode;
Matt W28use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W29use crate::views::change as cv;
Matt W30use crate::views::repo as rv;
Matt W31use crate::views::review as v;
Matt W32use crate::views::{self, Chrome};
Matt W33
Matt W34/// Everything the change pages load before they diverge.
Matt W35struct Loaded {
Matt W36 ctx: RepoContext,
Matt W37 change: ChangeRecord,
Matt W38 /// (seq, rev), oldest first.
Matt W39 revisions: Vec<(i32, String)>,
Matt W40 author: Option<String>,
Matt W41 author_name: Option<String>,
Matt W42 can_manage: bool,
Matt W43 comment_count: i64,
Matt W44 /// The right-hand column, identical on every tab.
Matt W45 aside: v::ChangeAside,
Matt W46}
Matt W47
Matt W48impl Loaded {
Matt W49 fn head(&self) -> Option<&str> {
Matt W50 self.revisions.last().map(|(_, r)| r.as_str())
Matt W51 }
Matt W52}
Matt W53
Matt W54/// Resolve the repo and the change, or produce the response that stands in for
Matt W55/// it — a 404, or the disambiguation page for an ambiguous prefix.
Matt W56async fn load(
Matt W57 state: &AppState,
Matt W58 owner: &str,
Matt W59 name: &str,
Matt W60 reference: &str,
Matt W61 user: Option<&df_db::models::User>,
Matt W62 csrf: &str,
Matt W63 nonce: &str,
Matt W64) -> AppResult<Result<Loaded, Response>> {
Matt W65 let ctx = RepoContext::load(state, owner, name, user).await?;
Matt W66
Matt W67 let change = match resolve_change(state, ctx.repo.id, reference).await? {
Matt W68 Resolution::One(c) => *c,
Matt W69 Resolution::Ambiguous(candidates) => {
Matt W70 let body = maud::html! {
Matt W71 (cv::ambiguous(&ctx, reference, &candidates))
Matt W72 };
Matt W73 return Ok(Err(views::page_with_bar(
Matt W74 Chrome { title: "Ambiguous change id", user, csrf, nonce },
Matt W75 rv::header(&ctx, "changes"),
Matt W76 body,
Matt W77 )
Matt W78 .into_response()));
Matt W79 }
Matt W80 Resolution::None => return Err(AppError::NotFound),
Matt W81 };
Matt W82
Matt W83 let revisions: Vec<(i32, String)> =
Matt W84 sqlx::query_as("SELECT seq, rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq")
Matt W85 .bind(change.id)
Matt W86 .fetch_all(&state.db)
Matt W87 .await?;
Matt W88
Matt W89 let author: Option<String> = sqlx::query_scalar(
Matt W90 "SELECT u.handle::text FROM users u
Matt W91 JOIN changes c ON c.author_user_id = u.id WHERE c.id = $1",
Matt W92 )
Matt W93 .bind(change.id)
Matt W94 .fetch_optional(&state.db)
Matt W95 .await?;
Matt W96
Matt W97 // What the commit itself says, for when no account matched its email.
Matt W98 let author_name: Option<String> = sqlx::query_scalar(
Matt W99 "SELECT r.author_name FROM revisions r
Matt W100 JOIN changes c ON c.head_revision_id = r.id WHERE c.id = $1",
Matt W101 )
Matt W102 .bind(change.id)
Matt W103 .fetch_optional(&state.db)
Matt W104 .await?;
Matt W105
Matt W106 // The author of a change manages it even without the maintain role — it is
Matt W107 // their work, and requiring a maintainer to retitle your own change would be
Matt W108 // absurd. Everything else still needs the role.
Matt W109 let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
Matt W110 let can_manage = ctx.access.can_manage_changes() || is_author;
Matt W111
Matt W112 let comment_count: i64 =
Matt W113 sqlx::query_scalar("SELECT count(*) FROM comments WHERE change_id_fk = $1")
Matt W114 .bind(change.id)
Matt W115 .fetch_one(&state.db)
Matt W116 .await?;
Matt W117
Matt W118 let aside = load_aside(state, &ctx, &change).await?;
Matt W119
Matt W120 Ok(Ok(Loaded {
Matt W121 ctx,
Matt W122 change,
Matt W123 revisions,
Matt W124 author,
Matt W125 author_name,
Matt W126 can_manage,
Matt W127 comment_count,
Matt W128 aside,
Matt W129 }))
Matt W130}
Matt W131
Matt W132/// Reviewers and the surrounding stack — the aside on every change tab.
Matt W133async fn load_aside(
Matt W134 state: &AppState,
Matt W135 ctx: &RepoContext,
Matt W136 change: &ChangeRecord,
Matt W137) -> AppResult<v::ChangeAside> {
Matt W138 // `DISTINCT ON` keeps each reviewer's most recent verdict: somebody who
Matt W139 // approved and later requested changes has one current position, not two.
Matt W140 let reviewers: Vec<(String, String, bool)> = sqlx::query_as(
Matt W141 r#"
Matt W142 SELECT DISTINCT ON (rv.reviewer_id)
Matt W143 u.handle::text,
Matt W144 rv.verdict::text,
Matt W145 (rv.revision_id = c.head_revision_id) AS at_head
Matt W146 FROM reviews rv
Matt W147 JOIN changes c ON c.id = rv.change_id_fk
Matt W148 JOIN users u ON u.id = rv.reviewer_id
Matt W149 WHERE rv.change_id_fk = $1
Matt W150 ORDER BY rv.reviewer_id, rv.created_at DESC
Matt W151 "#,
Matt W152 )
Matt W153 .bind(change.id)
Matt W154 .fetch_all(&state.db)
Matt W155 .await?;
Matt W156
Matt W157 // The chain this change sits in, walked in both directions. A recursive CTE
Matt W158 // rather than a fixed number of joins, because a stack has no maximum
Matt W159 // depth — and `UNION` (not `UNION ALL`) is what makes a cycle in a
Matt W160 // corrupted edge table terminate rather than run forever.
Matt W161 let chain: Vec<(String, i64, String, bool, i32)> = sqlx::query_as(
Matt W162 r#"
Matt W163 WITH RECURSIVE down AS (
Matt W164 SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
Matt W165 UNION
Matt W166 SELECT p.id, d.depth - 1
Matt W167 FROM down d
Matt W168 JOIN change_edges e ON e.child_change = d.id
Matt W169 JOIN changes p ON p.id = e.parent_change
Matt W170 ),
Matt W171 up AS (
Matt W172 SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
Matt W173 UNION
Matt W174 SELECT ch.id, u.depth + 1
Matt W175 FROM up u
Matt W176 JOIN change_edges e ON e.parent_change = u.id
Matt W177 JOIN changes ch ON ch.id = e.child_change
Matt W178 ),
Matt W179 chain AS (
Matt W180 SELECT id, min(depth) AS depth FROM (
Matt W181 SELECT * FROM down UNION ALL SELECT * FROM up
Matt W182 ) combined GROUP BY id
Matt W183 )
Matt W184 SELECT c.change_id, c.number, c.state::text, c.conflicted, chain.depth::int
Matt W185 FROM chain
Matt W186 JOIN changes c ON c.id = chain.id
Matt W187 WHERE c.repo_id = $2
Matt W188 ORDER BY chain.depth DESC
Matt W189 "#,
Matt W190 )
Matt W191 .bind(change.id)
Matt W192 .bind(ctx.repo.id)
Matt W193 .fetch_all(&state.db)
Matt W194 .await?;
Matt W195
Matt W196 // Depths come back relative to this change, which can make them negative.
Matt W197 // Shift so the bottom of the stack sits at zero, because the rail indents
Matt W198 // from there.
Matt W199 let floor = chain.iter().map(|(_, _, _, _, d)| *d).min().unwrap_or(0);
Matt W200
Matt W201 Ok(v::ChangeAside {
Matt W202 reviewers: reviewers
Matt W203 .into_iter()
Matt W204 .map(|(handle, verdict, at_head)| crate::views::change::Reviewer {
Matt W205 handle,
Matt W206 verdict,
Matt W207 at_head,
Matt W208 })
Matt W209 .collect(),
Matt W210 stack: chain
Matt W211 .into_iter()
Matt W212 .map(|(cid, number, st, conflicted, depth)| v::StackNodeMini {
Matt W213 is_current: cid == change.change_id,
Matt W214 change_id: cid,
Matt W215 number,
Matt W216 state: st,
Matt W217 conflicted,
Matt W218 depth: (depth - floor) as usize,
Matt W219 })
Matt W220 .collect(),
Matt W221 })
Matt W222}
Matt W223
Matt W224impl Loaded {
Matt W225 fn head_view<'a>(&'a self, csrf: &'a str, can_comment: bool) -> v::ChangeHead<'a> {
Matt W226 v::ChangeHead {
Matt W227 number: self.change.number,
Matt W228 change_id: &self.change.change_id,
Matt W229 synthetic: self.change.synthetic,
Matt W230 title: &self.change.title,
Matt W231 state: &self.change.state,
Matt W232 conflicted: self.change.conflicted,
Matt W233 target_bookmark: &self.change.target_bookmark,
Matt W234 author: self.author.as_deref(),
Matt W235 author_name: self.author_name.as_deref(),
Matt W236 revision_count: self.revisions.len(),
Matt W237 head_commit: self.head().map(df_store::abbreviate_rev),
Matt W238 created_at: self.change.created_at,
Matt W239 updated_at: self.change.updated_at,
Matt W240 file_count: None,
Matt W241 comment_count: self.comment_count,
Matt W242 can_manage: self.can_manage,
Matt W243 can_comment,
Matt W244 csrf,
Matt W245 }
Matt W246 }
Matt W247}
Matt W248
Matt W249// ─── overview ────────────────────────────────────────────────────────────────
Matt W250
Matt W251#[derive(Deserialize, Default)]
Matt W252pub struct Flash {
Matt W253 pub error: Option<String>,
Matt W254 pub notice: Option<String>,
Matt W255}
Matt W256
Matt W257/// `GET /{owner}/{repo}/changes/{ref}`
Matt W258pub async fn overview(
Matt W259 State(state): State<AppState>,
Matt W260 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W261 Query(flash): Query<Flash>,
Matt W262 CurrentUser(user): CurrentUser,
Matt W263 CsrfToken(csrf): CsrfToken,
Matt W264 Nonce(nonce): Nonce,
Matt W265) -> AppResult<Response> {
Matt W266 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
Matt W267 Ok(l) => l,
Matt W268 Err(res) => return Ok(res),
Matt W269 };
Matt W270
Matt W271 let can_comment = user.is_some() && l.ctx.access.can_comment();
Matt W272 let head = l.head_view(&csrf, can_comment);
Matt W273
Matt W274 let all_comments = load_comments(&state, &l.ctx, l.change.id).await?;
Matt W275 // Top-level and orphaned comments live in the timeline; the rest belong in
Matt W276 // the diff, where the line they are about is (spec §5 step 5).
Matt W277 let (orphaned, rest): (Vec<_>, Vec<_>) = all_comments
Matt W278 .into_iter()
Matt W279 .partition(|c| c.anchor_state == "orphaned");
Matt W280 let top_level: Vec<v::CommentRow> =
Matt W281 rest.into_iter().filter(|c| c.anchor_path.is_none()).collect();
Matt W282
Matt W283 let reviews = load_reviews(&state, &l.ctx, l.change.id, l.head()).await?;
Matt W284 let events = load_events(&state, l.ctx.repo.id, l.change.id).await?;
Matt W285
Matt W286 let viewer_reviewed = match (&user, l.head()) {
Matt W287 (Some(u), Some(_)) => reviews
Matt W288 .iter()
Matt W289 .any(|r| r.reviewer == u.handle && r.is_head),
Matt W290 _ => false,
Matt W291 };
Matt W292
Matt W293 // Cross-references resolve against this repository (spec §8).
Matt W294 let description_html = crate::routes::issue::render(&l.ctx, &l.change.description);
Matt W295
Matt W296 let body = maud::html! {
Matt W297 (v::header(&l.ctx, &head, "overview"))
Matt W298 (v::tab_body(&l.ctx, &l.aside, maud::html! {
Matt W299 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
Matt W300 @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
Matt W301 (v::overview(&l.ctx, &head, v::Overview {
Matt W302 description_html: &description_html,
Matt W303 description_raw: &l.change.description,
Matt W304 comments: &top_level,
Matt W305 orphaned: &orphaned,
Matt W306 reviews: &reviews,
Matt W307 events: &events,
Matt W308 viewer_reviewed,
Matt W309 }))
Matt W310 }))
Matt W311 };
Matt W312
Matt W313 Ok(views::page_with_bar(
Matt W314 Chrome {
Matt W315 title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name),
Matt W316 user: user.as_deref(),
Matt W317 csrf: &csrf,
Matt W318 nonce: &nonce,
Matt W319 },
Matt W320 rv::header(&l.ctx, "changes"),
Matt W321 body,
Matt W322 )
Matt W323 .into_response())
Matt W324}
Matt W325
Matt W326// ─── files ───────────────────────────────────────────────────────────────────
Matt W327
Matt W328#[derive(Deserialize, Default)]
Matt W329pub struct FilesQuery {
Matt W330 pub rev: Option<String>,
Matt W331 pub against: Option<String>,
Matt W332 /// Fold every file. A link rather than a script, so the folded view is a
Matt W333 /// URL and works with scripting off.
Matt W334 pub collapse: Option<String>,
Matt W335}
Matt W336
Matt W337/// `GET /{owner}/{repo}/changes/{ref}/files`
Matt W338///
Matt W339/// `rev` and `against` are revision tokens, and both are validated against the
Matt W340/// revisions *of this change* before reaching the store. That check is what
Matt W341/// stops the query string being used to diff arbitrary objects out of a
Matt W342/// repository the viewer can only partly see.
Matt W343pub async fn files(
Matt W344 State(state): State<AppState>,
Matt W345 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W346 Query(q): Query<FilesQuery>,
Matt W347 CurrentUser(user): CurrentUser,
Matt W348 CsrfToken(csrf): CsrfToken,
Matt W349 Nonce(nonce): Nonce,
Matt W350) -> AppResult<Response> {
Matt W351 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
Matt W352 Ok(l) => l,
Matt W353 Err(res) => return Ok(res),
Matt W354 };
Matt W355
Matt W356 let can_comment = user.is_some() && l.ctx.access.can_comment();
Matt W357 let head = l.head_view(&csrf, can_comment);
Matt W358
Matt W359 let known = |r: &str| l.revisions.iter().any(|(_, rev)| rev == r);
Matt W360
Matt W361 let rev = match q.rev.as_deref() {
Matt W362 Some(r) if known(r) => Some(r.to_owned()),
Matt W363 Some(_) => return Err(AppError::NotFound),
Matt W364 None => l.head().map(str::to_owned),
Matt W365 };
Matt W366 let against = match q.against.as_deref().filter(|s| !s.is_empty()) {
Matt W367 Some(r) if known(r) => Some(r.to_owned()),
Matt W368 Some(_) => return Err(AppError::NotFound),
Matt W369 None => None,
Matt W370 };
Matt W371
Matt W372 let opts = DiffOpts {
Matt W373 context_lines: 3,
Matt W374 max_files: state.config.max_diff_files,
Matt W375 max_lines: state.config.max_diff_lines,
Matt W376 };
Matt W377
Matt W378 let diff = match (&rev, &against) {
Matt W379 (Some(r), Some(a)) => state
Matt W380 .store
Matt W381 .diff(
Matt W382 l.ctx.store_id(),
Matt W383 &RevId::from_stored(a.clone()),
Matt W384 &RevId::from_stored(r.clone()),
Matt W385 opts,
Matt W386 )
Matt W387 .await
Matt W388 .ok(),
Matt W389 (Some(r), None) => state
Matt W390 .store
Matt W391 .diff_from_parent(l.ctx.store_id(), &RevId::from_stored(r.clone()), opts)
Matt W392 .await
Matt W393 .ok(),
Matt W394 _ => None,
Matt W395 };
Matt W396
Matt W397 // Only inline comments that still have a home in the diff.
Matt W398 let comments: Vec<v::CommentRow> = load_comments(&state, &l.ctx, l.change.id)
Matt W399 .await?
Matt W400 .into_iter()
Matt W401 .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned")
Matt W402 .collect();
Matt W403
Matt W404 // The tab strip can only count files once the diff has been computed.
Matt W405 let head = v::ChangeHead { file_count: diff.as_ref().map(|d| d.files.len()), ..head };
Matt W406
Matt W407 let body = maud::html! {
Matt W408 (v::header(&l.ctx, &head, "files"))
Matt W409 (v::tab_body(&l.ctx, &l.aside, v::files(&l.ctx, &head, v::FilesView {
Matt W410 diff: diff.as_ref(),
Matt W411 comments: &comments,
Matt W412 rev: rev.as_deref().unwrap_or(""),
Matt W413 against: against.as_deref(),
Matt W414 revisions: &l.revisions,
Matt W415 collapsed: q.collapse.is_some(),
Matt W416 })))
Matt W417 };
Matt W418
Matt W419 Ok(views::page_with_bar(
Matt W420 Chrome {
Matt W421 title: &format!("Files · {}", l.change.title),
Matt W422 user: user.as_deref(),
Matt W423 csrf: &csrf,
Matt W424 nonce: &nonce,
Matt W425 },
Matt W426 rv::header(&l.ctx, "changes"),
Matt W427 body,
Matt W428 )
Matt W429 .into_response())
Matt W430}
Matt W431
Matt W432// ─── revisions ───────────────────────────────────────────────────────────────
Matt W433
Matt W434/// Which two revisions the interdiff compares.
Matt W435#[derive(Deserialize, Default)]
Matt W436pub struct CompareQuery {
Matt W437 pub a: Option<i32>,
Matt W438 pub b: Option<i32>,
Matt W439}
Matt W440
Matt W441/// `GET /{owner}/{repo}/changes/{ref}/revisions`
Matt W442pub async fn revisions(
Matt W443 State(state): State<AppState>,
Matt W444 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W445 Query(q): Query<CompareQuery>,
Matt W446 CurrentUser(user): CurrentUser,
Matt W447 CsrfToken(csrf): CsrfToken,
Matt W448 Nonce(nonce): Nonce,
Matt W449) -> AppResult<Response> {
Matt W450 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
Matt W451 Ok(l) => l,
Matt W452 Err(res) => return Ok(res),
Matt W453 };
Matt W454 let head = l.head_view(&csrf, false);
Matt W455
Matt W456 /// `(seq, rev, message, author_name, pushed_at, conflicted, pushed_by,
Matt W457 /// parents)`
Matt W458 type RevRow = (
Matt W459 i32,
Matt W460 String,
Matt W461 String,
Matt W462 String,
Matt W463 chrono::DateTime<chrono::Utc>,
Matt W464 bool,
Matt W465 Option<String>,
Matt W466 Vec<String>,
Matt W467 );
Matt W468
Matt W469 let rows: Vec<RevRow> = sqlx::query_as(
Matt W470 "SELECT r.seq, r.rev, r.message, r.author_name, r.pushed_at, r.conflicted,
Matt W471 u.handle::text, r.parents
Matt W472 FROM revisions r
Matt W473 LEFT JOIN users u ON u.id = r.pushed_by
Matt W474 WHERE r.change_id_fk = $1 ORDER BY r.seq",
Matt W475 )
Matt W476 .bind(l.change.id)
Matt W477 .fetch_all(&state.db)
Matt W478 .await?;
Matt W479
Matt W480 // One repository open for every revision's diffstat, not one per row.
Matt W481 let all: Vec<RevId> = rows
Matt W482 .iter()
Matt W483 .map(|r| RevId::from_stored(r.1.clone()))
Matt W484 .collect();
Matt W485 let stats = state
Matt W486 .store
Matt W487 .diff_stats(l.ctx.store_id(), &all)
Matt W488 .await
Matt W489 .unwrap_or_default();
Matt W490
Matt W491 let revs: Vec<v::RevisionDetail> = rows
Matt W492 .into_iter()
Matt W493 .enumerate()
Matt W494 .map(
Matt W495 |(i, (seq, rev, message, author_name, pushed_at, conflicted, pushed_by, parents))| {
Matt W496 v::RevisionDetail {
Matt W497 seq,
Matt W498 rev,
Matt W499 message,
Matt W500 author_name,
Matt W501 pushed_at,
Matt W502 conflicted,
Matt W503 pushed_by,
Matt W504 diffstat: stats.get(i).copied().flatten(),
Matt W505 base: parents.first().map(|p| df_store::abbreviate_rev(p).to_owned()),
Matt W506 }
Matt W507 },
Matt W508 )
Matt W509 .collect();
Matt W510
Matt W511 // Defaults: the previous revision against the head, which is the comparison
Matt W512 // a returning reviewer wants. Out-of-range values are clamped rather than
Matt W513 // rejected — a stale link from before a revision was removed should still
Matt W514 // land on something sensible.
Matt W515 let last = revs.last().map(|r| r.seq).unwrap_or(1);
Matt W516 let first = revs.first().map(|r| r.seq).unwrap_or(1);
Matt W517 let clamp = |n: i32| n.clamp(first, last);
Matt W518 let b = clamp(q.b.unwrap_or(last));
Matt W519 let a = clamp(q.a.unwrap_or((b - 1).max(first)));
Matt W520
Matt W521 // The interdiff itself: two revisions of the same change, diffed against
Matt W522 // each other. This is the view a force-push destroys on a branch-based
Matt W523 // forge, and the reason revisions are stored rather than derived.
Matt W524 let diff = match (
Matt W525 revs.iter().find(|r| r.seq == a),
Matt W526 revs.iter().find(|r| r.seq == b),
Matt W527 ) {
Matt W528 (Some(ra), Some(rb)) if a != b => state
Matt W529 .store
Matt W530 .diff(
Matt W531 l.ctx.store_id(),
Matt W532 &RevId::from_stored(ra.rev.clone()),
Matt W533 &RevId::from_stored(rb.rev.clone()),
Matt W534 df_store::DiffOpts::default(),
Matt W535 )
Matt W536 .await
Matt W537 .ok(),
Matt W538 _ => None,
Matt W539 };
Matt W540
Matt W541 let body = maud::html! {
Matt W542 (v::header(&l.ctx, &head, "revisions"))
Matt W543 (v::tab_body(&l.ctx, &l.aside,
Matt W544 v::revisions(&l.ctx, &head, &revs, v::Compare { a, b, diff: diff.as_ref() })))
Matt W545 };
Matt W546
Matt W547 Ok(views::page_with_bar(
Matt W548 Chrome {
Matt W549 title: &format!("Revisions · {}", l.change.title),
Matt W550 user: user.as_deref(),
Matt W551 csrf: &csrf,
Matt W552 nonce: &nonce,
Matt W553 },
Matt W554 rv::header(&l.ctx, "changes"),
Matt W555 body,
Matt W556 )
Matt W557 .into_response())
Matt W558}
Matt W559
Matt W560/// `GET /{owner}/{repo}/changes/{ref}/checks`
Matt W561///
Matt W562/// Renders an honest empty state — see [`views::review::checks`].
Matt W563pub async fn checks(
Matt W564 State(state): State<AppState>,
Matt W565 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W566 CurrentUser(user): CurrentUser,
Matt W567 CsrfToken(csrf): CsrfToken,
Matt W568 Nonce(nonce): Nonce,
Matt W569) -> AppResult<Response> {
Matt W570 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
Matt W571 Ok(l) => l,
Matt W572 Err(res) => return Ok(res),
Matt W573 };
Matt W574 let head = l.head_view(&csrf, false);
Matt W575
Matt W576 let body = maud::html! {
Matt W577 (v::header(&l.ctx, &head, "checks"))
Matt W578 (v::tab_body(&l.ctx, &l.aside, v::checks(&l.ctx, &head)))
Matt W579 };
Matt W580
Matt W581 Ok(views::page_with_bar(
Matt W582 Chrome {
Matt W583 title: &format!("Checks · {}", l.change.title),
Matt W584 user: user.as_deref(),
Matt W585 csrf: &csrf,
Matt W586 nonce: &nonce,
Matt W587 },
Matt W588 rv::header(&l.ctx, "changes"),
Matt W589 body,
Matt W590 )
Matt W591 .into_response())
Matt W592}
Matt W593
Matt W594// ─── conflicts ───────────────────────────────────────────────────────────────
Matt W595
Matt W596/// `GET /{owner}/{repo}/changes/{ref}/conflicts`
Matt W597pub async fn conflicts(
Matt W598 State(state): State<AppState>,
Matt W599 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W600 CurrentUser(user): CurrentUser,
Matt W601 CsrfToken(csrf): CsrfToken,
Matt W602 Nonce(nonce): Nonce,
Matt W603) -> AppResult<Response> {
Matt W604 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
Matt W605 Ok(l) => l,
Matt W606 Err(res) => return Ok(res),
Matt W607 };
Matt W608 let head = l.head_view(&csrf, false);
Matt W609
Matt W610 let files = match l.head() {
Matt W611 Some(rev) => state
Matt W612 .store
Matt W613 .conflicts(l.ctx.store_id(), &RevId::from_stored(rev.to_owned()))
Matt W614 .await
Matt W615 .unwrap_or_else(|e| {
Matt W616 tracing::warn!(change = %l.change.id, "reading conflicts failed: {e}");
Matt W617 Vec::new()
Matt W618 }),
Matt W619 None => Vec::new(),
Matt W620 };
Matt W621
Matt W622 let body = maud::html! {
Matt W623 (v::header(&l.ctx, &head, "conflicts"))
Matt W624 (v::tab_body(&l.ctx, &l.aside, v::conflicts(&l.ctx, &head, &files)))
Matt W625 };
Matt W626
Matt W627 Ok(views::page_with_bar(
Matt W628 Chrome {
Matt W629 title: &format!("Conflicts · {}", l.change.title),
Matt W630 user: user.as_deref(),
Matt W631 csrf: &csrf,
Matt W632 nonce: &nonce,
Matt W633 },
Matt W634 rv::header(&l.ctx, "changes"),
Matt W635 body,
Matt W636 )
Matt W637 .into_response())
Matt W638}
Matt W639
Matt W640// ─── comments ────────────────────────────────────────────────────────────────
Matt W641
Matt W642#[derive(Deserialize)]
Matt W643pub struct NewComment {
Matt W644 pub body: String,
Matt W645 /// Present for inline comments.
Matt W646 pub path: Option<String>,
Matt W647 pub line: Option<i32>,
Matt W648 pub side: Option<String>,
Matt W649 pub rev: Option<String>,
Matt W650 pub context: Option<String>,
Matt W651}
Matt W652
Matt W653/// `POST /{owner}/{repo}/changes/{ref}/comments`
Matt W654pub async fn create_comment(
Matt W655 State(state): State<AppState>,
Matt W656 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W657 CurrentUser(user): CurrentUser,
Matt W658 CsrfToken(csrf): CsrfToken,
Matt W659 Form(form): Form<NewComment>,
Matt W660) -> AppResult<Response> {
Matt W661 let Some(user) = user else {
Matt W662 return Err(AppError::Unauthorized);
Matt W663 };
Matt W664 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
Matt W665 Ok(l) => l,
Matt W666 Err(_) => return Err(AppError::NotFound),
Matt W667 };
Matt W668
Matt W669 if !l.ctx.access.can_comment() {
Matt W670 return Err(AppError::Forbidden);
Matt W671 }
Matt W672
Matt W673 let body = form.body.trim();
Matt W674 if body.is_empty() {
Matt W675 return Ok(back(&l, "Comments cannot be empty."));
Matt W676 }
Matt W677 // A comment long enough to be a denial-of-service is not a comment.
Matt W678 if body.len() > 64 * 1024 {
Matt W679 return Ok(back(&l, "That comment is too long."));
Matt W680 }
Matt W681
Matt W682 // The anchor revision is resolved to a row id, and only from revisions that
Matt W683 // belong to this change — a forged `rev` cannot attach a comment to somebody
Matt W684 // else's change.
Matt W685 let anchor_revision: Option<Uuid> = match form.rev.as_deref() {
Matt W686 Some(rev) => sqlx::query_scalar(
Matt W687 "SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2",
Matt W688 )
Matt W689 .bind(l.change.id)
Matt W690 .bind(rev)
Matt W691 .fetch_optional(&state.db)
Matt W692 .await?,
Matt W693 None => None,
Matt W694 };
Matt W695
Matt W696 let is_inline = form.path.is_some() && form.line.is_some();
Matt W697 let side = form
Matt W698 .side
Matt W699 .as_deref()
Matt W700 .filter(|s| *s == "old" || *s == "new")
Matt W701 .unwrap_or("new");
Matt W702
Matt W703 sqlx::query(
Matt W704 "INSERT INTO comments (id, repo_id, change_id_fk, author_user_id, body,
Matt W705 anchor_revision, anchor_path, anchor_line, anchor_side,
Matt W706 anchor_context)
Matt W707 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
Matt W708 )
Matt W709 .bind(new_id())
Matt W710 .bind(l.ctx.repo.id)
Matt W711 .bind(l.change.id)
Matt W712 .bind(user.id)
Matt W713 .bind(body)
Matt W714 .bind(anchor_revision)
Matt W715 .bind(is_inline.then(|| form.path.clone()).flatten())
Matt W716 .bind(is_inline.then_some(form.line).flatten())
Matt W717 .bind(is_inline.then_some(side))
Matt W718 // The line's text at the time of writing. This is what anchor rebasing
Matt W719 // matches on, and what an outdated comment still shows (spec §5).
Matt W720 .bind(is_inline.then(|| form.context.clone()).flatten())
Matt W721 .execute(&state.db)
Matt W722 .await?;
Matt W723
Matt W724 crate::routes::issue::record_references(
Matt W725 &state,
Matt W726 l.ctx.repo.id,
Matt W727 "change",
Matt W728 l.change.id,
Matt W729 body,
Matt W730 )
Matt W731 .await;
Matt W732
Matt W733 event(
Matt W734 &state,
Matt W735 l.ctx.repo.id,
Matt W736 Some(user.id),
Matt W737 "change.commented",
Matt W738 l.change.id,
Matt W739 serde_json::json!({}),
Matt W740 )
Matt W741 .await;
Matt W742
Matt W743 touch(&state, l.change.id).await;
Matt W744
Matt W745 // Back to where the comment was written: the diff for an inline comment,
Matt W746 // the overview for a top-level one.
Matt W747 Ok(if is_inline {
Matt W748 Redirect::to(&format!(
Matt W749 "{}/changes/{}/files{}",
Matt W750 l.ctx.base(),
Matt W751 l.change.number,
Matt W752 form.rev
Matt W753 .as_deref()
Matt W754 .map(|r| format!("?rev={r}"))
Matt W755 .unwrap_or_default()
Matt W756 ))
Matt W757 .into_response()
Matt W758 } else {
Matt W759 Redirect::to(&format!("{}/changes/{}", l.ctx.base(), l.change.number)).into_response()
Matt W760 })
Matt W761}
Matt W762
Matt W763/// `POST /{owner}/{repo}/changes/{ref}/comments/{id}/resolve`
Matt W764pub async fn resolve_comment(
Matt W765 State(state): State<AppState>,
Matt W766 UrlPath((owner, name, reference, comment_id)): UrlPath<(String, String, String, Uuid)>,
Matt W767 CurrentUser(user): CurrentUser,
Matt W768 CsrfToken(csrf): CsrfToken,
Matt W769) -> AppResult<Response> {
Matt W770 let Some(user) = user else {
Matt W771 return Err(AppError::Unauthorized);
Matt W772 };
Matt W773 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
Matt W774 Ok(l) => l,
Matt W775 Err(_) => return Err(AppError::NotFound),
Matt W776 };
Matt W777 if !l.can_manage {
Matt W778 return Err(AppError::Forbidden);
Matt W779 }
Matt W780
Matt W781 // Scoped to this change, so a comment id from another repository does
Matt W782 // nothing here.
Matt W783 sqlx::query(
Matt W784 "UPDATE comments SET resolved_at = now(), resolved_by = $3
Matt W785 WHERE id = $1 AND change_id_fk = $2 AND resolved_at IS NULL",
Matt W786 )
Matt W787 .bind(comment_id)
Matt W788 .bind(l.change.id)
Matt W789 .bind(user.id)
Matt W790 .execute(&state.db)
Matt W791 .await?;
Matt W792
Matt W793 Ok(back(&l, ""))
Matt W794}
Matt W795
Matt W796// ─── reviews ─────────────────────────────────────────────────────────────────
Matt W797
Matt W798#[derive(Deserialize)]
Matt W799pub struct NewReview {
Matt W800 pub verdict: String,
Matt W801 pub body: Option<String>,
Matt W802}
Matt W803
Matt W804/// `POST /{owner}/{repo}/changes/{ref}/reviews`
Matt W805pub async fn create_review(
Matt W806 State(state): State<AppState>,
Matt W807 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W808 CurrentUser(user): CurrentUser,
Matt W809 CsrfToken(csrf): CsrfToken,
Matt W810 Form(form): Form<NewReview>,
Matt W811) -> AppResult<Response> {
Matt W812 let Some(user) = user else {
Matt W813 return Err(AppError::Unauthorized);
Matt W814 };
Matt W815 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
Matt W816 Ok(l) => l,
Matt W817 Err(_) => return Err(AppError::NotFound),
Matt W818 };
Matt W819 if !l.ctx.access.can_comment() {
Matt W820 return Err(AppError::Forbidden);
Matt W821 }
Matt W822
Matt W823 if !matches!(form.verdict.as_str(), "approve" | "request_changes" | "comment") {
Matt W824 return Ok(back(&l, "Unknown verdict."));
Matt W825 }
Matt W826
Matt W827 // A review is *of a revision*, not of a change. That is what makes an
Matt W828 // approval go stale when the author pushes again, and it is the whole point
Matt W829 // of indexing revisions separately.
Matt W830 let Some(head) = l.head() else {
Matt W831 return Ok(back(&l, "This change has no revisions to review."));
Matt W832 };
Matt W833 let revision_id: Uuid =
Matt W834 sqlx::query_scalar("SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2")
Matt W835 .bind(l.change.id)
Matt W836 .bind(head)
Matt W837 .fetch_one(&state.db)
Matt W838 .await?;
Matt W839
Matt W840 sqlx::query(
Matt W841 "INSERT INTO reviews (id, change_id_fk, revision_id, reviewer_id, verdict, body)
Matt W842 VALUES ($1, $2, $3, $4, $5::review_verdict, $6)",
Matt W843 )
Matt W844 .bind(new_id())
Matt W845 .bind(l.change.id)
Matt W846 .bind(revision_id)
Matt W847 .bind(user.id)
Matt W848 .bind(&form.verdict)
Matt W849 .bind(form.body.as_deref().map(str::trim).filter(|s| !s.is_empty()))
Matt W850 .execute(&state.db)
Matt W851 .await?;
Matt W852
Matt W853 event(
Matt W854 &state,
Matt W855 l.ctx.repo.id,
Matt W856 Some(user.id),
Matt W857 "change.reviewed",
Matt W858 l.change.id,
Matt W859 serde_json::json!({ "verdict": form.verdict }),
Matt W860 )
Matt W861 .await;
Matt W862 touch(&state, l.change.id).await;
Matt W863
Matt W864 Ok(back(&l, ""))
Matt W865}
Matt W866
Matt W867// ─── state and editing ───────────────────────────────────────────────────────
Matt W868
Matt W869#[derive(Deserialize)]
Matt W870pub struct EditChange {
Matt W871 pub title: String,
Matt W872 pub description: Option<String>,
Matt W873}
Matt W874
Matt W875/// `POST /{owner}/{repo}/changes/{ref}/edit`
Matt W876pub async fn edit(
Matt W877 State(state): State<AppState>,
Matt W878 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W879 CurrentUser(user): CurrentUser,
Matt W880 CsrfToken(csrf): CsrfToken,
Matt W881 Form(form): Form<EditChange>,
Matt W882) -> AppResult<Response> {
Matt W883 let Some(user) = user else {
Matt W884 return Err(AppError::Unauthorized);
Matt W885 };
Matt W886 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
Matt W887 Ok(l) => l,
Matt W888 Err(_) => return Err(AppError::NotFound),
Matt W889 };
Matt W890 if !l.can_manage {
Matt W891 return Err(AppError::Forbidden);
Matt W892 }
Matt W893
Matt W894 let title: String = form.title.trim().chars().take(300).collect();
Matt W895 if title.is_empty() {
Matt W896 return Ok(back(&l, "A change needs a title."));
Matt W897 }
Matt W898
Matt W899 let description = form.description.as_deref().unwrap_or("").trim();
Matt W900
Matt W901 sqlx::query(
Matt W902 "UPDATE changes SET title = $2, description = $3, updated_at = now() WHERE id = $1",
Matt W903 )
Matt W904 .bind(l.change.id)
Matt W905 .bind(&title)
Matt W906 .bind(description)
Matt W907 .execute(&state.db)
Matt W908 .await?;
Matt W909
Matt W910 // `#123` in a change description makes the issue show the change under
Matt W911 // "Referenced by". Recorded on write rather than scanned on read.
Matt W912 crate::routes::issue::record_references(
Matt W913 &state,
Matt W914 l.ctx.repo.id,
Matt W915 "change",
Matt W916 l.change.id,
Matt W917 description,
Matt W918 )
Matt W919 .await;
Matt W920
Matt W921 Ok(back(&l, ""))
Matt W922}
Matt W923
Matt W924#[derive(Deserialize)]
Matt W925pub struct SetState {
Matt W926 pub state: String,
Matt W927}
Matt W928
Matt W929/// `POST /{owner}/{repo}/changes/{ref}/state`
Matt W930///
Matt W931/// Draft, abandon, and reopen. These are the states the *author* owns; the
Matt W932/// indexer computes merged and must never overwrite them (decided, and pinned
Matt W933/// by `indexer::next_state`).
Matt W934pub async fn set_state(
Matt W935 State(state): State<AppState>,
Matt W936 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W937 CurrentUser(user): CurrentUser,
Matt W938 CsrfToken(csrf): CsrfToken,
Matt W939 Form(form): Form<SetState>,
Matt W940) -> AppResult<Response> {
Matt W941 let Some(user) = user else {
Matt W942 return Err(AppError::Unauthorized);
Matt W943 };
Matt W944 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
Matt W945 Ok(l) => l,
Matt W946 Err(_) => return Err(AppError::NotFound),
Matt W947 };
Matt W948 if !l.can_manage {
Matt W949 return Err(AppError::Forbidden);
Matt W950 }
Matt W951
Matt W952 // `merged` is not settable here: it is a fact about the target bookmark, and
Matt W953 // letting the UI assert it would make the state lie about the repository.
Matt W954 let next = match form.state.as_str() {
Matt W955 "draft" => "draft",
Matt W956 "open" => "open",
Matt W957 "abandoned" => "abandoned",
Matt W958 _ => return Ok(back(&l, "Unknown state.")),
Matt W959 };
Matt W960
Matt W961 sqlx::query("UPDATE changes SET state = $2::change_state, updated_at = now() WHERE id = $1")
Matt W962 .bind(l.change.id)
Matt W963 .bind(next)
Matt W964 .execute(&state.db)
Matt W965 .await?;
Matt W966
Matt W967 let kind = match (l.change.state.as_str(), next) {
Matt W968 (_, "draft") => "change.drafted",
Matt W969 (_, "abandoned") => "change.abandoned",
Matt W970 ("draft", "open") => "change.ready",
Matt W971 (_, "open") => "change.reopened",
Matt W972 _ => "change.updated",
Matt W973 };
Matt W974 event(&state, l.ctx.repo.id, Some(user.id), kind, l.change.id, serde_json::json!({})).await;
Matt W975
Matt W976 Ok(back(&l, ""))
Matt W977}
Matt W978
Matt W979// ─── stacks ──────────────────────────────────────────────────────────────────
Matt W980
Matt W981/// `GET /{owner}/{repo}/stacks/{change_id}`
Matt W982pub async fn stack(
Matt W983 State(state): State<AppState>,
Matt W984 UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>,
Matt W985 CurrentUser(user): CurrentUser,
Matt W986 CsrfToken(csrf): CsrfToken,
Matt W987 Nonce(nonce): Nonce,
Matt W988) -> AppResult<Response> {
Matt W989 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W990
Matt W991 let nodes = load_stack(&state, ctx.repo.id, &change_id).await?;
Matt W992 if nodes.is_empty() {
Matt W993 return Err(AppError::NotFound);
Matt W994 }
Matt W995
Matt W996 // Every change in a stack targets the same bookmark, so the bottom one
Matt W997 // names what the whole chain lands on.
Matt W998 let target: Option<String> = match nodes.first() {
Matt W999 Some(bottom) => sqlx::query_scalar(
Matt W1000 "SELECT target_bookmark FROM changes WHERE repo_id = $1 AND number = $2",
Matt W1001 )
Matt W1002 .bind(ctx.repo.id)
Matt W1003 .bind(bottom.number)
Matt W1004 .fetch_optional(&state.db)
Matt W1005 .await?,
Matt W1006 None => None,
Matt W1007 };
Matt W1008
Matt W1009 let body = maud::html! {
Matt W1010 (v::stack(
Matt W1011 &ctx,
Matt W1012 &nodes,
Matt W1013 &change_id,
Matt W1014 target.as_deref(),
Matt W1015 &csrf,
Matt W1016 ctx.access.can_manage_changes(),
Matt W1017 ))
Matt W1018 };
Matt W1019
Matt W1020 Ok(views::page_with_bar(
Matt W1021 Chrome {
Matt W1022 title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name),
Matt W1023 user: user.as_deref(),
Matt W1024 csrf: &csrf,
Matt W1025 nonce: &nonce,
Matt W1026 },
Matt W1027 rv::header(&ctx, "changes"),
Matt W1028 body,
Matt W1029 )
Matt W1030 .into_response())
Matt W1031}
Matt W1032
Matt W1033/// Walk `change_edges` down to the bottom of the stack and back up.
Matt W1034///
Matt W1035/// Reads the precomputed edges rather than the commit graph — spec §4: "do not
Matt W1036/// recompute the graph on page render".
Matt W1037async fn load_stack(
Matt W1038 state: &AppState,
Matt W1039 repo_id: Uuid,
Matt W1040 change_id: &str,
Matt W1041) -> AppResult<Vec<v::StackNode>> {
Matt W1042 // A recursive CTE in both directions from the requested change. Depth is
Matt W1043 // bounded so a cycle in the edge table — which should be impossible, but the
Matt W1044 // page must not hang if one ever appears — terminates.
Matt W1045 let rows: Vec<(i64, String, bool, String, String, bool, i32)> = sqlx::query_as(
Matt W1046 r#"
Matt W1047 WITH RECURSIVE start AS (
Matt W1048 SELECT id FROM changes WHERE repo_id = $1 AND change_id = $2
Matt W1049 ),
Matt W1050 down AS (
Matt W1051 SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id
Matt W1052 UNION ALL
Matt W1053 SELECT e.parent_change, d.depth - 1
Matt W1054 FROM change_edges e JOIN down d ON d.id = e.child_change
Matt W1055 WHERE e.repo_id = $1 AND d.depth > -50
Matt W1056 ),
Matt W1057 up AS (
Matt W1058 SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id
Matt W1059 UNION ALL
Matt W1060 SELECT e.child_change, u.depth + 1
Matt W1061 FROM change_edges e JOIN up u ON u.id = e.parent_change
Matt W1062 WHERE e.repo_id = $1 AND u.depth < 50
Matt W1063 ),
Matt W1064 all_nodes AS (
Matt W1065 SELECT id, min(depth) AS depth FROM (
Matt W1066 SELECT id, depth FROM down UNION ALL SELECT id, depth FROM up
Matt W1067 ) x GROUP BY id
Matt W1068 )
Matt W1069 SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, c.conflicted,
Matt W1070 (n.depth - (SELECT min(depth) FROM all_nodes))::int AS rel_depth
Matt W1071 FROM all_nodes n JOIN changes c ON c.id = n.id
Matt W1072 ORDER BY n.depth
Matt W1073 "#,
Matt W1074 )
Matt W1075 .bind(repo_id)
Matt W1076 .bind(change_id)
Matt W1077 .fetch_all(&state.db)
Matt W1078 .await?;
Matt W1079
Matt W1080 Ok(rows
Matt W1081 .into_iter()
Matt W1082 .map(
Matt W1083 |(number, cid, synthetic, title, st, conflicted, depth)| v::StackNode {
Matt W1084 is_current: cid == change_id,
Matt W1085 number,
Matt W1086 change_id: cid,
Matt W1087 synthetic,
Matt W1088 title,
Matt W1089 state: st,
Matt W1090 conflicted,
Matt W1091 depth: depth.max(0) as usize,
Matt W1092 },
Matt W1093 )
Matt W1094 .collect())
Matt W1095}
Matt W1096
Matt W1097// ─── loading helpers ─────────────────────────────────────────────────────────
Matt W1098
Matt W1099async fn load_comments(
Matt W1100 state: &AppState,
Matt W1101 ctx: &RepoContext,
Matt W1102 change_id: Uuid,
Matt W1103) -> AppResult<Vec<v::CommentRow>> {
Matt W1104 type Row = (
Matt W1105 Uuid,
Matt W1106 String,
Matt W1107 String,
Matt W1108 chrono::DateTime<chrono::Utc>,
Matt W1109 Option<chrono::DateTime<chrono::Utc>>,
Matt W1110 Option<String>,
Matt W1111 Option<i32>,
Matt W1112 Option<String>,
Matt W1113 String,
Matt W1114 Option<String>,
Matt W1115 Option<chrono::DateTime<chrono::Utc>>,
Matt W1116 );
Matt W1117
Matt W1118 let rows: Vec<Row> = sqlx::query_as(
Matt W1119 "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at,
Matt W1120 c.anchor_path, c.anchor_line, c.anchor_side, c.anchor_state::text,
Matt W1121 c.anchor_context, c.resolved_at
Matt W1122 FROM comments c JOIN users u ON u.id = c.author_user_id
Matt W1123 WHERE c.change_id_fk = $1
Matt W1124 ORDER BY c.created_at",
Matt W1125 )
Matt W1126 .bind(change_id)
Matt W1127 .fetch_all(&state.db)
Matt W1128 .await?;
Matt W1129
Matt W1130 Ok(rows
Matt W1131 .into_iter()
Matt W1132 .map(|r| v::CommentRow {
Matt W1133 id: r.0,
Matt W1134 author: r.1,
Matt W1135 // Comment bodies are user input rendered on our origin, so they go
Matt W1136 // through the same sanitiser a README does, and then get their
Matt W1137 // cross-references resolved.
Matt W1138 body_html: crate::routes::issue::render(ctx, &r.2),
Matt W1139 created_at: r.3,
Matt W1140 edited: r.4.is_some(),
Matt W1141 anchor_path: r.5,
Matt W1142 anchor_line: r.6,
Matt W1143 anchor_side: r.7,
Matt W1144 anchor_state: r.8,
Matt W1145 anchor_context: r.9,
Matt W1146 resolved: r.10.is_some(),
Matt W1147 })
Matt W1148 .collect())
Matt W1149}
Matt W1150
Matt W1151async fn load_reviews(
Matt W1152 state: &AppState,
Matt W1153 ctx: &RepoContext,
Matt W1154 change_id: Uuid,
Matt W1155 head: Option<&str>,
Matt W1156) -> AppResult<Vec<v::ReviewRow>> {
Matt W1157 let rows: Vec<(String, String, Option<String>, chrono::DateTime<chrono::Utc>, String)> =
Matt W1158 sqlx::query_as(
Matt W1159 "SELECT u.handle::text, r.verdict::text, r.body, r.created_at, rev.rev
Matt W1160 FROM reviews r
Matt W1161 JOIN users u ON u.id = r.reviewer_id
Matt W1162 JOIN revisions rev ON rev.id = r.revision_id
Matt W1163 WHERE r.change_id_fk = $1
Matt W1164 ORDER BY r.created_at DESC",
Matt W1165 )
Matt W1166 .bind(change_id)
Matt W1167 .fetch_all(&state.db)
Matt W1168 .await?;
Matt W1169
Matt W1170 Ok(rows
Matt W1171 .into_iter()
Matt W1172 .map(|(reviewer, verdict, body, created_at, rev)| v::ReviewRow {
Matt W1173 is_head: head == Some(rev.as_str()),
Matt W1174 rev: df_store::abbreviate_rev(&rev).to_owned(),
Matt W1175 reviewer,
Matt W1176 verdict,
Matt W1177 body_html: body.map(|b| crate::routes::issue::render(ctx, &b)).unwrap_or_default(),
Matt W1178 created_at,
Matt W1179 })
Matt W1180 .collect())
Matt W1181}
Matt W1182
Matt W1183async fn load_events(
Matt W1184 state: &AppState,
Matt W1185 repo_id: Uuid,
Matt W1186 change_id: Uuid,
Matt W1187) -> AppResult<Vec<v::EventRow>> {
Matt W1188 let rows: Vec<(String, Option<String>, chrono::DateTime<chrono::Utc>, serde_json::Value)> =
Matt W1189 sqlx::query_as(
Matt W1190 "SELECT e.kind, u.handle::text, e.created_at, e.payload
Matt W1191 FROM events e LEFT JOIN users u ON u.id = e.actor_id
Matt W1192 WHERE e.repo_id = $1 AND e.subject_type = 'change' AND e.subject_id = $2
Matt W1193 -- `change.commented` duplicates the comment itself in the
Matt W1194 -- timeline; the comment is the better rendering of it.
Matt W1195 AND e.kind <> 'change.commented'
Matt W1196 ORDER BY e.created_at
Matt W1197 LIMIT 200",
Matt W1198 )
Matt W1199 .bind(repo_id)
Matt W1200 .bind(change_id)
Matt W1201 .fetch_all(&state.db)
Matt W1202 .await?;
Matt W1203
Matt W1204 Ok(rows
Matt W1205 .into_iter()
Matt W1206 .map(|(kind, actor, created_at, payload)| v::EventRow {
Matt W1207 kind,
Matt W1208 actor,
Matt W1209 created_at,
Matt W1210 payload,
Matt W1211 })
Matt W1212 .collect())
Matt W1213}
Matt W1214
Matt W1215// ─── small helpers ───────────────────────────────────────────────────────────
Matt W1216
Matt W1217/// Record a timeline event. Best-effort — losing one must not fail the action
Matt W1218/// that caused it, but it is logged loudly because a gap in the timeline is not
Matt W1219/// something a reader can detect.
Matt W1220pub async fn event(
Matt W1221 state: &AppState,
Matt W1222 repo_id: Uuid,
Matt W1223 actor: Option<Uuid>,
Matt W1224 kind: &str,
Matt W1225 subject: Uuid,
Matt W1226 payload: serde_json::Value,
Matt W1227) {
Matt W1228 if let Err(e) = sqlx::query(
Matt W1229 "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id, payload)
Matt W1230 VALUES ($1, $2, $3, $4, 'change', $5, $6)",
Matt W1231 )
Matt W1232 .bind(new_id())
Matt W1233 .bind(repo_id)
Matt W1234 .bind(actor)
Matt W1235 .bind(kind)
Matt W1236 .bind(subject)
Matt W1237 .bind(payload)
Matt W1238 .execute(&state.db)
Matt W1239 .await
Matt W1240 {
Matt W1241 tracing::error!(%kind, "writing a timeline event failed: {e}");
Matt W1242 }
Matt W1243}
Matt W1244
Matt W1245/// Bump `updated_at` so the change list orders by real activity, not just pushes.
Matt W1246async fn touch(state: &AppState, change_id: Uuid) {
Matt W1247 let _ = sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1")
Matt W1248 .bind(change_id)
Matt W1249 .execute(&state.db)
Matt W1250 .await;
Matt W1251}
Matt W1252
Matt W1253fn back(l: &Loaded, error: &str) -> Response {
Matt W1254 let base = format!("{}/changes/{}", l.ctx.base(), l.change.number);
Matt W1255 if error.is_empty() {
Matt W1256 Redirect::to(&base).into_response()
Matt W1257 } else {
Matt W1258 Redirect::to(&format!("{base}?error={}", urlencode(error))).into_response()
Matt W1259 }
Matt W1260}
Matt W1261
Matt W1262// ─── merging (decided §13.2, §13.3) ──────────────────────────────────────────
Matt W1263
Matt W1264/// `POST /{owner}/{repo}/changes/{ref}/merge`
Matt W1265///
Matt W1266/// Server-side fast-forward, falling back to a merge commit (decided §13.2).
Matt W1267/// Refuses on conflict without writing anything.
Matt W1268pub async fn merge(
Matt W1269 State(state): State<AppState>,
Matt W1270 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
Matt W1271 CurrentUser(user): CurrentUser,
Matt W1272 CsrfToken(csrf): CsrfToken,
Matt W1273) -> AppResult<Response> {
Matt W1274 let Some(user) = user else {
Matt W1275 return Err(AppError::Unauthorized);
Matt W1276 };
Matt W1277 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
Matt W1278 Ok(l) => l,
Matt W1279 Err(_) => return Err(AppError::NotFound),
Matt W1280 };
Matt W1281
Matt W1282 // Merging is a maintainer action even for your own change: landing work on
Matt W1283 // a shared bookmark is not the same permission as writing it.
Matt W1284 if !l.ctx.access.can_manage_changes() {
Matt W1285 return Err(AppError::Forbidden);
Matt W1286 }
Matt W1287 if l.ctx.repo.archived {
Matt W1288 return Ok(back(&l, "This repository is archived."));
Matt W1289 }
Matt W1290
Matt W1291 match land(&state, &l.ctx, &l.change, &user).await? {
Matt W1292 Landed::Ok(msg) => {
Matt W1293 enqueue_reindex(&state, l.ctx.repo.id).await;
Matt W1294 Ok(back_notice(&l, &msg))
Matt W1295 }
Matt W1296 Landed::Refused(msg) => Ok(back(&l, &msg)),
Matt W1297 }
Matt W1298}
Matt W1299
Matt W1300/// `POST /{owner}/{repo}/stacks/{change_id}/merge`
Matt W1301///
Matt W1302/// Decided §13.3: "Merge stack" lands the whole chain bottom-up in one action.
Matt W1303///
Matt W1304/// Bottom-up matters: landing the top of a stack first would either fail or
Matt W1305/// pull the changes below it in as an unreviewed side effect. If any change in
Matt W1306/// the chain refuses, the ones already landed stay landed and the rest do not —
Matt W1307/// reported precisely, because pretending it was atomic would be a lie about
Matt W1308/// what is on the bookmark.
Matt W1309pub async fn merge_stack(
Matt W1310 State(state): State<AppState>,
Matt W1311 UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>,
Matt W1312 CurrentUser(user): CurrentUser,
Matt W1313) -> AppResult<Response> {
Matt W1314 let Some(user) = user else {
Matt W1315 return Err(AppError::Unauthorized);
Matt W1316 };
Matt W1317 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W1318 if !ctx.access.can_manage_changes() {
Matt W1319 return Err(AppError::Forbidden);
Matt W1320 }
Matt W1321 if ctx.repo.archived {
Matt W1322 return Err(AppError::BadRequest("This repository is archived.".into()));
Matt W1323 }
Matt W1324
Matt W1325 let nodes = load_stack(&state, ctx.repo.id, &change_id).await?;
Matt W1326 if nodes.is_empty() {
Matt W1327 return Err(AppError::NotFound);
Matt W1328 }
Matt W1329
Matt W1330 // `load_stack` returns bottom-first; that is the order to land in.
Matt W1331 let mut landed = 0usize;
Matt W1332 let mut refusal: Option<String> = None;
Matt W1333
Matt W1334 for node in &nodes {
Matt W1335 let record = match resolve_change(&state, ctx.repo.id, &node.number.to_string()).await? {
Matt W1336 Resolution::One(c) => *c,
Matt W1337 _ => continue,
Matt W1338 };
Matt W1339
Matt W1340 if record.state == "merged" {
Matt W1341 continue;
Matt W1342 }
Matt W1343 if record.state == "abandoned" || record.state == "draft" {
Matt W1344 refusal = Some(format!(
Matt W1345 "#{} is {} — nothing after it was merged.",
Matt W1346 record.number, record.state
Matt W1347 ));
Matt W1348 break;
Matt W1349 }
Matt W1350
Matt W1351 match land(&state, &ctx, &record, &user).await? {
Matt W1352 Landed::Ok(_) => landed += 1,
Matt W1353 Landed::Refused(msg) => {
Matt W1354 refusal = Some(format!("#{}: {msg} Nothing after it was merged.", record.number));
Matt W1355 break;
Matt W1356 }
Matt W1357 }
Matt W1358 }
Matt W1359
Matt W1360 enqueue_reindex(&state, ctx.repo.id).await;
Matt W1361
Matt W1362 let message = match refusal {
Matt W1363 Some(r) => format!("Merged {landed} change(s), then stopped. {r}"),
Matt W1364 None => format!("Merged {landed} change(s)."),
Matt W1365 };
Matt W1366
Matt W1367 Ok(Redirect::to(&format!(
Matt W1368 "{}/stacks/{change_id}?notice={}",
Matt W1369 ctx.base(),
Matt W1370 urlencode(&message)
Matt W1371 ))
Matt W1372 .into_response())
Matt W1373}
Matt W1374
Matt W1375enum Landed {
Matt W1376 Ok(String),
Matt W1377 Refused(String),
Matt W1378}
Matt W1379
Matt W1380/// Land one change onto its target bookmark and record the result.
Matt W1381async fn land(
Matt W1382 state: &AppState,
Matt W1383 ctx: &RepoContext,
Matt W1384 change: &ChangeRecord,
Matt W1385 actor: &df_db::models::User,
Matt W1386) -> AppResult<Landed> {
Matt W1387 if change.conflicted {
Matt W1388 return Ok(Landed::Refused(
Matt W1389 "This change is conflicted; resolve it in your working copy and push again.".into(),
Matt W1390 ));
Matt W1391 }
Matt W1392 if change.state == "merged" {
Matt W1393 return Ok(Landed::Ok("Already merged.".into()));
Matt W1394 }
Matt W1395 if change.state != "open" {
Matt W1396 return Ok(Landed::Refused(format!(
Matt W1397 "A change in state {} cannot be merged.",
Matt W1398 change.state
Matt W1399 )));
Matt W1400 }
Matt W1401
Matt W1402 let head: Option<String> = sqlx::query_scalar(
Matt W1403 "SELECT rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq DESC LIMIT 1",
Matt W1404 )
Matt W1405 .bind(change.id)
Matt W1406 .fetch_optional(&state.db)
Matt W1407 .await?;
Matt W1408
Matt W1409 let Some(head) = head else {
Matt W1410 return Ok(Landed::Refused("This change has no revisions.".into()));
Matt W1411 };
Matt W1412
Matt W1413 // The merge commit is attributed to whoever pressed the button, with their
Matt W1414 // handle rather than their email — the email is an OIDC claim we are told
Matt W1415 // not to key on and should not scatter into commit objects.
Matt W1416 let author = df_store::Signature {
Matt W1417 name: actor.label().to_owned(),
Matt W1418 email: format!("{}@users.noreply.{}", actor.handle, state.config.host()),
Matt W1419 when: chrono::Utc::now(),
Matt W1420 };
Matt W1421
Matt W1422 let message = format!(
Matt W1423 "Merge change #{} into {}\n\n{}\n\nChange-Id: {}\n",
Matt W1424 change.number, change.target_bookmark, change.title, change.change_id
Matt W1425 );
Matt W1426
Matt W1427 let outcome = match state
Matt W1428 .store
Matt W1429 .merge(
Matt W1430 ctx.store_id(),
Matt W1431 &change.target_bookmark,
Matt W1432 &RevId::from_stored(head.clone()),
Matt W1433 &message,
Matt W1434 &author,
Matt W1435 )
Matt W1436 .await
Matt W1437 {
Matt W1438 Ok(o) => o,
Matt W1439 Err(e) => {
Matt W1440 tracing::error!(change = %change.id, "merge failed: {e}");
Matt W1441 return Ok(Landed::Refused(format!("The merge could not be completed: {e}")));
Matt W1442 }
Matt W1443 };
Matt W1444
Matt W1445 let (text, merged) = match outcome {
Matt W1446 df_store::MergeOutcome::Conflicted => {
Matt W1447 return Ok(Landed::Refused(
Matt W1448 "The change conflicts with the target bookmark. Rebase it in your working \
Matt W1449 copy and push again — the server does not resolve conflicts."
Matt W1450 .into(),
Matt W1451 ))
Matt W1452 }
Matt W1453 df_store::MergeOutcome::AlreadyMerged => ("Already on the bookmark.".to_string(), true),
Matt W1454 df_store::MergeOutcome::FastForward { .. } => {
Matt W1455 (format!("Fast-forwarded {}.", change.target_bookmark), true)
Matt W1456 }
Matt W1457 df_store::MergeOutcome::Merged { .. } => {
Matt W1458 (format!("Merged into {}.", change.target_bookmark), true)
Matt W1459 }
Matt W1460 };
Matt W1461
Matt W1462 if merged {
Matt W1463 sqlx::query(
Matt W1464 "UPDATE changes SET state = 'merged', merged_at = COALESCE(merged_at, now()),
Matt W1465 updated_at = now()
Matt W1466 WHERE id = $1",
Matt W1467 )
Matt W1468 .bind(change.id)
Matt W1469 .execute(&state.db)
Matt W1470 .await?;
Matt W1471
Matt W1472 event(
Matt W1473 state,
Matt W1474 ctx.repo.id,
Matt W1475 Some(actor.id),
Matt W1476 "change.merged",
Matt W1477 change.id,
Matt W1478 serde_json::json!({ "bookmark": change.target_bookmark }),
Matt W1479 )
Matt W1480 .await;
Matt W1481 }
Matt W1482
Matt W1483 Ok(Landed::Ok(text))
Matt W1484}
Matt W1485
Matt W1486/// Ask the worker to reindex, so bookmarks and change states catch up with the
Matt W1487/// ref we just moved.
Matt W1488///
Matt W1489/// A merge does not go through the Git wire protocol, so no post-receive hook
Matt W1490/// fires and nothing else would notice.
Matt W1491async fn enqueue_reindex(state: &AppState, repo_id: Uuid) {
Matt W1492 if let Err(e) = sqlx::query(
Matt W1493 "INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)",
Matt W1494 )
Matt W1495 .bind(new_id())
Matt W1496 .bind(serde_json::json!({ "repo_id": repo_id }))
Matt W1497 .execute(&state.db)
Matt W1498 .await
Matt W1499 {
Matt W1500 tracing::error!(%repo_id, "enqueueing reindex after merge failed: {e}");
Matt W1501 }
Matt W1502}
Matt W1503
Matt W1504fn back_notice(l: &Loaded, msg: &str) -> Response {
Matt W1505 Redirect::to(&format!(
Matt W1506 "{}/changes/{}?notice={}",
Matt W1507 l.ctx.base(),
Matt W1508 l.change.number,
Matt W1509 urlencode(msg)
Matt W1510 ))
Matt W1511 .into_response()
Matt W1512}

1512 lines · Rust