Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Issues (M5).
Matt W2//!
Matt W3//! Issues share the `comments` table with changes — the `one_target` CHECK on
Matt W4//! that table is what keeps a comment from belonging to both. Sharing it means
Matt W5//! the sanitiser, the rendering, and the comment view are the same in both
Matt W6//! places rather than two implementations that drift.
Matt W7
Matt W8use axum::extract::{Path as UrlPath, Query, State};
Matt W9use axum::response::{IntoResponse, Redirect, Response};
Matt W10use axum::Form;
Matt W11use df_db::ids::new_id;
Matt W12use serde::Deserialize;
Matt W13use uuid::Uuid;
Matt W14
Matt W15use crate::error::{AppError, AppResult};
Matt W16use crate::repo_ctx::RepoContext;
Matt W17use crate::routes::settings::urlencode;
Matt W18use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W19use crate::views::issue as v;
Matt W20use crate::views::repo as rv;
Matt W21use crate::views::review::CommentRow;
Matt W22use crate::views::{self, Chrome};
Matt W23
Matt W24#[derive(Deserialize, Default)]
Matt W25pub struct ListQuery {
Matt W26 pub state: Option<String>,
Matt W27 pub label: Option<String>,
Matt W28 pub assignee: Option<String>,
Matt W29 pub error: Option<String>,
Matt W30}
Matt W31
Matt W32/// `GET /{owner}/{repo}/issues`
Matt W33pub async fn list(
Matt W34 State(state): State<AppState>,
Matt W35 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W36 Query(q): Query<ListQuery>,
Matt W37 CurrentUser(user): CurrentUser,
Matt W38 CsrfToken(csrf): CsrfToken,
Matt W39 Nonce(nonce): Nonce,
Matt W40) -> AppResult<Response> {
Matt W41 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W42
Matt W43 let state_filter = q.state.as_deref().unwrap_or("open");
Matt W44 let label = q.label.as_deref().filter(|s| !s.is_empty());
Matt W45 let assignee = q.assignee.as_deref().filter(|s| !s.is_empty());
Matt W46
Matt W47 let rows = load_list(&state, ctx.repo.id, state_filter, label, assignee).await?;
Matt W48 let all_labels = load_labels(&state, ctx.repo.id).await?;
Matt W49
Matt W50 let body = maud::html! {
Matt W51 (v::list(&ctx, &rows, v::ListFilters {
Matt W52 state: state_filter,
Matt W53 label,
Matt W54 assignee,
Matt W55 all_labels: &all_labels,
Matt W56 }))
Matt W57 };
Matt W58
Matt W59 Ok(views::page_with_bar(
Matt W60 Chrome {
Matt W61 title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name),
Matt W62 user: user.as_deref(),
Matt W63 csrf: &csrf,
Matt W64 nonce: &nonce,
Matt W65 },
Matt W66 rv::header(&ctx, "issues"),
Matt W67 body,
Matt W68 )
Matt W69 .into_response())
Matt W70}
Matt W71
Matt W72/// `GET /{owner}/{repo}/issues/new`
Matt W73pub async fn new_form(
Matt W74 State(state): State<AppState>,
Matt W75 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W76 Query(q): Query<ListQuery>,
Matt W77 CurrentUser(user): CurrentUser,
Matt W78 CsrfToken(csrf): CsrfToken,
Matt W79 Nonce(nonce): Nonce,
Matt W80) -> AppResult<Response> {
Matt W81 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W82 if user.is_none() {
Matt W83 return Err(AppError::Unauthorized);
Matt W84 }
Matt W85 // Filing an issue is a comment-level action: anybody who can read a public
Matt W86 // repository can open one.
Matt W87 if !ctx.access.can_comment() {
Matt W88 return Err(AppError::Forbidden);
Matt W89 }
Matt W90
Matt W91 let labels = load_labels(&state, ctx.repo.id).await?;
Matt W92
Matt W93 let body = maud::html! {
Matt W94 (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() }))
Matt W95 };
Matt W96
Matt W97 Ok(views::page_with_bar(
Matt W98 Chrome {
Matt W99 title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name),
Matt W100 user: user.as_deref(),
Matt W101 csrf: &csrf,
Matt W102 nonce: &nonce,
Matt W103 },
Matt W104 rv::header(&ctx, "issues"),
Matt W105 body,
Matt W106 )
Matt W107 .into_response())
Matt W108}
Matt W109
Matt W110#[derive(Deserialize)]
Matt W111pub struct CreateIssue {
Matt W112 pub title: String,
Matt W113 pub body: Option<String>,
Matt W114 /// Repeated checkbox; absent when none are ticked.
Matt W115 #[serde(default)]
Matt W116 pub labels: Vec<String>,
Matt W117}
Matt W118
Matt W119/// `POST /{owner}/{repo}/issues`
Matt W120pub async fn create(
Matt W121 State(state): State<AppState>,
Matt W122 UrlPath((owner, name)): UrlPath<(String, String)>,
Matt W123 CurrentUser(user): CurrentUser,
Matt W124 Form(form): Form<CreateIssue>,
Matt W125) -> AppResult<Response> {
Matt W126 let Some(user) = user else {
Matt W127 return Err(AppError::Unauthorized);
Matt W128 };
Matt W129 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W130 if !ctx.access.can_comment() {
Matt W131 return Err(AppError::Forbidden);
Matt W132 }
Matt W133
Matt W134 let title: String = form.title.trim().chars().take(300).collect();
Matt W135 if title.is_empty() {
Matt W136 return Ok(Redirect::to(&format!(
Matt W137 "{}/issues/new?error={}",
Matt W138 ctx.base(),
Matt W139 urlencode("An issue needs a title.")
Matt W140 ))
Matt W141 .into_response());
Matt W142 }
Matt W143
Matt W144 let body = form.body.as_deref().unwrap_or("").trim();
Matt W145 if body.len() > 256 * 1024 {
Matt W146 return Ok(Redirect::to(&format!(
Matt W147 "{}/issues/new?error={}",
Matt W148 ctx.base(),
Matt W149 urlencode("That description is too long.")
Matt W150 ))
Matt W151 .into_response());
Matt W152 }
Matt W153
Matt W154 let mut tx = state.db.begin().await?;
Matt W155
Matt W156 // Same row-lock pattern the change counter uses, so two concurrent issues
Matt W157 // cannot take the same number.
Matt W158 let (number,): (i64,) = sqlx::query_as(
Matt W159 "UPDATE repo_counters SET next_issue = next_issue + 1
Matt W160 WHERE repo_id = $1 RETURNING next_issue - 1",
Matt W161 )
Matt W162 .bind(ctx.repo.id)
Matt W163 .fetch_one(&mut *tx)
Matt W164 .await?;
Matt W165
Matt W166 let issue_id = new_id();
Matt W167 sqlx::query(
Matt W168 "INSERT INTO issues (id, repo_id, number, title, body, author_user_id)
Matt W169 VALUES ($1, $2, $3, $4, $5, $6)",
Matt W170 )
Matt W171 .bind(issue_id)
Matt W172 .bind(ctx.repo.id)
Matt W173 .bind(number)
Matt W174 .bind(&title)
Matt W175 .bind(body)
Matt W176 .bind(user.id)
Matt W177 .execute(&mut *tx)
Matt W178 .await?;
Matt W179
Matt W180 for label in form.labels.iter().take(20) {
Matt W181 sqlx::query(
Matt W182 "INSERT INTO issue_labels (issue_id, label_id)
Matt W183 SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3
Matt W184 ON CONFLICT DO NOTHING",
Matt W185 )
Matt W186 .bind(issue_id)
Matt W187 .bind(ctx.repo.id)
Matt W188 .bind(label)
Matt W189 .execute(&mut *tx)
Matt W190 .await?;
Matt W191 }
Matt W192
Matt W193 tx.commit().await?;
Matt W194
Matt W195 record_references(&state, ctx.repo.id, "issue", issue_id, body).await;
Matt W196
Matt W197 crate::routes::review::event(
Matt W198 &state,
Matt W199 ctx.repo.id,
Matt W200 Some(user.id),
Matt W201 "issue.opened",
Matt W202 issue_id,
Matt W203 serde_json::json!({ "number": number }),
Matt W204 )
Matt W205 .await;
Matt W206
Matt W207 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
Matt W208}
Matt W209
Matt W210/// `GET /{owner}/{repo}/issues/{number}`
Matt W211pub async fn detail(
Matt W212 State(state): State<AppState>,
Matt W213 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
Matt W214 CurrentUser(user): CurrentUser,
Matt W215 CsrfToken(csrf): CsrfToken,
Matt W216 Nonce(nonce): Nonce,
Matt W217) -> AppResult<Response> {
Matt W218 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
Matt W219 let issue = load_issue(&state, ctx.repo.id, number)
Matt W220 .await?
Matt W221 .ok_or(AppError::NotFound)?;
Matt W222
Matt W223 let labels = load_issue_labels(&state, issue.id).await?;
Matt W224 let all_labels = load_labels(&state, ctx.repo.id).await?;
Matt W225 let assignees = load_assignees(&state, issue.id).await?;
Matt W226 let comments = load_comments(&state, issue.id, &ctx).await?;
Matt W227 let referenced_by = load_referenced_by(&state, ctx.repo.id, "issue", issue.id).await?;
Matt W228
Matt W229 let is_author = matches!((&user, &issue.author), (Some(u), Some(a)) if &u.handle == a);
Matt W230 let can_manage = ctx.access.can_manage_changes() || is_author;
Matt W231
Matt W232 let body_html = render(&ctx, &issue.body);
Matt W233
Matt W234 let body = maud::html! {
Matt W235 (v::detail(&ctx, v::Detail {
Matt W236 number: issue.number,
Matt W237 title: &issue.title,
Matt W238 body_html: &body_html,
Matt W239 state: &issue.state,
Matt W240 author: issue.author.as_deref(),
Matt W241 created_at: issue.created_at,
Matt W242 labels: &labels,
Matt W243 all_labels: &all_labels,
Matt W244 assignees: &assignees,
Matt W245 comments: &comments,
Matt W246 referenced_by: &referenced_by,
Matt W247 can_comment: user.is_some() && ctx.access.can_comment(),
Matt W248 can_manage,
Matt W249 csrf: &csrf,
Matt W250 }))
Matt W251 };
Matt W252
Matt W253 Ok(views::page_with_bar(
Matt W254 Chrome {
Matt W255 title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name),
Matt W256 user: user.as_deref(),
Matt W257 csrf: &csrf,
Matt W258 nonce: &nonce,
Matt W259 },
Matt W260 rv::header(&ctx, "issues"),
Matt W261 body,
Matt W262 )
Matt W263 .into_response())
Matt W264}
Matt W265
Matt W266#[derive(Deserialize)]
Matt W267pub struct IssueComment {
Matt W268 pub body: String,
Matt W269 /// Set by the "comment and close" button.
Matt W270 pub state: Option<String>,
Matt W271}
Matt W272
Matt W273/// `POST /{owner}/{repo}/issues/{number}/comments`
Matt W274pub async fn comment(
Matt W275 State(state): State<AppState>,
Matt W276 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
Matt W277 CurrentUser(user): CurrentUser,
Matt W278 Form(form): Form<IssueComment>,
Matt W279) -> AppResult<Response> {
Matt W280 let Some(user) = user else {
Matt W281 return Err(AppError::Unauthorized);
Matt W282 };
Matt W283 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W284 if !ctx.access.can_comment() {
Matt W285 return Err(AppError::Forbidden);
Matt W286 }
Matt W287 let issue = load_issue(&state, ctx.repo.id, number)
Matt W288 .await?
Matt W289 .ok_or(AppError::NotFound)?;
Matt W290
Matt W291 let body = form.body.trim();
Matt W292 if !body.is_empty() && body.len() <= 64 * 1024 {
Matt W293 let comment_id = new_id();
Matt W294 sqlx::query(
Matt W295 "INSERT INTO comments (id, repo_id, issue_id, author_user_id, body)
Matt W296 VALUES ($1, $2, $3, $4, $5)",
Matt W297 )
Matt W298 .bind(comment_id)
Matt W299 .bind(ctx.repo.id)
Matt W300 .bind(issue.id)
Matt W301 .bind(user.id)
Matt W302 .execute(&state.db)
Matt W303 .await
Matt W304 .map(|_| ())
Matt W305 .or_else(|e| {
Matt W306 tracing::error!("inserting an issue comment failed: {e}");
Matt W307 Err(e)
Matt W308 })?;
Matt W309
Matt W310 record_references(&state, ctx.repo.id, "comment", comment_id, body).await;
Matt W311 }
Matt W312
Matt W313 // "Comment and close" is one button and must be one action; two round trips
Matt W314 // would let the comment land and the close fail.
Matt W315 if let Some(next) = form.state.as_deref() {
Matt W316 let is_author = issue.author.as_deref() == Some(user.handle.as_str());
Matt W317 if (ctx.access.can_manage_changes() || is_author)
Matt W318 && matches!(next, "open" | "closed")
Matt W319 {
Matt W320 set_issue_state(&state, &ctx, &issue, next, user.id).await?;
Matt W321 }
Matt W322 }
Matt W323
Matt W324 sqlx::query("UPDATE issues SET updated_at = now() WHERE id = $1")
Matt W325 .bind(issue.id)
Matt W326 .execute(&state.db)
Matt W327 .await?;
Matt W328
Matt W329 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
Matt W330}
Matt W331
Matt W332#[derive(Deserialize)]
Matt W333pub struct SetState {
Matt W334 pub state: String,
Matt W335}
Matt W336
Matt W337/// `POST /{owner}/{repo}/issues/{number}/state`
Matt W338pub async fn set_state(
Matt W339 State(state): State<AppState>,
Matt W340 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
Matt W341 CurrentUser(user): CurrentUser,
Matt W342 Form(form): Form<SetState>,
Matt W343) -> AppResult<Response> {
Matt W344 let Some(user) = user else {
Matt W345 return Err(AppError::Unauthorized);
Matt W346 };
Matt W347 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W348 let issue = load_issue(&state, ctx.repo.id, number)
Matt W349 .await?
Matt W350 .ok_or(AppError::NotFound)?;
Matt W351
Matt W352 let is_author = issue.author.as_deref() == Some(user.handle.as_str());
Matt W353 if !ctx.access.can_manage_changes() && !is_author {
Matt W354 return Err(AppError::Forbidden);
Matt W355 }
Matt W356 if !matches!(form.state.as_str(), "open" | "closed") {
Matt W357 return Err(AppError::BadRequest("unknown state".into()));
Matt W358 }
Matt W359
Matt W360 set_issue_state(&state, &ctx, &issue, &form.state, user.id).await?;
Matt W361
Matt W362 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
Matt W363}
Matt W364
Matt W365#[derive(Deserialize)]
Matt W366pub struct SetLabels {
Matt W367 #[serde(default)]
Matt W368 pub labels: Vec<String>,
Matt W369}
Matt W370
Matt W371/// `POST /{owner}/{repo}/issues/{number}/labels`
Matt W372pub async fn set_labels(
Matt W373 State(state): State<AppState>,
Matt W374 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
Matt W375 CurrentUser(user): CurrentUser,
Matt W376 Form(form): Form<SetLabels>,
Matt W377) -> AppResult<Response> {
Matt W378 let Some(user) = user else {
Matt W379 return Err(AppError::Unauthorized);
Matt W380 };
Matt W381 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W382 if !ctx.access.can_manage_changes() {
Matt W383 return Err(AppError::Forbidden);
Matt W384 }
Matt W385 let issue = load_issue(&state, ctx.repo.id, number)
Matt W386 .await?
Matt W387 .ok_or(AppError::NotFound)?;
Matt W388
Matt W389 let mut tx = state.db.begin().await?;
Matt W390 sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1")
Matt W391 .bind(issue.id)
Matt W392 .execute(&mut *tx)
Matt W393 .await?;
Matt W394
Matt W395 for label in form.labels.iter().take(20) {
Matt W396 sqlx::query(
Matt W397 "INSERT INTO issue_labels (issue_id, label_id)
Matt W398 SELECT $1, id FROM labels WHERE repo_id = $2 AND name = $3
Matt W399 ON CONFLICT DO NOTHING",
Matt W400 )
Matt W401 .bind(issue.id)
Matt W402 .bind(ctx.repo.id)
Matt W403 .bind(label)
Matt W404 .execute(&mut *tx)
Matt W405 .await?;
Matt W406 }
Matt W407 tx.commit().await?;
Matt W408
Matt W409 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
Matt W410}
Matt W411
Matt W412#[derive(Deserialize)]
Matt W413pub struct SetAssignees {
Matt W414 pub assignees: String,
Matt W415}
Matt W416
Matt W417/// `POST /{owner}/{repo}/issues/{number}/assignees`
Matt W418pub async fn set_assignees(
Matt W419 State(state): State<AppState>,
Matt W420 UrlPath((owner, name, number)): UrlPath<(String, String, i64)>,
Matt W421 CurrentUser(user): CurrentUser,
Matt W422 Form(form): Form<SetAssignees>,
Matt W423) -> AppResult<Response> {
Matt W424 let Some(user) = user else {
Matt W425 return Err(AppError::Unauthorized);
Matt W426 };
Matt W427 let ctx = RepoContext::load(&state, &owner, &name, Some(&user)).await?;
Matt W428 if !ctx.access.can_manage_changes() {
Matt W429 return Err(AppError::Forbidden);
Matt W430 }
Matt W431 let issue = load_issue(&state, ctx.repo.id, number)
Matt W432 .await?
Matt W433 .ok_or(AppError::NotFound)?;
Matt W434
Matt W435 let handles: Vec<String> = form
Matt W436 .assignees
Matt W437 .split(',')
Matt W438 .map(|s| s.trim().trim_start_matches('@').to_lowercase())
Matt W439 .filter(|s| !s.is_empty())
Matt W440 .take(20)
Matt W441 .collect();
Matt W442
Matt W443 let mut tx = state.db.begin().await?;
Matt W444 sqlx::query("DELETE FROM issue_assignees WHERE issue_id = $1")
Matt W445 .bind(issue.id)
Matt W446 .execute(&mut *tx)
Matt W447 .await?;
Matt W448
Matt W449 for handle in &handles {
Matt W450 // Only users who can actually read the repository. Assigning somebody
Matt W451 // to an issue they cannot open would be a way to learn that a private
Matt W452 // repository exists.
Matt W453 sqlx::query(
Matt W454 "INSERT INTO issue_assignees (issue_id, user_id)
Matt W455 SELECT $1, u.id FROM users u
Matt W456 WHERE u.handle = $2
Matt W457 AND ($3 = 'public'
Matt W458 OR u.is_admin
Matt W459 OR u.id = $4
Matt W460 OR EXISTS (SELECT 1 FROM repo_collaborators c
Matt W461 WHERE c.repo_id = $5 AND c.user_id = u.id)
Matt W462 OR EXISTS (SELECT 1 FROM org_members m
Matt W463 WHERE m.org_id = $6 AND m.user_id = u.id))
Matt W464 ON CONFLICT DO NOTHING",
Matt W465 )
Matt W466 .bind(issue.id)
Matt W467 .bind(handle)
Matt W468 .bind(if ctx.repo.is_public() { "public" } else { "private" })
Matt W469 .bind(ctx.repo.owner_user_id)
Matt W470 .bind(ctx.repo.id)
Matt W471 .bind(ctx.repo.owner_org_id)
Matt W472 .execute(&mut *tx)
Matt W473 .await?;
Matt W474 }
Matt W475 tx.commit().await?;
Matt W476
Matt W477 Ok(Redirect::to(&format!("{}/issues/{number}", ctx.base())).into_response())
Matt W478}
Matt W479
Matt W480// ─── loading ─────────────────────────────────────────────────────────────────
Matt W481
Matt W482pub struct Issue {
Matt W483 pub id: Uuid,
Matt W484 pub number: i64,
Matt W485 pub title: String,
Matt W486 pub body: String,
Matt W487 pub state: String,
Matt W488 pub author: Option<String>,
Matt W489 pub created_at: chrono::DateTime<chrono::Utc>,
Matt W490}
Matt W491
Matt W492async fn load_issue(state: &AppState, repo_id: Uuid, number: i64) -> AppResult<Option<Issue>> {
Matt W493 let row: Option<(
Matt W494 Uuid,
Matt W495 i64,
Matt W496 String,
Matt W497 String,
Matt W498 String,
Matt W499 Option<String>,
Matt W500 chrono::DateTime<chrono::Utc>,
Matt W501 )> = sqlx::query_as(
Matt W502 "SELECT i.id, i.number, i.title, i.body, i.state::text, u.handle::text, i.created_at
Matt W503 FROM issues i LEFT JOIN users u ON u.id = i.author_user_id
Matt W504 WHERE i.repo_id = $1 AND i.number = $2",
Matt W505 )
Matt W506 .bind(repo_id)
Matt W507 .bind(number)
Matt W508 .fetch_optional(&state.db)
Matt W509 .await?;
Matt W510
Matt W511 Ok(row.map(
Matt W512 |(id, number, title, body, st, author, created_at)| Issue {
Matt W513 id,
Matt W514 number,
Matt W515 title,
Matt W516 body,
Matt W517 state: st,
Matt W518 author,
Matt W519 created_at,
Matt W520 },
Matt W521 ))
Matt W522}
Matt W523
Matt W524async fn load_list(
Matt W525 state: &AppState,
Matt W526 repo_id: Uuid,
Matt W527 state_filter: &str,
Matt W528 label: Option<&str>,
Matt W529 assignee: Option<&str>,
Matt W530) -> AppResult<Vec<v::IssueRow>> {
Matt W531 let rows: Vec<(i64, String, String, Option<String>, chrono::DateTime<chrono::Utc>, i64)> =
Matt W532 sqlx::query_as(
Matt W533 r#"
Matt W534 SELECT i.number, i.title, i.state::text, u.handle::text, i.updated_at,
Matt W535 (SELECT count(*) FROM comments c WHERE c.issue_id = i.id) AS comments
Matt W536 FROM issues i
Matt W537 LEFT JOIN users u ON u.id = i.author_user_id
Matt W538 WHERE i.repo_id = $1
Matt W539 AND ($2 = 'all' OR i.state::text = $2)
Matt W540 AND ($3::text IS NULL OR EXISTS (
Matt W541 SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id
Matt W542 WHERE il.issue_id = i.id AND l.name = $3))
Matt W543 AND ($4::text IS NULL OR EXISTS (
Matt W544 SELECT 1 FROM issue_assignees ia JOIN users au ON au.id = ia.user_id
Matt W545 WHERE ia.issue_id = i.id AND au.handle = $4))
Matt W546 ORDER BY i.updated_at DESC
Matt W547 LIMIT 100
Matt W548 "#,
Matt W549 )
Matt W550 .bind(repo_id)
Matt W551 .bind(state_filter)
Matt W552 .bind(label)
Matt W553 .bind(assignee)
Matt W554 .fetch_all(&state.db)
Matt W555 .await?;
Matt W556
Matt W557 // Labels and assignees are loaded per row rather than aggregated in the
Matt W558 // query above: the list is capped at 100, and two small extra queries per
Matt W559 // row read far better than three levels of array_agg.
Matt W560 let mut out = Vec::with_capacity(rows.len());
Matt W561 for (number, title, st, author, updated_at, comment_count) in rows {
Matt W562 let id: Uuid =
Matt W563 sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2")
Matt W564 .bind(repo_id)
Matt W565 .bind(number)
Matt W566 .fetch_one(&state.db)
Matt W567 .await?;
Matt W568
Matt W569 out.push(v::IssueRow {
Matt W570 number,
Matt W571 title,
Matt W572 state: st,
Matt W573 author,
Matt W574 updated_at,
Matt W575 comment_count,
Matt W576 labels: load_issue_labels(state, id).await?,
Matt W577 assignees: load_assignees(state, id).await?,
Matt W578 });
Matt W579 }
Matt W580 Ok(out)
Matt W581}
Matt W582
Matt W583async fn load_labels(state: &AppState, repo_id: Uuid) -> AppResult<Vec<v::Label>> {
Matt W584 let rows: Vec<(String, String)> =
Matt W585 sqlx::query_as("SELECT name, color FROM labels WHERE repo_id = $1 ORDER BY name")
Matt W586 .bind(repo_id)
Matt W587 .fetch_all(&state.db)
Matt W588 .await?;
Matt W589 Ok(rows
Matt W590 .into_iter()
Matt W591 .map(|(name, color)| v::Label { name, color })
Matt W592 .collect())
Matt W593}
Matt W594
Matt W595async fn load_issue_labels(state: &AppState, issue_id: Uuid) -> AppResult<Vec<v::Label>> {
Matt W596 let rows: Vec<(String, String)> = sqlx::query_as(
Matt W597 "SELECT l.name, l.color FROM labels l
Matt W598 JOIN issue_labels il ON il.label_id = l.id
Matt W599 WHERE il.issue_id = $1 ORDER BY l.name",
Matt W600 )
Matt W601 .bind(issue_id)
Matt W602 .fetch_all(&state.db)
Matt W603 .await?;
Matt W604 Ok(rows
Matt W605 .into_iter()
Matt W606 .map(|(name, color)| v::Label { name, color })
Matt W607 .collect())
Matt W608}
Matt W609
Matt W610async fn load_assignees(state: &AppState, issue_id: Uuid) -> AppResult<Vec<String>> {
Matt W611 Ok(sqlx::query_scalar(
Matt W612 "SELECT u.handle::text FROM users u
Matt W613 JOIN issue_assignees a ON a.user_id = u.id
Matt W614 WHERE a.issue_id = $1 ORDER BY u.handle",
Matt W615 )
Matt W616 .bind(issue_id)
Matt W617 .fetch_all(&state.db)
Matt W618 .await?)
Matt W619}
Matt W620
Matt W621async fn load_comments(
Matt W622 state: &AppState,
Matt W623 issue_id: Uuid,
Matt W624 ctx: &RepoContext,
Matt W625) -> AppResult<Vec<CommentRow>> {
Matt W626 let rows: Vec<(
Matt W627 Uuid,
Matt W628 String,
Matt W629 String,
Matt W630 chrono::DateTime<chrono::Utc>,
Matt W631 Option<chrono::DateTime<chrono::Utc>>,
Matt W632 )> = sqlx::query_as(
Matt W633 "SELECT c.id, u.handle::text, c.body, c.created_at, c.edited_at
Matt W634 FROM comments c JOIN users u ON u.id = c.author_user_id
Matt W635 WHERE c.issue_id = $1 ORDER BY c.created_at",
Matt W636 )
Matt W637 .bind(issue_id)
Matt W638 .fetch_all(&state.db)
Matt W639 .await?;
Matt W640
Matt W641 Ok(rows
Matt W642 .into_iter()
Matt W643 .map(|(id, author, body, created_at, edited_at)| CommentRow {
Matt W644 id,
Matt W645 author,
Matt W646 body_html: render(ctx, &body),
Matt W647 created_at,
Matt W648 edited: edited_at.is_some(),
Matt W649 anchor_path: None,
Matt W650 anchor_line: None,
Matt W651 anchor_side: None,
Matt W652 anchor_state: "current".into(),
Matt W653 anchor_context: None,
Matt W654 resolved: false,
Matt W655 })
Matt W656 .collect())
Matt W657}
Matt W658
Matt W659async fn load_referenced_by(
Matt W660 state: &AppState,
Matt W661 repo_id: Uuid,
Matt W662 target_type: &str,
Matt W663 target_id: Uuid,
Matt W664) -> AppResult<Vec<(String, i64, String)>> {
Matt W665 Ok(sqlx::query_as(
Matt W666 r#"
Matt W667 SELECT 'change'::text, c.number, c.title
Matt W668 FROM cross_references x JOIN changes c ON c.id = x.source_id
Matt W669 WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3
Matt W670 AND x.source_type = 'change'
Matt W671 UNION ALL
Matt W672 SELECT 'issue'::text, i.number, i.title
Matt W673 FROM cross_references x JOIN issues i ON i.id = x.source_id
Matt W674 WHERE x.repo_id = $1 AND x.target_type = $2 AND x.target_id = $3
Matt W675 AND x.source_type = 'issue'
Matt W676 LIMIT 50
Matt W677 "#,
Matt W678 )
Matt W679 .bind(repo_id)
Matt W680 .bind(target_type)
Matt W681 .bind(target_id)
Matt W682 .fetch_all(&state.db)
Matt W683 .await?)
Matt W684}
Matt W685
Matt W686// ─── helpers ─────────────────────────────────────────────────────────────────
Matt W687
Matt W688/// Render markdown and then resolve cross-references, in that order.
Matt W689///
Matt W690/// Autolinking after rendering is what keeps `#123` inside a code fence a
Matt W691/// literal (spec §8, and `df_render::autolink`'s own tests).
Matt W692pub fn render(ctx: &RepoContext, source: &str) -> String {
Matt W693 let html = df_render::comment_to_html(source);
Matt W694 df_render::autolink::autolink(
Matt W695 &html,
Matt W696 &df_render::autolink::LinkContext { repo_base: &ctx.base() },
Matt W697 )
Matt W698}
Matt W699
Matt W700async fn set_issue_state(
Matt W701 state: &AppState,
Matt W702 ctx: &RepoContext,
Matt W703 issue: &Issue,
Matt W704 next: &str,
Matt W705 actor: Uuid,
Matt W706) -> AppResult<()> {
Matt W707 sqlx::query(
Matt W708 "UPDATE issues
Matt W709 SET state = $2::issue_state,
Matt W710 closed_at = CASE WHEN $2 = 'closed' THEN now() ELSE NULL END,
Matt W711 updated_at = now()
Matt W712 WHERE id = $1",
Matt W713 )
Matt W714 .bind(issue.id)
Matt W715 .bind(next)
Matt W716 .execute(&state.db)
Matt W717 .await?;
Matt W718
Matt W719 crate::routes::review::event(
Matt W720 state,
Matt W721 ctx.repo.id,
Matt W722 Some(actor),
Matt W723 if next == "closed" { "issue.closed" } else { "issue.reopened" },
Matt W724 issue.id,
Matt W725 serde_json::json!({ "number": issue.number }),
Matt W726 )
Matt W727 .await;
Matt W728
Matt W729 Ok(())
Matt W730}
Matt W731
Matt W732/// Extract `#123` references from a body and record them.
Matt W733///
Matt W734/// Best-effort and idempotent: the unique constraint makes a re-run a no-op,
Matt W735/// and a failure loses a cross-reference rather than the text that contained it.
Matt W736pub async fn record_references(
Matt W737 state: &AppState,
Matt W738 repo_id: Uuid,
Matt W739 source_type: &str,
Matt W740 source_id: Uuid,
Matt W741 body: &str,
Matt W742) {
Matt W743 for number in issue_references(body) {
Matt W744 let target: Result<Option<Uuid>, _> =
Matt W745 sqlx::query_scalar("SELECT id FROM issues WHERE repo_id = $1 AND number = $2")
Matt W746 .bind(repo_id)
Matt W747 .bind(number)
Matt W748 .fetch_optional(&state.db)
Matt W749 .await;
Matt W750
Matt W751 let Ok(Some(target_id)) = target else { continue };
Matt W752 if target_id == source_id {
Matt W753 continue;
Matt W754 }
Matt W755
Matt W756 let _ = sqlx::query(
Matt W757 "INSERT INTO cross_references
Matt W758 (id, repo_id, source_type, source_id, target_type, target_id)
Matt W759 VALUES ($1, $2, $3, $4, 'issue', $5)
Matt W760 ON CONFLICT DO NOTHING",
Matt W761 )
Matt W762 .bind(new_id())
Matt W763 .bind(repo_id)
Matt W764 .bind(source_type)
Matt W765 .bind(source_id)
Matt W766 .bind(target_id)
Matt W767 .execute(&state.db)
Matt W768 .await;
Matt W769 }
Matt W770}
Matt W771
Matt W772/// Issue numbers referenced in a body, at word boundaries.
Matt W773///
Matt W774/// Deliberately simpler than the renderer's autolinker: this feeds a database
Matt W775/// lookup, so a false positive costs a wasted query and a false negative costs
Matt W776/// a missing back-reference. Neither is a correctness problem, which is why it
Matt W777/// does not need to know about code fences.
Matt W778fn issue_references(body: &str) -> Vec<i64> {
Matt W779 let b = body.as_bytes();
Matt W780 let mut out = Vec::new();
Matt W781 let mut i = 0;
Matt W782
Matt W783 while i < b.len() {
Matt W784 if b[i] == b'#' && (i == 0 || !b[i - 1].is_ascii_alphanumeric()) {
Matt W785 let mut j = i + 1;
Matt W786 while j < b.len() && b[j].is_ascii_digit() {
Matt W787 j += 1;
Matt W788 }
Matt W789 if j > i + 1 && j - i - 1 <= 9 {
Matt W790 if let Ok(n) = body[i + 1..j].parse::<i64>() {
Matt W791 if !out.contains(&n) {
Matt W792 out.push(n);
Matt W793 }
Matt W794 }
Matt W795 }
Matt W796 i = j;
Matt W797 continue;
Matt W798 }
Matt W799 i += 1;
Matt W800 }
Matt W801
Matt W802 out.truncate(50);
Matt W803 out
Matt W804}
Matt W805
Matt W806#[cfg(test)]
Matt W807mod tests {
Matt W808 use super::issue_references;
Matt W809
Matt W810 #[test]
Matt W811 fn finds_issue_references() {
Matt W812 assert_eq!(issue_references("fixes #12 and #7"), vec![12, 7]);
Matt W813 assert_eq!(issue_references("no references here"), Vec::<i64>::new());
Matt W814 }
Matt W815
Matt W816 #[test]
Matt W817 fn ignores_mid_word_hashes_and_duplicates() {
Matt W818 assert_eq!(issue_references("abc#12"), Vec::<i64>::new());
Matt W819 assert_eq!(issue_references("#3 and #3 again"), vec![3]);
Matt W820 }
Matt W821
Matt W822 #[test]
Matt W823 fn absurd_numbers_are_ignored() {
Matt W824 assert_eq!(issue_references("#12345678901"), Vec::<i64>::new());
Matt W825 }
Matt W826
Matt W827 #[test]
Matt W828 fn a_body_full_of_references_is_capped() {
Matt W829 let body: String = (1..200).map(|n| format!("#{n} ")).collect();
Matt W830 assert_eq!(issue_references(&body).len(), 50);
Matt W831 }
Matt W832}

832 lines · Rust