| 1 | //! Change list and detail (M3). |
| 2 | |
| 3 | use axum::extract::{Path as UrlPath, Query, State}; |
| 4 | use axum::response::{IntoResponse, Redirect, Response}; |
| 5 | use axum::Form; |
| 6 | use serde::Deserialize; |
| 7 | use uuid::Uuid; |
| 8 | |
| 9 | use crate::error::{AppError, AppResult}; |
| 10 | use crate::repo_ctx::RepoContext; |
| 11 | use crate::revset; |
| 12 | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; |
| 13 | use crate::views::change as v; |
| 14 | use crate::views::repo as rv; |
| 15 | use crate::views::{self, Chrome}; |
| 16 | |
| 17 | #[derive(Deserialize, Default)] |
| 18 | pub struct ListQuery { |
| 19 | pub state: Option<String>, |
| 20 | pub revset: Option<String>, |
| 21 | } |
| 22 | |
| 23 | /// `GET /{owner}/{repo}/changes` |
| 24 | pub async fn list( |
| 25 | State(state): State<AppState>, |
| 26 | UrlPath((owner, name)): UrlPath<(String, String)>, |
| 27 | Query(q): Query<ListQuery>, |
| 28 | CurrentUser(user): CurrentUser, |
| 29 | CsrfToken(csrf): CsrfToken, |
| 30 | Nonce(nonce): Nonce, |
| 31 | ) -> AppResult<Response> { |
| 32 | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; |
| 33 | |
| 34 | let state_filter = q.state.as_deref().unwrap_or("open"); |
| 35 | let revset_input = q.revset.unwrap_or_default(); |
| 36 | |
| 37 | // Parse the revset before touching the database. An unsupported expression |
| 38 | // must produce a clear message, never a partial result (spec §8). |
| 39 | let (revset_sql, revset_vals, revset_error) = match revset::parse(&revset_input) { |
| 40 | Ok(Some(expr)) => { |
| 41 | let mut n = 3; // $1 = repo_id, $2 = state filter |
| 42 | let (sql, vals) = revset::to_sql(&expr, &mut n); |
| 43 | (Some(sql), vals, None) |
| 44 | } |
| 45 | Ok(None) => (None, vec![], None), |
| 46 | Err(e) => (None, vec![], Some(e.to_string())), |
| 47 | }; |
| 48 | |
| 49 | // On a revset error, show the message and no rows rather than silently |
| 50 | // listing everything — which would look like the filter had matched. |
| 51 | let mut rows = if revset_error.is_some() { |
| 52 | Vec::new() |
| 53 | } else { |
| 54 | load_changes( |
| 55 | &state, |
| 56 | ctx.repo.id, |
| 57 | state_filter, |
| 58 | user.as_ref().map(|u| u.id), |
| 59 | revset_sql, |
| 60 | revset_vals, |
| 61 | ) |
| 62 | .await? |
| 63 | }; |
| 64 | |
| 65 | decorate(&state, &ctx, &mut rows).await; |
| 66 | let rows = v::arrange(rows); |
| 67 | |
| 68 | let counts = list_counts(&state, ctx.repo.id, user.as_deref()).await?; |
| 69 | let week = week_stats(&state, ctx.repo.id).await?; |
| 70 | |
| 71 | let body = maud::html! { |
| 72 | (v::list(&ctx, &rows, v::ListFilters { |
| 73 | state: state_filter, |
| 74 | revset: &revset_input, |
| 75 | revset_error: revset_error.as_deref(), |
| 76 | counts, |
| 77 | signed_in: user.is_some(), |
| 78 | week, |
| 79 | })) |
| 80 | }; |
| 81 | |
| 82 | Ok(views::page_with_bar( |
| 83 | Chrome { |
| 84 | title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name), |
| 85 | user: user.as_deref(), |
| 86 | csrf: &csrf, |
| 87 | nonce: &nonce, |
| 88 | }, |
| 89 | rv::header(&ctx, "changes"), |
| 90 | body, |
| 91 | ) |
| 92 | .into_response()) |
| 93 | } |
| 94 | |
| 95 | /// The SQL predicate behind each filter tab. |
| 96 | /// |
| 97 | /// "Conflicted" and "Mine" are not values of `changes.state` — they are views |
| 98 | /// over it. Keeping the mapping in one place is what stops the tab counts and |
| 99 | /// the tab contents from drifting apart: [`list_counts`] uses these same |
| 100 | /// fragments to count what each tab will show. |
| 101 | /// |
| 102 | /// `$2` is the viewer's id, which is null for an anonymous request. The `mine` |
| 103 | /// clause therefore matches nothing when nobody is signed in, rather than |
| 104 | /// matching every change with no author. |
| 105 | fn state_predicate(filter: &str) -> &'static str { |
| 106 | match filter { |
| 107 | "conflicted" => "c.state = 'open' AND c.conflicted", |
| 108 | "merged" => "c.state = 'merged'", |
| 109 | "abandoned" => "c.state = 'abandoned'", |
| 110 | "mine" => "c.author_user_id = $2 AND c.state IN ('open', 'draft')", |
| 111 | "all" => "true", |
| 112 | // Anything unrecognised falls back to the default tab rather than to |
| 113 | // "everything": a typo'd query string should not widen a listing. |
| 114 | _ => "c.state = 'open'", |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | async fn load_changes( |
| 119 | state: &AppState, |
| 120 | repo_id: Uuid, |
| 121 | state_filter: &str, |
| 122 | viewer: Option<Uuid>, |
| 123 | revset_sql: Option<String>, |
| 124 | revset_vals: Vec<String>, |
| 125 | ) -> AppResult<Vec<v::ChangeRow>> { |
| 126 | // `hr.author_name` is the fallback when no account matched the commit's |
| 127 | // email — the person is still known, just not linkable. |
| 128 | let mut sql = format!( |
| 129 | "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, |
| 130 | c.conflicted, c.updated_at, |
| 131 | u.handle::text AS author, |
| 132 | hr.author_name, |
| 133 | hr.rev AS head_rev, |
| 134 | (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount, |
| 135 | (SELECT count(*) FROM comments cm WHERE cm.change_id_fk = c.id) AS comments, |
| 136 | COALESCE(( |
| 137 | SELECT array_agg(pp.change_id) |
| 138 | FROM change_edges e |
| 139 | JOIN changes pp ON pp.id = e.parent_change |
| 140 | WHERE e.child_change = c.id |
| 141 | ), '{{}}') AS parents |
| 142 | FROM changes c |
| 143 | LEFT JOIN users u ON u.id = c.author_user_id |
| 144 | LEFT JOIN revisions hr ON hr.id = c.head_revision_id |
| 145 | WHERE c.repo_id = $1 |
| 146 | AND ({})", |
| 147 | state_predicate(state_filter) |
| 148 | ); |
| 149 | |
| 150 | if let Some(frag) = &revset_sql { |
| 151 | sql.push_str(" AND "); |
| 152 | sql.push_str(frag); |
| 153 | } |
| 154 | sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100"); |
| 155 | |
| 156 | type Row = ( |
| 157 | i64, |
| 158 | String, |
| 159 | bool, |
| 160 | String, |
| 161 | String, |
| 162 | bool, |
| 163 | chrono::DateTime<chrono::Utc>, |
| 164 | Option<String>, |
| 165 | Option<String>, |
| 166 | Option<String>, |
| 167 | i64, |
| 168 | i64, |
| 169 | Vec<String>, |
| 170 | ); |
| 171 | |
| 172 | let mut query = sqlx::query_as::<_, Row>(&sql).bind(repo_id).bind(viewer); |
| 173 | |
| 174 | for v in revset_vals { |
| 175 | query = query.bind(v); |
| 176 | } |
| 177 | |
| 178 | let rows = query.fetch_all(&state.db).await?; |
| 179 | |
| 180 | Ok(rows |
| 181 | .into_iter() |
| 182 | .map( |
| 183 | |( |
| 184 | number, |
| 185 | change_id, |
| 186 | synthetic, |
| 187 | title, |
| 188 | st, |
| 189 | conflicted, |
| 190 | updated_at, |
| 191 | author, |
| 192 | author_name, |
| 193 | head_rev, |
| 194 | revcount, |
| 195 | comments, |
| 196 | parents, |
| 197 | )| { |
| 198 | v::ChangeRow { |
| 199 | number, |
| 200 | change_id, |
| 201 | synthetic, |
| 202 | title, |
| 203 | state: st, |
| 204 | conflicted, |
| 205 | updated_at, |
| 206 | author, |
| 207 | author_name, |
| 208 | head_rev, |
| 209 | revision_count: revcount, |
| 210 | comments, |
| 211 | parents, |
| 212 | reviewers: Vec::new(), |
| 213 | diffstat: None, |
| 214 | depth: 0, |
| 215 | stack_size: 0, |
| 216 | } |
| 217 | }, |
| 218 | ) |
| 219 | .collect()) |
| 220 | } |
| 221 | |
| 222 | /// Fill in the two columns that need more than the changes table: who has |
| 223 | /// reviewed each row, and how big its diff is. |
| 224 | /// |
| 225 | /// Both are best-effort. They are decoration on a list whose job is to link to |
| 226 | /// changes, and neither is worth turning a 200 into a 500 over. |
| 227 | async fn decorate(state: &AppState, ctx: &RepoContext, rows: &mut [v::ChangeRow]) { |
| 228 | if rows.is_empty() { |
| 229 | return; |
| 230 | } |
| 231 | |
| 232 | // One query for every reviewer of every row. `DISTINCT ON` keeps only each |
| 233 | // reviewer's most recent verdict per change — an approval followed by a |
| 234 | // rejection is one reviewer with one current position, not two marks. |
| 235 | let numbers: Vec<i64> = rows.iter().map(|r| r.number).collect(); |
| 236 | let reviews: Vec<(i64, String, String, bool)> = sqlx::query_as( |
| 237 | r#" |
| 238 | SELECT DISTINCT ON (c.number, rv.reviewer_id) |
| 239 | c.number, |
| 240 | u.handle::text, |
| 241 | rv.verdict::text, |
| 242 | (rv.revision_id = c.head_revision_id) AS at_head |
| 243 | FROM reviews rv |
| 244 | JOIN changes c ON c.id = rv.change_id_fk |
| 245 | JOIN users u ON u.id = rv.reviewer_id |
| 246 | WHERE c.repo_id = $1 AND c.number = ANY($2) |
| 247 | ORDER BY c.number, rv.reviewer_id, rv.created_at DESC |
| 248 | "#, |
| 249 | ) |
| 250 | .bind(ctx.repo.id) |
| 251 | .bind(&numbers) |
| 252 | .fetch_all(&state.db) |
| 253 | .await |
| 254 | .unwrap_or_default(); |
| 255 | |
| 256 | for (number, handle, verdict, at_head) in reviews { |
| 257 | if let Some(row) = rows.iter_mut().find(|r| r.number == number) { |
| 258 | row.reviewers.push(v::Reviewer { handle, verdict, at_head }); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // One repository open for the whole page, not one per row. |
| 263 | let revs: Vec<df_store::RevId> = rows |
| 264 | .iter() |
| 265 | .filter_map(|r| r.head_rev.as_deref()) |
| 266 | .map(df_store::RevId::from_stored) |
| 267 | .collect(); |
| 268 | |
| 269 | if revs.is_empty() { |
| 270 | return; |
| 271 | } |
| 272 | |
| 273 | let stats = state |
| 274 | .store |
| 275 | .diff_stats(ctx.store_id(), &revs) |
| 276 | .await |
| 277 | .unwrap_or_default(); |
| 278 | |
| 279 | let mut stats = stats.into_iter(); |
| 280 | for row in rows.iter_mut().filter(|r| r.head_rev.is_some()) { |
| 281 | row.diffstat = stats.next().flatten(); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /// The number behind each filter tab. |
| 286 | /// |
| 287 | /// Counted with the same predicates the tabs filter by, so a tab that says 3 |
| 288 | /// shows 3 rows. Deliberately *not* narrowed by the active revset: the counts |
| 289 | /// are how you decide where to go next, and a revset that matches nothing would |
| 290 | /// otherwise blank out every tab and leave no way back. |
| 291 | async fn list_counts( |
| 292 | state: &AppState, |
| 293 | repo_id: Uuid, |
| 294 | viewer: Option<&df_db::models::User>, |
| 295 | ) -> AppResult<v::ListCounts> { |
| 296 | let sql = format!( |
| 297 | r#" |
| 298 | SELECT |
| 299 | count(*) FILTER (WHERE {open}) AS open, |
| 300 | count(*) FILTER (WHERE {conflicted}) AS conflicted, |
| 301 | count(*) FILTER (WHERE {merged}) AS merged, |
| 302 | count(*) FILTER (WHERE {abandoned}) AS abandoned, |
| 303 | count(*) FILTER (WHERE {mine}) AS mine |
| 304 | FROM changes c |
| 305 | WHERE c.repo_id = $1 |
| 306 | "#, |
| 307 | open = state_predicate("open"), |
| 308 | conflicted = state_predicate("conflicted"), |
| 309 | merged = state_predicate("merged"), |
| 310 | abandoned = state_predicate("abandoned"), |
| 311 | mine = state_predicate("mine"), |
| 312 | ); |
| 313 | |
| 314 | Ok(sqlx::query_as(&sql) |
| 315 | .bind(repo_id) |
| 316 | .bind(viewer.map(|u| u.id)) |
| 317 | .fetch_one(&state.db) |
| 318 | .await?) |
| 319 | } |
| 320 | |
| 321 | /// The aside's weekly numbers. |
| 322 | /// |
| 323 | /// "Median time to first review" is computed from the event log: the gap |
| 324 | /// between a change being opened and the first `change.reviewed` event on it. |
| 325 | /// Only changes that have actually been reviewed count — including the |
| 326 | /// unreviewed ones as an infinite wait would be more honest but unplottable, |
| 327 | /// and including them as zero would be a lie. |
| 328 | async fn week_stats(state: &AppState, repo_id: Uuid) -> AppResult<v::WeekStats> { |
| 329 | Ok(sqlx::query_as( |
| 330 | r#" |
| 331 | WITH firsts AS ( |
| 332 | SELECT e.subject_id, |
| 333 | min(e.created_at) AS first_review |
| 334 | FROM events e |
| 335 | WHERE e.repo_id = $1 |
| 336 | AND e.subject_type = 'change' |
| 337 | AND e.kind = 'change.reviewed' |
| 338 | AND e.created_at > now() - interval '7 days' |
| 339 | GROUP BY e.subject_id |
| 340 | ) |
| 341 | SELECT |
| 342 | (SELECT count(*) FROM changes |
| 343 | WHERE repo_id = $1 AND state = 'merged' |
| 344 | AND merged_at > now() - interval '7 days') AS merged, |
| 345 | (SELECT count(*) FROM changes |
| 346 | WHERE repo_id = $1 |
| 347 | AND created_at > now() - interval '7 days') AS opened, |
| 348 | (SELECT count(*) FROM events |
| 349 | WHERE repo_id = $1 AND kind = 'change.resolved' |
| 350 | AND created_at > now() - interval '7 days') AS resolved, |
| 351 | (SELECT (percentile_cont(0.5) WITHIN GROUP ( |
| 352 | ORDER BY EXTRACT(EPOCH FROM (f.first_review - c.created_at)) / 60 |
| 353 | ))::bigint |
| 354 | FROM firsts f |
| 355 | JOIN changes c ON c.id = f.subject_id) AS median_first_review_mins |
| 356 | "#, |
| 357 | ) |
| 358 | .bind(repo_id) |
| 359 | .fetch_one(&state.db) |
| 360 | .await?) |
| 361 | } |
| 362 | |
| 363 | pub struct ChangeRecord { |
| 364 | pub id: Uuid, |
| 365 | pub number: i64, |
| 366 | pub change_id: String, |
| 367 | pub synthetic: bool, |
| 368 | pub title: String, |
| 369 | pub description: String, |
| 370 | pub state: String, |
| 371 | pub conflicted: bool, |
| 372 | pub target_bookmark: String, |
| 373 | pub created_at: chrono::DateTime<chrono::Utc>, |
| 374 | pub updated_at: chrono::DateTime<chrono::Utc>, |
| 375 | } |
| 376 | |
| 377 | pub enum Resolution { |
| 378 | One(Box<ChangeRecord>), |
| 379 | /// (number, change_id, title) |
| 380 | Ambiguous(Vec<(i64, String, String)>), |
| 381 | None, |
| 382 | } |
| 383 | |
| 384 | type ChangeTuple = ( |
| 385 | Uuid, |
| 386 | i64, |
| 387 | String, |
| 388 | bool, |
| 389 | String, |
| 390 | String, |
| 391 | String, |
| 392 | bool, |
| 393 | String, |
| 394 | chrono::DateTime<chrono::Utc>, |
| 395 | chrono::DateTime<chrono::Utc>, |
| 396 | ); |
| 397 | |
| 398 | fn to_record(t: ChangeTuple) -> ChangeRecord { |
| 399 | ChangeRecord { |
| 400 | id: t.0, |
| 401 | number: t.1, |
| 402 | change_id: t.2, |
| 403 | synthetic: t.3, |
| 404 | title: t.4, |
| 405 | description: t.5, |
| 406 | state: t.6, |
| 407 | conflicted: t.7, |
| 408 | target_bookmark: t.8, |
| 409 | created_at: t.9, |
| 410 | updated_at: t.10, |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | const SELECT_CHANGE: &str = "SELECT id, number, change_id, synthetic, title, description, |
| 415 | state::text, conflicted, target_bookmark, |
| 416 | created_at, updated_at |
| 417 | FROM changes"; |
| 418 | |
| 419 | pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> { |
| 420 | // A pure number is a display number. |
| 421 | if let Ok(number) = reference.parse::<i64>() { |
| 422 | let row: Option<ChangeTuple> = |
| 423 | sqlx::query_as(&format!("{SELECT_CHANGE} WHERE repo_id = $1 AND number = $2")) |
| 424 | .bind(repo_id) |
| 425 | .bind(number) |
| 426 | .fetch_optional(&state.db) |
| 427 | .await?; |
| 428 | return Ok(match row { |
| 429 | Some(r) => Resolution::One(Box::new(to_record(r))), |
| 430 | None => Resolution::None, |
| 431 | }); |
| 432 | } |
| 433 | |
| 434 | // Otherwise a change-id prefix. Reject anything outside the alphabet before |
| 435 | // it reaches a LIKE pattern. |
| 436 | if reference.is_empty() |
| 437 | || reference.len() > 32 |
| 438 | || !reference.bytes().all(|b| (b'k'..=b'z').contains(&b)) |
| 439 | { |
| 440 | return Ok(Resolution::None); |
| 441 | } |
| 442 | |
| 443 | // Uses changes_prefix_idx (repo_id, change_id text_pattern_ops). |
| 444 | let rows: Vec<ChangeTuple> = sqlx::query_as(&format!( |
| 445 | "{SELECT_CHANGE} WHERE repo_id = $1 AND change_id LIKE $2 || '%' ORDER BY number LIMIT 25" |
| 446 | )) |
| 447 | .bind(repo_id) |
| 448 | .bind(reference) |
| 449 | .fetch_all(&state.db) |
| 450 | .await?; |
| 451 | |
| 452 | Ok(match rows.len() { |
| 453 | 0 => Resolution::None, |
| 454 | 1 => Resolution::One(Box::new(to_record(rows.into_iter().next().unwrap()))), |
| 455 | // Never guess (spec §7). |
| 456 | _ => Resolution::Ambiguous( |
| 457 | rows.into_iter() |
| 458 | .map(|r| (r.1, r.2, r.4)) |
| 459 | .collect(), |
| 460 | ), |
| 461 | }) |
| 462 | } |
| 463 | |
| 464 | // ─── opening a change for review (M3) ──────────────────────────────────────── |
| 465 | |
| 466 | /// `GET /{owner}/{repo}/changes/new` |
| 467 | /// |
| 468 | /// A change is not created here — the indexer creates one the moment a change |
| 469 | /// id is first seen on a push. What this form does is *propose* already-pushed |
| 470 | /// work: pick the change, set its target bookmark, title and description, and |
| 471 | /// move it out of draft. |
| 472 | /// |
| 473 | /// That is the honest model for a jj forge. The work exists in the repository |
| 474 | /// before anybody opens a review of it, and pretending the review created it |
| 475 | /// would mean either inventing a commit or refusing to show work that is |
| 476 | /// already pushed. |
| 477 | pub async fn new_form( |
| 478 | State(state): State<AppState>, |
| 479 | UrlPath((owner, name)): UrlPath<(String, String)>, |
| 480 | Query(q): Query<NewQuery>, |
| 481 | CurrentUser(user): CurrentUser, |
| 482 | CsrfToken(csrf): CsrfToken, |
| 483 | Nonce(nonce): Nonce, |
| 484 | ) -> AppResult<Response> { |
| 485 | let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?; |
| 486 | if user.is_none() { |
| 487 | return Err(AppError::Unauthorized); |
| 488 | } |
| 489 | ctx.require_push()?; |
| 490 | |
| 491 | let candidates = load_proposable(&state, ctx.repo.id).await?; |
| 492 | let bookmarks: Vec<String> = |
| 493 | sqlx::query_scalar("SELECT name FROM bookmarks WHERE repo_id = $1 ORDER BY name") |
| 494 | .bind(ctx.repo.id) |
| 495 | .fetch_all(&state.db) |
| 496 | .await?; |
| 497 | |
| 498 | let body = maud::html! { |
| 499 | (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref())) |
| 500 | }; |
| 501 | |
| 502 | Ok(views::page_with_bar( |
| 503 | Chrome { |
| 504 | title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name), |
| 505 | user: user.as_deref(), |
| 506 | csrf: &csrf, |
| 507 | nonce: &nonce, |
| 508 | }, |
| 509 | rv::header(&ctx, "changes"), |
| 510 | body, |
| 511 | ) |
| 512 | .into_response()) |
| 513 | } |
| 514 | |
| 515 | #[derive(Deserialize, Default)] |
| 516 | pub struct NewQuery { |
| 517 | pub error: Option<String>, |
| 518 | } |
| 519 | |
| 520 | #[derive(Deserialize)] |
| 521 | pub struct OpenChange { |
| 522 | pub change: String, |
| 523 | pub target_bookmark: String, |
| 524 | pub title: String, |
| 525 | pub description: Option<String>, |
| 526 | } |
| 527 | |
| 528 | /// `POST /{owner}/{repo}/changes` |
| 529 | pub async fn create( |
| 530 | State(state): State<AppState>, |
| 531 | UrlPath((owner, name)): UrlPath<(String, String)>, |
| 532 | CurrentUser(user): CurrentUser, |
| 533 | Form(form): Form<OpenChange>, |
| 534 | ) -> AppResult<Response> { |
| 535 | let Some(user) = user else { |
| 536 | return Err(AppError::Unauthorized); |
| 537 | }; |
| 538 | let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?; |
| 539 | ctx.require_push()?; |
| 540 | |
| 541 | let reject = |msg: &str| -> Response { |
| 542 | Redirect::to(&format!( |
| 543 | "{}/changes/new?error={}", |
| 544 | ctx.base(), |
| 545 | crate::routes::settings::urlencode(msg) |
| 546 | )) |
| 547 | .into_response() |
| 548 | }; |
| 549 | |
| 550 | let title: String = form.title.trim().chars().take(300).collect(); |
| 551 | if title.is_empty() { |
| 552 | return Ok(reject("A change needs a title.")); |
| 553 | } |
| 554 | |
| 555 | // The target must be a bookmark this repository actually has. Accepting an |
| 556 | // arbitrary string would produce a change that can never be merged and no |
| 557 | // error until somebody tried. |
| 558 | let bookmark_exists: bool = sqlx::query_scalar( |
| 559 | "SELECT EXISTS (SELECT 1 FROM bookmarks WHERE repo_id = $1 AND name = $2)", |
| 560 | ) |
| 561 | .bind(ctx.repo.id) |
| 562 | .bind(form.target_bookmark.trim()) |
| 563 | .fetch_one(&state.db) |
| 564 | .await?; |
| 565 | if !bookmark_exists { |
| 566 | return Ok(reject("That bookmark does not exist in this repository.")); |
| 567 | } |
| 568 | |
| 569 | // Scoped to the repository, so a change id from elsewhere resolves to |
| 570 | // nothing rather than being adopted. |
| 571 | let row: Option<(Uuid, i64)> = |
| 572 | sqlx::query_as("SELECT id, number FROM changes WHERE repo_id = $1 AND change_id = $2") |
| 573 | .bind(ctx.repo.id) |
| 574 | .bind(form.change.trim()) |
| 575 | .fetch_optional(&state.db) |
| 576 | .await?; |
| 577 | |
| 578 | let Some((change_uuid, number)) = row else { |
| 579 | return Ok(reject("That change is not in this repository.")); |
| 580 | }; |
| 581 | |
| 582 | sqlx::query( |
| 583 | "UPDATE changes |
| 584 | SET title = $2, description = $3, target_bookmark = $4, |
| 585 | author_user_id = COALESCE(author_user_id, $5), |
| 586 | -- Only a draft is promoted. A change already open, merged or |
| 587 | -- abandoned keeps the state it has; this form proposes work, it |
| 588 | -- does not resurrect it. |
| 589 | state = CASE WHEN state = 'draft' THEN 'open'::change_state ELSE state END, |
| 590 | updated_at = now() |
| 591 | WHERE id = $1", |
| 592 | ) |
| 593 | .bind(change_uuid) |
| 594 | .bind(&title) |
| 595 | .bind(form.description.as_deref().unwrap_or("").trim()) |
| 596 | .bind(form.target_bookmark.trim()) |
| 597 | .bind(user.id) |
| 598 | .execute(&state.db) |
| 599 | .await?; |
| 600 | |
| 601 | crate::routes::review::event( |
| 602 | &state, |
| 603 | ctx.repo.id, |
| 604 | Some(user.id), |
| 605 | "change.opened", |
| 606 | change_uuid, |
| 607 | serde_json::json!({ "change_id": form.change.trim() }), |
| 608 | ) |
| 609 | .await; |
| 610 | |
| 611 | Ok(Redirect::to(&format!("{}/changes/{number}", ctx.base())).into_response()) |
| 612 | } |
| 613 | |
| 614 | /// Changes that are worth proposing: pushed, not landed, not abandoned. |
| 615 | async fn load_proposable(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Proposable>> { |
| 616 | let rows: Vec<(String, i64, String, bool, i64, String)> = sqlx::query_as( |
| 617 | "SELECT c.change_id, c.number, c.title, c.synthetic, |
| 618 | (SELECT count(*) FROM revisions r WHERE r.change_id_fk = c.id) AS revcount, |
| 619 | c.state::text |
| 620 | FROM changes c |
| 621 | WHERE c.repo_id = $1 AND c.state IN ('draft', 'open') |
| 622 | ORDER BY c.updated_at DESC |
| 623 | LIMIT 100", |
| 624 | ) |
| 625 | .bind(repo_id) |
| 626 | .fetch_all(&state.db) |
| 627 | .await?; |
| 628 | |
| 629 | Ok(rows |
| 630 | .into_iter() |
| 631 | .map(|(change_id, number, title, synthetic, revisions, state)| v::Proposable { |
| 632 | change_id, |
| 633 | number, |
| 634 | title, |
| 635 | synthetic, |
| 636 | revisions, |
| 637 | state, |
| 638 | }) |
| 639 | .collect()) |
| 640 | } |
640 lines · Rust