| 1 | //! Per-repository issue tracker UI: list, new-issue form, detail page with |
| 2 | //! comments, and open/close. |
| 3 | //! |
| 4 | //! Access mirrors the rest of the forge: anyone who can read the repo can |
| 5 | //! read its issues; any logged-in reader can open issues and comment; |
| 6 | //! closing/reopening is for the issue author or anyone with write access. |
| 7 | //! Bodies are markdown, rendered with the same pipeline as file views. |
| 8 | |
| 9 | use std::collections::HashMap; |
| 10 | |
| 11 | use anvil_core::{ |
| 12 | App, |
| 13 | Issue, |
| 14 | User, |
| 15 | access, |
| 16 | issues, |
| 17 | users, |
| 18 | }; |
| 19 | use axum::{ |
| 20 | Form, |
| 21 | Router, |
| 22 | extract::{ |
| 23 | Path, |
| 24 | Query, |
| 25 | State, |
| 26 | }, |
| 27 | response::{ |
| 28 | IntoResponse, |
| 29 | Redirect, |
| 30 | Response, |
| 31 | }, |
| 32 | routing::get, |
| 33 | }; |
| 34 | use maud::{ |
| 35 | Markup, |
| 36 | html, |
| 37 | }; |
| 38 | use serde::Deserialize; |
| 39 | |
| 40 | use crate::{ |
| 41 | auth::{ |
| 42 | Csrf, |
| 43 | CurrentUser, |
| 44 | verify_csrf, |
| 45 | }, |
| 46 | ui::{ |
| 47 | csrf_input, |
| 48 | fmt_relative, |
| 49 | fmt_time, |
| 50 | layout, |
| 51 | not_found, |
| 52 | render_markdown, |
| 53 | resolve_repo, |
| 54 | server_error, |
| 55 | }, |
| 56 | }; |
| 57 | |
| 58 | /// Mount the issue routes. |
| 59 | pub fn routes(router: Router<App>) -> Router<App> { |
| 60 | router |
| 61 | .route("/{owner}/{repo}/issues", get(list)) |
| 62 | .route("/{owner}/{repo}/issues/new", get(new_form).post(new_submit)) |
| 63 | .route("/{owner}/{repo}/issues/{number}", get(detail)) |
| 64 | .route( |
| 65 | "/{owner}/{repo}/issues/{number}/comment", |
| 66 | axum::routing::post(comment_submit), |
| 67 | ) |
| 68 | .route( |
| 69 | "/{owner}/{repo}/issues/{number}/state", |
| 70 | axum::routing::post(state_submit), |
| 71 | ) |
| 72 | } |
| 73 | |
| 74 | #[derive(Deserialize)] |
| 75 | struct ListQuery { |
| 76 | #[serde(default)] |
| 77 | state: String, |
| 78 | } |
| 79 | |
| 80 | /// `GET /{owner}/{repo}/issues` — open issues, with a closed tab. |
| 81 | async fn list( |
| 82 | State(app): State<App>, |
| 83 | CurrentUser(user): CurrentUser, |
| 84 | Path((owner, repo)): Path<(String, String)>, |
| 85 | Query(q): Query<ListQuery>, |
| 86 | ) -> Result<Markup, Response> { |
| 87 | let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; |
| 88 | let state = if q.state == issues::state::CLOSED { |
| 89 | issues::state::CLOSED |
| 90 | } else { |
| 91 | issues::state::OPEN |
| 92 | }; |
| 93 | let items = issues::list(&app.db, meta.id, state) |
| 94 | .await |
| 95 | .map_err(server_error)?; |
| 96 | let (open, closed) = issues::counts(&app.db, meta.id) |
| 97 | .await |
| 98 | .map_err(server_error)?; |
| 99 | let names = usernames(&app, items.iter().map(|i| i.author_id)).await; |
| 100 | |
| 101 | Ok(layout( |
| 102 | &format!("{owner}/{repo}: issues"), |
| 103 | user.as_ref(), |
| 104 | html! { |
| 105 | div style="display:flex;align-items:center;gap:8px" { |
| 106 | h1 style="margin-right:auto" { |
| 107 | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · issues" |
| 108 | } |
| 109 | @if user.is_some() { |
| 110 | a.btn href=(format!("/{owner}/{repo}/issues/new")) { "New issue" } |
| 111 | } |
| 112 | } |
| 113 | p { |
| 114 | span.pill-group { |
| 115 | a.pill.active[state == issues::state::OPEN] |
| 116 | href=(format!("/{owner}/{repo}/issues")) { (open) " open" } |
| 117 | a.pill.active[state == issues::state::CLOSED] |
| 118 | href=(format!("/{owner}/{repo}/issues?state=closed")) { (closed) " closed" } |
| 119 | } |
| 120 | } |
| 121 | @if items.is_empty() { |
| 122 | p.muted { |
| 123 | @if state == issues::state::OPEN { "No open issues." } |
| 124 | @else { "No closed issues." } |
| 125 | } |
| 126 | } @else { |
| 127 | div.box { |
| 128 | @for issue in &items { |
| 129 | div.row { |
| 130 | a.entry href=(format!("/{owner}/{repo}/issues/{}", issue.number)) { |
| 131 | (state_dot(&issue.state)) |
| 132 | (issue.title) |
| 133 | } |
| 134 | span.muted style="white-space:nowrap" { |
| 135 | "#" (issue.number) |
| 136 | " · " (names.get(&issue.author_id).map(String::as_str).unwrap_or("?")) |
| 137 | " · " span title=(fmt_time(issue.updated_at)) { (fmt_relative(issue.updated_at)) } |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | }, |
| 144 | )) |
| 145 | } |
| 146 | |
| 147 | /// `GET /{owner}/{repo}/issues/new` — the new-issue form (login required). |
| 148 | async fn new_form( |
| 149 | State(app): State<App>, |
| 150 | CurrentUser(user): CurrentUser, |
| 151 | csrf: Csrf, |
| 152 | Path((owner, repo)): Path<(String, String)>, |
| 153 | ) -> Result<Markup, Response> { |
| 154 | resolve_repo(&app, user.as_ref(), &owner, &repo).await?; |
| 155 | let Some(user) = user else { |
| 156 | return Err(Redirect::to("/-/login").into_response()); |
| 157 | }; |
| 158 | Ok(new_issue_page(&owner, &repo, Some(&user), None, &csrf.0)) |
| 159 | } |
| 160 | |
| 161 | #[derive(Deserialize)] |
| 162 | struct NewIssueForm { |
| 163 | #[serde(default)] |
| 164 | title: String, |
| 165 | #[serde(default)] |
| 166 | body: String, |
| 167 | #[serde(default)] |
| 168 | csrf: String, |
| 169 | } |
| 170 | |
| 171 | /// `POST /{owner}/{repo}/issues/new` |
| 172 | async fn new_submit( |
| 173 | State(app): State<App>, |
| 174 | CurrentUser(user): CurrentUser, |
| 175 | csrf: Csrf, |
| 176 | Path((owner, repo)): Path<(String, String)>, |
| 177 | Form(form): Form<NewIssueForm>, |
| 178 | ) -> Response { |
| 179 | let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { |
| 180 | Ok(v) => v, |
| 181 | Err(resp) => return resp, |
| 182 | }; |
| 183 | let Some(user) = user else { |
| 184 | return Redirect::to("/-/login").into_response(); |
| 185 | }; |
| 186 | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { |
| 187 | return resp; |
| 188 | } |
| 189 | match issues::create(&app.db, meta.id, user.id, &form.title, &form.body).await { |
| 190 | Ok(issue) => { |
| 191 | Redirect::to(&format!("/{owner}/{repo}/issues/{}", issue.number)).into_response() |
| 192 | } |
| 193 | Err(anvil_core::Error::Invalid(m)) => { |
| 194 | new_issue_page(&owner, &repo, Some(&user), Some(&m), &csrf.0).into_response() |
| 195 | } |
| 196 | Err(e) => server_error(e), |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | fn new_issue_page( |
| 201 | owner: &str, |
| 202 | repo: &str, |
| 203 | user: Option<&User>, |
| 204 | error: Option<&str>, |
| 205 | csrf: &str, |
| 206 | ) -> Markup { |
| 207 | layout( |
| 208 | &format!("{owner}/{repo}: new issue"), |
| 209 | user, |
| 210 | html! { |
| 211 | h1 { |
| 212 | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } |
| 213 | " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" } |
| 214 | " · new" |
| 215 | } |
| 216 | @if let Some(error) = error { p style="color:#cf222e" { (error) } } |
| 217 | form.stack method="post" action=(format!("/{owner}/{repo}/issues/new")) { |
| 218 | (csrf_input(csrf)) |
| 219 | p { label { "Title" br; input type="text" name="title" required; } } |
| 220 | p { label { "Description (markdown)" br; textarea name="body" rows="8" {} } } |
| 221 | p { button.btn type="submit" { "Open issue" } } |
| 222 | } |
| 223 | }, |
| 224 | ) |
| 225 | } |
| 226 | |
| 227 | /// `GET /{owner}/{repo}/issues/{number}` — one issue with its comments. |
| 228 | async fn detail( |
| 229 | State(app): State<App>, |
| 230 | CurrentUser(user): CurrentUser, |
| 231 | csrf: Csrf, |
| 232 | Path((owner, repo, number)): Path<(String, String, i64)>, |
| 233 | ) -> Result<Markup, Response> { |
| 234 | let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; |
| 235 | let issue = issues::find(&app.db, meta.id, number) |
| 236 | .await |
| 237 | .map_err(server_error)? |
| 238 | .ok_or_else(|| not_found("no such issue"))?; |
| 239 | let comments = issues::comments(&app.db, issue.id) |
| 240 | .await |
| 241 | .map_err(server_error)?; |
| 242 | let names = usernames( |
| 243 | &app, |
| 244 | std::iter::once(issue.author_id).chain(comments.iter().map(|c| c.author_id)), |
| 245 | ) |
| 246 | .await; |
| 247 | let name = |id: &i64| names.get(id).map(String::as_str).unwrap_or("?").to_string(); |
| 248 | let may_toggle = user |
| 249 | .as_ref() |
| 250 | .is_some_and(|u| u.id == issue.author_id || access::can_write(&meta, Some(u))); |
| 251 | let open = issue.state == issues::state::OPEN; |
| 252 | |
| 253 | Ok(layout( |
| 254 | &format!("{owner}/{repo}: {} (#{})", issue.title, issue.number), |
| 255 | user.as_ref(), |
| 256 | html! { |
| 257 | h1 { |
| 258 | a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } |
| 259 | " · " a href=(format!("/{owner}/{repo}/issues")) { "issues" } |
| 260 | } |
| 261 | h2 style="font-size:18px;margin:12px 0 4px" { (issue.title) " " span.muted { "#" (issue.number) } } |
| 262 | p { |
| 263 | (state_badge(&issue.state)) |
| 264 | " " span.muted { |
| 265 | (name(&issue.author_id)) " opened " |
| 266 | span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) } |
| 267 | " · " (comments.len()) " comment" @if comments.len() != 1 { "s" } |
| 268 | } |
| 269 | } |
| 270 | div.box.issue-post { |
| 271 | div.issue-head { |
| 272 | (name(&issue.author_id)) |
| 273 | " · " span title=(fmt_time(issue.created_at)) { (fmt_relative(issue.created_at)) } |
| 274 | } |
| 275 | div.md-body { |
| 276 | @if issue.body.is_empty() { p.muted { "No description." } } |
| 277 | @else { (render_markdown(&issue.body)) } |
| 278 | } |
| 279 | } |
| 280 | @for c in &comments { |
| 281 | div.box.issue-post { |
| 282 | div.issue-head { |
| 283 | (name(&c.author_id)) |
| 284 | " · " span title=(fmt_time(c.created_at)) { (fmt_relative(c.created_at)) } |
| 285 | } |
| 286 | div.md-body { (render_markdown(&c.body)) } |
| 287 | } |
| 288 | } |
| 289 | @if user.is_some() { |
| 290 | form.stack method="post" action=(format!("/{owner}/{repo}/issues/{}/comment", issue.number)) { |
| 291 | (csrf_input(&csrf.0)) |
| 292 | p { label { "Comment (markdown)" br; textarea name="body" rows="4" required {} } } |
| 293 | p { |
| 294 | button.btn type="submit" { "Comment" } |
| 295 | @if may_toggle { |
| 296 | " " |
| 297 | button.btn.btn-secondary type="submit" |
| 298 | formaction=(format!("/{owner}/{repo}/issues/{}/state", issue.number)) |
| 299 | formnovalidate |
| 300 | name="state" value=(if open { "closed" } else { "open" }) { |
| 301 | @if open { "Close issue" } @else { "Reopen issue" } |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | } @else { |
| 307 | p.muted { a href="/-/login" { "Log in" } " to comment." } |
| 308 | } |
| 309 | }, |
| 310 | )) |
| 311 | } |
| 312 | |
| 313 | #[derive(Deserialize)] |
| 314 | struct CommentForm { |
| 315 | #[serde(default)] |
| 316 | body: String, |
| 317 | #[serde(default)] |
| 318 | csrf: String, |
| 319 | } |
| 320 | |
| 321 | /// `POST /{owner}/{repo}/issues/{number}/comment` |
| 322 | async fn comment_submit( |
| 323 | State(app): State<App>, |
| 324 | CurrentUser(user): CurrentUser, |
| 325 | csrf: Csrf, |
| 326 | Path((owner, repo, number)): Path<(String, String, i64)>, |
| 327 | Form(form): Form<CommentForm>, |
| 328 | ) -> Response { |
| 329 | let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await { |
| 330 | Ok(v) => v, |
| 331 | Err(resp) => return resp, |
| 332 | }; |
| 333 | let _ = meta; |
| 334 | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { |
| 335 | return resp; |
| 336 | } |
| 337 | match issues::comment(&app.db, &mut issue, user.id, &form.body).await { |
| 338 | Ok(_) | Err(anvil_core::Error::Invalid(_)) => { |
| 339 | Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response() |
| 340 | } |
| 341 | Err(e) => server_error(e), |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | #[derive(Deserialize)] |
| 346 | struct StateForm { |
| 347 | #[serde(default)] |
| 348 | state: String, |
| 349 | #[serde(default)] |
| 350 | csrf: String, |
| 351 | } |
| 352 | |
| 353 | /// `POST /{owner}/{repo}/issues/{number}/state` — close or reopen. |
| 354 | async fn state_submit( |
| 355 | State(app): State<App>, |
| 356 | CurrentUser(user): CurrentUser, |
| 357 | csrf: Csrf, |
| 358 | Path((owner, repo, number)): Path<(String, String, i64)>, |
| 359 | Form(form): Form<StateForm>, |
| 360 | ) -> Response { |
| 361 | let (meta, mut issue, user) = match load_for_update(&app, user, &owner, &repo, number).await { |
| 362 | Ok(v) => v, |
| 363 | Err(resp) => return resp, |
| 364 | }; |
| 365 | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { |
| 366 | return resp; |
| 367 | } |
| 368 | let may_toggle = user.id == issue.author_id || access::can_write(&meta, Some(&user)); |
| 369 | if !may_toggle { |
| 370 | return not_found("no such issue"); |
| 371 | } |
| 372 | let new_state = if form.state == issues::state::CLOSED { |
| 373 | issues::state::CLOSED |
| 374 | } else { |
| 375 | issues::state::OPEN |
| 376 | }; |
| 377 | match issues::set_state(&app.db, &mut issue, new_state).await { |
| 378 | Ok(()) => Redirect::to(&format!("/{owner}/{repo}/issues/{number}")).into_response(), |
| 379 | Err(e) => server_error(e), |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /// Shared resolve for the mutating endpoints: readable repo, existing issue, |
| 384 | /// logged-in user. |
| 385 | async fn load_for_update( |
| 386 | app: &App, |
| 387 | user: Option<User>, |
| 388 | owner: &str, |
| 389 | repo: &str, |
| 390 | number: i64, |
| 391 | ) -> Result<(anvil_core::Repository, Issue, User), Response> { |
| 392 | let (_, meta) = resolve_repo(app, user.as_ref(), owner, repo).await?; |
| 393 | let issue = issues::find(&app.db, meta.id, number) |
| 394 | .await |
| 395 | .map_err(server_error)? |
| 396 | .ok_or_else(|| not_found("no such issue"))?; |
| 397 | let user = user.ok_or_else(|| Redirect::to("/-/login").into_response())?; |
| 398 | Ok((meta, issue, user)) |
| 399 | } |
| 400 | |
| 401 | /// author_id → username for the ids in `ids`. |
| 402 | async fn usernames(app: &App, ids: impl Iterator<Item = i64>) -> HashMap<i64, String> { |
| 403 | let mut out = HashMap::new(); |
| 404 | for id in ids { |
| 405 | if let std::collections::hash_map::Entry::Vacant(entry) = out.entry(id) |
| 406 | && let Ok(Some(u)) = users::find_by_id(&app.db, id).await |
| 407 | { |
| 408 | entry.insert(u.username); |
| 409 | } |
| 410 | } |
| 411 | out |
| 412 | } |
| 413 | |
| 414 | /// Small open/closed indicator for list rows. |
| 415 | fn state_dot(state: &str) -> Markup { |
| 416 | html! { span class=(format!("issue-dot {state}")) {} } |
| 417 | } |
| 418 | |
| 419 | /// Open/closed pill for the detail page. |
| 420 | fn state_badge(state: &str) -> Markup { |
| 421 | html! { span class=(format!("st issue-{state}")) { (state) } } |
| 422 | } |