Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Change list and detail (M3).
2
3use axum::extract::{Path as UrlPath, Query, State};
4use axum::response::{IntoResponse, Redirect, Response};
5use axum::Form;
6use serde::Deserialize;
7use uuid::Uuid;
8
9use crate::error::{AppError, AppResult};
10use crate::repo_ctx::RepoContext;
11use crate::revset;
12use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
13use crate::views::change as v;
14use crate::views::repo as rv;
15use crate::views::{self, Chrome};
16
17#[derive(Deserialize, Default)]
18pub struct ListQuery {
19 pub state: Option<String>,
20 pub revset: Option<String>,
21}
22
23/// `GET /{owner}/{repo}/changes`
24pub 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.
105fn 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
118async 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.
227async 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.
291async 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.
328async 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
363pub 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
377pub enum Resolution {
378 One(Box<ChangeRecord>),
379 /// (number, change_id, title)
380 Ambiguous(Vec<(i64, String, String)>),
381 None,
382}
383
384type 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
398fn 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
414const 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
419pub 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.
477pub 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)]
516pub struct NewQuery {
517 pub error: Option<String>,
518}
519
520#[derive(Deserialize)]
521pub 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`
529pub 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.
615async 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