Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Issues (M5).
2//!
3//! Issues share the `comments` table with changes — the `one_target` CHECK on
4//! that table is what keeps a comment from belonging to both. Sharing it means
5//! the sanitiser, the rendering, and the comment view are the same in both
6//! places rather than two implementations that drift.
7
8use axum::extract::{Path as UrlPath, Query, State};
9use axum::response::{IntoResponse, Redirect, Response};
10use axum::Form;
11use df_db::ids::new_id;
12use serde::Deserialize;
13use uuid::Uuid;
14
15use crate::error::{AppError, AppResult};
16use crate::repo_ctx::RepoContext;
17use crate::routes::settings::urlencode;
18use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
19use crate::views::issue as v;
20use crate::views::repo as rv;
21use crate::views::review::CommentRow;
22use crate::views::{self, Chrome};
23
24#[derive(Deserialize, Default)]
25pub struct ListQuery {
26 pub state: Option<String>,
27 pub label: Option<String>,
28 pub assignee: Option<String>,
29 pub error: Option<String>,
30}
31
32/// `GET /{owner}/{repo}/issues`
33pub async fn list(
34 State(state): State<AppState>,
35 UrlPath((owner, name)): UrlPath<(String, String)>,
36 Query(q): Query<ListQuery>,
37 CurrentUser(user): CurrentUser,
38 CsrfToken(csrf): CsrfToken,
39 Nonce(nonce): Nonce,
40) -> AppResult<Response> {
41 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
42
43 let state_filter = q.state.as_deref().unwrap_or("open");
44 let label = q.label.as_deref().filter(|s| !s.is_empty());
45 let assignee = q.assignee.as_deref().filter(|s| !s.is_empty());
46
47 let rows = load_list(&state, ctx.repo.id, state_filter, label, assignee).await?;
48 let all_labels = load_labels(&state, ctx.repo.id).await?;
49
50 let body = maud::html! {
51 (v::list(&ctx, &rows, v::ListFilters {
52 state: state_filter,
53 label,
54 assignee,
55 all_labels: &all_labels,
56 }))
57 };
58
59 Ok(views::page_with_bar(
60 Chrome {
61 title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name),
62 user: user.as_deref(),
63 csrf: &csrf,
64 nonce: &nonce,
65 },
66 rv::header(&ctx, "issues"),
67 body,
68 )
69 .into_response())
70}
71
72/// `GET /{owner}/{repo}/issues/new`
73pub async fn new_form(
74 State(state): State<AppState>,
75 UrlPath((owner, name)): UrlPath<(String, String)>,
76 Query(q): Query<ListQuery>,
77 CurrentUser(user): CurrentUser,
78 CsrfToken(csrf): CsrfToken,
79 Nonce(nonce): Nonce,
80) -> AppResult<Response> {
81 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
82 if user.is_none() {
83 return Err(AppError::Unauthorized);
84 }
85 // Filing an issue is a comment-level action: anybody who can read a public
86 // repository can open one.
87 if !ctx.access.can_comment() {
88 return Err(AppError::Forbidden);
89 }
90
91 let labels = load_labels(&state, ctx.repo.id).await?;
92
93 let body = maud::html! {
94 (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() }))
95 };
96
97 Ok(views::page_with_bar(
98 Chrome {
99 title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name),
100 user: user.as_deref(),
101 csrf: &csrf,
102 nonce: &nonce,
103 },
104 rv::header(&ctx, "issues"),
105 body,
106 )
107 .into_response())
108}
109
110#[derive(Deserialize)]
111pub struct CreateIssue {
112 pub title: String,
113 pub body: Option<String>,
114 /// Repeated checkbox; absent when none are ticked.
115 #[serde(default)]
116 pub labels: Vec<String>,
117}
118
119/// `POST /{owner}/{repo}/issues`
120pub async fn create(
121 State(state): State<AppState>,
122 UrlPath((owner, name)): UrlPath<(String, String)>,
123 CurrentUser(user): CurrentUser,
124 Form(form): Form<CreateIssue>,
125) -> AppResult<Response> {
126 let Some(user) = user else {
127 return Err(AppError::Unauthorized);
128 };
129 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
130 if !ctx.access.can_comment() {
131 return Err(AppError::Forbidden);
132 }
133
134 let title: String = form.title.trim().chars().take(300).collect();
135 if title.is_empty() {
136 return Ok(Redirect::to(&format!(
137 "{}/issues/new?error={}",
138 ctx.base(),
139 urlencode("An issue needs a title.")
140 ))
141 .into_response());
142 }
143
144 let body = form.body.as_deref().unwrap_or("").trim();
145 if body.len() > 256 * 1024 {
146 return Ok(Redirect::to(&format!(
147 "{}/issues/new?error={}",
148 ctx.base(),
149 urlencode("That description is too long.")
150 ))
151 .into_response());
152 }
153
154 let mut tx = state.db.begin().await?;
155
156 // Same row-lock pattern the change counter uses, so two concurrent issues
157 // cannot take the same number.
158 let (number,): (i64,) = sqlx::query_as(
159 "UPDATE repo_counters SET next_issue = next_issue + 1
160 WHERE repo_id = $1 RETURNING next_issue - 1",
161 )
162 .bind(ctx.repo.id)
163 .fetch_one(&mut *tx)
164 .await?;
165
166 let issue_id = new_id();
167 sqlx::query(
168 "INSERT INTO issues (id, repo_id, number, title, body, author_user_id)
169 VALUES ($1, $2, $3, $4, $5, $6)",
170 )
171 .bind(issue_id)
172 .bind(ctx.repo.id)
173 .bind(number)
174 .bind(&title)
175 .bind(body)
176 .bind(user.id)
177 .execute(&mut *tx)
178 .await?;
179
180 for label in form.labels.iter().take(20) {
181 sqlx::query(
182 "INSERT INTO issue_labels (issue_id, label_id)
183 SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3
184 ON CONFLICT DO NOTHING",
185 )
186 .bind(issue_id)
187 .bind(ctx.repo.id)
188 .bind(label)
189 .execute(&mut *tx)
190 .await?;
191 }
192
193 tx.commit().await?;
194
195 record_references(&state, ctx.repo.id, "issue", issue_id, body).await;
196
197 crate::routes::review::event(
198 &state,
199 ctx.repo.id,
200 Some(user.id),
201 "issue.opened",
202 issue_id,
203 serde_json::json!({ "number": number }),
204 )
205 .await;
206
207 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
208}
209
210/// `GET /{owner}/{repo}/issues/{number}`
211pub async fn detail(
212 State(state): State<AppState>,
213 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
214 CurrentUser(user): CurrentUser,
215 CsrfToken(csrf): CsrfToken,
216 Nonce(nonce): Nonce,
217) -> AppResult<Response> {
218 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
219 let issue = load_issue(&state, ctx.repo.id, number)
220 .await?
221 .ok_or(AppError::NotFound)?;
222
223 let labels = load_issue_labels(&state, issue.id).await?;
224 let all_labels = load_labels(&state, ctx.repo.id).await?;
225 let assignees = load_assignees(&state, issue.id).await?;
226 let comments = load_comments(&state, issue.id, &ctx).await?;
227 let referenced_by = load_referenced_by(&state, ctx.repo.id, "issue", issue.id).await?;
228
229 let is_author = matches!((&user, &issue.author), (Some(u), Some(a)) if &u.handle == a);
230 let can_manage = ctx.access.can_manage_changes() || is_author;
231
232 let body_html = render(&ctx, &issue.body);
233
234 let body = maud::html! {
235 (v::detail(&ctx, v::Detail {
236 number: issue.number,
237 title: &issue.title,
238 body_html: &body_html,
239 state: &issue.state,
240 author: issue.author.as_deref(),
241 created_at: issue.created_at,
242 labels: &labels,
243 all_labels: &all_labels,
244 assignees: &assignees,
245 comments: &comments,
246 referenced_by: &referenced_by,
247 can_comment: user.is_some() && ctx.access.can_comment(),
248 can_manage,
249 csrf: &csrf,
250 }))
251 };
252
253 Ok(views::page_with_bar(
254 Chrome {
255 title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name),
256 user: user.as_deref(),
257 csrf: &csrf,
258 nonce: &nonce,
259 },
260 rv::header(&ctx, "issues"),
261 body,
262 )
263 .into_response())
264}
265
266#[derive(Deserialize)]
267pub struct IssueComment {
268 pub body: String,
269 /// Set by the "comment and close" button.
270 pub state: Option<String>,
271}
272
273/// `POST /{owner}/{repo}/issues/{number}/comments`
274pub async fn comment(
275 State(state): State<AppState>,
276 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
277 CurrentUser(user): CurrentUser,
278 Form(form): Form<IssueComment>,
279) -> AppResult<Response> {
280 let Some(user) = user else {
281 return Err(AppError::Unauthorized);
282 };
283 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
284 if !ctx.access.can_comment() {
285 return Err(AppError::Forbidden);
286 }
287 let issue = load_issue(&state, ctx.repo.id, number)
288 .await?
289 .ok_or(AppError::NotFound)?;
290
291 let body = form.body.trim();
292 if !body.is_empty() && body.len() <= 64 * 1024 {
293 let comment_id = new_id();
294 sqlx::query(
295 "INSERT INTO comments (id, repo_id, issue_id, author_user_id, body)
296 VALUES ($1, $2, $3, $4, $5)",
297 )
298 .bind(comment_id)
299 .bind(ctx.repo.id)
300 .bind(issue.id)
301 .bind(user.id)
302 .execute(&state.db)
303 .await
304 .map(|_| ())
305 .or_else(|e| {
306 tracing::error!("inserting an issue comment failed: {e}");
307 Err(e)
308 })?;
309
310 record_references(&state, ctx.repo.id, "comment", comment_id, body).await;
311 }
312
313 // "Comment and close" is one button and must be one action; two round trips
314 // would let the comment land and the close fail.
315 if let Some(next) = form.state.as_deref() {
316 let is_author = issue.author.as_deref() == Some(user.handle.as_str());
317 if (ctx.access.can_manage_changes() || is_author)
318 && matches!(next, "open" | "closed")
319 {
320 set_issue_state(&state, &ctx, &issue, next, user.id).await?;
321 }
322 }
323
324 sqlx::query("UPDATE issues SET updated_at = now() WHERE id = $1")
325 .bind(issue.id)
326 .execute(&state.db)
327 .await?;
328
329 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
330}
331
332#[derive(Deserialize)]
333pub struct SetState {
334 pub state: String,
335}
336
337/// `POST /{owner}/{repo}/issues/{number}/state`
338pub async fn set_state(
339 State(state): State<AppState>,
340 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
341 CurrentUser(user): CurrentUser,
342 Form(form): Form<SetState>,
343) -> AppResult<Response> {
344 let Some(user) = user else {
345 return Err(AppError::Unauthorized);
346 };
347 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
348 let issue = load_issue(&state, ctx.repo.id, number)
349 .await?
350 .ok_or(AppError::NotFound)?;
351
352 let is_author = issue.author.as_deref() == Some(user.handle.as_str());
353 if !ctx.access.can_manage_changes() && !is_author {
354 return Err(AppError::Forbidden);
355 }
356 if !matches!(form.state.as_str(), "open" | "closed") {
357 return Err(AppError::BadRequest("unknown state".into()));
358 }
359
360 set_issue_state(&state, &ctx, &issue, &form.state, user.id).await?;
361
362 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
363}
364
365#[derive(Deserialize)]
366pub struct SetLabels {
367 #[serde(default)]
368 pub labels: Vec<String>,
369}
370
371/// `POST /{owner}/{repo}/issues/{number}/labels`
372pub async fn set_labels(
373 State(state): State<AppState>,
374 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
375 CurrentUser(user): CurrentUser,
376 Form(form): Form<SetLabels>,
377) -> AppResult<Response> {
378 let Some(user) = user else {
379 return Err(AppError::Unauthorized);
380 };
381 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
382 if !ctx.access.can_manage_changes() {
383 return Err(AppError::Forbidden);
384 }
385 let issue = load_issue(&state, ctx.repo.id, number)
386 .await?
387 .ok_or(AppError::NotFound)?;
388
389 let mut tx = state.db.begin().await?;
390 sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1")
391 .bind(issue.id)
392 .execute(&mut *tx)
393 .await?;
394
395 for label in form.labels.iter().take(20) {
396 sqlx::query(
397 "INSERT INTO issue_labels (issue_id, label_id)
398 SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3
399 ON CONFLICT DO NOTHING",
400 )
401 .bind(issue.id)
402 .bind(ctx.repo.id)
403 .bind(label)
404 .execute(&mut *tx)
405 .await?;
406 }
407 tx.commit().await?;
408
409 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
410}
411
412#[derive(Deserialize)]
413pub struct SetAssignees {
414 pub assignees: String,
415}
416
417/// `POST /{owner}/{repo}/issues/{number}/assignees`
418pub async fn set_assignees(
419 State(state): State<AppState>,
420 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
421 CurrentUser(user): CurrentUser,
422 Form(form): Form<SetAssignees>,
423) -> AppResult<Response> {
424 let Some(user) = user else {
425 return Err(AppError::Unauthorized);
426 };
427 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
428 if !ctx.access.can_manage_changes() {
429 return Err(AppError::Forbidden);
430 }
431 let issue = load_issue(&state, ctx.repo.id, number)
432 .await?
433 .ok_or(AppError::NotFound)?;
434
435 let handles: Vec<String> = form
436 .assignees
437 .split(',')
438 .map(|s| s.trim().trim_start_matches('@').to_lowercase())
439 .filter(|s| !s.is_empty())
440 .take(20)
441 .collect();
442
443 let mut tx = state.db.begin().await?;
444 sqlx::query("DELETE FROM issue_assignees WHERE issue_id = $1")
445 .bind(issue.id)
446 .execute(&mut *tx)
447 .await?;
448
449 for handle in &handles {
450 // Only users who can actually read the repository. Assigning somebody
451 // to an issue they cannot open would be a way to learn that a private
452 // repository exists.
453 sqlx::query(
454 "INSERT INTO issue_assignees (issue_id, user_id)
455 SELECT $1, u.id FROM users u
456 WHERE u.handle = $2
457 AND ($3 = 'public'
458 OR u.is_admin
459 OR u.id = $4
460 OR EXISTS (SELECT 1 FROM repo_collaborators c
461 WHERE c.repo_id = $5 AND c.user_id = u.id)
462 OR EXISTS (SELECT 1 FROM org_members m
463 WHERE m.org_id = $6 AND m.user_id = u.id))
464 ON CONFLICT DO NOTHING",
465 )
466 .bind(issue.id)
467 .bind(handle)
468 .bind(if ctx.repo.is_public() { "public" } else { "private" })
469 .bind(ctx.repo.owner_user_id)
470 .bind(ctx.repo.id)
471 .bind(ctx.repo.owner_org_id)
472 .execute(&mut *tx)
473 .await?;
474 }
475 tx.commit().await?;
476
477 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
478}
479
480// ─── loading ─────────────────────────────────────────────────────────────────
481
482pub struct Issue {
483 pub id: Uuid,
484 pub number: i64,
485 pub title: String,
486 pub body: String,
487 pub state: String,
488 pub author: Option<String>,
489 pub created_at: chrono::DateTime<chrono::Utc>,
490}
491
492async fn load_issue(state: &AppState, repo_id: Uuid, number: i64) -> AppResult<Option<Issue>> {
493 let row: Option<(
494 Uuid,
495 i64,
496 String,
497 String,
498 String,
499 Option<String>,
500 chrono::DateTime<chrono::Utc>,
501 )> = sqlx::query_as(
502 "SELECT i.id, i.number, i.title, i.body, i.state::text, u.handle::text, i.created_at
503 FROM issues i LEFT JOIN users u ON u.id = i.author_user_id
504 WHERE i.repo_id = $1 AND i.number = $2",
505 )
506 .bind(repo_id)
507 .bind(number)
508 .fetch_optional(&state.db)
509 .await?;
510
511 Ok(row.map(
512 |(id, number, title, body, st, author, created_at)| Issue {
513 id,
514 number,
515 title,
516 body,
517 state: st,
518 author,
519 created_at,
520 },
521 ))
522}
523
524async fn load_list(
525 state: &AppState,
526 repo_id: Uuid,
527 state_filter: &str,
528 label: Option<&str>,
529 assignee: Option<&str>,
530) -> AppResult<Vec<v::IssueRow>> {
531 let rows: Vec<(i64, String, String, Option<String>, chrono::DateTime<chrono::Utc>, i64)> =
532 sqlx::query_as(
533 r#"
534 SELECT i.number, i.title, i.state::text, u.handle::text, i.updated_at,
535 (SELECT count(*) FROM comments c WHERE c.issue_id = i.id) AS comments
536 FROM issues i
537 LEFT JOIN users u ON u.id = i.author_user_id
538 WHERE i.repo_id = $1
539 AND ($2 = 'all' OR i.state::text = $2)
540 AND ($3::text IS NULL OR EXISTS (
541 SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id
542 WHERE il.issue_id = i.id AND l.name = $3))
543 AND ($4::text IS NULL OR EXISTS (
544 SELECT 1 FROM issue_assignees ia JOIN users au ON au.id = ia.user_id
545 WHERE ia.issue_id = i.id AND au.handle = $4))
546 ORDER BY i.updated_at DESC
547 LIMIT 100
548 "#,
549 )
550 .bind(repo_id)
551 .bind(state_filter)
552 .bind(label)
553 .bind(assignee)
554 .fetch_all(&state.db)
555 .await?;
556
557 // Labels and assignees are loaded per row rather than aggregated in the
558 // query above: the list is capped at 100, and two small extra queries per
559 // row read far better than three levels of array_agg.
560 let mut out = Vec::with_capacity(rows.len());
561 for (number, title, st, author, updated_at, comment_count) in rows {
562 let id: Uuid =
563 sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2")
564 .bind(repo_id)
565 .bind(number)
566 .fetch_one(&state.db)
567 .await?;
568
569 out.push(v::IssueRow {
570 number,
571 title,
572 state: st,
573 author,
574 updated_at,
575 comment_count,
576 labels: load_issue_labels(state, id).await?,
577 assignees: load_assignees(state, id).await?,
578 });
579 }
580 Ok(out)
581}
582
583async fn load_labels(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Label>> {
584 let rows: Vec<(String, String)> =
585 sqlx::query_as("SELECT name, color FROM labels WHERE repo_id = $1 ORDER BY name")
586 .bind(repo_id)
587 .fetch_all(&state.db)
588 .await?;
589 Ok(rows
590 .into_iter()
591 .map(|(name, color)| v::Label { name, color })
592 .collect())
593}
594
595async fn load_issue_labels(state: &AppState, issue_id: Uuid) -> AppResult<Vec<v::Label>> {
596 let rows: Vec<(String, String)> = sqlx::query_as(
597 "SELECT l.name, l.color FROM labels l
598 JOIN issue_labels il ON il.label_id = l.id
599 WHERE il.issue_id = $1 ORDER BY l.name",
600 )
601 .bind(issue_id)
602 .fetch_all(&state.db)
603 .await?;
604 Ok(rows
605 .into_iter()
606 .map(|(name, color)| v::Label { name, color })
607 .collect())
608}
609
610async fn load_assignees(state: &AppState, issue_id: Uuid) -> AppResult<Vec<String>> {
611 Ok(sqlx::query_scalar(
612 "SELECT u.handle::text FROM users u
613 JOIN issue_assignees a ON a.user_id = u.id
614 WHERE a.issue_id = $1 ORDER BY u.handle",
615 )
616 .bind(issue_id)
617 .fetch_all(&state.db)
618 .await?)
619}
620
621async fn load_comments(
622 state: &AppState,
623 issue_id: Uuid,
624 ctx: &RepoContext,
625) -> AppResult<Vec<CommentRow>> {
626 let rows: Vec<(
627 Uuid,
628 String,
629 String,
630 chrono::DateTime<chrono::Utc>,
631 Option<chrono::DateTime<chrono::Utc>>,
632 )> = sqlx::query_as(
633 "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at
634 FROM comments c JOIN users u ON u.id = c.author_user_id
635 WHERE c.issue_id = $1 ORDER BY c.created_at",
636 )
637 .bind(issue_id)
638 .fetch_all(&state.db)
639 .await?;
640
641 Ok(rows
642 .into_iter()
643 .map(|(id, author, body, created_at, edited_at)| CommentRow {
644 id,
645 author,
646 body_html: render(ctx, &body),
647 created_at,
648 edited: edited_at.is_some(),
649 anchor_path: None,
650 anchor_line: None,
651 anchor_side: None,
652 anchor_state: "current".into(),
653 anchor_context: None,
654 resolved: false,
655 })
656 .collect())
657}
658
659async fn load_referenced_by(
660 state: &AppState,
661 repo_id: Uuid,
662 target_type: &str,
663 target_id: Uuid,
664) -> AppResult<Vec<(String, i64, String)>> {
665 Ok(sqlx::query_as(
666 r#"
667 SELECT 'change'::text, c.number, c.title
668 FROM cross_references x JOIN changes c ON c.id = x.source_id
669 WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3
670 AND x.source_type = 'change'
671 UNION ALL
672 SELECT 'issue'::text, i.number, i.title
673 FROM cross_references x JOIN issues i ON i.id = x.source_id
674 WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3
675 AND x.source_type = 'issue'
676 LIMIT 50
677 "#,
678 )
679 .bind(repo_id)
680 .bind(target_type)
681 .bind(target_id)
682 .fetch_all(&state.db)
683 .await?)
684}
685
686// ─── helpers ─────────────────────────────────────────────────────────────────
687
688/// Render markdown and then resolve cross-references, in that order.
689///
690/// Autolinking after rendering is what keeps `#123` inside a code fence a
691/// literal (spec §8, and `df_render::autolink`'s own tests).
692pub fn render(ctx: &RepoContext, source: &str) -> String {
693 let html = df_render::comment_to_html(source);
694 df_render::autolink::autolink(
695 &html,
696 &df_render::autolink::LinkContext { repo_base: &ctx.base() },
697 )
698}
699
700async fn set_issue_state(
701 state: &AppState,
702 ctx: &RepoContext,
703 issue: &Issue,
704 next: &str,
705 actor: Uuid,
706) -> AppResult<()> {
707 sqlx::query(
708 "UPDATE issues
709 SET state = $2::issue_state,
710 closed_at = CASE WHEN $2 = 'closed' THEN now() ELSE NULL END,
711 updated_at = now()
712 WHERE id = $1",
713 )
714 .bind(issue.id)
715 .bind(next)
716 .execute(&state.db)
717 .await?;
718
719 crate::routes::review::event(
720 state,
721 ctx.repo.id,
722 Some(actor),
723 if next == "closed" { "issue.closed" } else { "issue.reopened" },
724 issue.id,
725 serde_json::json!({ "number": issue.number }),
726 )
727 .await;
728
729 Ok(())
730}
731
732/// Extract `#123` references from a body and record them.
733///
734/// Best-effort and idempotent: the unique constraint makes a re-run a no-op,
735/// and a failure loses a cross-reference rather than the text that contained it.
736pub async fn record_references(
737 state: &AppState,
738 repo_id: Uuid,
739 source_type: &str,
740 source_id: Uuid,
741 body: &str,
742) {
743 for number in issue_references(body) {
744 let target: Result<Option<Uuid>, _> =
745 sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2")
746 .bind(repo_id)
747 .bind(number)
748 .fetch_optional(&state.db)
749 .await;
750
751 let Ok(Some(target_id)) = target else { continue };
752 if target_id == source_id {
753 continue;
754 }
755
756 let _ = sqlx::query(
757 "INSERT INTO cross_references
758 (id, repo_id, source_type, source_id, target_type, target_id)
759 VALUES ($1, $2, $3, $4, 'issue', $5)
760 ON CONFLICT DO NOTHING",
761 )
762 .bind(new_id())
763 .bind(repo_id)
764 .bind(source_type)
765 .bind(source_id)
766 .bind(target_id)
767 .execute(&state.db)
768 .await;
769 }
770}
771
772/// Issue numbers referenced in a body, at word boundaries.
773///
774/// Deliberately simpler than the renderer's autolinker: this feeds a database
775/// lookup, so a false positive costs a wasted query and a false negative costs
776/// a missing back-reference. Neither is a correctness problem, which is why it
777/// does not need to know about code fences.
778fn issue_references(body: &str) -> Vec<i64> {
779 let b = body.as_bytes();
780 let mut out = Vec::new();
781 let mut i = 0;
782
783 while i < b.len() {
784 if b[i] == b'#' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) {
785 let mut j = i + 1;
786 while j < b.len() && b[j].is_ascii_digit() {
787 j += 1;
788 }
789 if j > i + 1 && j - i - 1 <= 9 {
790 if let Ok(n) = body[i + 1..j].parse::<i64>() {
791 if !out.contains(&n) {
792 out.push(n);
793 }
794 }
795 }
796 i = j;
797 continue;
798 }
799 i += 1;
800 }
801
802 out.truncate(50);
803 out
804}
805
806#[cfg(test)]
807mod tests {
808 use super::issue_references;
809
810 #[test]
811 fn finds_issue_references() {
812 assert_eq!(issue_references("fixes #12 and #7"), vec![12, 7]);
813 assert_eq!(issue_references("no references here"), Vec::<i64>::new());
814 }
815
816 #[test]
817 fn ignores_mid_word_hashes_and_duplicates() {
818 assert_eq!(issue_references("abc#12"), Vec::<i64>::new());
819 assert_eq!(issue_references("#3 and #3 again"), vec![3]);
820 }
821
822 #[test]
823 fn absurd_numbers_are_ignored() {
824 assert_eq!(issue_references("#12345678901"), Vec::<i64>::new());
825 }
826
827 #[test]
828 fn a_body_full_of_references_is_capped() {
829 let body: String = (1..200).map(|n| format!("#{n} ")).collect();
830 assert_eq!(issue_references(&body).len(), 50);
831 }
832}

832 lines · Rust