| 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