1use serde_json::json;
18use sqlx::{PgConnection, PgPool};
19use uuid::Uuid;
20
21use crate::{
22 auth::Actor,
23 domain::{
24 self, ChangeStatus, Decision, LifecycleState, LineAction, NotificationKind, Priority,
25 ReviewOutcome, Role, WebhookEvent,
26 },
27 error::{AppError, AppResult},
28 models::{Change, ChangeItem, ChangeReview, ChangeReviewRound},
29 services::{
30 access, as_built, audit, impacts, interchange, items, next_counter,
31 notifications::{self, Notice},
32 project_policy, projects, readiness, requests, revisions, users, webhooks, Page,
33 },
34};
35
36#[derive(Clone, Debug)]
38pub struct SignOff {
39 pub method: domain::SignOffMethod,
40 pub meaning: String,
42}
43
44impl SignOff {
45 pub fn new(method: domain::SignOffMethod, meaning: &str) -> SignOff {
46 SignOff {
47 method,
48 meaning: meaning.to_string(),
49 }
50 }
51}
52
53pub(crate) async fn releases_of(
60 pool: &PgPool,
61 actor: &Actor,
62 revision_ids: &[Uuid],
63) -> AppResult<std::collections::HashMap<Uuid, serde_json::Value>> {
64 let mut out = std::collections::HashMap::new();
65 if revision_ids.is_empty() {
66 return Ok(out);
67 }
68 let changes = sqlx::query_as::<
69 _,
70 (
71 Uuid,
72 Uuid,
73 String,
74 String,
75 Option<chrono::DateTime<chrono::Utc>>,
76 bool,
77 ),
78 >(&format!(
79 "SELECT ci.revision_id, c.id, c.number, c.title, c.closed_at, {visible}
80 FROM change_items ci JOIN changes c ON c.id = ci.change_id
81 WHERE ci.revision_id = ANY($1) AND ci.action = 'release' AND c.status = 'released'",
82 visible = access::change_visible("c", 2, 3),
83 ))
84 .bind(revision_ids)
85 .bind(actor.id)
86 .bind(&actor.hidden_projects)
87 .fetch_all(pool)
88 .await?;
89 let visible_ids: Vec<Uuid> = changes.iter().filter(|c| c.5).map(|c| c.1).collect();
90 let approvals =
91 sqlx::query_as::<_, (Uuid, String, String, Option<chrono::DateTime<chrono::Utc>>)>(
92 "SELECT cr.change_id, u.username, u.display_name, cr.decided_at
93 FROM change_reviews cr JOIN users u ON u.id = cr.reviewer_id
94 WHERE cr.change_id = ANY($1) AND cr.decision = 'approved'
95 ORDER BY cr.decided_at, u.username",
96 )
97 .bind(&visible_ids)
98 .fetch_all(pool)
99 .await?;
100 for (revision_id, change_id, number, title, released_at, visible) in changes {
101 let value = if visible {
102 json!({
103 "change": number,
104 "title": title,
105 "released_at": released_at,
106 "approvals": approvals
107 .iter()
108 .filter(|a| a.0 == change_id)
109 .map(|a| json!({ "reviewer": a.1, "name": a.2, "approved_at": a.3 }))
110 .collect::<Vec<_>>(),
111 })
112 } else {
113 json!({ "restricted": true })
114 };
115 out.insert(revision_id, value);
116 }
117 Ok(out)
118}
119
120pub(crate) const CHANGE_SELECT: &str = "
121 SELECT c.id, c.number, c.title, c.description, c.priority, c.status,
122 c.created_by, u.display_name AS created_by_name,
123 c.created_at, c.updated_at, c.submitted_at, c.closed_at
124 FROM changes c JOIN users u ON u.id = c.created_by";
125
126pub fn status_of(change: &Change) -> ChangeStatus {
127 ChangeStatus::parse(&change.status).unwrap_or(ChangeStatus::Cancelled)
128}
129
130pub async fn list(
133 pool: &PgPool,
134 actor: &Actor,
135 status: Option<ChangeStatus>,
136 page: Page,
137) -> AppResult<(Vec<Change>, i64)> {
138 let status = status.map(ChangeStatus::as_str);
139 let guard = access::change_visible("c", 4, 5);
140 let rows = sqlx::query_as::<_, Change>(&format!(
141 "{CHANGE_SELECT} WHERE ($1::text IS NULL OR c.status = $1) AND {guard}
142 ORDER BY c.created_at DESC LIMIT $2 OFFSET $3"
143 ))
144 .bind(status)
145 .bind(page.limit)
146 .bind(page.offset)
147 .bind(actor.id)
148 .bind(&actor.hidden_projects)
149 .fetch_all(pool)
150 .await?;
151 let total = sqlx::query_scalar::<_, i64>(&format!(
152 "SELECT COUNT(*) FROM changes c WHERE ($1::text IS NULL OR c.status = $1) AND {}",
153 access::change_visible("c", 2, 3)
154 ))
155 .bind(status)
156 .bind(actor.id)
157 .bind(&actor.hidden_projects)
158 .fetch_one(pool)
159 .await?;
160 Ok((rows, total))
161}
162
163pub(crate) async fn is_visible(
165 conn: &mut PgConnection,
166 actor: &Actor,
167 change_id: Uuid,
168) -> AppResult<bool> {
169 Ok(sqlx::query_scalar::<_, bool>(&format!(
170 "SELECT EXISTS(SELECT 1 FROM changes c WHERE c.id = $1 AND {})",
171 access::change_visible("c", 2, 3)
172 ))
173 .bind(change_id)
174 .bind(actor.id)
175 .bind(&actor.hidden_projects)
176 .fetch_one(&mut *conn)
177 .await?)
178}
179
180pub(crate) async fn sees_all_items(
183 conn: &mut PgConnection,
184 actor: &Actor,
185 change_id: Uuid,
186) -> AppResult<bool> {
187 if actor.hidden_projects.is_empty() {
188 return Ok(true);
189 }
190 Ok(!sqlx::query_scalar::<_, bool>(&format!(
191 "SELECT EXISTS(SELECT 1 FROM change_items ci
192 JOIN revisions r ON r.id = ci.revision_id
193 JOIN items i ON i.id = r.item_id
194 WHERE ci.change_id = $1 AND NOT {})",
195 access::item_visible("i", 2)
196 ))
197 .bind(change_id)
198 .bind(&actor.hidden_projects)
199 .fetch_one(&mut *conn)
200 .await?)
201}
202
203fn ensure_sees_all(sees_all: bool) -> AppResult<()> {
204 if sees_all {
205 Ok(())
206 } else {
207 Err(AppError::Forbidden(
208 "You can no longer see all items of this change.".into(),
209 ))
210 }
211}
212
213pub(crate) async fn get_unchecked(pool: &PgPool, id: Uuid) -> AppResult<Change> {
214 sqlx::query_as::<_, Change>(&format!("{CHANGE_SELECT} WHERE c.id = $1"))
215 .bind(id)
216 .fetch_optional(pool)
217 .await?
218 .ok_or_else(|| AppError::not_found("Change not found."))
219}
220
221pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Change> {
223 let change = get_unchecked(pool, id).await?;
224 let mut conn = pool.acquire().await?;
225 if !is_visible(&mut conn, actor, id).await? {
226 return Err(AppError::not_found("Change not found."));
227 }
228 Ok(change)
229}
230
231pub async fn get_by_number(pool: &PgPool, actor: &Actor, number: &str) -> AppResult<Change> {
233 let number = number.trim().to_ascii_uppercase();
234 let missing = || AppError::not_found(format!("Change '{number}' not found."));
235 let change = sqlx::query_as::<_, Change>(&format!("{CHANGE_SELECT} WHERE c.number = $1"))
236 .bind(&number)
237 .fetch_optional(pool)
238 .await?
239 .ok_or_else(missing)?;
240 let mut conn = pool.acquire().await?;
241 if !is_visible(&mut conn, actor, change.id).await? {
242 return Err(missing());
243 }
244 Ok(change)
245}
246
247async fn lock(conn: &mut PgConnection, actor: &Actor, id: Uuid) -> AppResult<Change> {
249 let change =
250 sqlx::query_as::<_, Change>(&format!("{CHANGE_SELECT} WHERE c.id = $1 FOR UPDATE OF c"))
251 .bind(id)
252 .fetch_optional(&mut *conn)
253 .await?
254 .ok_or_else(|| AppError::not_found("Change not found."))?;
255 if !is_visible(conn, actor, id).await? {
256 return Err(AppError::not_found("Change not found."));
257 }
258 Ok(change)
259}
260
261pub async fn items(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Vec<ChangeItem>> {
264 let mut rows = items_unchecked(pool, change_id).await?;
265 for row in &mut rows {
266 if !actor.can_see_project(row.project_id) {
267 row.mask();
268 } else if row.successor_number.is_some() && !actor.can_see_project(row.successor_project_id)
269 {
270 row.successor_number = Some(access::RESTRICTED_LABEL.into());
271 }
272 }
273 rows.sort_by_key(|r| r.restricted);
275 Ok(rows)
276}
277
278pub(crate) async fn items_unchecked(pool: &PgPool, change_id: Uuid) -> AppResult<Vec<ChangeItem>> {
279 Ok(sqlx::query_as::<_, ChangeItem>(
280 "SELECT r.id AS revision_id, i.id AS item_id, i.number AS item_number, r.code, r.name, r.state,
281 ci.action, ssi.number AS successor_number, ssi.project_id AS successor_project_id,
282 co.display_name AS checked_out_by_name, i.project_id
283 FROM change_items ci
284 JOIN revisions r ON r.id = ci.revision_id
285 JOIN items i ON i.id = r.item_id
286 LEFT JOIN users co ON co.id = r.checked_out_by
287 LEFT JOIN items ssi ON ssi.id = ci.successor_item_id
288 WHERE ci.change_id = $1
289 ORDER BY i.number",
290 )
291 .bind(change_id)
292 .fetch_all(pool)
293 .await?)
294}
295
296pub async fn reviews(pool: &PgPool, change_id: Uuid) -> AppResult<Vec<ChangeReview>> {
300 Ok(sqlx::query_as::<_, ChangeReview>(&format!(
301 "SELECT cr.reviewer_id, u.username AS reviewer_username, u.display_name AS reviewer_name,
302 cr.decision, cr.comment, cr.decided_at, cr.sign_off_method, cr.sign_off_meaning,
303 CASE WHEN cr.decision <> 'pending' THEN NULL
304 WHEN NOT u.active THEN 'deactivated'
305 WHEN u.role NOT IN ('editor', 'admin') THEN 'not_editor'
306 WHEN EXISTS (SELECT 1 FROM change_items ci
307 JOIN revisions r ON r.id = ci.revision_id
308 JOIN items i ON i.id = r.item_id
309 WHERE ci.change_id = cr.change_id AND NOT {})
310 THEN 'cannot_see_items'
311 END AS absent
312 FROM change_reviews cr JOIN users u ON u.id = cr.reviewer_id
313 WHERE cr.change_id = $1
314 ORDER BY u.display_name",
315 access::user_sees_project("u", "i.project_id")
316 ))
317 .bind(change_id)
318 .fetch_all(pool)
319 .await?)
320}
321
322pub async fn review_round(pool: &PgPool, change_id: Uuid) -> AppResult<i32> {
324 Ok(
325 sqlx::query_scalar::<_, i32>("SELECT review_round FROM changes WHERE id = $1")
326 .bind(change_id)
327 .fetch_optional(pool)
328 .await?
329 .unwrap_or(1),
330 )
331}
332
333pub async fn earlier_rounds(pool: &PgPool, change_id: Uuid) -> AppResult<Vec<ChangeReviewRound>> {
335 Ok(sqlx::query_as::<_, ChangeReviewRound>(
336 "SELECT rr.round, rr.reviewer_id, u.username AS reviewer_username,
337 u.display_name AS reviewer_name, rr.decision, rr.comment, rr.decided_at, rr.closed_at,
338 rr.sign_off_method, rr.sign_off_meaning
339 FROM change_review_rounds rr JOIN users u ON u.id = rr.reviewer_id
340 WHERE rr.change_id = $1
341 ORDER BY rr.round DESC, u.display_name",
342 )
343 .bind(change_id)
344 .fetch_all(pool)
345 .await?)
346}
347
348pub async fn for_revision(
351 pool: &PgPool,
352 actor: &Actor,
353 revision_id: Uuid,
354) -> AppResult<(Vec<Change>, i64)> {
355 let all = sqlx::query_as::<_, Change>(&format!(
356 "{CHANGE_SELECT} JOIN change_items ci ON ci.change_id = c.id
357 WHERE ci.revision_id = $1 ORDER BY c.created_at DESC"
358 ))
359 .bind(revision_id)
360 .fetch_all(pool)
361 .await?;
362 let visible = visible_ids(pool, actor, &all.iter().map(|c| c.id).collect::<Vec<_>>()).await?;
363 let hidden = all.iter().filter(|c| !visible.contains(&c.id)).count() as i64;
364 Ok((
365 all.into_iter()
366 .filter(|c| visible.contains(&c.id))
367 .collect(),
368 hidden,
369 ))
370}
371
372pub(crate) async fn visible_ids(
374 pool: &PgPool,
375 actor: &Actor,
376 ids: &[Uuid],
377) -> AppResult<Vec<Uuid>> {
378 if ids.is_empty() {
379 return Ok(Vec::new());
380 }
381 Ok(sqlx::query_scalar::<_, Uuid>(&format!(
382 "SELECT c.id FROM changes c WHERE c.id = ANY($1) AND {}",
383 access::change_visible("c", 2, 3)
384 ))
385 .bind(ids)
386 .bind(actor.id)
387 .bind(&actor.hidden_projects)
388 .fetch_all(pool)
389 .await?)
390}
391
392pub async fn pending_reviews(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Change>> {
395 Ok(sqlx::query_as::<_, Change>(&format!(
396 "{CHANGE_SELECT} JOIN change_reviews cr ON cr.change_id = c.id
397 WHERE c.status = 'in_review' AND cr.reviewer_id = $1 AND cr.decision = 'pending'
398 AND NOT EXISTS (SELECT 1 FROM change_items pci
399 JOIN revisions pr ON pr.id = pci.revision_id
400 JOIN items pi ON pi.id = pr.item_id
401 WHERE pci.change_id = c.id AND NOT {})
402 ORDER BY c.submitted_at",
403 access::item_visible("pi", 2)
404 ))
405 .bind(actor.id)
406 .bind(&actor.hidden_projects)
407 .fetch_all(pool)
408 .await?)
409}
410
411pub async fn open_by(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Change>> {
413 Ok(sqlx::query_as::<_, Change>(&format!(
414 "{CHANGE_SELECT} WHERE c.created_by = $1 AND c.status IN ('draft', 'in_review', 'rejected')
415 ORDER BY c.updated_at DESC"
416 ))
417 .bind(actor.id)
418 .fetch_all(pool)
419 .await?)
420}
421
422pub async fn drafts(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Change>> {
424 Ok(sqlx::query_as::<_, Change>(&format!(
425 "{CHANGE_SELECT} WHERE c.status = 'draft' AND {} ORDER BY c.number DESC",
426 access::change_visible("c", 1, 2)
427 ))
428 .bind(actor.id)
429 .bind(&actor.hidden_projects)
430 .fetch_all(pool)
431 .await?)
432}
433
434pub(crate) fn editors_sql(actions: usize) -> String {
439 format!(
440 "SELECT r.created_by AS user_id
441 FROM change_items ci JOIN revisions r ON r.id = ci.revision_id
442 WHERE ci.change_id = $1
443 UNION
444 SELECT f.uploaded_by
445 FROM change_items ci JOIN files f ON f.revision_id = ci.revision_id
446 WHERE ci.change_id = $1
447 UNION
448 SELECT a.user_id
449 FROM change_items ci
450 JOIN audit_log a ON a.entity_type = 'revision' AND a.entity_id = ci.revision_id
451 WHERE ci.change_id = $1 AND a.user_id IS NOT NULL AND a.action = ANY(${actions})"
452 )
453}
454
455pub(crate) async fn change_editors(
457 conn: &mut PgConnection,
458 change_id: Uuid,
459) -> AppResult<Vec<Uuid>> {
460 Ok(sqlx::query_scalar::<_, Uuid>(&editors_sql(2))
461 .bind(change_id)
462 .bind(crate::services::reviewers::EDIT_ACTIONS)
463 .fetch_all(&mut *conn)
464 .await?)
465}
466
467pub(crate) const PREPARE_ACTIONS: &[&str] = &[
469 "change.add_item",
470 "change.remove_item",
471 "change.add_reviewer",
472 "change.remove_reviewer",
473 "change.replace_reviewer",
474 "change.update",
475 "change.submit",
476 "change.reopen",
477];
478
479async fn change_preparers(
483 conn: &mut PgConnection,
484 change_id: Uuid,
485 extra: Option<Uuid>,
486) -> AppResult<Vec<Uuid>> {
487 let mut users = sqlx::query_scalar::<_, Uuid>(
488 "SELECT submitted_by FROM changes WHERE id = $1 AND submitted_by IS NOT NULL
489 UNION
490 SELECT user_id FROM audit_log
491 WHERE entity_type = 'change' AND entity_id = $1 AND user_id IS NOT NULL
492 AND action = ANY($2)",
493 )
494 .bind(change_id)
495 .bind(PREPARE_ACTIONS)
496 .fetch_all(&mut *conn)
497 .await?;
498 users.extend(extra);
499 Ok(users)
500}
501
502pub(crate) async fn review_conflict(
509 conn: &mut PgConnection,
510 change: &Change,
511 reviewer: Uuid,
512 acting: Option<Uuid>,
513 author_only: bool,
514) -> AppResult<Option<domain::ReviewConflict>> {
515 if author_only {
516 return Ok(domain::conflicting_reviewer(
517 reviewer,
518 change.created_by,
519 None,
520 &[],
521 ));
522 }
523 let editors = change_editors(conn, change.id).await?;
524 let preparers = change_preparers(conn, change.id, acting).await?;
525 Ok(domain::conflicting_reviewer(
526 reviewer,
527 change.created_by,
528 preparers.contains(&reviewer).then_some(reviewer),
529 &editors,
530 ))
531}
532
533fn ensure_owner(change: &Change, actor: &Actor) -> AppResult<()> {
534 actor.require(Role::Editor)?;
535 if change.created_by == actor.id || actor.is_admin() {
536 Ok(())
537 } else {
538 Err(AppError::Forbidden(format!(
539 "Only the author of {} or an administrator can do this.",
540 change.number
541 )))
542 }
543}
544
545async fn stands_in_for_author(
550 conn: &mut PgConnection,
551 change: &Change,
552 actor: &Actor,
553) -> AppResult<bool> {
554 if !actor.can_edit() || change.created_by == actor.id {
555 return Ok(false);
556 }
557 let author_active = sqlx::query_scalar::<_, bool>("SELECT active FROM users WHERE id = $1")
558 .bind(change.created_by)
559 .fetch_optional(&mut *conn)
560 .await?
561 .unwrap_or(false);
562 if author_active {
563 return Ok(false);
564 }
565 let groups = sqlx::query_scalar::<_, Option<Uuid>>(
566 "SELECT DISTINCT i.project_id FROM change_items ci
567 JOIN revisions r ON r.id = ci.revision_id JOIN items i ON i.id = r.item_id
568 WHERE ci.change_id = $1",
569 )
570 .bind(change.id)
571 .fetch_all(&mut *conn)
572 .await?;
573 if groups.is_empty() {
574 return Ok(false);
575 }
576 for group in groups {
577 let Some(group) = group else {
578 return Ok(false);
579 };
580 if !projects::can_manage(conn, actor, group).await? {
581 return Ok(false);
582 }
583 }
584 Ok(true)
585}
586
587async fn ensure_owner_or_stand_in(
590 conn: &mut PgConnection,
591 change: &Change,
592 actor: &Actor,
593) -> AppResult<()> {
594 match ensure_owner(change, actor) {
595 Ok(()) => Ok(()),
596 Err(AppError::Forbidden(_)) if stands_in_for_author(conn, change, actor).await? => Ok(()),
597 Err(AppError::Forbidden(_)) => Err(AppError::Forbidden(format!(
598 "Only the author of {} or an administrator can do this (or, when the author is \
599 deactivated, a manager of every affected project group).",
600 change.number
601 ))),
602 Err(e) => Err(e),
603 }
604}
605
606pub async fn may_stand_in(pool: &PgPool, actor: &Actor, change: &Change) -> AppResult<bool> {
609 let mut conn = pool.acquire().await?;
610 stands_in_for_author(&mut conn, change, actor).await
611}
612
613pub async fn take_over(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
631 actor.require(Role::Editor)?;
632 let mut tx = pool.begin().await?;
633 let change = lock(&mut tx, actor, change_id).await?;
634 if change.created_by == actor.id {
635 return Err(AppError::Conflict(format!(
636 "You are already the author of {}.",
637 change.number
638 )));
639 }
640 let author_active = sqlx::query_scalar::<_, bool>("SELECT active FROM users WHERE id = $1")
641 .bind(change.created_by)
642 .fetch_optional(&mut *tx)
643 .await?
644 .unwrap_or(false);
645 let allowed = if actor.is_admin() {
646 !author_active
647 } else {
648 stands_in_for_author(&mut tx, &change, actor).await?
649 };
650 if !allowed {
651 return Err(AppError::Forbidden(format!(
652 "{} can be taken over only while its author is deactivated, by a manager of every \
653 affected project group or an administrator.",
654 change.number
655 )));
656 }
657 ensure_status(&change, &[ChangeStatus::Draft, ChangeStatus::Rejected])?;
658 ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
659 let reviewing = sqlx::query_scalar::<_, bool>(
660 "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2)",
661 )
662 .bind(change_id)
663 .bind(actor.id)
664 .fetch_one(&mut *tx)
665 .await?;
666 if reviewing {
667 return Err(AppError::Conflict(format!(
668 "You review {}; author and reviewer must differ. Remove yourself as reviewer first.",
669 change.number
670 )));
671 }
672 sqlx::query("UPDATE changes SET created_by = $2, updated_at = now() WHERE id = $1")
673 .bind(change_id)
674 .bind(actor.id)
675 .execute(&mut *tx)
676 .await?;
677 audit::record(
678 &mut tx,
679 Some(actor),
680 "change.handover",
681 "change",
682 Some(change_id),
683 format!(
684 "{} taken over from {} by {}",
685 change.number, change.created_by_name, actor.display_name
686 ),
687 json!({ "from": change.created_by, "to": actor.id, "taken_over": true }),
688 )
689 .await?;
690 tx.commit().await?;
691 get(pool, actor, change_id).await
692}
693
694fn ensure_status(change: &Change, allowed: &[ChangeStatus]) -> AppResult<ChangeStatus> {
695 let status = status_of(change);
696 if allowed.contains(&status) {
697 Ok(status)
698 } else {
699 Err(AppError::Conflict(format!(
700 "{} is {}; this action is not possible in that state.",
701 change.number,
702 status.label()
703 )))
704 }
705}
706
707async fn touch(conn: &mut PgConnection, change_id: Uuid) -> AppResult<()> {
708 sqlx::query("UPDATE changes SET updated_at = now() WHERE id = $1")
709 .bind(change_id)
710 .execute(&mut *conn)
711 .await?;
712 Ok(())
713}
714
715async fn reset_reviews(conn: &mut PgConnection, change_id: Uuid) -> AppResult<()> {
718 let decided = sqlx::query_scalar::<_, bool>(
719 "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND decision <> 'pending')",
720 )
721 .bind(change_id)
722 .fetch_one(&mut *conn)
723 .await?;
724 if decided {
725 sqlx::query(
726 "INSERT INTO change_review_rounds (change_id, round, reviewer_id, decision, comment,
727 decided_at, sign_off_method, sign_off_meaning)
728 SELECT cr.change_id, c.review_round, cr.reviewer_id, cr.decision, cr.comment,
729 cr.decided_at, cr.sign_off_method, cr.sign_off_meaning
730 FROM change_reviews cr JOIN changes c ON c.id = cr.change_id
731 WHERE cr.change_id = $1
732 ON CONFLICT DO NOTHING",
733 )
734 .bind(change_id)
735 .execute(&mut *conn)
736 .await?;
737 sqlx::query("UPDATE changes SET review_round = review_round + 1 WHERE id = $1")
738 .bind(change_id)
739 .execute(&mut *conn)
740 .await?;
741 }
742 sqlx::query(
743 "UPDATE change_reviews SET decision = 'pending', comment = '', decided_at = NULL,
744 sign_off_method = NULL, sign_off_meaning = ''
745 WHERE change_id = $1",
746 )
747 .bind(change_id)
748 .execute(&mut *conn)
749 .await?;
750 Ok(())
751}
752
753async fn transition_items(
755 conn: &mut PgConnection,
756 change_id: Uuid,
757 to: LifecycleState,
758) -> AppResult<Vec<String>> {
759 let ids = sqlx::query_scalar::<_, Uuid>(
760 "SELECT revision_id FROM change_items
761 WHERE change_id = $1 AND action = 'release' ORDER BY revision_id",
762 )
763 .bind(change_id)
764 .fetch_all(&mut *conn)
765 .await?;
766 let mut labels = Vec::with_capacity(ids.len());
767 for id in ids {
768 let rev = revisions::lock(conn, id).await?;
769 revisions::transition(conn, &rev, to).await?;
770 labels.push(revisions::label(&rev));
771 }
772 Ok(labels)
773}
774
775async fn action_lines(
778 conn: &mut PgConnection,
779 change_id: Uuid,
780) -> AppResult<Vec<(Uuid, LineAction, Option<Uuid>)>> {
781 let rows = sqlx::query_as::<_, (Uuid, String, Option<Uuid>)>(
782 "SELECT revision_id, action, successor_item_id FROM change_items
783 WHERE change_id = $1 AND action <> 'release' ORDER BY revision_id",
784 )
785 .bind(change_id)
786 .fetch_all(&mut *conn)
787 .await?;
788 Ok(rows
789 .into_iter()
790 .filter_map(|(rev, action, successor)| {
791 LineAction::parse(&action).map(|a| (rev, a, successor))
792 })
793 .collect())
794}
795
796async fn check_action_line(
799 conn: &mut PgConnection,
800 rev: &crate::models::Revision,
801 action: LineAction,
802 successor: Option<Uuid>,
803) -> AppResult<()> {
804 if revisions::state_of(rev) != action.required_state() {
805 return Err(AppError::Conflict(format!(
806 "{} is {}; only released revisions can be obsoleted or superseded through a change.",
807 revisions::label(rev),
808 revisions::state_of(rev).label()
809 )));
810 }
811 if let Some(successor) = successor {
812 let item = items::get_unchecked(&mut *conn, rev.item_id).await?;
813 let successor = items::get_unchecked(&mut *conn, successor).await?;
814 interchange::ensure_no_circle(conn, &item, &successor).await?;
815 }
816 Ok(())
817}
818
819async fn apply_action_lines(
825 conn: &mut PgConnection,
826 actor: &Actor,
827 change: &Change,
828) -> AppResult<Vec<String>> {
829 let lines = action_lines(conn, change.id).await?;
830 let mut labels = Vec::with_capacity(lines.len());
831 for (id, action, successor) in lines {
832 let rev = revisions::lock(conn, id).await?;
833 check_action_line(conn, &rev, action, successor).await?;
834 revisions::obsolete_in(conn, actor, &rev).await?;
835 if let Some(successor) = successor {
836 let item = items::get_unchecked(&mut *conn, rev.item_id).await?;
837 let successor = items::get_unchecked(&mut *conn, successor).await?;
838 let note = format!("Through {}", change.number);
839 interchange::supersede_in(conn, actor, &item, &successor, ¬e).await?;
840 }
841 labels.push(revisions::label(&rev));
842 }
843 Ok(labels)
844}
845
846fn release_summary(released: &[String], obsoleted: &[String]) -> String {
848 let mut parts = Vec::new();
849 if !released.is_empty() {
850 parts.push(format!("released {}", released.join(", ")));
851 }
852 if !obsoleted.is_empty() {
853 parts.push(format!("obsoleted {}", obsoleted.join(", ")));
854 }
855 parts.join("; ")
856}
857
858async fn reviewer_ids(conn: &mut PgConnection, change_id: Uuid) -> AppResult<Vec<Uuid>> {
860 Ok(
861 sqlx::query_scalar::<_, Uuid>(
862 "SELECT reviewer_id FROM change_reviews WHERE change_id = $1",
863 )
864 .bind(change_id)
865 .fetch_all(&mut *conn)
866 .await?,
867 )
868}
869
870async fn tell(
872 conn: &mut PgConnection,
873 actor: &Actor,
874 change: &Change,
875 kind: NotificationKind,
876 recipients: &[Uuid],
877 title: String,
878 body: String,
879) -> AppResult<()> {
880 let notice = Notice::change(kind, change.id, &change.number, title, body);
881 notifications::notify(conn, Some(actor), recipients, ¬ice).await?;
882 Ok(())
883}
884
885#[allow(clippy::too_many_arguments)]
888async fn tell_naming(
889 conn: &mut PgConnection,
890 actor: &Actor,
891 change: &Change,
892 kind: NotificationKind,
893 recipients: &[Uuid],
894 title: String,
895 body: String,
896 named: &audit::Mentions,
897 redacted_body: String,
898) -> AppResult<()> {
899 let notice = Notice::change(kind, change.id, &change.number, title.clone(), body).naming(
900 named.clone(),
901 title,
902 redacted_body,
903 );
904 notifications::notify(conn, Some(actor), recipients, ¬ice).await?;
905 Ok(())
906}
907
908async fn tell_item_watchers(
911 conn: &mut PgConnection,
912 actor: &Actor,
913 change: &Change,
914 revision_ids: &[Uuid],
915 already: &[Uuid],
916) -> AppResult<()> {
917 for id in revision_ids {
918 let rev = revisions::lock(conn, *id).await?;
919 let mut watchers = notifications::watchers(conn, "item", &[rev.item_id]).await?;
920 watchers.retain(|w| !already.contains(w));
921 let title = format!("{} was released", revisions::label(&rev));
922 let notice = Notice::revision(
925 NotificationKind::ItemUpdate,
926 &rev,
927 title.clone(),
928 format!(
929 "{} – released with {} ({}).",
930 rev.name, change.number, change.title
931 ),
932 )
933 .naming(
934 audit::Mentions::change(change.id),
935 title,
936 format!("{} – released.", rev.name),
937 );
938 notifications::notify(conn, Some(actor), &watchers, ¬ice).await?;
939 }
940 Ok(())
941}
942
943pub struct ChangeInput {
944 pub title: String,
945 pub description: String,
946 pub priority: Priority,
947}
948
949pub async fn create(pool: &PgPool, actor: &Actor, input: ChangeInput) -> AppResult<Change> {
960 let mut tx = pool.begin().await?;
961 let id = create_in(&mut tx, actor, input).await?;
962 tx.commit().await?;
963 get(pool, actor, id).await
964}
965
966pub(crate) async fn create_in(
969 tx: &mut PgConnection,
970 actor: &Actor,
971 input: ChangeInput,
972) -> AppResult<Uuid> {
973 actor.require(Role::Editor)?;
974 let title = domain::require_text("Title", &input.title, 200)?;
975 let description = domain::limit_text("Description", &input.description, 20_000)?;
976 let number = domain::format_change_number(next_counter(&mut *tx, "change").await?);
977 let id = Uuid::new_v4();
978 sqlx::query(
979 "INSERT INTO changes (id, number, title, description, priority, status, created_by)
980 VALUES ($1, $2, $3, $4, $5, 'draft', $6)",
981 )
982 .bind(id)
983 .bind(&number)
984 .bind(&title)
985 .bind(&description)
986 .bind(input.priority.as_str())
987 .bind(actor.id)
988 .execute(&mut *tx)
989 .await?;
990 audit::record(
991 &mut *tx,
992 Some(actor),
993 "change.create",
994 "change",
995 Some(id),
996 format!("Created change {number} – {title}"),
997 json!({ "priority": input.priority.as_str() }),
998 )
999 .await?;
1000 Ok(id)
1001}
1002
1003pub async fn update(
1014 pool: &PgPool,
1015 actor: &Actor,
1016 id: Uuid,
1017 input: ChangeInput,
1018) -> AppResult<Change> {
1019 let title = domain::require_text("Title", &input.title, 200)?;
1020 let description = domain::limit_text("Description", &input.description, 20_000)?;
1021 let mut tx = pool.begin().await?;
1022 let change = lock(&mut tx, actor, id).await?;
1023 ensure_owner(&change, actor)?;
1024 ensure_status(&change, &[ChangeStatus::Draft, ChangeStatus::Rejected])?;
1025 sqlx::query(
1026 "UPDATE changes SET title = $2, description = $3, priority = $4, updated_at = now() WHERE id = $1",
1027 )
1028 .bind(id)
1029 .bind(&title)
1030 .bind(&description)
1031 .bind(input.priority.as_str())
1032 .execute(&mut *tx)
1033 .await?;
1034 audit::record(
1035 &mut tx,
1036 Some(actor),
1037 "change.update",
1038 "change",
1039 Some(id),
1040 format!("Updated change {}", change.number),
1041 json!({ "priority": input.priority.as_str() }),
1042 )
1043 .await?;
1044 tx.commit().await?;
1045 get(pool, actor, id).await
1046}
1047
1048pub async fn add_item(
1061 pool: &PgPool,
1062 actor: &Actor,
1063 change_id: Uuid,
1064 revision_id: Uuid,
1065) -> AppResult<()> {
1066 let mut tx = pool.begin().await?;
1067 add_item_in(&mut tx, actor, change_id, revision_id).await?;
1068 tx.commit().await?;
1069 Ok(())
1070}
1071
1072pub(crate) async fn add_item_in(
1076 tx: &mut PgConnection,
1077 actor: &Actor,
1078 change_id: Uuid,
1079 revision_id: Uuid,
1080) -> AppResult<String> {
1081 let change = lock(&mut *tx, actor, change_id).await?;
1082 ensure_owner(&change, actor)?;
1083 ensure_status(&change, &[ChangeStatus::Draft])?;
1084 add_item_tx(
1085 &mut *tx,
1086 actor,
1087 &change,
1088 revision_id,
1089 LineAction::Release,
1090 None,
1091 )
1092 .await?;
1093 let rev = revisions::lock(&mut *tx, revision_id).await?;
1094 Ok(revisions::label(&rev))
1095}
1096
1097async fn add_item_tx(
1099 conn: &mut PgConnection,
1100 actor: &Actor,
1101 change: &Change,
1102 revision_id: Uuid,
1103 action: LineAction,
1104 successor: Option<Uuid>,
1105) -> AppResult<bool> {
1106 let change_id = change.id;
1107 let rev = revisions::lock(conn, revision_id).await?;
1108 revisions::ensure_project_access(&rev, actor)?;
1110 if action == LineAction::Release {
1111 if revisions::state_of(&rev) != LifecycleState::InWork {
1112 return Err(AppError::Conflict(format!(
1113 "Only in-work revisions can be added to a change; {} is {}.",
1114 revisions::label(&rev),
1115 revisions::state_of(&rev).label()
1116 )));
1117 }
1118 } else {
1119 check_action_line(conn, &rev, action, successor).await?;
1120 }
1121 let other = sqlx::query_as::<_, (String, bool)>(&format!(
1124 "SELECT c.number, {} FROM change_items ci JOIN changes c ON c.id = ci.change_id
1125 WHERE ci.revision_id = $1 AND c.id <> $2 AND c.status IN ('draft', 'in_review', 'rejected')
1126 LIMIT 1",
1127 access::change_visible("c", 3, 4)
1128 ))
1129 .bind(revision_id)
1130 .bind(change_id)
1131 .bind(actor.id)
1132 .bind(&actor.hidden_projects)
1133 .fetch_optional(&mut *conn)
1134 .await?;
1135 if let Some((other, visible)) = other {
1136 return Err(AppError::Conflict(if visible {
1137 format!(
1138 "{} is already part of the open change {other}.",
1139 revisions::label(&rev)
1140 )
1141 } else {
1142 format!(
1143 "{} is already part of another open change.",
1144 revisions::label(&rev)
1145 )
1146 }));
1147 }
1148 let blind = sqlx::query_scalar::<_, String>(&format!(
1150 "SELECT u.display_name FROM change_reviews cr JOIN users u ON u.id = cr.reviewer_id
1151 WHERE cr.change_id = $1 AND NOT {} LIMIT 1",
1152 access::user_sees_project("u", "$2::uuid")
1153 ))
1154 .bind(change_id)
1155 .bind(rev.project_id)
1156 .fetch_optional(&mut *conn)
1157 .await?;
1158 if let Some(name) = blind {
1159 return Err(AppError::Conflict(format!(
1160 "Reviewer {name} is not a member of group {}.",
1161 rev.project_code.as_deref().unwrap_or("?")
1162 )));
1163 }
1164 let inserted = sqlx::query(
1165 "INSERT INTO change_items (change_id, revision_id, action, successor_item_id)
1166 VALUES ($1, $2, $3, $4)
1167 ON CONFLICT (change_id, revision_id) DO UPDATE
1168 SET action = EXCLUDED.action, successor_item_id = EXCLUDED.successor_item_id
1169 WHERE change_items.action <> 'release'
1170 AND (change_items.action, change_items.successor_item_id)
1171 IS DISTINCT FROM (EXCLUDED.action, EXCLUDED.successor_item_id)",
1172 )
1173 .bind(change_id)
1174 .bind(revision_id)
1175 .bind(action.as_str())
1176 .bind(successor)
1177 .execute(&mut *conn)
1178 .await?
1179 .rows_affected();
1180 if inserted > 0 {
1181 touch(conn, change_id).await?;
1182 let verb = match action {
1183 LineAction::Release => "Added",
1184 LineAction::Obsolete => "Added an obsolete line for",
1185 LineAction::Supersede => "Added a supersede line for",
1186 };
1187 audit::record_related(
1188 conn,
1189 Some(actor),
1190 "change.add_item",
1191 "change",
1192 Some(change_id),
1193 Some(rev.item_id),
1194 format!("{verb} {} to {}", revisions::label(&rev), change.number),
1195 json!({ "revision_id": revision_id, "item_id": rev.item_id,
1196 "action": action.as_str(), "successor_item_id": successor }),
1197 )
1198 .await?;
1199 }
1200 Ok(inserted > 0)
1201}
1202
1203pub async fn add_action(
1221 pool: &PgPool,
1222 actor: &Actor,
1223 change_id: Uuid,
1224 revision_id: Uuid,
1225 action: LineAction,
1226 successor_number: Option<&str>,
1227) -> AppResult<()> {
1228 let number = successor_number.map(str::trim).filter(|s| !s.is_empty());
1229 let successor =
1230 match (action, number) {
1231 (LineAction::Release, _) => return Err(AppError::BadRequest(
1232 "Use add_item to release an in-work revision; actions are obsolete and supersede."
1233 .into(),
1234 )),
1235 (LineAction::Obsolete, Some(_)) => {
1236 return Err(AppError::BadRequest(
1237 "An obsolete line takes no successor; use supersede.".into(),
1238 ))
1239 }
1240 (LineAction::Obsolete, None) => None,
1241 (LineAction::Supersede, None) => {
1242 return Err(AppError::BadRequest(
1243 "Name the successor item for a supersede line.".into(),
1244 ))
1245 }
1246 (LineAction::Supersede, Some(number)) => {
1247 Some(items::get_by_number(pool, actor, number).await?)
1248 }
1249 };
1250 let mut tx = pool.begin().await?;
1251 let change = lock(&mut tx, actor, change_id).await?;
1252 ensure_owner(&change, actor)?;
1253 ensure_status(&change, &[ChangeStatus::Draft])?;
1254 if let Some(successor) = &successor {
1255 let rev = revisions::lock(&mut tx, revision_id).await?;
1256 if rev.item_id == successor.id {
1257 return Err(AppError::BadRequest(
1258 "An item cannot supersede itself.".into(),
1259 ));
1260 }
1261 }
1262 add_item_tx(
1263 &mut tx,
1264 actor,
1265 &change,
1266 revision_id,
1267 action,
1268 successor.map(|s| s.id),
1269 )
1270 .await?;
1271 tx.commit().await?;
1272 Ok(())
1273}
1274
1275pub async fn readiness(
1277 pool: &PgPool,
1278 actor: &Actor,
1279 change_id: Uuid,
1280) -> AppResult<readiness::Readiness> {
1281 let mut conn = pool.acquire().await?;
1282 Ok(readiness::check(&mut conn, change_id).await?.redact(actor))
1283}
1284
1285pub async fn impact(
1288 pool: &PgPool,
1289 actor: &Actor,
1290 change_id: Uuid,
1291) -> AppResult<(Vec<impacts::Dependent>, i64)> {
1292 get(pool, actor, change_id).await?;
1293 let ids: Vec<Uuid> = items_unchecked(pool, change_id)
1294 .await?
1295 .iter()
1296 .map(|i| i.revision_id)
1297 .collect();
1298 impacts::analysis(pool, actor, &ids).await
1299}
1300
1301const MAX_DEPENDENCY_ROUNDS: usize = 64;
1304
1305pub async fn add_dependencies(
1319 pool: &PgPool,
1320 actor: &Actor,
1321 change_id: Uuid,
1322) -> AppResult<Vec<String>> {
1323 let mut tx = pool.begin().await?;
1324 let change = lock(&mut tx, actor, change_id).await?;
1325 ensure_owner(&change, actor)?;
1326 ensure_status(&change, &[ChangeStatus::Draft])?;
1327 let mut added = Vec::new();
1328 for _ in 0..MAX_DEPENDENCY_ROUNDS {
1329 let ids = readiness::check(&mut tx, change_id)
1330 .await?
1331 .addable_blockers();
1332 if ids.is_empty() {
1333 break;
1334 }
1335 let mut progress = false;
1336 for id in ids {
1337 let rev = revisions::lock(&mut tx, id).await?;
1341 if revisions::ensure_project_access(&rev, actor).is_err() {
1342 continue;
1343 }
1344 if add_item_tx(&mut tx, actor, &change, id, LineAction::Release, None).await? {
1345 progress = true;
1346 added.push(revisions::label(&rev));
1347 }
1348 }
1349 if !progress {
1350 break;
1351 }
1352 }
1353 tx.commit().await?;
1354 Ok(added)
1355}
1356
1357pub async fn remove_item(
1368 pool: &PgPool,
1369 actor: &Actor,
1370 change_id: Uuid,
1371 revision_id: Uuid,
1372) -> AppResult<()> {
1373 let mut tx = pool.begin().await?;
1374 let change = lock(&mut tx, actor, change_id).await?;
1375 ensure_owner(&change, actor)?;
1376 ensure_status(&change, &[ChangeStatus::Draft])?;
1377 let removed = sqlx::query("DELETE FROM change_items WHERE change_id = $1 AND revision_id = $2")
1378 .bind(change_id)
1379 .bind(revision_id)
1380 .execute(&mut *tx)
1381 .await?
1382 .rows_affected();
1383 if removed == 0 {
1384 return Err(AppError::not_found(
1385 "The revision is not part of this change.",
1386 ));
1387 }
1388 let rev = revisions::lock(&mut tx, revision_id).await?;
1389 touch(&mut tx, change_id).await?;
1390 audit::record_related(
1391 &mut tx,
1392 Some(actor),
1393 "change.remove_item",
1394 "change",
1395 Some(change_id),
1396 Some(rev.item_id),
1397 format!("Removed {} from {}", revisions::label(&rev), change.number),
1398 json!({ "revision_id": revision_id, "item_id": rev.item_id }),
1399 )
1400 .await?;
1401 tx.commit().await?;
1402 Ok(())
1403}
1404
1405pub async fn add_reviewer(
1417 pool: &PgPool,
1418 actor: &Actor,
1419 change_id: Uuid,
1420 username: &str,
1421) -> AppResult<()> {
1422 let reviewer = users::find_by_username(pool, username)
1423 .await?
1424 .ok_or_else(|| AppError::BadRequest(format!("Unknown user '{}'.", username.trim())))?;
1425 if !reviewer.active {
1426 return Err(AppError::BadRequest(format!(
1427 "{} is deactivated.",
1428 reviewer.display_name
1429 )));
1430 }
1431 if Role::parse(&reviewer.role).unwrap_or(Role::Viewer) < Role::Editor {
1432 return Err(AppError::BadRequest(format!(
1433 "{} has the viewer role and cannot review changes.",
1434 reviewer.display_name
1435 )));
1436 }
1437 let mut tx = pool.begin().await?;
1438 let change = lock(&mut tx, actor, change_id).await?;
1439 ensure_owner(&change, actor)?;
1440 ensure_status(&change, &[ChangeStatus::Draft])?;
1441 ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1444 if let Some(conflict) =
1445 review_conflict(&mut tx, &change, reviewer.id, Some(actor.id), false).await?
1446 {
1447 return Err(AppError::BadRequest(format!(
1448 "{} cannot review {}: they {} (four-eyes principle).",
1449 reviewer.display_name,
1450 change.number,
1451 conflict.describe()
1452 )));
1453 }
1454 let blind = sqlx::query_scalar::<_, bool>(&format!(
1456 "SELECT EXISTS(SELECT 1 FROM change_items ci
1457 JOIN revisions r ON r.id = ci.revision_id
1458 JOIN items i ON i.id = r.item_id
1459 JOIN users u ON u.id = $2
1460 WHERE ci.change_id = $1 AND NOT {})",
1461 access::user_sees_project("u", "i.project_id")
1462 ))
1463 .bind(change_id)
1464 .bind(reviewer.id)
1465 .fetch_one(&mut *tx)
1466 .await?;
1467 if blind {
1468 return Err(AppError::Conflict(format!(
1469 "{} cannot see every item of {} (not a member of a restricted project group).",
1470 reviewer.display_name, change.number
1471 )));
1472 }
1473 let inserted = sqlx::query(
1474 "INSERT INTO change_reviews (change_id, reviewer_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
1475 )
1476 .bind(change_id)
1477 .bind(reviewer.id)
1478 .execute(&mut *tx)
1479 .await?
1480 .rows_affected();
1481 if inserted > 0 {
1482 touch(&mut tx, change_id).await?;
1483 audit::record(
1484 &mut tx,
1485 Some(actor),
1486 "change.add_reviewer",
1487 "change",
1488 Some(change_id),
1489 format!(
1490 "Asked {} to review {}",
1491 reviewer.display_name, change.number
1492 ),
1493 json!({ "reviewer_id": reviewer.id }),
1494 )
1495 .await?;
1496 }
1497 tx.commit().await?;
1498 Ok(())
1499}
1500
1501pub async fn replace_reviewer(
1516 pool: &PgPool,
1517 actor: &Actor,
1518 change_id: Uuid,
1519 old_reviewer_id: Uuid,
1520 username: &str,
1521) -> AppResult<()> {
1522 let substitute = users::find_by_username(pool, username)
1523 .await?
1524 .ok_or_else(|| AppError::BadRequest(format!("Unknown user '{}'.", username.trim())))?;
1525 if !substitute.active {
1526 return Err(AppError::BadRequest(format!(
1527 "{} is deactivated.",
1528 substitute.display_name
1529 )));
1530 }
1531 if Role::parse(&substitute.role).unwrap_or(Role::Viewer) < Role::Editor {
1532 return Err(AppError::BadRequest(format!(
1533 "{} has the viewer role and cannot review changes.",
1534 substitute.display_name
1535 )));
1536 }
1537 let mut tx = pool.begin().await?;
1538 let change = lock(&mut tx, actor, change_id).await?;
1539 ensure_owner(&change, actor)?;
1540 ensure_status(&change, &[ChangeStatus::InReview])?;
1541 ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1542 let current = sqlx::query_as::<_, (String, String)>(
1543 "SELECT cr.decision, u.display_name FROM change_reviews cr
1544 JOIN users u ON u.id = cr.reviewer_id
1545 WHERE cr.change_id = $1 AND cr.reviewer_id = $2",
1546 )
1547 .bind(change_id)
1548 .bind(old_reviewer_id)
1549 .fetch_optional(&mut *tx)
1550 .await?
1551 .ok_or_else(|| AppError::not_found("This user is not a reviewer of the change."))?;
1552 if current.0 != Decision::Pending.as_str() {
1553 return Err(AppError::Conflict(format!(
1554 "{} has already decided; only a pending reviewer can be replaced.",
1555 current.1
1556 )));
1557 }
1558 let already = sqlx::query_scalar::<_, bool>(
1559 "SELECT EXISTS(SELECT 1 FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2)",
1560 )
1561 .bind(change_id)
1562 .bind(substitute.id)
1563 .fetch_one(&mut *tx)
1564 .await?;
1565 if already {
1566 return Err(AppError::Conflict(format!(
1567 "{} is already a reviewer of {}.",
1568 substitute.display_name, change.number
1569 )));
1570 }
1571 let four_eyes = sqlx::query_scalar::<_, bool>("SELECT four_eyes FROM changes WHERE id = $1")
1572 .bind(change_id)
1573 .fetch_one(&mut *tx)
1574 .await?;
1575 if let Some(conflict) =
1576 review_conflict(&mut tx, &change, substitute.id, Some(actor.id), !four_eyes).await?
1577 {
1578 return Err(AppError::BadRequest(format!(
1579 "{} cannot review {}: they {} (four-eyes principle).",
1580 substitute.display_name,
1581 change.number,
1582 conflict.describe()
1583 )));
1584 }
1585 let blind = sqlx::query_scalar::<_, bool>(&format!(
1586 "SELECT EXISTS(SELECT 1 FROM change_items ci
1587 JOIN revisions r ON r.id = ci.revision_id
1588 JOIN items i ON i.id = r.item_id
1589 JOIN users u ON u.id = $2
1590 WHERE ci.change_id = $1 AND NOT {})",
1591 access::user_sees_project("u", "i.project_id")
1592 ))
1593 .bind(change_id)
1594 .bind(substitute.id)
1595 .fetch_one(&mut *tx)
1596 .await?;
1597 if blind {
1598 return Err(AppError::Conflict(format!(
1599 "{} cannot see every item of {} (not a member of a restricted project group).",
1600 substitute.display_name, change.number
1601 )));
1602 }
1603 sqlx::query(
1604 "UPDATE change_reviews SET reviewer_id = $3 WHERE change_id = $1 AND reviewer_id = $2",
1605 )
1606 .bind(change_id)
1607 .bind(old_reviewer_id)
1608 .bind(substitute.id)
1609 .execute(&mut *tx)
1610 .await?;
1611 touch(&mut tx, change_id).await?;
1612 audit::record(
1613 &mut tx,
1614 Some(actor),
1615 "change.replace_reviewer",
1616 "change",
1617 Some(change_id),
1618 format!(
1619 "Replaced reviewer {} by {} on {}",
1620 current.1, substitute.display_name, change.number
1621 ),
1622 json!({ "old_reviewer_id": old_reviewer_id, "reviewer_id": substitute.id }),
1623 )
1624 .await?;
1625 tell(
1626 &mut tx,
1627 actor,
1628 &change,
1629 NotificationKind::ReviewRequested,
1630 &[substitute.id],
1631 format!("Review requested: {} – {}", change.number, change.title),
1632 format!("{} asks you to review this change.", actor.display_name),
1633 )
1634 .await?;
1635 tx.commit().await?;
1636 Ok(())
1637}
1638
1639pub async fn remove_reviewer(
1650 pool: &PgPool,
1651 actor: &Actor,
1652 change_id: Uuid,
1653 reviewer_id: Uuid,
1654) -> AppResult<()> {
1655 let mut tx = pool.begin().await?;
1656 let change = lock(&mut tx, actor, change_id).await?;
1657 ensure_owner(&change, actor)?;
1658 ensure_status(&change, &[ChangeStatus::Draft])?;
1659 let removed =
1660 sqlx::query("DELETE FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2")
1661 .bind(change_id)
1662 .bind(reviewer_id)
1663 .execute(&mut *tx)
1664 .await?
1665 .rows_affected();
1666 if removed == 0 {
1667 return Err(AppError::not_found(
1668 "This user is not a reviewer of the change.",
1669 ));
1670 }
1671 touch(&mut tx, change_id).await?;
1672 audit::record(
1673 &mut tx,
1674 Some(actor),
1675 "change.remove_reviewer",
1676 "change",
1677 Some(change_id),
1678 format!("Removed a reviewer from {}", change.number),
1679 json!({ "reviewer_id": reviewer_id }),
1680 )
1681 .await?;
1682 tx.commit().await?;
1683 Ok(())
1684}
1685
1686pub async fn submit(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
1706 let mut tx = pool.begin().await?;
1707 let change = lock(&mut tx, actor, change_id).await?;
1708 ensure_owner(&change, actor)?;
1709 ensure_status(&change, &[ChangeStatus::Draft])?;
1710 ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1711 let item_ids = sqlx::query_scalar::<_, Uuid>(
1712 "SELECT revision_id FROM change_items WHERE change_id = $1 ORDER BY revision_id",
1713 )
1714 .bind(change_id)
1715 .fetch_all(&mut *tx)
1716 .await?;
1717 if item_ids.is_empty() {
1718 return Err(AppError::Conflict(
1719 "Add at least one revision before submitting the change.".into(),
1720 ));
1721 }
1722 let reviewer_count =
1723 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM change_reviews WHERE change_id = $1")
1724 .bind(change_id)
1725 .fetch_one(&mut *tx)
1726 .await?;
1727 if reviewer_count == 0 {
1728 return Err(AppError::Conflict(
1729 "Assign at least one reviewer before submitting the change.".into(),
1730 ));
1731 }
1732 let reviewers = sqlx::query_as::<_, (Uuid, String, bool)>(
1734 "SELECT cr.reviewer_id, u.display_name, u.active FROM change_reviews cr
1735 JOIN users u ON u.id = cr.reviewer_id WHERE cr.change_id = $1 ORDER BY u.username",
1736 )
1737 .bind(change_id)
1738 .fetch_all(&mut *tx)
1739 .await?;
1740 for (reviewer, name, active) in &reviewers {
1741 if !active {
1744 return Err(AppError::Conflict(format!(
1745 "{name} is deactivated and cannot review {}. Replace them first: remove them as \
1746 reviewer and add someone else.",
1747 change.number
1748 )));
1749 }
1750 if let Some(conflict) =
1751 review_conflict(&mut tx, &change, *reviewer, Some(actor.id), false).await?
1752 {
1753 return Err(AppError::Conflict(format!(
1754 "{name} cannot review {}: they {} (four-eyes principle). Remove them as reviewer first.",
1755 change.number,
1756 conflict.describe()
1757 )));
1758 }
1759 }
1760 let requirement = project_policy::review_requirement(&mut tx, Some(actor), change_id).await?;
1762 let reviewer_uuids: Vec<Uuid> = reviewers.iter().map(|(id, _, _)| *id).collect();
1763 for (user, name) in &requirement.required_users {
1766 if reviewer_uuids.contains(user) {
1767 continue;
1768 }
1769 if let Some(conflict) =
1770 review_conflict(&mut tx, &change, *user, Some(actor.id), false).await?
1771 {
1772 return Err(AppError::Conflict(format!(
1773 "{name} is required by the review policy of {} but cannot review it: they {} (four-eyes principle). Change the policy or the change.",
1774 change.number,
1775 conflict.describe()
1776 )));
1777 }
1778 }
1779 let shortfalls = domain::policy_shortfalls(&reviewer_uuids, &requirement);
1780 if !shortfalls.is_empty() {
1781 return Err(AppError::Conflict(format!(
1782 "{} does not meet the review policy of its project groups: {}.",
1783 change.number,
1784 shortfalls.join("; ")
1785 )));
1786 }
1787 let action_lines = action_lines(&mut tx, change_id).await?;
1788 for (id, action, successor) in &action_lines {
1789 let rev = revisions::lock(&mut tx, *id).await?;
1790 revisions::ensure_project_access(&rev, actor)?;
1791 check_action_line(&mut tx, &rev, *action, *successor).await?;
1792 }
1793 for id in &item_ids {
1794 if action_lines.iter().any(|(a, _, _)| a == id) {
1795 continue;
1796 }
1797 let rev = revisions::lock(&mut tx, *id).await?;
1798 revisions::ensure_project_access(&rev, actor)?;
1800 as_built::ensure_ready(&mut tx, &rev).await?;
1803 if let Some(owner) = rev.checked_out_by {
1804 if owner != actor.id {
1805 return Err(AppError::Conflict(format!(
1806 "{} is checked out by {}; it must be checked in first.",
1807 revisions::label(&rev),
1808 rev.checked_out_by_name.as_deref().unwrap_or("another user")
1809 )));
1810 }
1811 }
1812 }
1813 let ready = readiness::check(&mut tx, change_id).await?.redact(actor);
1814 if !ready.is_ready() {
1815 return Err(AppError::Conflict(format!(
1816 "{} cannot be submitted: {} not released – {}. Add {} to this change or release {} first.",
1817 change.number,
1818 if ready.blockers.len() == 1 { "a dependency is" } else { "dependencies are" },
1819 ready.blocker_summary(),
1820 if ready.blockers.len() == 1 { "it" } else { "them" },
1821 if ready.blockers.len() == 1 { "it" } else { "them" },
1822 )));
1823 }
1824 let labels = transition_items(&mut tx, change_id, LifecycleState::InReview).await?;
1825 reset_reviews(&mut tx, change_id).await?;
1826 sqlx::query(
1827 "UPDATE changes SET status = 'in_review', submitted_at = now(), submitted_by = $2, four_eyes = true, updated_at = now() WHERE id = $1",
1828 )
1829 .bind(change_id)
1830 .bind(actor.id)
1831 .execute(&mut *tx)
1832 .await?;
1833 let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
1837 audit::record_mentioning(
1838 &mut tx,
1839 Some(actor),
1840 "change.submit",
1841 "change",
1842 Some(change_id),
1843 &named,
1844 format!(
1845 "Submitted {} for review ({})",
1846 change.number,
1847 labels.join(", ")
1848 ),
1849 json!({ "revisions": labels }),
1850 )
1851 .await?;
1852 let reviewers = reviewer_ids(&mut tx, change_id).await?;
1853 tell_naming(
1854 &mut tx,
1855 actor,
1856 &change,
1857 NotificationKind::ReviewRequested,
1858 &reviewers,
1859 format!("Review requested: {} – {}", change.number, change.title),
1860 format!(
1861 "{} asks you to review {}.",
1862 actor.display_name,
1863 labels.join(", ")
1864 ),
1865 &named,
1866 format!("{} asks you to review this change.", actor.display_name),
1867 )
1868 .await?;
1869 let mut watchers = notifications::watchers(&mut tx, "change", &[change_id]).await?;
1870 watchers.retain(|w| !reviewers.contains(w));
1871 tell_naming(
1872 &mut tx,
1873 actor,
1874 &change,
1875 NotificationKind::ChangeUpdate,
1876 &watchers,
1877 format!("{} was submitted for review", change.number),
1878 format!("{} – {}", change.title, labels.join(", ")),
1879 &named,
1880 change.title.clone(),
1881 )
1882 .await?;
1883 if webhooks::wanted(&mut tx, &[WebhookEvent::ChangeSubmitted]).await? {
1884 let (revs, projects) = webhooks::change_revisions(&mut tx, change_id).await?;
1885 webhooks::emit(
1886 &mut tx,
1887 Some(actor),
1888 WebhookEvent::ChangeSubmitted,
1889 &projects,
1890 json!({ "change": webhooks::change_object(&change, ChangeStatus::InReview.as_str()),
1891 "revisions": revs }),
1892 )
1893 .await?;
1894 }
1895 tx.commit().await?;
1896 get(pool, actor, change_id).await
1897}
1898
1899pub async fn review(
1925 pool: &PgPool,
1926 actor: &Actor,
1927 change_id: Uuid,
1928 decision: Decision,
1929 comment: &str,
1930 sign_off: &SignOff,
1931 reauth: domain::ApprovalReauth,
1932) -> AppResult<Change> {
1933 actor.require(Role::Editor)?;
1934 let comment = domain::limit_text("Comment", comment, 4000)?;
1935 let meaning = domain::limit_text(
1936 "Meaning of the sign-off",
1937 &sign_off.meaning,
1938 domain::MAX_SIGN_OFF_MEANING,
1939 )?;
1940 if decision == Decision::Pending {
1941 return Err(AppError::BadRequest("Choose approve or reject.".into()));
1942 }
1943 if decision == Decision::Rejected && comment.is_empty() {
1944 return Err(AppError::BadRequest(
1945 "Please explain why you reject the change.".into(),
1946 ));
1947 }
1948 if reauth.requires(decision) && !sign_off.method.is_fresh() {
1949 return Err(AppError::Forbidden(
1950 "Confirm your decision with your password (or sign in again with single sign-on): \
1951 this server asks for a fresh sign-in for review decisions."
1952 .into(),
1953 ));
1954 }
1955 let mut tx = pool.begin().await?;
1956 let change = lock(&mut tx, actor, change_id).await?;
1957 ensure_status(&change, &[ChangeStatus::InReview])?;
1958 ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
1959 let current = sqlx::query_scalar::<_, String>(
1960 "SELECT decision FROM change_reviews WHERE change_id = $1 AND reviewer_id = $2",
1961 )
1962 .bind(change_id)
1963 .bind(actor.id)
1964 .fetch_optional(&mut *tx)
1965 .await?
1966 .ok_or_else(|| AppError::Forbidden(format!("You are not a reviewer of {}.", change.number)))?;
1967 let four_eyes = sqlx::query_scalar::<_, bool>("SELECT four_eyes FROM changes WHERE id = $1")
1968 .bind(change_id)
1969 .fetch_one(&mut *tx)
1970 .await?;
1971 if let Some(conflict) = review_conflict(&mut tx, &change, actor.id, None, !four_eyes).await? {
1972 return Err(AppError::Forbidden(format!(
1973 "You cannot decide on {}: you {} (four-eyes principle).",
1974 change.number,
1975 conflict.describe()
1976 )));
1977 }
1978 if current != Decision::Pending.as_str() {
1979 return Err(AppError::Conflict(
1980 "You have already decided on this change.".into(),
1981 ));
1982 }
1983 if decision == Decision::Approved {
1984 as_built::ensure_concessions_confirmed(&mut tx, change_id).await?;
1987 }
1988 sqlx::query(
1989 "UPDATE change_reviews SET decision = $3, comment = $4, decided_at = now(),
1990 sign_off_method = $5, sign_off_meaning = $6
1991 WHERE change_id = $1 AND reviewer_id = $2",
1992 )
1993 .bind(change_id)
1994 .bind(actor.id)
1995 .bind(decision.as_str())
1996 .bind(&comment)
1997 .bind(sign_off.method.as_str())
1998 .bind(&meaning)
1999 .execute(&mut *tx)
2000 .await?;
2001 audit::record(
2002 &mut tx,
2003 Some(actor),
2004 if decision == Decision::Approved {
2005 "change.approve"
2006 } else {
2007 "change.reject"
2008 },
2009 "change",
2010 Some(change_id),
2011 format!(
2012 "{} {}{}",
2013 if decision == Decision::Approved {
2014 "Approved"
2015 } else {
2016 "Rejected"
2017 },
2018 change.number,
2019 if comment.is_empty() {
2020 String::new()
2021 } else {
2022 format!(": {comment}")
2023 }
2024 ),
2025 json!({ "comment": comment, "sign_off": sign_off.method.as_str(), "meaning": meaning }),
2026 )
2027 .await?;
2028
2029 let votes: Vec<(Uuid, Decision)> = sqlx::query_as::<_, (Uuid, String)>(
2030 "SELECT reviewer_id, decision FROM change_reviews WHERE change_id = $1",
2031 )
2032 .bind(change_id)
2033 .fetch_all(&mut *tx)
2034 .await?
2035 .into_iter()
2036 .filter_map(|(u, d)| Decision::parse(&d).map(|d| (u, d)))
2037 .collect();
2038 let people = notifications::change_people(&mut tx, change_id).await?;
2039 let requirement = project_policy::review_requirement(&mut tx, None, change_id).await?;
2040 let voters: Vec<Uuid> = votes.iter().map(|(u, _)| *u).collect();
2042 let requirement = domain::attainable(&requirement, &voters);
2043 let outcome = domain::review_outcome_with(&votes, &requirement);
2044 match outcome {
2045 ReviewOutcome::Pending => {
2046 touch(&mut tx, change_id).await?;
2047 tell(
2048 &mut tx,
2049 actor,
2050 &change,
2051 NotificationKind::ChangeUpdate,
2052 &people,
2053 format!(
2054 "{} {} {}",
2055 actor.display_name,
2056 if decision == Decision::Approved {
2057 "approved"
2058 } else {
2059 "rejected"
2060 },
2061 change.number
2062 ),
2063 if comment.is_empty() {
2064 format!("{} – waiting for the other reviewers.", change.title)
2065 } else {
2066 format!("{} – {comment}", change.title)
2067 },
2068 )
2069 .await?;
2070 }
2071 ReviewOutcome::Rejected => {
2072 let labels = transition_items(&mut tx, change_id, LifecycleState::InWork).await?;
2073 sqlx::query("UPDATE changes SET status = 'rejected', updated_at = now() WHERE id = $1")
2074 .bind(change_id)
2075 .execute(&mut *tx)
2076 .await?;
2077 let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
2078 audit::record_mentioning(
2079 &mut tx,
2080 Some(actor),
2081 "change.rejected",
2082 "change",
2083 Some(change_id),
2084 &named,
2085 format!(
2086 "{} was rejected; {} returned to In Work",
2087 change.number,
2088 labels.join(", ")
2089 ),
2090 json!({ "revisions": labels }),
2091 )
2092 .await?;
2093 tell_naming(
2094 &mut tx,
2095 actor,
2096 &change,
2097 NotificationKind::ChangeUpdate,
2098 &people,
2099 format!("{} was rejected by {}", change.number, actor.display_name),
2100 format!("{comment}\n{} returned to In Work.", labels.join(", ")),
2101 &named,
2102 format!("{comment}\nIts revisions returned to In Work."),
2103 )
2104 .await?;
2105 }
2106 ReviewOutcome::Approved => {
2107 let labels = transition_items(&mut tx, change_id, LifecycleState::Released).await?;
2108 let released = sqlx::query_scalar::<_, Uuid>(
2110 "SELECT revision_id FROM change_items WHERE change_id = $1 AND action = 'release'",
2111 )
2112 .bind(change_id)
2113 .fetch_all(&mut *tx)
2114 .await?;
2115 impacts::on_release(&mut tx, actor, Some(change_id), &released).await?;
2116 sqlx::query(
2117 "UPDATE changes SET status = 'released', closed_at = now(), updated_at = now() WHERE id = $1",
2118 )
2119 .bind(change_id)
2120 .execute(&mut *tx)
2121 .await?;
2122 let obsoleted = apply_action_lines(&mut tx, actor, &change).await?;
2123 let summary = release_summary(&labels, &obsoleted);
2124 let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
2125 audit::record_mentioning(
2126 &mut tx,
2127 Some(actor),
2128 "change.released",
2129 "change",
2130 Some(change_id),
2131 &named,
2132 format!("{} was approved; {summary}", change.number),
2133 json!({ "revisions": labels, "obsoleted": obsoleted }),
2134 )
2135 .await?;
2136 tell_naming(
2137 &mut tx,
2138 actor,
2139 &change,
2140 NotificationKind::ChangeUpdate,
2141 &people,
2142 format!("{} was released", change.number),
2143 format!(
2144 "{} – {} (last approval by {}).",
2145 change.title, summary, actor.display_name
2146 ),
2147 &named,
2148 format!(
2149 "{} – released (last approval by {}).",
2150 change.title, actor.display_name
2151 ),
2152 )
2153 .await?;
2154 tell_item_watchers(&mut tx, actor, &change, &released, &people).await?;
2155 requests::on_change_released(&mut tx, actor, &change).await?;
2157 }
2158 }
2159 emit_review_events(&mut tx, actor, &change, decision, &comment, outcome).await?;
2160 tx.commit().await?;
2161 get(pool, actor, change_id).await
2162}
2163
2164async fn emit_review_events(
2168 conn: &mut PgConnection,
2169 actor: &Actor,
2170 change: &Change,
2171 decision: Decision,
2172 comment: &str,
2173 outcome: ReviewOutcome,
2174) -> AppResult<()> {
2175 let status = match outcome {
2176 ReviewOutcome::Pending => ChangeStatus::InReview,
2177 ReviewOutcome::Rejected => ChangeStatus::Rejected,
2178 ReviewOutcome::Approved => ChangeStatus::Released,
2179 }
2180 .as_str();
2181 let event = if decision == Decision::Approved {
2182 WebhookEvent::ChangeApproved
2183 } else {
2184 WebhookEvent::ChangeRejected
2185 };
2186 let mut events = vec![event];
2187 if outcome == ReviewOutcome::Approved {
2188 events.extend([WebhookEvent::RevisionReleased, WebhookEvent::ChangeReleased]);
2189 }
2190 if !webhooks::wanted(conn, &events).await? {
2191 return Ok(());
2192 }
2193 let change_json = webhooks::change_object(change, status);
2194 let (revs, projects) = webhooks::change_revisions(conn, change.id).await?;
2195 let review = json!({
2196 "reviewer": actor.username,
2197 "decision": decision.as_str(),
2198 "comment": comment,
2199 "change_status": status,
2200 });
2201 webhooks::emit(
2202 conn,
2203 Some(actor),
2204 event,
2205 &projects,
2206 json!({ "change": change_json, "review": review, "revisions": revs }),
2207 )
2208 .await?;
2209 if outcome == ReviewOutcome::Approved {
2210 let each = revs
2211 .iter()
2212 .zip(&projects)
2213 .filter(|(rev, _)| rev["action"] == LineAction::Release.as_str())
2215 .map(|(rev, project)| (*project, json!({ "revision": rev, "change": change_json })))
2216 .collect();
2217 webhooks::emit_each(
2218 conn,
2219 Some(actor),
2220 WebhookEvent::RevisionReleased,
2221 each,
2222 &projects,
2223 )
2224 .await?;
2225 webhooks::emit(
2226 conn,
2227 Some(actor),
2228 WebhookEvent::ChangeReleased,
2229 &projects,
2230 json!({ "change": change_json, "revisions": revs }),
2231 )
2232 .await?;
2233 }
2234 Ok(())
2235}
2236
2237pub async fn withdraw(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
2251 let mut tx = pool.begin().await?;
2252 let change = lock(&mut tx, actor, change_id).await?;
2253 ensure_owner_or_stand_in(&mut tx, &change, actor).await?;
2254 ensure_status(&change, &[ChangeStatus::InReview])?;
2255 let labels = transition_items(&mut tx, change_id, LifecycleState::InWork).await?;
2256 reset_reviews(&mut tx, change_id).await?;
2257 sqlx::query(
2258 "UPDATE changes SET status = 'draft', submitted_at = NULL, updated_at = now() WHERE id = $1",
2259 )
2260 .bind(change_id)
2261 .execute(&mut *tx)
2262 .await?;
2263 let named = audit::Mentions::items(audit::change_item_ids(&mut tx, change_id).await?);
2264 audit::record_mentioning(
2265 &mut tx,
2266 Some(actor),
2267 "change.withdraw",
2268 "change",
2269 Some(change_id),
2270 &named,
2271 format!("Withdrew {} from review", change.number),
2272 json!({ "revisions": labels }),
2273 )
2274 .await?;
2275 let people = notifications::change_people(&mut tx, change_id).await?;
2276 tell(
2277 &mut tx,
2278 actor,
2279 &change,
2280 NotificationKind::ChangeUpdate,
2281 &people,
2282 format!("{} was withdrawn from review", change.number),
2283 format!(
2284 "{} – {} took it back; no review is needed for now.",
2285 change.title, actor.display_name
2286 ),
2287 )
2288 .await?;
2289 tx.commit().await?;
2290 get(pool, actor, change_id).await
2291}
2292
2293pub async fn reopen(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
2305 let mut tx = pool.begin().await?;
2306 let change = lock(&mut tx, actor, change_id).await?;
2307 ensure_owner_or_stand_in(&mut tx, &change, actor).await?;
2308 ensure_status(&change, &[ChangeStatus::Rejected])?;
2309 ensure_sees_all(sees_all_items(&mut tx, actor, change_id).await?)?;
2310 reset_reviews(&mut tx, change_id).await?;
2311 sqlx::query(
2312 "UPDATE changes SET status = 'draft', submitted_at = NULL, updated_at = now() WHERE id = $1",
2313 )
2314 .bind(change_id)
2315 .execute(&mut *tx)
2316 .await?;
2317 audit::record(
2318 &mut tx,
2319 Some(actor),
2320 "change.reopen",
2321 "change",
2322 Some(change_id),
2323 format!("Reopened {} as draft", change.number),
2324 json!({}),
2325 )
2326 .await?;
2327 tx.commit().await?;
2328 get(pool, actor, change_id).await
2329}
2330
2331pub async fn cancel(pool: &PgPool, actor: &Actor, change_id: Uuid) -> AppResult<Change> {
2343 let mut tx = pool.begin().await?;
2344 let change = lock(&mut tx, actor, change_id).await?;
2345 ensure_owner_or_stand_in(&mut tx, &change, actor).await?;
2346 ensure_status(&change, &[ChangeStatus::Draft, ChangeStatus::Rejected])?;
2347 sqlx::query(
2348 "UPDATE changes SET status = 'cancelled', closed_at = now(), updated_at = now() WHERE id = $1",
2349 )
2350 .bind(change_id)
2351 .execute(&mut *tx)
2352 .await?;
2353 audit::record(
2354 &mut tx,
2355 Some(actor),
2356 "change.cancel",
2357 "change",
2358 Some(change_id),
2359 format!("Cancelled {}", change.number),
2360 json!({}),
2361 )
2362 .await?;
2363 let people = notifications::change_people(&mut tx, change_id).await?;
2364 tell(
2365 &mut tx,
2366 actor,
2367 &change,
2368 NotificationKind::ChangeUpdate,
2369 &people,
2370 format!("{} was cancelled", change.number),
2371 format!("{} – cancelled by {}.", change.title, actor.display_name),
2372 )
2373 .await?;
2374 tx.commit().await?;
2375 get(pool, actor, change_id).await
2376}
2377
2378#[cfg(test)]
2379mod tests {
2380 use super::release_summary;
2381
2382 #[test]
2383 fn the_release_summary_names_what_was_released_and_obsoleted() {
2384 let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
2385 assert_eq!(
2386 release_summary(&s(&["A/A", "B/A"]), &[]),
2387 "released A/A, B/A"
2388 );
2389 assert_eq!(release_summary(&[], &s(&["C/B"])), "obsoleted C/B");
2390 assert_eq!(
2391 release_summary(&s(&["A/A"]), &s(&["C/B", "D/A"])),
2392 "released A/A; obsoleted C/B, D/A"
2393 );
2394 assert_eq!(release_summary(&[], &[]), "");
2395 }
2396}