Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Change detail, files, revisions, conflicts, comments, reviews, stacks
2//! (M3, M4).
3//!
4//! Split from `routes::change` — which owns the *list* — because the review
5//! surface is where most of the product's behaviour lives and the two have very
6//! little in common beyond resolving a change.
7//!
8//! Two rules every handler here follows:
9//!
10//! * `RepoContext::load` first, so an unauthorised private repository 404s
11//! before anything else runs.
12//! * Permission checks name the capability, not the role: `can_comment`,
13//! `can_manage_changes`. The mapping from role to capability lives in
14//! `df_auth::permissions` and nowhere else (spec §6).
15
16use axum::extract::{Path as UrlPath, Query, State};
17use axum::response::{IntoResponse, Redirect, Response};
18use axum::Form;
19use df_db::ids::new_id;
20use df_store::{DiffOpts, RevId};
21use serde::Deserialize;
22use uuid::Uuid;
23
24use crate::error::{AppError, AppResult};
25use crate::repo_ctx::RepoContext;
26use crate::routes::change::{resolve_change, ChangeRecord, Resolution};
27use crate::routes::settings::urlencode;
28use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
29use crate::views::change as cv;
30use crate::views::repo as rv;
31use crate::views::review as v;
32use crate::views::{self, Chrome};
33
34/// Everything the change pages load before they diverge.
35struct Loaded {
36 ctx: RepoContext,
37 change: ChangeRecord,
38 /// (seq, rev), oldest first.
39 revisions: Vec<(i32, String)>,
40 author: Option<String>,
41 author_name: Option<String>,
42 can_manage: bool,
43 comment_count: i64,
44 /// The right-hand column, identical on every tab.
45 aside: v::ChangeAside,
46}
47
48impl Loaded {
49 fn head(&self) -> Option<&str> {
50 self.revisions.last().map(|(_, r)| r.as_str())
51 }
52}
53
54/// Resolve the repo and the change, or produce the response that stands in for
55/// it — a 404, or the disambiguation page for an ambiguous prefix.
56async fn load(
57 state: &AppState,
58 owner: &str,
59 name: &str,
60 reference: &str,
61 user: Option<&df_db::models::User>,
62 csrf: &str,
63 nonce: &str,
64) -> AppResult<Result<Loaded, Response>> {
65 let ctx = RepoContext::load(state, owner, name, user).await?;
66
67 let change = match resolve_change(state, ctx.repo.id, reference).await? {
68 Resolution::One(c) => *c,
69 Resolution::Ambiguous(candidates) => {
70 let body = maud::html! {
71 (cv::ambiguous(&ctx, reference, &candidates))
72 };
73 return Ok(Err(views::page_with_bar(
74 Chrome { title: "Ambiguous change id", user, csrf, nonce },
75 rv::header(&ctx, "changes"),
76 body,
77 )
78 .into_response()));
79 }
80 Resolution::None => return Err(AppError::NotFound),
81 };
82
83 let revisions: Vec<(i32, String)> =
84 sqlx::query_as("SELECT seq, rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq")
85 .bind(change.id)
86 .fetch_all(&state.db)
87 .await?;
88
89 let author: Option<String> = sqlx::query_scalar(
90 "SELECT u.handle::text FROM users u
91 JOIN changes c ON c.author_user_id = u.id WHERE c.id = $1",
92 )
93 .bind(change.id)
94 .fetch_optional(&state.db)
95 .await?;
96
97 // What the commit itself says, for when no account matched its email.
98 let author_name: Option<String> = sqlx::query_scalar(
99 "SELECT r.author_name FROM revisions r
100 JOIN changes c ON c.head_revision_id = r.id WHERE c.id = $1",
101 )
102 .bind(change.id)
103 .fetch_optional(&state.db)
104 .await?;
105
106 // The author of a change manages it even without the maintain role — it is
107 // their work, and requiring a maintainer to retitle your own change would be
108 // absurd. Everything else still needs the role.
109 let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
110 let can_manage = ctx.access.can_manage_changes() || is_author;
111
112 let comment_count: i64 =
113 sqlx::query_scalar("SELECT count(*) FROM comments WHERE change_id_fk = $1")
114 .bind(change.id)
115 .fetch_one(&state.db)
116 .await?;
117
118 let aside = load_aside(state, &ctx, &change).await?;
119
120 Ok(Ok(Loaded {
121 ctx,
122 change,
123 revisions,
124 author,
125 author_name,
126 can_manage,
127 comment_count,
128 aside,
129 }))
130}
131
132/// Reviewers and the surrounding stack — the aside on every change tab.
133async fn load_aside(
134 state: &AppState,
135 ctx: &RepoContext,
136 change: &ChangeRecord,
137) -> AppResult<v::ChangeAside> {
138 // `DISTINCT ON` keeps each reviewer's most recent verdict: somebody who
139 // approved and later requested changes has one current position, not two.
140 let reviewers: Vec<(String, String, bool)> = sqlx::query_as(
141 r#"
142 SELECT DISTINCT ON (rv.reviewer_id)
143 u.handle::text,
144 rv.verdict::text,
145 (rv.revision_id = c.head_revision_id) AS at_head
146 FROM reviews rv
147 JOIN changes c ON c.id = rv.change_id_fk
148 JOIN users u ON u.id = rv.reviewer_id
149 WHERE rv.change_id_fk = $1
150 ORDER BY rv.reviewer_id, rv.created_at DESC
151 "#,
152 )
153 .bind(change.id)
154 .fetch_all(&state.db)
155 .await?;
156
157 // The chain this change sits in, walked in both directions. A recursive CTE
158 // rather than a fixed number of joins, because a stack has no maximum
159 // depth — and `UNION` (not `UNION ALL`) is what makes a cycle in a
160 // corrupted edge table terminate rather than run forever.
161 let chain: Vec<(String, i64, String, bool, i32)> = sqlx::query_as(
162 r#"
163 WITH RECURSIVE down AS (
164 SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
165 UNION
166 SELECT p.id, d.depth - 1
167 FROM down d
168 JOIN change_edges e ON e.child_change = d.id
169 JOIN changes p ON p.id = e.parent_change
170 ),
171 up AS (
172 SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
173 UNION
174 SELECT ch.id, u.depth + 1
175 FROM up u
176 JOIN change_edges e ON e.parent_change = u.id
177 JOIN changes ch ON ch.id = e.child_change
178 ),
179 chain AS (
180 SELECT id, min(depth) AS depth FROM (
181 SELECT * FROM down UNION ALL SELECT * FROM up
182 ) combined GROUP BY id
183 )
184 SELECT c.change_id, c.number, c.state::text, c.conflicted, chain.depth::int
185 FROM chain
186 JOIN changes c ON c.id = chain.id
187 WHERE c.repo_id = $2
188 ORDER BY chain.depth DESC
189 "#,
190 )
191 .bind(change.id)
192 .bind(ctx.repo.id)
193 .fetch_all(&state.db)
194 .await?;
195
196 // Depths come back relative to this change, which can make them negative.
197 // Shift so the bottom of the stack sits at zero, because the rail indents
198 // from there.
199 let floor = chain.iter().map(|(_, _, _, _, d)| *d).min().unwrap_or(0);
200
201 Ok(v::ChangeAside {
202 reviewers: reviewers
203 .into_iter()
204 .map(|(handle, verdict, at_head)| crate::views::change::Reviewer {
205 handle,
206 verdict,
207 at_head,
208 })
209 .collect(),
210 stack: chain
211 .into_iter()
212 .map(|(cid, number, st, conflicted, depth)| v::StackNodeMini {
213 is_current: cid == change.change_id,
214 change_id: cid,
215 number,
216 state: st,
217 conflicted,
218 depth: (depth - floor) as usize,
219 })
220 .collect(),
221 })
222}
223
224impl Loaded {
225 fn head_view<'a>(&'a self, csrf: &'a str, can_comment: bool) -> v::ChangeHead<'a> {
226 v::ChangeHead {
227 number: self.change.number,
228 change_id: &self.change.change_id,
229 synthetic: self.change.synthetic,
230 title: &self.change.title,
231 state: &self.change.state,
232 conflicted: self.change.conflicted,
233 target_bookmark: &self.change.target_bookmark,
234 author: self.author.as_deref(),
235 author_name: self.author_name.as_deref(),
236 revision_count: self.revisions.len(),
237 head_commit: self.head().map(df_store::abbreviate_rev),
238 created_at: self.change.created_at,
239 updated_at: self.change.updated_at,
240 file_count: None,
241 comment_count: self.comment_count,
242 can_manage: self.can_manage,
243 can_comment,
244 csrf,
245 }
246 }
247}
248
249// ─── overview ────────────────────────────────────────────────────────────────
250
251#[derive(Deserialize, Default)]
252pub struct Flash {
253 pub error: Option<String>,
254 pub notice: Option<String>,
255}
256
257/// `GET /{owner}/{repo}/changes/{ref}`
258pub async fn overview(
259 State(state): State<AppState>,
260 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
261 Query(flash): Query<Flash>,
262 CurrentUser(user): CurrentUser,
263 CsrfToken(csrf): CsrfToken,
264 Nonce(nonce): Nonce,
265) -> AppResult<Response> {
266 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
267 Ok(l) => l,
268 Err(res) => return Ok(res),
269 };
270
271 let can_comment = user.is_some() && l.ctx.access.can_comment();
272 let head = l.head_view(&csrf, can_comment);
273
274 let all_comments = load_comments(&state, &l.ctx, l.change.id).await?;
275 // Top-level and orphaned comments live in the timeline; the rest belong in
276 // the diff, where the line they are about is (spec §5 step 5).
277 let (orphaned, rest): (Vec<_>, Vec<_>) = all_comments
278 .into_iter()
279 .partition(|c| c.anchor_state == "orphaned");
280 let top_level: Vec<v::CommentRow> =
281 rest.into_iter().filter(|c| c.anchor_path.is_none()).collect();
282
283 let reviews = load_reviews(&state, &l.ctx, l.change.id, l.head()).await?;
284 let events = load_events(&state, l.ctx.repo.id, l.change.id).await?;
285
286 let viewer_reviewed = match (&user, l.head()) {
287 (Some(u), Some(_)) => reviews
288 .iter()
289 .any(|r| r.reviewer == u.handle && r.is_head),
290 _ => false,
291 };
292
293 // Cross-references resolve against this repository (spec §8).
294 let description_html = crate::routes::issue::render(&l.ctx, &l.change.description);
295
296 let body = maud::html! {
297 (v::header(&l.ctx, &head, "overview"))
298 (v::tab_body(&l.ctx, &l.aside, maud::html! {
299 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
300 @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
301 (v::overview(&l.ctx, &head, v::Overview {
302 description_html: &description_html,
303 description_raw: &l.change.description,
304 comments: &top_level,
305 orphaned: &orphaned,
306 reviews: &reviews,
307 events: &events,
308 viewer_reviewed,
309 }))
310 }))
311 };
312
313 Ok(views::page_with_bar(
314 Chrome {
315 title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name),
316 user: user.as_deref(),
317 csrf: &csrf,
318 nonce: &nonce,
319 },
320 rv::header(&l.ctx, "changes"),
321 body,
322 )
323 .into_response())
324}
325
326// ─── files ───────────────────────────────────────────────────────────────────
327
328#[derive(Deserialize, Default)]
329pub struct FilesQuery {
330 pub rev: Option<String>,
331 pub against: Option<String>,
332 /// Fold every file. A link rather than a script, so the folded view is a
333 /// URL and works with scripting off.
334 pub collapse: Option<String>,
335}
336
337/// `GET /{owner}/{repo}/changes/{ref}/files`
338///
339/// `rev` and `against` are revision tokens, and both are validated against the
340/// revisions *of this change* before reaching the store. That check is what
341/// stops the query string being used to diff arbitrary objects out of a
342/// repository the viewer can only partly see.
343pub async fn files(
344 State(state): State<AppState>,
345 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
346 Query(q): Query<FilesQuery>,
347 CurrentUser(user): CurrentUser,
348 CsrfToken(csrf): CsrfToken,
349 Nonce(nonce): Nonce,
350) -> AppResult<Response> {
351 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
352 Ok(l) => l,
353 Err(res) => return Ok(res),
354 };
355
356 let can_comment = user.is_some() && l.ctx.access.can_comment();
357 let head = l.head_view(&csrf, can_comment);
358
359 let known = |r: &str| l.revisions.iter().any(|(_, rev)| rev == r);
360
361 let rev = match q.rev.as_deref() {
362 Some(r) if known(r) => Some(r.to_owned()),
363 Some(_) => return Err(AppError::NotFound),
364 None => l.head().map(str::to_owned),
365 };
366 let against = match q.against.as_deref().filter(|s| !s.is_empty()) {
367 Some(r) if known(r) => Some(r.to_owned()),
368 Some(_) => return Err(AppError::NotFound),
369 None => None,
370 };
371
372 let opts = DiffOpts {
373 context_lines: 3,
374 max_files: state.config.max_diff_files,
375 max_lines: state.config.max_diff_lines,
376 };
377
378 let diff = match (&rev, &against) {
379 (Some(r), Some(a)) => state
380 .store
381 .diff(
382 l.ctx.store_id(),
383 &RevId::from_stored(a.clone()),
384 &RevId::from_stored(r.clone()),
385 opts,
386 )
387 .await
388 .ok(),
389 (Some(r), None) => state
390 .store
391 .diff_from_parent(l.ctx.store_id(), &RevId::from_stored(r.clone()), opts)
392 .await
393 .ok(),
394 _ => None,
395 };
396
397 // Only inline comments that still have a home in the diff.
398 let comments: Vec<v::CommentRow> = load_comments(&state, &l.ctx, l.change.id)
399 .await?
400 .into_iter()
401 .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned")
402 .collect();
403
404 // The tab strip can only count files once the diff has been computed.
405 let head = v::ChangeHead { file_count: diff.as_ref().map(|d| d.files.len()), ..head };
406
407 let body = maud::html! {
408 (v::header(&l.ctx, &head, "files"))
409 (v::tab_body(&l.ctx, &l.aside, v::files(&l.ctx, &head, v::FilesView {
410 diff: diff.as_ref(),
411 comments: &comments,
412 rev: rev.as_deref().unwrap_or(""),
413 against: against.as_deref(),
414 revisions: &l.revisions,
415 collapsed: q.collapse.is_some(),
416 })))
417 };
418
419 Ok(views::page_with_bar(
420 Chrome {
421 title: &format!("Files · {}", l.change.title),
422 user: user.as_deref(),
423 csrf: &csrf,
424 nonce: &nonce,
425 },
426 rv::header(&l.ctx, "changes"),
427 body,
428 )
429 .into_response())
430}
431
432// ─── revisions ───────────────────────────────────────────────────────────────
433
434/// Which two revisions the interdiff compares.
435#[derive(Deserialize, Default)]
436pub struct CompareQuery {
437 pub a: Option<i32>,
438 pub b: Option<i32>,
439}
440
441/// `GET /{owner}/{repo}/changes/{ref}/revisions`
442pub async fn revisions(
443 State(state): State<AppState>,
444 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
445 Query(q): Query<CompareQuery>,
446 CurrentUser(user): CurrentUser,
447 CsrfToken(csrf): CsrfToken,
448 Nonce(nonce): Nonce,
449) -> AppResult<Response> {
450 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
451 Ok(l) => l,
452 Err(res) => return Ok(res),
453 };
454 let head = l.head_view(&csrf, false);
455
456 /// `(seq, rev, message, author_name, pushed_at, conflicted, pushed_by,
457 /// parents)`
458 type RevRow = (
459 i32,
460 String,
461 String,
462 String,
463 chrono::DateTime<chrono::Utc>,
464 bool,
465 Option<String>,
466 Vec<String>,
467 );
468
469 let rows: Vec<RevRow> = sqlx::query_as(
470 "SELECT r.seq, r.rev, r.message, r.author_name, r.pushed_at, r.conflicted,
471 u.handle::text, r.parents
472 FROM revisions r
473 LEFT JOIN users u ON u.id = r.pushed_by
474 WHERE r.change_id_fk = $1 ORDER BY r.seq",
475 )
476 .bind(l.change.id)
477 .fetch_all(&state.db)
478 .await?;
479
480 // One repository open for every revision's diffstat, not one per row.
481 let all: Vec<RevId> = rows
482 .iter()
483 .map(|r| RevId::from_stored(r.1.clone()))
484 .collect();
485 let stats = state
486 .store
487 .diff_stats(l.ctx.store_id(), &all)
488 .await
489 .unwrap_or_default();
490
491 let revs: Vec<v::RevisionDetail> = rows
492 .into_iter()
493 .enumerate()
494 .map(
495 |(i, (seq, rev, message, author_name, pushed_at, conflicted, pushed_by, parents))| {
496 v::RevisionDetail {
497 seq,
498 rev,
499 message,
500 author_name,
501 pushed_at,
502 conflicted,
503 pushed_by,
504 diffstat: stats.get(i).copied().flatten(),
505 base: parents.first().map(|p| df_store::abbreviate_rev(p).to_owned()),
506 }
507 },
508 )
509 .collect();
510
511 // Defaults: the previous revision against the head, which is the comparison
512 // a returning reviewer wants. Out-of-range values are clamped rather than
513 // rejected — a stale link from before a revision was removed should still
514 // land on something sensible.
515 let last = revs.last().map(|r| r.seq).unwrap_or(1);
516 let first = revs.first().map(|r| r.seq).unwrap_or(1);
517 let clamp = |n: i32| n.clamp(first, last);
518 let b = clamp(q.b.unwrap_or(last));
519 let a = clamp(q.a.unwrap_or((b - 1).max(first)));
520
521 // The interdiff itself: two revisions of the same change, diffed against
522 // each other. This is the view a force-push destroys on a branch-based
523 // forge, and the reason revisions are stored rather than derived.
524 let diff = match (
525 revs.iter().find(|r| r.seq == a),
526 revs.iter().find(|r| r.seq == b),
527 ) {
528 (Some(ra), Some(rb)) if a != b => state
529 .store
530 .diff(
531 l.ctx.store_id(),
532 &RevId::from_stored(ra.rev.clone()),
533 &RevId::from_stored(rb.rev.clone()),
534 df_store::DiffOpts::default(),
535 )
536 .await
537 .ok(),
538 _ => None,
539 };
540
541 let body = maud::html! {
542 (v::header(&l.ctx, &head, "revisions"))
543 (v::tab_body(&l.ctx, &l.aside,
544 v::revisions(&l.ctx, &head, &revs, v::Compare { a, b, diff: diff.as_ref() })))
545 };
546
547 Ok(views::page_with_bar(
548 Chrome {
549 title: &format!("Revisions · {}", l.change.title),
550 user: user.as_deref(),
551 csrf: &csrf,
552 nonce: &nonce,
553 },
554 rv::header(&l.ctx, "changes"),
555 body,
556 )
557 .into_response())
558}
559
560/// `GET /{owner}/{repo}/changes/{ref}/checks`
561///
562/// Renders an honest empty state — see [`views::review::checks`].
563pub async fn checks(
564 State(state): State<AppState>,
565 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
566 CurrentUser(user): CurrentUser,
567 CsrfToken(csrf): CsrfToken,
568 Nonce(nonce): Nonce,
569) -> AppResult<Response> {
570 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
571 Ok(l) => l,
572 Err(res) => return Ok(res),
573 };
574 let head = l.head_view(&csrf, false);
575
576 let body = maud::html! {
577 (v::header(&l.ctx, &head, "checks"))
578 (v::tab_body(&l.ctx, &l.aside, v::checks(&l.ctx, &head)))
579 };
580
581 Ok(views::page_with_bar(
582 Chrome {
583 title: &format!("Checks · {}", l.change.title),
584 user: user.as_deref(),
585 csrf: &csrf,
586 nonce: &nonce,
587 },
588 rv::header(&l.ctx, "changes"),
589 body,
590 )
591 .into_response())
592}
593
594// ─── conflicts ───────────────────────────────────────────────────────────────
595
596/// `GET /{owner}/{repo}/changes/{ref}/conflicts`
597pub async fn conflicts(
598 State(state): State<AppState>,
599 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
600 CurrentUser(user): CurrentUser,
601 CsrfToken(csrf): CsrfToken,
602 Nonce(nonce): Nonce,
603) -> AppResult<Response> {
604 let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
605 Ok(l) => l,
606 Err(res) => return Ok(res),
607 };
608 let head = l.head_view(&csrf, false);
609
610 let files = match l.head() {
611 Some(rev) => state
612 .store
613 .conflicts(l.ctx.store_id(), &RevId::from_stored(rev.to_owned()))
614 .await
615 .unwrap_or_else(|e| {
616 tracing::warn!(change = %l.change.id, "reading conflicts failed: {e}");
617 Vec::new()
618 }),
619 None => Vec::new(),
620 };
621
622 let body = maud::html! {
623 (v::header(&l.ctx, &head, "conflicts"))
624 (v::tab_body(&l.ctx, &l.aside, v::conflicts(&l.ctx, &head, &files)))
625 };
626
627 Ok(views::page_with_bar(
628 Chrome {
629 title: &format!("Conflicts · {}", l.change.title),
630 user: user.as_deref(),
631 csrf: &csrf,
632 nonce: &nonce,
633 },
634 rv::header(&l.ctx, "changes"),
635 body,
636 )
637 .into_response())
638}
639
640// ─── comments ────────────────────────────────────────────────────────────────
641
642#[derive(Deserialize)]
643pub struct NewComment {
644 pub body: String,
645 /// Present for inline comments.
646 pub path: Option<String>,
647 pub line: Option<i32>,
648 pub side: Option<String>,
649 pub rev: Option<String>,
650 pub context: Option<String>,
651}
652
653/// `POST /{owner}/{repo}/changes/{ref}/comments`
654pub async fn create_comment(
655 State(state): State<AppState>,
656 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
657 CurrentUser(user): CurrentUser,
658 CsrfToken(csrf): CsrfToken,
659 Form(form): Form<NewComment>,
660) -> AppResult<Response> {
661 let Some(user) = user else {
662 return Err(AppError::Unauthorized);
663 };
664 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
665 Ok(l) => l,
666 Err(_) => return Err(AppError::NotFound),
667 };
668
669 if !l.ctx.access.can_comment() {
670 return Err(AppError::Forbidden);
671 }
672
673 let body = form.body.trim();
674 if body.is_empty() {
675 return Ok(back(&l, "Comments cannot be empty."));
676 }
677 // A comment long enough to be a denial-of-service is not a comment.
678 if body.len() > 64 * 1024 {
679 return Ok(back(&l, "That comment is too long."));
680 }
681
682 // The anchor revision is resolved to a row id, and only from revisions that
683 // belong to this change — a forged `rev` cannot attach a comment to somebody
684 // else's change.
685 let anchor_revision: Option<Uuid> = match form.rev.as_deref() {
686 Some(rev) => sqlx::query_scalar(
687 "SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2",
688 )
689 .bind(l.change.id)
690 .bind(rev)
691 .fetch_optional(&state.db)
692 .await?,
693 None => None,
694 };
695
696 let is_inline = form.path.is_some() && form.line.is_some();
697 let side = form
698 .side
699 .as_deref()
700 .filter(|s| *s == "old" || *s == "new")
701 .unwrap_or("new");
702
703 sqlx::query(
704 "INSERT INTO comments (id, repo_id, change_id_fk, author_user_id, body,
705 anchor_revision, anchor_path, anchor_line, anchor_side,
706 anchor_context)
707 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
708 )
709 .bind(new_id())
710 .bind(l.ctx.repo.id)
711 .bind(l.change.id)
712 .bind(user.id)
713 .bind(body)
714 .bind(anchor_revision)
715 .bind(is_inline.then(|| form.path.clone()).flatten())
716 .bind(is_inline.then_some(form.line).flatten())
717 .bind(is_inline.then_some(side))
718 // The line's text at the time of writing. This is what anchor rebasing
719 // matches on, and what an outdated comment still shows (spec §5).
720 .bind(is_inline.then(|| form.context.clone()).flatten())
721 .execute(&state.db)
722 .await?;
723
724 crate::routes::issue::record_references(
725 &state,
726 l.ctx.repo.id,
727 "change",
728 l.change.id,
729 body,
730 )
731 .await;
732
733 event(
734 &state,
735 l.ctx.repo.id,
736 Some(user.id),
737 "change.commented",
738 l.change.id,
739 serde_json::json!({}),
740 )
741 .await;
742
743 touch(&state, l.change.id).await;
744
745 // Back to where the comment was written: the diff for an inline comment,
746 // the overview for a top-level one.
747 Ok(if is_inline {
748 Redirect::to(&format!(
749 "{}/changes/{}/files{}",
750 l.ctx.base(),
751 l.change.number,
752 form.rev
753 .as_deref()
754 .map(|r| format!("?rev={r}"))
755 .unwrap_or_default()
756 ))
757 .into_response()
758 } else {
759 Redirect::to(&format!("{}/changes/{}", l.ctx.base(), l.change.number)).into_response()
760 })
761}
762
763/// `POST /{owner}/{repo}/changes/{ref}/comments/{id}/resolve`
764pub async fn resolve_comment(
765 State(state): State<AppState>,
766 UrlPath((owner, name, reference, comment_id)): UrlPath<(String, String, String, Uuid)>,
767 CurrentUser(user): CurrentUser,
768 CsrfToken(csrf): CsrfToken,
769) -> AppResult<Response> {
770 let Some(user) = user else {
771 return Err(AppError::Unauthorized);
772 };
773 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
774 Ok(l) => l,
775 Err(_) => return Err(AppError::NotFound),
776 };
777 if !l.can_manage {
778 return Err(AppError::Forbidden);
779 }
780
781 // Scoped to this change, so a comment id from another repository does
782 // nothing here.
783 sqlx::query(
784 "UPDATE comments SET resolved_at = now(), resolved_by = $3
785 WHERE id = $1 AND change_id_fk = $2 AND resolved_at IS NULL",
786 )
787 .bind(comment_id)
788 .bind(l.change.id)
789 .bind(user.id)
790 .execute(&state.db)
791 .await?;
792
793 Ok(back(&l, ""))
794}
795
796// ─── reviews ─────────────────────────────────────────────────────────────────
797
798#[derive(Deserialize)]
799pub struct NewReview {
800 pub verdict: String,
801 pub body: Option<String>,
802}
803
804/// `POST /{owner}/{repo}/changes/{ref}/reviews`
805pub async fn create_review(
806 State(state): State<AppState>,
807 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
808 CurrentUser(user): CurrentUser,
809 CsrfToken(csrf): CsrfToken,
810 Form(form): Form<NewReview>,
811) -> AppResult<Response> {
812 let Some(user) = user else {
813 return Err(AppError::Unauthorized);
814 };
815 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
816 Ok(l) => l,
817 Err(_) => return Err(AppError::NotFound),
818 };
819 if !l.ctx.access.can_comment() {
820 return Err(AppError::Forbidden);
821 }
822
823 if !matches!(form.verdict.as_str(), "approve" | "request_changes" | "comment") {
824 return Ok(back(&l, "Unknown verdict."));
825 }
826
827 // A review is *of a revision*, not of a change. That is what makes an
828 // approval go stale when the author pushes again, and it is the whole point
829 // of indexing revisions separately.
830 let Some(head) = l.head() else {
831 return Ok(back(&l, "This change has no revisions to review."));
832 };
833 let revision_id: Uuid =
834 sqlx::query_scalar("SELECT id FROM revisions WHERE change_id_fk = $1 AND rev = $2")
835 .bind(l.change.id)
836 .bind(head)
837 .fetch_one(&state.db)
838 .await?;
839
840 sqlx::query(
841 "INSERT INTO reviews (id, change_id_fk, revision_id, reviewer_id, verdict, body)
842 VALUES ($1, $2, $3, $4, $5::review_verdict, $6)",
843 )
844 .bind(new_id())
845 .bind(l.change.id)
846 .bind(revision_id)
847 .bind(user.id)
848 .bind(&form.verdict)
849 .bind(form.body.as_deref().map(str::trim).filter(|s| !s.is_empty()))
850 .execute(&state.db)
851 .await?;
852
853 event(
854 &state,
855 l.ctx.repo.id,
856 Some(user.id),
857 "change.reviewed",
858 l.change.id,
859 serde_json::json!({ "verdict": form.verdict }),
860 )
861 .await;
862 touch(&state, l.change.id).await;
863
864 Ok(back(&l, ""))
865}
866
867// ─── state and editing ───────────────────────────────────────────────────────
868
869#[derive(Deserialize)]
870pub struct EditChange {
871 pub title: String,
872 pub description: Option<String>,
873}
874
875/// `POST /{owner}/{repo}/changes/{ref}/edit`
876pub async fn edit(
877 State(state): State<AppState>,
878 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
879 CurrentUser(user): CurrentUser,
880 CsrfToken(csrf): CsrfToken,
881 Form(form): Form<EditChange>,
882) -> AppResult<Response> {
883 let Some(user) = user else {
884 return Err(AppError::Unauthorized);
885 };
886 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
887 Ok(l) => l,
888 Err(_) => return Err(AppError::NotFound),
889 };
890 if !l.can_manage {
891 return Err(AppError::Forbidden);
892 }
893
894 let title: String = form.title.trim().chars().take(300).collect();
895 if title.is_empty() {
896 return Ok(back(&l, "A change needs a title."));
897 }
898
899 let description = form.description.as_deref().unwrap_or("").trim();
900
901 sqlx::query(
902 "UPDATE changes SET title = $2, description = $3, updated_at = now() WHERE id = $1",
903 )
904 .bind(l.change.id)
905 .bind(&title)
906 .bind(description)
907 .execute(&state.db)
908 .await?;
909
910 // `#123` in a change description makes the issue show the change under
911 // "Referenced by". Recorded on write rather than scanned on read.
912 crate::routes::issue::record_references(
913 &state,
914 l.ctx.repo.id,
915 "change",
916 l.change.id,
917 description,
918 )
919 .await;
920
921 Ok(back(&l, ""))
922}
923
924#[derive(Deserialize)]
925pub struct SetState {
926 pub state: String,
927}
928
929/// `POST /{owner}/{repo}/changes/{ref}/state`
930///
931/// Draft, abandon, and reopen. These are the states the *author* owns; the
932/// indexer computes merged and must never overwrite them (decided, and pinned
933/// by `indexer::next_state`).
934pub async fn set_state(
935 State(state): State<AppState>,
936 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
937 CurrentUser(user): CurrentUser,
938 CsrfToken(csrf): CsrfToken,
939 Form(form): Form<SetState>,
940) -> AppResult<Response> {
941 let Some(user) = user else {
942 return Err(AppError::Unauthorized);
943 };
944 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
945 Ok(l) => l,
946 Err(_) => return Err(AppError::NotFound),
947 };
948 if !l.can_manage {
949 return Err(AppError::Forbidden);
950 }
951
952 // `merged` is not settable here: it is a fact about the target bookmark, and
953 // letting the UI assert it would make the state lie about the repository.
954 let next = match form.state.as_str() {
955 "draft" => "draft",
956 "open" => "open",
957 "abandoned" => "abandoned",
958 _ => return Ok(back(&l, "Unknown state.")),
959 };
960
961 sqlx::query("UPDATE changes SET state = $2::change_state, updated_at = now() WHERE id = $1")
962 .bind(l.change.id)
963 .bind(next)
964 .execute(&state.db)
965 .await?;
966
967 let kind = match (l.change.state.as_str(), next) {
968 (_, "draft") => "change.drafted",
969 (_, "abandoned") => "change.abandoned",
970 ("draft", "open") => "change.ready",
971 (_, "open") => "change.reopened",
972 _ => "change.updated",
973 };
974 event(&state, l.ctx.repo.id, Some(user.id), kind, l.change.id, serde_json::json!({})).await;
975
976 Ok(back(&l, ""))
977}
978
979// ─── stacks ──────────────────────────────────────────────────────────────────
980
981/// `GET /{owner}/{repo}/stacks/{change_id}`
982pub async fn stack(
983 State(state): State<AppState>,
984 UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>,
985 CurrentUser(user): CurrentUser,
986 CsrfToken(csrf): CsrfToken,
987 Nonce(nonce): Nonce,
988) -> AppResult<Response> {
989 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
990
991 let nodes = load_stack(&state, ctx.repo.id, &change_id).await?;
992 if nodes.is_empty() {
993 return Err(AppError::NotFound);
994 }
995
996 // Every change in a stack targets the same bookmark, so the bottom one
997 // names what the whole chain lands on.
998 let target: Option<String> = match nodes.first() {
999 Some(bottom) => sqlx::query_scalar(
1000 "SELECT target_bookmark FROM changes WHERE repo_id = $1 AND number = $2",
1001 )
1002 .bind(ctx.repo.id)
1003 .bind(bottom.number)
1004 .fetch_optional(&state.db)
1005 .await?,
1006 None => None,
1007 };
1008
1009 let body = maud::html! {
1010 (v::stack(
1011 &ctx,
1012 &nodes,
1013 &change_id,
1014 target.as_deref(),
1015 &csrf,
1016 ctx.access.can_manage_changes(),
1017 ))
1018 };
1019
1020 Ok(views::page_with_bar(
1021 Chrome {
1022 title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name),
1023 user: user.as_deref(),
1024 csrf: &csrf,
1025 nonce: &nonce,
1026 },
1027 rv::header(&ctx, "changes"),
1028 body,
1029 )
1030 .into_response())
1031}
1032
1033/// Walk `change_edges` down to the bottom of the stack and back up.
1034///
1035/// Reads the precomputed edges rather than the commit graph — spec §4: "do not
1036/// recompute the graph on page render".
1037async fn load_stack(
1038 state: &AppState,
1039 repo_id: Uuid,
1040 change_id: &str,
1041) -> AppResult<Vec<v::StackNode>> {
1042 // A recursive CTE in both directions from the requested change. Depth is
1043 // bounded so a cycle in the edge table — which should be impossible, but the
1044 // page must not hang if one ever appears — terminates.
1045 let rows: Vec<(i64, String, bool, String, String, bool, i32)> = sqlx::query_as(
1046 r#"
1047 WITH RECURSIVE start AS (
1048 SELECT id FROM changes WHERE repo_id = $1 AND change_id = $2
1049 ),
1050 down AS (
1051 SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id
1052 UNION ALL
1053 SELECT e.parent_change, d.depth - 1
1054 FROM change_edges e JOIN down d ON d.id = e.child_change
1055 WHERE e.repo_id = $1 AND d.depth > -50
1056 ),
1057 up AS (
1058 SELECT c.id, 0 AS depth FROM changes c JOIN start s ON s.id = c.id
1059 UNION ALL
1060 SELECT e.child_change, u.depth + 1
1061 FROM change_edges e JOIN up u ON u.id = e.parent_change
1062 WHERE e.repo_id = $1 AND u.depth < 50
1063 ),
1064 all_nodes AS (
1065 SELECT id, min(depth) AS depth FROM (
1066 SELECT id, depth FROM down UNION ALL SELECT id, depth FROM up
1067 ) x GROUP BY id
1068 )
1069 SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text, c.conflicted,
1070 (n.depth - (SELECT min(depth) FROM all_nodes))::int AS rel_depth
1071 FROM all_nodes n JOIN changes c ON c.id = n.id
1072 ORDER BY n.depth
1073 "#,
1074 )
1075 .bind(repo_id)
1076 .bind(change_id)
1077 .fetch_all(&state.db)
1078 .await?;
1079
1080 Ok(rows
1081 .into_iter()
1082 .map(
1083 |(number, cid, synthetic, title, st, conflicted, depth)| v::StackNode {
1084 is_current: cid == change_id,
1085 number,
1086 change_id: cid,
1087 synthetic,
1088 title,
1089 state: st,
1090 conflicted,
1091 depth: depth.max(0) as usize,
1092 },
1093 )
1094 .collect())
1095}
1096
1097// ─── loading helpers ─────────────────────────────────────────────────────────
1098
1099async fn load_comments(
1100 state: &AppState,
1101 ctx: &RepoContext,
1102 change_id: Uuid,
1103) -> AppResult<Vec<v::CommentRow>> {
1104 type Row = (
1105 Uuid,
1106 String,
1107 String,
1108 chrono::DateTime<chrono::Utc>,
1109 Option<chrono::DateTime<chrono::Utc>>,
1110 Option<String>,
1111 Option<i32>,
1112 Option<String>,
1113 String,
1114 Option<String>,
1115 Option<chrono::DateTime<chrono::Utc>>,
1116 );
1117
1118 let rows: Vec<Row> = sqlx::query_as(
1119 "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at,
1120 c.anchor_path, c.anchor_line, c.anchor_side, c.anchor_state::text,
1121 c.anchor_context, c.resolved_at
1122 FROM comments c JOIN users u ON u.id = c.author_user_id
1123 WHERE c.change_id_fk = $1
1124 ORDER BY c.created_at",
1125 )
1126 .bind(change_id)
1127 .fetch_all(&state.db)
1128 .await?;
1129
1130 Ok(rows
1131 .into_iter()
1132 .map(|r| v::CommentRow {
1133 id: r.0,
1134 author: r.1,
1135 // Comment bodies are user input rendered on our origin, so they go
1136 // through the same sanitiser a README does, and then get their
1137 // cross-references resolved.
1138 body_html: crate::routes::issue::render(ctx, &r.2),
1139 created_at: r.3,
1140 edited: r.4.is_some(),
1141 anchor_path: r.5,
1142 anchor_line: r.6,
1143 anchor_side: r.7,
1144 anchor_state: r.8,
1145 anchor_context: r.9,
1146 resolved: r.10.is_some(),
1147 })
1148 .collect())
1149}
1150
1151async fn load_reviews(
1152 state: &AppState,
1153 ctx: &RepoContext,
1154 change_id: Uuid,
1155 head: Option<&str>,
1156) -> AppResult<Vec<v::ReviewRow>> {
1157 let rows: Vec<(String, String, Option<String>, chrono::DateTime<chrono::Utc>, String)> =
1158 sqlx::query_as(
1159 "SELECT u.handle::text, r.verdict::text, r.body, r.created_at, rev.rev
1160 FROM reviews r
1161 JOIN users u ON u.id = r.reviewer_id
1162 JOIN revisions rev ON rev.id = r.revision_id
1163 WHERE r.change_id_fk = $1
1164 ORDER BY r.created_at DESC",
1165 )
1166 .bind(change_id)
1167 .fetch_all(&state.db)
1168 .await?;
1169
1170 Ok(rows
1171 .into_iter()
1172 .map(|(reviewer, verdict, body, created_at, rev)| v::ReviewRow {
1173 is_head: head == Some(rev.as_str()),
1174 rev: df_store::abbreviate_rev(&rev).to_owned(),
1175 reviewer,
1176 verdict,
1177 body_html: body.map(|b| crate::routes::issue::render(ctx, &b)).unwrap_or_default(),
1178 created_at,
1179 })
1180 .collect())
1181}
1182
1183async fn load_events(
1184 state: &AppState,
1185 repo_id: Uuid,
1186 change_id: Uuid,
1187) -> AppResult<Vec<v::EventRow>> {
1188 let rows: Vec<(String, Option<String>, chrono::DateTime<chrono::Utc>, serde_json::Value)> =
1189 sqlx::query_as(
1190 "SELECT e.kind, u.handle::text, e.created_at, e.payload
1191 FROM events e LEFT JOIN users u ON u.id = e.actor_id
1192 WHERE e.repo_id = $1 AND e.subject_type = 'change' AND e.subject_id = $2
1193 -- `change.commented` duplicates the comment itself in the
1194 -- timeline; the comment is the better rendering of it.
1195 AND e.kind <> 'change.commented'
1196 ORDER BY e.created_at
1197 LIMIT 200",
1198 )
1199 .bind(repo_id)
1200 .bind(change_id)
1201 .fetch_all(&state.db)
1202 .await?;
1203
1204 Ok(rows
1205 .into_iter()
1206 .map(|(kind, actor, created_at, payload)| v::EventRow {
1207 kind,
1208 actor,
1209 created_at,
1210 payload,
1211 })
1212 .collect())
1213}
1214
1215// ─── small helpers ───────────────────────────────────────────────────────────
1216
1217/// Record a timeline event. Best-effort — losing one must not fail the action
1218/// that caused it, but it is logged loudly because a gap in the timeline is not
1219/// something a reader can detect.
1220pub async fn event(
1221 state: &AppState,
1222 repo_id: Uuid,
1223 actor: Option<Uuid>,
1224 kind: &str,
1225 subject: Uuid,
1226 payload: serde_json::Value,
1227) {
1228 if let Err(e) = sqlx::query(
1229 "INSERT INTO events (id, repo_id, actor_id, kind, subject_type, subject_id, payload)
1230 VALUES ($1, $2, $3, $4, 'change', $5, $6)",
1231 )
1232 .bind(new_id())
1233 .bind(repo_id)
1234 .bind(actor)
1235 .bind(kind)
1236 .bind(subject)
1237 .bind(payload)
1238 .execute(&state.db)
1239 .await
1240 {
1241 tracing::error!(%kind, "writing a timeline event failed: {e}");
1242 }
1243}
1244
1245/// Bump `updated_at` so the change list orders by real activity, not just pushes.
1246async fn touch(state: &AppState, change_id: Uuid) {
1247 let _ = sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1")
1248 .bind(change_id)
1249 .execute(&state.db)
1250 .await;
1251}
1252
1253fn back(l: &Loaded, error: &str) -> Response {
1254 let base = format!("{}/changes/{}", l.ctx.base(), l.change.number);
1255 if error.is_empty() {
1256 Redirect::to(&base).into_response()
1257 } else {
1258 Redirect::to(&format!("{base}?error={}", urlencode(error))).into_response()
1259 }
1260}
1261
1262// ─── merging (decided §13.2, §13.3) ──────────────────────────────────────────
1263
1264/// `POST /{owner}/{repo}/changes/{ref}/merge`
1265///
1266/// Server-side fast-forward, falling back to a merge commit (decided §13.2).
1267/// Refuses on conflict without writing anything.
1268pub async fn merge(
1269 State(state): State<AppState>,
1270 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
1271 CurrentUser(user): CurrentUser,
1272 CsrfToken(csrf): CsrfToken,
1273) -> AppResult<Response> {
1274 let Some(user) = user else {
1275 return Err(AppError::Unauthorized);
1276 };
1277 let l = match load(&state, &owner, &name, &reference, Some(&user), &csrf, "").await? {
1278 Ok(l) => l,
1279 Err(_) => return Err(AppError::NotFound),
1280 };
1281
1282 // Merging is a maintainer action even for your own change: landing work on
1283 // a shared bookmark is not the same permission as writing it.
1284 if !l.ctx.access.can_manage_changes() {
1285 return Err(AppError::Forbidden);
1286 }
1287 if l.ctx.repo.archived {
1288 return Ok(back(&l, "This repository is archived."));
1289 }
1290
1291 match land(&state, &l.ctx, &l.change, &user).await? {
1292 Landed::Ok(msg) => {
1293 enqueue_reindex(&state, l.ctx.repo.id).await;
1294 Ok(back_notice(&l, &msg))
1295 }
1296 Landed::Refused(msg) => Ok(back(&l, &msg)),
1297 }
1298}
1299
1300/// `POST /{owner}/{repo}/stacks/{change_id}/merge`
1301///
1302/// Decided §13.3: "Merge stack" lands the whole chain bottom-up in one action.
1303///
1304/// Bottom-up matters: landing the top of a stack first would either fail or
1305/// pull the changes below it in as an unreviewed side effect. If any change in
1306/// the chain refuses, the ones already landed stay landed and the rest do not —
1307/// reported precisely, because pretending it was atomic would be a lie about
1308/// what is on the bookmark.
1309pub async fn merge_stack(
1310 State(state): State<AppState>,
1311 UrlPath((owner, name, change_id)): UrlPath<(String, String, String)>,
1312 CurrentUser(user): CurrentUser,
1313) -> AppResult<Response> {
1314 let Some(user) = user else {
1315 return Err(AppError::Unauthorized);
1316 };
1317 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
1318 if !ctx.access.can_manage_changes() {
1319 return Err(AppError::Forbidden);
1320 }
1321 if ctx.repo.archived {
1322 return Err(AppError::BadRequest("This repository is archived.".into()));
1323 }
1324
1325 let nodes = load_stack(&state, ctx.repo.id, &change_id).await?;
1326 if nodes.is_empty() {
1327 return Err(AppError::NotFound);
1328 }
1329
1330 // `load_stack` returns bottom-first; that is the order to land in.
1331 let mut landed = 0usize;
1332 let mut refusal: Option<String> = None;
1333
1334 for node in &nodes {
1335 let record = match resolve_change(&state, ctx.repo.id, &node.number.to_string()).await? {
1336 Resolution::One(c) => *c,
1337 _ => continue,
1338 };
1339
1340 if record.state == "merged" {
1341 continue;
1342 }
1343 if record.state == "abandoned" || record.state == "draft" {
1344 refusal = Some(format!(
1345 "#{} is {} — nothing after it was merged.",
1346 record.number, record.state
1347 ));
1348 break;
1349 }
1350
1351 match land(&state, &ctx, &record, &user).await? {
1352 Landed::Ok(_) => landed += 1,
1353 Landed::Refused(msg) => {
1354 refusal = Some(format!("#{}: {msg} Nothing after it was merged.", record.number));
1355 break;
1356 }
1357 }
1358 }
1359
1360 enqueue_reindex(&state, ctx.repo.id).await;
1361
1362 let message = match refusal {
1363 Some(r) => format!("Merged {landed} change(s), then stopped. {r}"),
1364 None => format!("Merged {landed} change(s)."),
1365 };
1366
1367 Ok(Redirect::to(&format!(
1368 "{}/stacks/{change_id}?notice={}",
1369 ctx.base(),
1370 urlencode(&message)
1371 ))
1372 .into_response())
1373}
1374
1375enum Landed {
1376 Ok(String),
1377 Refused(String),
1378}
1379
1380/// Land one change onto its target bookmark and record the result.
1381async fn land(
1382 state: &AppState,
1383 ctx: &RepoContext,
1384 change: &ChangeRecord,
1385 actor: &df_db::models::User,
1386) -> AppResult<Landed> {
1387 if change.conflicted {
1388 return Ok(Landed::Refused(
1389 "This change is conflicted; resolve it in your working copy and push again.".into(),
1390 ));
1391 }
1392 if change.state == "merged" {
1393 return Ok(Landed::Ok("Already merged.".into()));
1394 }
1395 if change.state != "open" {
1396 return Ok(Landed::Refused(format!(
1397 "A change in state {} cannot be merged.",
1398 change.state
1399 )));
1400 }
1401
1402 let head: Option<String> = sqlx::query_scalar(
1403 "SELECT rev FROM revisions WHERE change_id_fk = $1 ORDER BY seq DESC LIMIT 1",
1404 )
1405 .bind(change.id)
1406 .fetch_optional(&state.db)
1407 .await?;
1408
1409 let Some(head) = head else {
1410 return Ok(Landed::Refused("This change has no revisions.".into()));
1411 };
1412
1413 // The merge commit is attributed to whoever pressed the button, with their
1414 // handle rather than their email — the email is an OIDC claim we are told
1415 // not to key on and should not scatter into commit objects.
1416 let author = df_store::Signature {
1417 name: actor.label().to_owned(),
1418 email: format!("{}@users.noreply.{}", actor.handle, state.config.host()),
1419 when: chrono::Utc::now(),
1420 };
1421
1422 let message = format!(
1423 "Merge change #{} into {}\n\n{}\n\nChange-Id: {}\n",
1424 change.number, change.target_bookmark, change.title, change.change_id
1425 );
1426
1427 let outcome = match state
1428 .store
1429 .merge(
1430 ctx.store_id(),
1431 &change.target_bookmark,
1432 &RevId::from_stored(head.clone()),
1433 &message,
1434 &author,
1435 )
1436 .await
1437 {
1438 Ok(o) => o,
1439 Err(e) => {
1440 tracing::error!(change = %change.id, "merge failed: {e}");
1441 return Ok(Landed::Refused(format!("The merge could not be completed: {e}")));
1442 }
1443 };
1444
1445 let (text, merged) = match outcome {
1446 df_store::MergeOutcome::Conflicted => {
1447 return Ok(Landed::Refused(
1448 "The change conflicts with the target bookmark. Rebase it in your working \
1449 copy and push again — the server does not resolve conflicts."
1450 .into(),
1451 ))
1452 }
1453 df_store::MergeOutcome::AlreadyMerged => ("Already on the bookmark.".to_string(), true),
1454 df_store::MergeOutcome::FastForward { .. } => {
1455 (format!("Fast-forwarded {}.", change.target_bookmark), true)
1456 }
1457 df_store::MergeOutcome::Merged { .. } => {
1458 (format!("Merged into {}.", change.target_bookmark), true)
1459 }
1460 };
1461
1462 if merged {
1463 sqlx::query(
1464 "UPDATE changes SET state = 'merged', merged_at = COALESCE(merged_at, now()),
1465 updated_at = now()
1466 WHERE id = $1",
1467 )
1468 .bind(change.id)
1469 .execute(&state.db)
1470 .await?;
1471
1472 event(
1473 state,
1474 ctx.repo.id,
1475 Some(actor.id),
1476 "change.merged",
1477 change.id,
1478 serde_json::json!({ "bookmark": change.target_bookmark }),
1479 )
1480 .await;
1481 }
1482
1483 Ok(Landed::Ok(text))
1484}
1485
1486/// Ask the worker to reindex, so bookmarks and change states catch up with the
1487/// ref we just moved.
1488///
1489/// A merge does not go through the Git wire protocol, so no post-receive hook
1490/// fires and nothing else would notice.
1491async fn enqueue_reindex(state: &AppState, repo_id: Uuid) {
1492 if let Err(e) = sqlx::query(
1493 "INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)",
1494 )
1495 .bind(new_id())
1496 .bind(serde_json::json!({ "repo_id": repo_id }))
1497 .execute(&state.db)
1498 .await
1499 {
1500 tracing::error!(%repo_id, "enqueueing reindex after merge failed: {e}");
1501 }
1502}
1503
1504fn back_notice(l: &Loaded, msg: &str) -> Response {
1505 Redirect::to(&format!(
1506 "{}/changes/{}?notice={}",
1507 l.ctx.base(),
1508 l.change.number,
1509 urlencode(msg)
1510 ))
1511 .into_response()
1512}

1512 lines · Rust