Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Change list and detail (M3).
Matt W2
Matt W3use axum::extract::{Path as UrlPath, Query, State};
Matt W4use axum::response::{IntoResponse, Redirect, Response};
Matt W5use axum::Form;
Matt W6use serde::Deserialize;
Matt W7use uuid::Uuid;
Matt W8
Matt W9use crate::error::{AppError, AppResult};
Matt W10use crate::repo_ctx::RepoContext;
Matt W11use crate::revset;
Matt W12use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W13use crate::views::change as v;
Matt W14use crate::views::repo as rv;
Matt W15use crate::views::{self, Chrome};
Matt W16
Matt W17#[derive(Deserialize, Default)]
Matt W18pub struct ListQuery {
Matt W19 pub state: Option<String>,
Matt W20 pub revset: Option<String>,
Matt W21}
Matt W22
Matt W23/// `GET /{owner}/{repo}/changes`
Matt W24pub async fn list(
Matt W25 State(state): State<AppState>,
Matt W26 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W27 Query(q): Query<ListQuery>,
Matt W28 CurrentUser(user): CurrentUser,
Matt W29 CsrfToken(csrf): CsrfToken,
Matt W30 Nonce(nonce): Nonce,
Matt W31) -> AppResult<Response> {
Matt W32 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W33
Matt W34 let state_filter = q.state.as_deref().unwrap_or("open");
Matt W35 let revset_input = q.revset.unwrap_or_default();
Matt W36
Matt W37 // Parse the revset before touching the database. An unsupported expression
Matt W38 // must produce a clear message, never a partial result (spec §8).
Matt W39 let (revset_sql, revset_vals, revset_error) = match revset::parse(&revset_input) {
Matt W40 Ok(Some(expr)) => {
Matt W41 let mut n = 3; // $1 = repo_id, $2 = state filter
Matt W42 let (sql, vals) = revset::to_sql(&expr, &mut n);
Matt W43 (Some(sql), vals, None)
Matt W44 }
Matt W45 Ok(None) => (None, vec![], None),
Matt W46 Err(e) => (None, vec![], Some(e.to_string())),
Matt W47 };
Matt W48
Matt W49 // On a revset error, show the message and no rows rather than silently
Matt W50 // listing everything — which would look like the filter had matched.
Matt W51 let mut rows = if revset_error.is_some() {
Matt W52 Vec::new()
Matt W53 } else {
Matt W54 load_changes(
Matt W55 &state,
Matt W56 ctx.repo.id,
Matt W57 state_filter,
Matt W58 user.as_ref().map(|u| u.id),
Matt W59 revset_sql,
Matt W60 revset_vals,
Matt W61 )
Matt W62 .await?
Matt W63 };
Matt W64
Matt W65 decorate(&state, &ctx, &mut rows).await;
Matt W66 let rows = v::arrange(rows);
Matt W67
Matt W68 let counts = list_counts(&state, ctx.repo.id, user.as_deref()).await?;
Matt W69 let week = week_stats(&state, ctx.repo.id).await?;
Matt W70
Matt W71 let body = maud::html! {
Matt W72 (v::list(&ctx, &rows, v::ListFilters {
Matt W73 state: state_filter,
Matt W74 revset: &revset_input,
Matt W75 revset_error: revset_error.as_deref(),
Matt W76 counts,
Matt W77 signed_in: user.is_some(),
Matt W78 week,
Matt W79 }))
Matt W80 };
Matt W81
Matt W82 Ok(views::page_with_bar(
Matt W83 Chrome {
Matt W84 title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name),
Matt W85 user: user.as_deref(),
Matt W86 csrf: &csrf,
Matt W87 nonce: &nonce,
Matt W88 },
Matt W89 rv::header(&ctx, "changes"),
Matt W90 body,
Matt W91 )
Matt W92 .into_response())
Matt W93}
Matt W94
Matt W95/// The SQL predicate behind each filter tab.
Matt W96///
Matt W97/// "Conflicted" and "Mine" are not values of `changes.state` — they are views
Matt W98/// over it. Keeping the mapping in one place is what stops the tab counts and
Matt W99/// the tab contents from drifting apart: [`list_counts`] uses these same
Matt W100/// fragments to count what each tab will show.
Matt W101///
Matt W102/// `$2` is the viewer's id, which is null for an anonymous request. The `mine`
Matt W103/// clause therefore matches nothing when nobody is signed in, rather than
Matt W104/// matching every change with no author.
Matt W105fn state_predicate(filter: &str) -> &'static str {
Matt W106 match filter {
Matt W107 "conflicted" => "c.state = 'open' AND c.conflicted",
Matt W108 "merged" => "c.state = 'merged'",
Matt W109 "abandoned" => "c.state = 'abandoned'",
Matt W110 "mine" => "c.author_user_id = $2 AND c.state IN ('open', 'draft')",
Matt W111 "all" => "true",
Matt W112 // Anything unrecognised falls back to the default tab rather than to
Matt W113 // "everything": a typo'd query string should not widen a listing.
Matt W114 _ => "c.state = 'open'",
Matt W115 }
Matt W116}
Matt W117
Matt W118async fn load_changes(
Matt W119 state: &AppState,
Matt W120 repo_id: Uuid,
Matt W121 state_filter: &str,
Matt W122 viewer: Option<Uuid>,
Matt W123 revset_sql: Option<String>,
Matt W124 revset_vals: Vec<String>,
Matt W125) -> AppResult<Vec<v::ChangeRow>> {
Matt W126 // `hr.author_name` is the fallback when no account matched the commit's
Matt W127 // email — the person is still known, just not linkable.
Matt W128 let mut sql = format!(
Matt W129 "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text,
Matt W130 c.conflicted, c.updated_at,
Matt W131 u.handle::text AS author,
Matt W132 hr.author_name,
Matt W133 hr.rev AS head_rev,
Matt W134 (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount,
Matt W135 (SELECT count(*) FROM comments cm WHERE cm.change_id_fk = c.id) AS comments,
Matt W136 COALESCE((
Matt W137 SELECT array_agg(pp.change_id)
Matt W138 FROM change_edges e
Matt W139 JOIN changes pp ON pp.id = e.parent_change
Matt W140 WHERE e.child_change = c.id
Matt W141 ), '{{}}') AS parents
Matt W142 FROM changes c
Matt W143 LEFT JOIN users u ON u.id = c.author_user_id
Matt W144 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
Matt W145 WHERE c.repo_id = $1
Matt W146 AND ({})",
Matt W147 state_predicate(state_filter)
Matt W148 );
Matt W149
Matt W150 if let Some(frag) = &revset_sql {
Matt W151 sql.push_str(" AND ");
Matt W152 sql.push_str(frag);
Matt W153 }
Matt W154 sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100");
Matt W155
Matt W156 type Row = (
Matt W157 i64,
Matt W158 String,
Matt W159 bool,
Matt W160 String,
Matt W161 String,
Matt W162 bool,
Matt W163 chrono::DateTime<chrono::Utc>,
Matt W164 Option<String>,
Matt W165 Option<String>,
Matt W166 Option<String>,
Matt W167 i64,
Matt W168 i64,
Matt W169 Vec<String>,
Matt W170 );
Matt W171
Matt W172 let mut query = sqlx::query_as::<_, Row>(&sql).bind(repo_id).bind(viewer);
Matt W173
Matt W174 for v in revset_vals {
Matt W175 query = query.bind(v);
Matt W176 }
Matt W177
Matt W178 let rows = query.fetch_all(&state.db).await?;
Matt W179
Matt W180 Ok(rows
Matt W181 .into_iter()
Matt W182 .map(
Matt W183 |(
Matt W184 number,
Matt W185 change_id,
Matt W186 synthetic,
Matt W187 title,
Matt W188 st,
Matt W189 conflicted,
Matt W190 updated_at,
Matt W191 author,
Matt W192 author_name,
Matt W193 head_rev,
Matt W194 revcount,
Matt W195 comments,
Matt W196 parents,
Matt W197 )| {
Matt W198 v::ChangeRow {
Matt W199 number,
Matt W200 change_id,
Matt W201 synthetic,
Matt W202 title,
Matt W203 state: st,
Matt W204 conflicted,
Matt W205 updated_at,
Matt W206 author,
Matt W207 author_name,
Matt W208 head_rev,
Matt W209 revision_count: revcount,
Matt W210 comments,
Matt W211 parents,
Matt W212 reviewers: Vec::new(),
Matt W213 diffstat: None,
Matt W214 depth: 0,
Matt W215 stack_size: 0,
Matt W216 }
Matt W217 },
Matt W218 )
Matt W219 .collect())
Matt W220}
Matt W221
Matt W222/// Fill in the two columns that need more than the changes table: who has
Matt W223/// reviewed each row, and how big its diff is.
Matt W224///
Matt W225/// Both are best-effort. They are decoration on a list whose job is to link to
Matt W226/// changes, and neither is worth turning a 200 into a 500 over.
Matt W227async fn decorate(state: &AppState, ctx: &RepoContext, rows: &mut [v::ChangeRow]) {
Matt W228 if rows.is_empty() {
Matt W229 return;
Matt W230 }
Matt W231
Matt W232 // One query for every reviewer of every row. `DISTINCT ON` keeps only each
Matt W233 // reviewer's most recent verdict per change — an approval followed by a
Matt W234 // rejection is one reviewer with one current position, not two marks.
Matt W235 let numbers: Vec<i64> = rows.iter().map(|r| r.number).collect();
Matt W236 let reviews: Vec<(i64, String, String, bool)> = sqlx::query_as(
Matt W237 r#"
Matt W238 SELECT DISTINCT ON (c.number, rv.reviewer_id)
Matt W239 c.number,
Matt W240 u.handle::text,
Matt W241 rv.verdict::text,
Matt W242 (rv.revision_id = c.head_revision_id) AS at_head
Matt W243 FROM reviews rv
Matt W244 JOIN changes c ON c.id = rv.change_id_fk
Matt W245 JOIN users u ON u.id = rv.reviewer_id
Matt W246 WHERE c.repo_id = $1 AND c.number = ANY($2)
Matt W247 ORDER BY c.number, rv.reviewer_id, rv.created_at DESC
Matt W248 "#,
Matt W249 )
Matt W250 .bind(ctx.repo.id)
Matt W251 .bind(&numbers)
Matt W252 .fetch_all(&state.db)
Matt W253 .await
Matt W254 .unwrap_or_default();
Matt W255
Matt W256 for (number, handle, verdict, at_head) in reviews {
Matt W257 if let Some(row) = rows.iter_mut().find(|r| r.number == number) {
Matt W258 row.reviewers.push(v::Reviewer { handle, verdict, at_head });
Matt W259 }
Matt W260 }
Matt W261
Matt W262 // One repository open for the whole page, not one per row.
Matt W263 let revs: Vec<df_store::RevId> = rows
Matt W264 .iter()
Matt W265 .filter_map(|r| r.head_rev.as_deref())
Matt W266 .map(df_store::RevId::from_stored)
Matt W267 .collect();
Matt W268
Matt W269 if revs.is_empty() {
Matt W270 return;
Matt W271 }
Matt W272
Matt W273 let stats = state
Matt W274 .store
Matt W275 .diff_stats(ctx.store_id(), &revs)
Matt W276 .await
Matt W277 .unwrap_or_default();
Matt W278
Matt W279 let mut stats = stats.into_iter();
Matt W280 for row in rows.iter_mut().filter(|r| r.head_rev.is_some()) {
Matt W281 row.diffstat = stats.next().flatten();
Matt W282 }
Matt W283}
Matt W284
Matt W285/// The number behind each filter tab.
Matt W286///
Matt W287/// Counted with the same predicates the tabs filter by, so a tab that says 3
Matt W288/// shows 3 rows. Deliberately *not* narrowed by the active revset: the counts
Matt W289/// are how you decide where to go next, and a revset that matches nothing would
Matt W290/// otherwise blank out every tab and leave no way back.
Matt W291async fn list_counts(
Matt W292 state: &AppState,
Matt W293 repo_id: Uuid,
Matt W294 viewer: Option<&df_db::models::User>,
Matt W295) -> AppResult<v::ListCounts> {
Matt W296 let sql = format!(
Matt W297 r#"
Matt W298 SELECT
Matt W299 count(*) FILTER (WHERE {open}) AS open,
Matt W300 count(*) FILTER (WHERE {conflicted}) AS conflicted,
Matt W301 count(*) FILTER (WHERE {merged}) AS merged,
Matt W302 count(*) FILTER (WHERE {abandoned}) AS abandoned,
Matt W303 count(*) FILTER (WHERE {mine}) AS mine
Matt W304 FROM changes c
Matt W305 WHERE c.repo_id = $1
Matt W306 "#,
Matt W307 open = state_predicate("open"),
Matt W308 conflicted = state_predicate("conflicted"),
Matt W309 merged = state_predicate("merged"),
Matt W310 abandoned = state_predicate("abandoned"),
Matt W311 mine = state_predicate("mine"),
Matt W312 );
Matt W313
Matt W314 Ok(sqlx::query_as(&sql)
Matt W315 .bind(repo_id)
Matt W316 .bind(viewer.map(|u| u.id))
Matt W317 .fetch_one(&state.db)
Matt W318 .await?)
Matt W319}
Matt W320
Matt W321/// The aside's weekly numbers.
Matt W322///
Matt W323/// "Median time to first review" is computed from the event log: the gap
Matt W324/// between a change being opened and the first `change.reviewed` event on it.
Matt W325/// Only changes that have actually been reviewed count — including the
Matt W326/// unreviewed ones as an infinite wait would be more honest but unplottable,
Matt W327/// and including them as zero would be a lie.
Matt W328async fn week_stats(state: &AppState, repo_id: Uuid) -> AppResult<v::WeekStats> {
Matt W329 Ok(sqlx::query_as(
Matt W330 r#"
Matt W331 WITH firsts AS (
Matt W332 SELECT e.subject_id,
Matt W333 min(e.created_at) AS first_review
Matt W334 FROM events e
Matt W335 WHERE e.repo_id = $1
Matt W336 AND e.subject_type = 'change'
Matt W337 AND e.kind = 'change.reviewed'
Matt W338 AND e.created_at > now() - interval '7 days'
Matt W339 GROUP BY e.subject_id
Matt W340 )
Matt W341 SELECT
Matt W342 (SELECT count(*) FROM changes
Matt W343 WHERE repo_id = $1 AND state = 'merged'
Matt W344 AND merged_at > now() - interval '7 days') AS merged,
Matt W345 (SELECT count(*) FROM changes
Matt W346 WHERE repo_id = $1
Matt W347 AND created_at > now() - interval '7 days') AS opened,
Matt W348 (SELECT count(*) FROM events
Matt W349 WHERE repo_id = $1 AND kind = 'change.resolved'
Matt W350 AND created_at > now() - interval '7 days') AS resolved,
Matt W351 (SELECT (percentile_cont(0.5) WITHIN GROUP (
Matt W352 ORDER BY EXTRACT(EPOCH FROM (f.first_review - c.created_at)) / 60
Matt W353 ))::bigint
Matt W354 FROM firsts f
Matt W355 JOIN changes c ON c.id = f.subject_id) AS median_first_review_mins
Matt W356 "#,
Matt W357 )
Matt W358 .bind(repo_id)
Matt W359 .fetch_one(&state.db)
Matt W360 .await?)
Matt W361}
Matt W362
Matt W363pub struct ChangeRecord {
Matt W364 pub id: Uuid,
Matt W365 pub number: i64,
Matt W366 pub change_id: String,
Matt W367 pub synthetic: bool,
Matt W368 pub title: String,
Matt W369 pub description: String,
Matt W370 pub state: String,
Matt W371 pub conflicted: bool,
Matt W372 pub target_bookmark: String,
Matt W373 pub created_at: chrono::DateTime<chrono::Utc>,
Matt W374 pub updated_at: chrono::DateTime<chrono::Utc>,
Matt W375}
Matt W376
Matt W377pub enum Resolution {
Matt W378 One(Box<ChangeRecord>),
Matt W379 /// (number, change_id, title)
Matt W380 Ambiguous(Vec<(i64, String, String)>),
Matt W381 None,
Matt W382}
Matt W383
Matt W384type ChangeTuple = (
Matt W385 Uuid,
Matt W386 i64,
Matt W387 String,
Matt W388 bool,
Matt W389 String,
Matt W390 String,
Matt W391 String,
Matt W392 bool,
Matt W393 String,
Matt W394 chrono::DateTime<chrono::Utc>,
Matt W395 chrono::DateTime<chrono::Utc>,
Matt W396);
Matt W397
Matt W398fn to_record(t: ChangeTuple) -> ChangeRecord {
Matt W399 ChangeRecord {
Matt W400 id: t.0,
Matt W401 number: t.1,
Matt W402 change_id: t.2,
Matt W403 synthetic: t.3,
Matt W404 title: t.4,
Matt W405 description: t.5,
Matt W406 state: t.6,
Matt W407 conflicted: t.7,
Matt W408 target_bookmark: t.8,
Matt W409 created_at: t.9,
Matt W410 updated_at: t.10,
Matt W411 }
Matt W412}
Matt W413
Matt W414const SELECT_CHANGE: &str = "SELECT id, number, change_id, synthetic, title, description,
Matt W415 state::text, conflicted, target_bookmark,
Matt W416 created_at, updated_at
Matt W417 FROM changes";
Matt W418
Matt W419pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> {
Matt W420 // A pure number is a display number.
Matt W421 if let Ok(number) = reference.parse::<i64>() {
Matt W422 let row: Option<ChangeTuple> =
Matt W423 sqlx::query_as(&format!("{SELECT_CHANGE} WHERE repo_id = $1 AND number = $2"))
Matt W424 .bind(repo_id)
Matt W425 .bind(number)
Matt W426 .fetch_optional(&state.db)
Matt W427 .await?;
Matt W428 return Ok(match row {
Matt W429 Some(r) => Resolution::One(Box::new(to_record(r))),
Matt W430 None => Resolution::None,
Matt W431 });
Matt W432 }
Matt W433
Matt W434 // Otherwise a change-id prefix. Reject anything outside the alphabet before
Matt W435 // it reaches a LIKE pattern.
Matt W436 if reference.is_empty()
Matt W437 || reference.len() > 32
Matt W438 || !reference.bytes().all(|b| (b'k'..=b'z').contains(&b))
Matt W439 {
Matt W440 return Ok(Resolution::None);
Matt W441 }
Matt W442
Matt W443 // Uses changes_prefix_idx (repo_id, change_id text_pattern_ops).
Matt W444 let rows: Vec<ChangeTuple> = sqlx::query_as(&format!(
Matt W445 "{SELECT_CHANGE} WHERE repo_id = $1 AND change_id LIKE $2 || '%' ORDER BY number LIMIT 25"
Matt W446 ))
Matt W447 .bind(repo_id)
Matt W448 .bind(reference)
Matt W449 .fetch_all(&state.db)
Matt W450 .await?;
Matt W451
Matt W452 Ok(match rows.len() {
Matt W453 0 => Resolution::None,
Matt W454 1 => Resolution::One(Box::new(to_record(rows.into_iter().next().unwrap()))),
Matt W455 // Never guess (spec §7).
Matt W456 _ => Resolution::Ambiguous(
Matt W457 rows.into_iter()
Matt W458 .map(|r| (r.1, r.2, r.4))
Matt W459 .collect(),
Matt W460 ),
Matt W461 })
Matt W462}
Matt W463
Matt W464// ─── opening a change for review (M3) ────────────────────────────────────────
Matt W465
Matt W466/// `GET /{owner}/{repo}/changes/new`
Matt W467///
Matt W468/// A change is not created here — the indexer creates one the moment a change
Matt W469/// id is first seen on a push. What this form does is *propose* already-pushed
Matt W470/// work: pick the change, set its target bookmark, title and description, and
Matt W471/// move it out of draft.
Matt W472///
Matt W473/// That is the honest model for a jj forge. The work exists in the repository
Matt W474/// before anybody opens a review of it, and pretending the review created it
Matt W475/// would mean either inventing a commit or refusing to show work that is
Matt W476/// already pushed.
Matt W477pub async fn new_form(
Matt W478 State(state): State<AppState>,
Matt W479 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W480 Query(q): Query<NewQuery>,
Matt W481 CurrentUser(user): CurrentUser,
Matt W482 CsrfToken(csrf): CsrfToken,
Matt W483 Nonce(nonce): Nonce,
Matt W484) -> AppResult<Response> {
Matt W485 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W486 if user.is_none() {
Matt W487 return Err(AppError::Unauthorized);
Matt W488 }
Matt W489 ctx.require_push()?;
Matt W490
Matt W491 let candidates = load_proposable(&state, ctx.repo.id).await?;
Matt W492 let bookmarks: Vec<String> =
Matt W493 sqlx::query_scalar("SELECT name FROM bookmarks WHERE repo_id = $1 ORDER BY name")
Matt W494 .bind(ctx.repo.id)
Matt W495 .fetch_all(&state.db)
Matt W496 .await?;
Matt W497
Matt W498 let body = maud::html! {
Matt W499 (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref()))
Matt W500 };
Matt W501
Matt W502 Ok(views::page_with_bar(
Matt W503 Chrome {
Matt W504 title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name),
Matt W505 user: user.as_deref(),
Matt W506 csrf: &csrf,
Matt W507 nonce: &nonce,
Matt W508 },
Matt W509 rv::header(&ctx, "changes"),
Matt W510 body,
Matt W511 )
Matt W512 .into_response())
Matt W513}
Matt W514
Matt W515#[derive(Deserialize, Default)]
Matt W516pub struct NewQuery {
Matt W517 pub error: Option<String>,
Matt W518}
Matt W519
Matt W520#[derive(Deserialize)]
Matt W521pub struct OpenChange {
Matt W522 pub change: String,
Matt W523 pub target_bookmark: String,
Matt W524 pub title: String,
Matt W525 pub description: Option<String>,
Matt W526}
Matt W527
Matt W528/// `POST /{owner}/{repo}/changes`
Matt W529pub async fn create(
Matt W530 State(state): State<AppState>,
Matt W531 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W532 CurrentUser(user): CurrentUser,
Matt W533 Form(form): Form<OpenChange>,
Matt W534) -> AppResult<Response> {
Matt W535 let Some(user) = user else {
Matt W536 return Err(AppError::Unauthorized);
Matt W537 };
Matt W538 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W539 ctx.require_push()?;
Matt W540
Matt W541 let reject = |msg: &str| -> Response {
Matt W542 Redirect::to(&format!(
Matt W543 "{}/changes/new?error={}",
Matt W544 ctx.base(),
Matt W545 crate::routes::settings::urlencode(msg)
Matt W546 ))
Matt W547 .into_response()
Matt W548 };
Matt W549
Matt W550 let title: String = form.title.trim().chars().take(300).collect();
Matt W551 if title.is_empty() {
Matt W552 return Ok(reject("A change needs a title."));
Matt W553 }
Matt W554
Matt W555 // The target must be a bookmark this repository actually has. Accepting an
Matt W556 // arbitrary string would produce a change that can never be merged and no
Matt W557 // error until somebody tried.
Matt W558 let bookmark_exists: bool = sqlx::query_scalar(
Matt W559 "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)",
Matt W560 )
Matt W561 .bind(ctx.repo.id)
Matt W562 .bind(form.target_bookmark.trim())
Matt W563 .fetch_one(&state.db)
Matt W564 .await?;
Matt W565 if !bookmark_exists {
Matt W566 return Ok(reject("That bookmark does not exist in this repository."));
Matt W567 }
Matt W568
Matt W569 // Scoped to the repository, so a change id from elsewhere resolves to
Matt W570 // nothing rather than being adopted.
Matt W571 let row: Option<(Uuid, i64)> =
Matt W572 sqlx::query_as("SELECT id, number FROM changes WHERE repo_id = $1 AND change_id = $2")
Matt W573 .bind(ctx.repo.id)
Matt W574 .bind(form.change.trim())
Matt W575 .fetch_optional(&state.db)
Matt W576 .await?;
Matt W577
Matt W578 let Some((change_uuid, number)) = row else {
Matt W579 return Ok(reject("That change is not in this repository."));
Matt W580 };
Matt W581
Matt W582 sqlx::query(
Matt W583 "UPDATE changes
Matt W584 SET title = $2, description = $3, target_bookmark = $4,
Matt W585 author_user_id = COALESCE(author_user_id, $5),
Matt W586 -- Only a draft is promoted. A change already open, merged or
Matt W587 -- abandoned keeps the state it has; this form proposes work, it
Matt W588 -- does not resurrect it.
Matt W589 state = CASE WHEN state = 'draft' THEN 'open'::change_state ELSE state END,
Matt W590 updated_at = now()
Matt W591 WHERE id = $1",
Matt W592 )
Matt W593 .bind(change_uuid)
Matt W594 .bind(&title)
Matt W595 .bind(form.description.as_deref().unwrap_or("").trim())
Matt W596 .bind(form.target_bookmark.trim())
Matt W597 .bind(user.id)
Matt W598 .execute(&state.db)
Matt W599 .await?;
Matt W600
Matt W601 crate::routes::review::event(
Matt W602 &state,
Matt W603 ctx.repo.id,
Matt W604 Some(user.id),
Matt W605 "change.opened",
Matt W606 change_uuid,
Matt W607 serde_json::json!({ "change_id": form.change.trim() }),
Matt W608 )
Matt W609 .await;
Matt W610
Matt W611 Ok(Redirect::to(&format!("{}/changes/{number}", ctx.base())).into_response())
Matt W612}
Matt W613
Matt W614/// Changes that are worth proposing: pushed, not landed, not abandoned.
Matt W615async fn load_proposable(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Proposable>> {
Matt W616 let rows: Vec<(String, i64, String, bool, i64, String)> = sqlx::query_as(
Matt W617 "SELECT c.change_id, c.number, c.title, c.synthetic,
Matt W618 (SELECT count(*) FROM revisions r WHERE r.change_id_fk = c.id) AS revcount,
Matt W619 c.state::text
Matt W620 FROM changes c
Matt W621 WHERE c.repo_id = $1 AND c.state IN ('draft', 'open')
Matt W622 ORDER BY c.updated_at DESC
Matt W623 LIMIT 100",
Matt W624 )
Matt W625 .bind(repo_id)
Matt W626 .fetch_all(&state.db)
Matt W627 .await?;
Matt W628
Matt W629 Ok(rows
Matt W630 .into_iter()
Matt W631 .map(|(change_id, number, title, synthetic, revisions, state)| v::Proposable {
Matt W632 change_id,
Matt W633 number,
Matt W634 title,
Matt W635 synthetic,
Matt W636 revisions,
Matt W637 state,
Matt W638 })
Matt W639 .collect())
Matt W640}

640 lines · Rust