1use super::*;
4
5#[derive(Clone, Debug, Serialize)]
7pub struct CreatedChange {
8 pub change: Change,
9 pub added: Vec<String>,
11 pub skipped: Vec<String>,
13}
14
15async fn ensure_implementable(
16 conn: &mut PgConnection,
17 actor: &Actor,
18 request: &ChangeRequest,
19) -> AppResult<()> {
20 let projects = project_ids(conn, request.id).await?;
21 if !may_triage(actor, &projects) {
22 return Err(forbidden(request, "implement"));
23 }
24 if status_of(request) != RequestStatus::Accepted {
25 return Err(AppError::Conflict(format!(
26 "{} is {}; only accepted change requests get a change order.",
27 request.number,
28 status_of(request).label()
29 )));
30 }
31 Ok(())
32}
33
34pub(super) async fn link(
35 conn: &mut PgConnection,
36 actor: &Actor,
37 request_id: Uuid,
38 change_id: Uuid,
39) -> AppResult<bool> {
40 Ok(sqlx::query(
41 "INSERT INTO change_request_changes (request_id, change_id, linked_by) VALUES ($1, $2, $3)
42 ON CONFLICT DO NOTHING",
43 )
44 .bind(request_id)
45 .bind(change_id)
46 .bind(actor.id)
47 .execute(&mut *conn)
48 .await?
49 .rows_affected()
50 > 0)
51}
52
53pub async fn create_change(
58 pool: &PgPool,
59 actor: &Actor,
60 id: Uuid,
61 title: Option<&str>,
62 priority: Option<Priority>,
63) -> AppResult<CreatedChange> {
64 let mut tx = pool.begin().await?;
65 let request = lock(&mut tx, actor, id).await?;
66 ensure_implementable(&mut tx, actor, &request).await?;
67 let title = match title.map(str::trim).filter(|s| !s.is_empty()) {
68 Some(t) => t.to_string(),
69 None => domain::truncate_chars(&format!("{}: {}", request.number, request.title), 200),
70 };
71 let description = domain::truncate_chars(
72 &format!(
73 "Implements {} – {}\n\n{}",
74 request.number, request.title, request.description
75 ),
76 20_000,
77 );
78 let priority = priority
79 .or_else(|| Priority::parse(&request.priority))
80 .unwrap_or(Priority::Normal);
81 let change_id = changes::create_in(
82 &mut tx,
83 actor,
84 changes::ChangeInput {
85 title,
86 description,
87 priority,
88 },
89 )
90 .await?;
91 link(&mut tx, actor, id, change_id).await?;
92 let affected = sqlx::query_as::<_, (Uuid, String, Option<Uuid>, Option<Uuid>)>(
93 "SELECT i.id, i.number, i.project_id,
94 (SELECT r.id FROM revisions r WHERE r.item_id = i.id AND r.state = 'in_work')
95 FROM change_request_items cri JOIN items i ON i.id = cri.item_id
96 WHERE cri.request_id = $1 ORDER BY i.number",
97 )
98 .bind(id)
99 .fetch_all(&mut *tx)
100 .await?;
101 let mut added = Vec::new();
102 let mut named_items = Vec::new();
104 let mut skipped = Vec::new();
105 for (item_id, number, project_id, in_work) in affected {
106 if !actor.can_see_project(project_id) {
109 skipped.push(format!(
110 "{}: not visible to you – ask a member of its group",
111 access::RESTRICTED_LABEL
112 ));
113 continue;
114 }
115 named_items.push(item_id);
116 let Some(rev_id) = in_work else {
117 skipped.push(format!(
118 "{number}: no revision in work – revise it, then add it to the change"
119 ));
120 continue;
121 };
122 match changes::add_item_in(&mut tx, actor, change_id, rev_id).await {
124 Ok(label) => added.push(label),
125 Err(
126 e @ (AppError::Conflict(_)
127 | AppError::Forbidden(_)
128 | AppError::BadRequest(_)
129 | AppError::NotFound(_)),
130 ) => skipped.push(format!("{number}: {e}")),
131 Err(e) => return Err(e),
132 }
133 }
134 let change_number = sqlx::query_scalar::<_, String>("SELECT number FROM changes WHERE id = $1")
135 .bind(change_id)
136 .fetch_one(&mut *tx)
137 .await?;
138 touch(&mut tx, id).await?;
139 audit::record_mentioning(
142 &mut tx,
143 Some(actor),
144 "change_request.create_change",
145 "change_request",
146 Some(id),
147 &audit::Mentions::items(named_items).with_change(change_id),
148 format!("Created change {change_number} for {}", request.number),
149 json!({ "change_id": change_id, "added": added, "skipped": skipped }),
150 )
151 .await?;
152 tell_people_naming(
153 &mut tx,
154 actor,
155 &request,
156 (
157 format!("{change_number} implements {}", request.number),
158 format!(
159 "{} created the change order {change_number} for {}.",
160 actor.display_name, request.title
161 ),
162 ),
163 audit::Mentions::change(change_id),
164 (
165 format!("{} has a change order", request.number),
166 format!(
167 "{} created a change order for {}.",
168 actor.display_name, request.title
169 ),
170 ),
171 )
172 .await?;
173 tx.commit().await?;
174 Ok(CreatedChange {
175 change: changes::get(pool, actor, change_id).await?,
176 added,
177 skipped,
178 })
179}
180
181pub async fn link_change(
183 pool: &PgPool,
184 actor: &Actor,
185 id: Uuid,
186 change_id: Uuid,
187) -> AppResult<Change> {
188 let mut tx = pool.begin().await?;
189 let (change_number, change_status) = sqlx::query_as::<_, (String, String)>(
192 "SELECT number, status FROM changes WHERE id = $1 FOR UPDATE",
193 )
194 .bind(change_id)
195 .fetch_optional(&mut *tx)
196 .await?
197 .ok_or_else(|| AppError::not_found("Change not found."))?;
198 if !changes::is_visible(&mut tx, actor, change_id).await? {
201 return Err(AppError::not_found("Change not found."));
202 }
203 let request = lock(&mut tx, actor, id).await?;
204 ensure_implementable(&mut tx, actor, &request).await?;
205 let open = domain::ChangeStatus::parse(&change_status).is_some_and(|s| s.is_open());
206 if !open {
207 return Err(AppError::Conflict(format!(
208 "{change_number} is {change_status}; link a draft or an open change order."
209 )));
210 }
211 if !link(&mut tx, actor, id, change_id).await? {
212 return Err(AppError::Conflict(format!(
213 "{change_number} is already linked to {}.",
214 request.number
215 )));
216 }
217 touch(&mut tx, id).await?;
218 audit::record_mentioning(
219 &mut tx,
220 Some(actor),
221 "change_request.link_change",
222 "change_request",
223 Some(id),
224 &audit::Mentions::change(change_id),
225 format!("Linked {change_number} to {}", request.number),
226 json!({ "change_id": change_id }),
227 )
228 .await?;
229 tell_people_naming(
230 &mut tx,
231 actor,
232 &request,
233 (
234 format!("{change_number} implements {}", request.number),
235 format!(
236 "{} linked the change order {change_number} to {}.",
237 actor.display_name, request.title
238 ),
239 ),
240 audit::Mentions::change(change_id),
241 (
242 format!("{} has a change order", request.number),
243 format!(
244 "{} linked a change order to {}.",
245 actor.display_name, request.title
246 ),
247 ),
248 )
249 .await?;
250 tx.commit().await?;
251 changes::get(pool, actor, change_id).await
252}
253
254pub(crate) async fn on_change_released(
257 conn: &mut PgConnection,
258 actor: &Actor,
259 change: &Change,
260) -> AppResult<()> {
261 let ids = sqlx::query_scalar::<_, Uuid>(
262 "SELECT cr.id FROM change_requests cr
263 JOIN change_request_changes x ON x.request_id = cr.id
264 WHERE x.change_id = $1 AND cr.status = 'accepted'
265 ORDER BY cr.number",
266 )
267 .bind(change.id)
268 .fetch_all(&mut *conn)
269 .await?;
270 for id in ids {
271 let request = lock_unchecked(conn, id).await?;
272 set_status(conn, &request, RequestStatus::Implemented).await?;
273 audit::record_mentioning(
276 conn,
277 Some(actor),
278 "change_request.implemented",
279 "change_request",
280 Some(id),
281 &audit::Mentions::change(change.id),
282 format!(
283 "{} is implemented – {} was released",
284 request.number, change.number
285 ),
286 json!({ "change_id": change.id }),
287 )
288 .await?;
289 tell_people_naming(
290 conn,
291 actor,
292 &request,
293 (
294 format!("{} is implemented", request.number),
295 format!(
296 "{} – released with {} ({}).",
297 request.title, change.number, change.title
298 ),
299 ),
300 audit::Mentions::change(change.id),
301 (
302 format!("{} is implemented", request.number),
303 format!("{} – the change order was released.", request.title),
304 ),
305 )
306 .await?;
307 }
308 Ok(())
309}