anvilsign in

collin/anvil

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
9use std::collections::HashMap;
10
11use anvil_core::{
12 App,
13 Issue,
14 User,
15 access,
16 issues,
17 users,
18};
19use 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};
34use maud::{
35 Markup,
36 html,
37};
38use serde::Deserialize;
39
40use 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.
59pub 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)]
75struct ListQuery {
76 #[serde(default)]
77 state: String,
78}
79
80/// `GET /{owner}/{repo}/issues` — open issues, with a closed tab.
81async 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).
148async 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)]
162struct 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`
172async 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
200fn 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.
228async 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)]
314struct CommentForm {
315 #[serde(default)]
316 body: String,
317 #[serde(default)]
318 csrf: String,
319}
320
321/// `POST /{owner}/{repo}/issues/{number}/comment`
322async 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)]
346struct 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.
354async 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.
385async 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`.
402async 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.
415fn state_dot(state: &str) -> Markup {
416 html! { span class=(format!("issue-dot {state}")) {} }
417}
418
419/// Open/closed pill for the detail page.
420fn state_badge(state: &str) -> Markup {
421 html! { span class=(format!("st issue-{state}")) { (state) } }
422}