steid

@jamesgill /

Rust41.2 KBCode·Blame·History·Raw
1//! Issues — `/{handle}/repos/{name}/issues`.
2//!
3//! Three pages: the list, the new-issue form, and one issue with its conversation.
4//! `new` is a static segment and `{number}` a parameterised one, so the router prefers
5//! `new` — and a number can never be the word `new` anyway, so unlike repository names
6//! there is nothing to reserve.
7//!
8//! **Bodies are markdown, rendered through [`markdown`](super::markdown), which never
9//! passes raw HTML from the source through.** That module writes every tag itself, so a
10//! `<script>` someone types into an issue arrives as visible, inert text. This is user
11//! content on the same origin as the session cookie, so that property is the reason it
12//! can be rendered at all — see that module's header before changing anything here.
13//!
14//! A visitor who may read the repository may read its issues; every write is the
15//! owner's. The use cases decide both — the guards on this page only keep a form from
16//! rendering for someone whose submission would be refused anyway.
17
18use std::time::SystemTime;
19
20use serde::Deserialize;
21use topcoat::{
22 Result,
23 context::Cx,
24 icon::{icon, iconify::iconify_icon},
25 router::{
26 StatusCode,
27 content::Form,
28 error::{bad_request, not_found},
29 page, path_param, query_params,
30 },
31 view::{attributes, component, view},
32};
33
34use crate::{
35 application::{
36 CommentView, IssueDetail, IssueEdit, IssueList, IssueSummary, NewIssue, Numbered, RepoView,
37 Stores, comment_on_issue, create_issue, edit_issue, list_issues, set_issue_state,
38 view_issue,
39 },
40 components::{
41 badge::{BadgeVariant, badge},
42 button::{ButtonSize, ButtonVariant, button, button_variants},
43 flash::{FlashKind, flash},
44 input::input,
45 label::label,
46 textarea::textarea,
47 },
48 domain::{IssueNumber, IssueState, OrgName},
49};
50
51use super::{
52 atom::{feed_link, issues_feed_path},
53 context::{
54 current_actor, issues, labels, location, memberships, orgs, owned_repo, repos,
55 server_error, users, write_failure,
56 },
57 csrf::{csrf_field, csrf_verified},
58 format::{ago, timestamp},
59 labels::{encode, label_pill, labels_url},
60 layout::wide,
61 markdown,
62 paging::{Pages, list_pages, requested_page, showing},
63 pulls::{Found, elsewhere_or_missing},
64 repo::{Tab, repo_for, repo_header},
65};
66
67/// `{number}` from the path, raw — validation is [`IssueNumber`]'s job.
68#[path_param]
69struct Number(str);
70
71/// `?state=closed`, and `?label=bug`. Anything else in `state`, including nothing,
72/// means open.
73#[query_params(error = bad_request)]
74struct StateQuery {
75 state: Option<String>,
76 label: Option<String>,
77 /// `?page=N`, 1-based. It travels beside the filters rather than instead of them —
78 /// see [`filtered_url`].
79 page: Option<String>,
80}
81
82#[derive(Debug, Deserialize)]
83struct IssueForm {
84 /// The hidden field every form carries; see [`csrf`](super::csrf).
85 ///
86 /// `default` so that a submission with no token at all is refused by
87 /// `csrf_verified` — a 403 saying what happened — rather than failing
88 /// deserialization into a bare 400 that says nothing.
89 #[serde(default)]
90 csrf_token: String,
91 title: String,
92 body: String,
93}
94
95#[derive(Debug, Deserialize)]
96struct CommentForm {
97 /// The hidden field every form carries; see [`csrf`](super::csrf).
98 ///
99 /// `default` so that a submission with no token at all is refused by
100 /// `csrf_verified` — a 403 saying what happened — rather than failing
101 /// deserialization into a bare 400 that says nothing.
102 #[serde(default)]
103 csrf_token: String,
104 body: String,
105}
106
107#[derive(Debug, Deserialize)]
108struct StateForm {
109 /// The hidden field every form carries; see [`csrf`](super::csrf).
110 ///
111 /// `default` so that a submission with no token at all is refused by
112 /// `csrf_verified` — a 403 saying what happened — rather than failing
113 /// deserialization into a bare 400 that says nothing.
114 #[serde(default)]
115 csrf_token: String,
116 state: String,
117}
118
119// --- URLs ---------------------------------------------------------------------------
120
121fn issues_url(handle: &str, name: &str) -> String {
122 format!("/{handle}/repos/{name}/issues")
123}
124
125/// The list's URL with whichever of state, label and page it needs.
126///
127/// One builder for all three, because the parts have to survive each other: paging out
128/// of a filter would silently widen the list, and switching state must not lose the
129/// label or strand the reader on page 4 of a list that now has one page.
130fn filtered_url(handle: &str, name: &str, state: IssueState, filter: &str, page: usize) -> String {
131 let mut url = issues_url(handle, name);
132 let mut parts = Vec::new();
133
134 if !state.is_open() {
135 parts.push("state=closed".to_owned());
136 }
137
138 if !filter.is_empty() {
139 parts.push(format!("label={}", encode(filter)));
140 }
141
142 // Page 1 is the bare URL: a `?page=1` in a shared link says nothing and makes two
143 // URLs for one page.
144 if page > 1 {
145 parts.push(format!("page={page}"));
146 }
147
148 if !parts.is_empty() {
149 url.push('?');
150 url.push_str(&parts.join("&"));
151 }
152
153 url
154}
155
156pub(super) fn issue_url(handle: &str, name: &str, number: IssueNumber) -> String {
157 format!("/{handle}/repos/{name}/issues/{number}")
158}
159
160// --- Resolution ---------------------------------------------------------------------
161
162/// The number in the path, or 404.
163///
164/// A number that does not parse is not a bad request: `/issues/banana` is a URL that
165/// names nothing, exactly like `/issues/9999`, and answering differently would tell a
166/// prober which of the two they had found.
167fn number_for(cx: &Cx) -> Result<IssueNumber> {
168 path_param::<Number>(cx)
169 .parse::<IssueNumber>()
170 .map_err(|_| not_found().into())
171}
172
173/// The repository's issues in the state the query string asks for, narrowed to the label
174/// it names.
175async fn listing(
176 cx: &Cx,
177 repo: &RepoView,
178 state: IssueState,
179 filter: Option<&str>,
180 page: usize,
181) -> Result<IssueList> {
182 let listed = list_issues(
183 &current_actor(cx).await?,
184 &repo.handle,
185 &repo.name,
186 state,
187 filter,
188 page,
189 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
190 &issues(cx),
191 &users(cx),
192 &labels(cx),
193 )
194 .await
195 .map_err(server_error)?;
196
197 // `repo_for` already resolved the repository as visible, so `None` here would mean
198 // it vanished between the two reads. A 404 is the honest answer to that.
199 listed.ok_or_else(|| not_found().into())
200}
201
202/// One issue, or 404 — for a number that is not there and for a repository that is not
203/// visible alike.
204///
205/// The one exception is a number that is a **pull request**: the two share a sequence
206/// precisely so that `#7` names one thing, so the caller sends the visitor there rather
207/// than 404ing at a cross-reference that was written correctly. See
208/// [`elsewhere_or_missing`](super::pulls::elsewhere_or_missing), which both directions
209/// share so they cannot disagree about when a number is worth redirecting.
210async fn detail(cx: &Cx, repo: &RepoView, number: IssueNumber) -> Result<Found<IssueDetail>> {
211 let found = view_issue(
212 &current_actor(cx).await?,
213 &repo.handle,
214 &repo.name,
215 number,
216 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
217 &issues(cx),
218 &users(cx),
219 &labels(cx),
220 )
221 .await
222 .map_err(server_error)?;
223
224 match found {
225 Some(issue) => Ok(Found::Here(Box::new(issue))),
226 None => elsewhere_or_missing(cx, repo, number, Numbered::PullRequest).await,
227 }
228}
229
230/// The issue a failed write re-renders from.
231///
232/// A write only reaches here having already resolved the issue, so a number that has
233/// become a pull request in the meantime is a state nothing can produce and a 404 is the
234/// honest answer.
235async fn reread(cx: &Cx, repo: &RepoView, number: IssueNumber) -> Result<Box<IssueDetail>> {
236 match detail(cx, repo, number).await? {
237 Found::Here(issue) => Ok(issue),
238 Found::Elsewhere(_) => Err(not_found().into()),
239 }
240}
241
242// --- The list -------------------------------------------------------------------------
243
244#[page("/{handle}/repos/{name}/issues")]
245async fn issues_page(cx: &Cx) -> Result {
246 let repo = repo_for(cx).await?;
247
248 // An unrecognised `?state=` shows the open list rather than erroring: this is a
249 // link someone may have mangled, not a form, and the only thing it can reveal is
250 // which issues are open anyway.
251 let query = query_params::<StateQuery>(cx)?;
252
253 let state = match query.state.as_deref() {
254 Some("closed") => IssueState::Closed,
255 _ => IssueState::Open,
256 };
257
258 // Kept as the raw string as well as resolved, so a filter naming a label that has
259 // been deleted still says what it was filtering on rather than silently widening.
260 let filter = query.label.as_deref().filter(|name| !name.is_empty());
261 let page = requested_page(query.page.as_deref());
262 let listed = listing(cx, &repo, state, filter, page).await?;
263
264 view! {
265 wide(
266 repo_header(
267 repo: &repo,
268 rev: "",
269 active: Tab::Issues,
270 )
271 issue_list(
272 handle: repo.handle.as_str(),
273 name: repo.name.as_str(),
274 listed: &listed,
275 filter: filter.unwrap_or(""),
276 )
277 )
278 }
279}
280
281/// The state filter, the label filter, and the way in, on one row.
282#[component]
283async fn issues_toolbar(handle: &str, name: &str, listed: &IssueList, filter: &str) -> Result {
284 // The primary colour marks which list you are looking at, the same way the tab
285 // strip marks which page you are on. Nothing else on the row is coloured.
286 let current = |active: bool| {
287 if active {
288 "text-foreground"
289 } else {
290 "text-muted-foreground hover:text-foreground"
291 }
292 };
293 let url = issues_url(handle, name);
294 let feed = issues_feed_path(handle, name);
295
296 // The state links keep the label filter, so switching between Open and Closed does
297 // not silently widen the list you are looking at — and drop the page, because page
298 // 4 of the open issues is not page 4 of the closed ones.
299 let carrying = |state: IssueState| filtered_url(handle, name, state, filter, 1);
300
301 view! {
302 <div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
303 <a href=(carrying(IssueState::Open)) class=(format!("flex items-center gap-1.5 {}", current(listed.state.is_open())))>
304 state_icon(state: IssueState::Open, muted: !listed.state.is_open())
305 "Open"
306 <span class="font-mono text-xs text-muted-foreground">(listed.open)</span>
307 </a>
308 <a href=(carrying(IssueState::Closed)) class=(format!("flex items-center gap-1.5 {}", current(!listed.state.is_open())))>
309 state_icon(state: IssueState::Closed, muted: listed.state.is_open())
310 "Closed"
311 <span class="font-mono text-xs text-muted-foreground">(listed.closed)</span>
312 </a>
313
314 // The active filter, with the way out of it. Shown from the raw query value
315 // rather than only when it resolved, so filtering on a label that has since
316 // been deleted still says what it is filtering on.
317 if !filter.is_empty() {
318 <span class="flex items-center gap-1.5">
319 if let Some(pill) = listed.filter.as_ref() {
320 label_pill(handle: handle, name: name, pill: pill)
321 } else {
322 <span class="label-pill label-gray">(filter)</span>
323 }
324 <a
325 href=(if listed.state.is_open() { url.clone() } else { format!("{url}?state=closed") })
326 class="text-muted-foreground hover:text-foreground"
327 title=("Clear this filter")
328 >"×"</a>
329 </span>
330 }
331
332 <span class="ml-auto flex items-center gap-4">
333 <a
334 href=(labels_url(handle, name))
335 class="text-muted-foreground hover:text-foreground"
336 >"Labels"</a>
337 feed_link(href: feed.as_str())
338 if listed.viewer_is_owner {
339 <a
340 href=(format!("{url}/new"))
341 class=(button_variants(ButtonVariant::Primary, ButtonSize::Sm))
342 >"New issue"</a>
343 }
344 </span>
345 </div>
346 }
347}
348
349/// The state marker: open in the success colour, closed muted.
350///
351/// The one live thing about an issue is whether it is still open, so that is what
352/// carries the colour. A closed issue is history and reads as history.
353#[component]
354async fn state_icon(state: IssueState, #[default] muted: bool) -> Result {
355 let tone = if muted {
356 "text-muted-foreground"
357 } else if state.is_open() {
358 "text-success"
359 } else {
360 "text-muted-foreground"
361 };
362
363 view! {
364 icon(
365 data: if state.is_open() {
366 iconify_icon!("feather:alert-circle")
367 } else {
368 iconify_icon!("feather:check-circle")
369 },
370 attrs: attributes! { class=(format!("size-3.5 shrink-0 {tone}")) }
371 )
372 }
373}
374
375#[component]
376async fn issue_list(handle: &str, name: &str, listed: &IssueList, filter: &str) -> Result {
377 let issues = &listed.issues;
378 // The noun says which list is being counted, so a filtered page cannot read as
379 // though it were showing 3 of every issue in the repository.
380 let noun = match (listed.state.is_open(), filter.is_empty()) {
381 (true, true) => "open issues".to_owned(),
382 (false, true) => "closed issues".to_owned(),
383 (true, false) => format!("open issues labelled {filter}"),
384 (false, false) => format!("closed issues labelled {filter}"),
385 };
386 let sentence = showing(issues.offset(), issues.items.len(), issues.total, &noun);
387 let pages = Pages::numbered(issues.page, issues.pages, |page| {
388 filtered_url(handle, name, listed.state, filter, page)
389 });
390
391 view! {
392 issues_toolbar(handle: handle, name: name, listed: listed, filter: filter)
393
394 if listed.issues.items.is_empty() {
395 <div class="rounded-lg border border-border px-4 py-10 text-center">
396 <p class="text-sm text-muted-foreground">
397 if !filter.is_empty() {
398 "No " if listed.state.is_open() { "open" } else { "closed" }
399 " issues labelled " (filter) "."
400 } else if listed.state.is_open() { "No open issues." } else { "No closed issues." }
401 </p>
402 // No "New issue" button here: the toolbar already carries one for the
403 // owner, forty pixels above, and offering it twice reads as two things.
404 if !filter.is_empty() {
405 <p class="mt-1 text-xs">
406 <a
407 href=(issues_url(handle, name))
408 class="text-muted-foreground underline hover:text-foreground"
409 >"Show every issue"</a>
410 </p>
411 } else if !listed.viewer_is_owner && listed.state.is_open() {
412 <p class="mt-1 text-xs text-muted-foreground">
413 "Only the owner can open one here."
414 </p>
415 }
416 </div>
417 } else {
418 <ul class="divide-y divide-border rounded-lg border border-border">
419 for issue in &listed.issues.items {
420 issue_row(handle: handle, name: name, issue: issue)
421 }
422 </ul>
423 list_pages(summary: sentence.as_str(), pages: &pages)
424 }
425 }
426}
427
428/// One issue on one line: what it is, then who and when, then how much talk.
429#[component]
430async fn issue_row(handle: &str, name: &str, issue: &IssueSummary) -> Result {
431 let author = issue
432 .author
433 .as_ref()
434 .map(OrgName::as_str)
435 .unwrap_or("someone");
436
437 view! {
438 <li class="flex items-center gap-3 px-4 py-2.5 text-sm">
439 state_icon(state: issue.state)
440
441 <div class="min-w-0 flex-1">
442 <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
443 <a
444 href=(issue_url(handle, name, issue.number))
445 class="truncate font-medium hover:text-primary"
446 >(issue.title.as_str())</a>
447 // After the title and before the meta line: a pill is part of what
448 // the issue is, not part of when it happened.
449 for pill in &issue.labels {
450 label_pill(handle: handle, name: name, pill: pill)
451 }
452 </div>
453 <p class="mt-0.5 truncate text-xs text-muted-foreground">
454 <span class="font-mono">"#" (issue.number.get())</span>
455 " opened "
456 <span title=(timestamp(issue.created_at))>(ago(issue.created_at))</span>
457 " by "
458 <span class="font-mono">"@" (author)</span>
459 </p>
460 </div>
461
462 if issue.comments > 0 {
463 <span
464 class="flex shrink-0 items-center gap-1 text-xs text-muted-foreground"
465 title=(format!("{} comments", issue.comments))
466 >
467 icon(
468 data: iconify_icon!("feather:message-square"),
469 attrs: attributes! { class="size-3.5" }
470 )
471 <span class="font-mono">(issue.comments)</span>
472 </span>
473 }
474 </li>
475 }
476}
477
478// --- Opening one ----------------------------------------------------------------------
479
480#[page("/{handle}/repos/{name}/issues/new")]
481async fn new_issue_page(cx: &Cx) -> Result {
482 let repo = owned_repo(cx).await?;
483
484 view! {
485 wide(
486 repo_header(
487 repo: &repo,
488 rev: "",
489 active: Tab::Issues,
490 )
491 new_issue_form(
492 handle: repo.handle.as_str(),
493 name: repo.name.as_str(),
494 title: "",
495 body: "",
496 error: "",
497 )
498 )
499 }
500}
501
502/// Opens the issue, then redirects to it.
503///
504/// The redirect is a 303 rather than `redirect()`'s 307, which would re-POST the form
505/// to the new issue's own URL — see [`location`].
506#[page(POST "/{handle}/repos/{name}/issues")]
507async fn create(cx: &Cx, Form(submitted): Form<IssueForm>) -> Result {
508 csrf_verified(cx, &submitted.csrf_token).await?;
509
510 let repo = owned_repo(cx).await?;
511
512 let outcome = create_issue(
513 &current_actor(cx).await?,
514 &repo.handle,
515 &repo.name,
516 &NewIssue {
517 title: submitted.title.clone(),
518 body: submitted.body.clone(),
519 },
520 SystemTime::now(),
521 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
522 &issues(cx),
523 )
524 .await;
525
526 let message = match outcome {
527 Ok(issue) => {
528 return view! {
529 (StatusCode::SEE_OTHER)
530 (location(&issue_url(repo.handle.as_str(), repo.name.as_str(), issue.number))?)
531 };
532 }
533 Err(error) => write_failure(error)?,
534 };
535
536 // Re-rendered with what was typed. Bouncing back to an empty form would throw away
537 // a body someone spent ten minutes on to tell them the title was too long.
538 view! {
539 wide(
540 repo_header(
541 repo: &repo,
542 rev: "",
543 active: Tab::Issues,
544 )
545 new_issue_form(
546 handle: repo.handle.as_str(),
547 name: repo.name.as_str(),
548 title: submitted.title.as_str(),
549 body: submitted.body.as_str(),
550 error: message.as_str(),
551 )
552 )
553 }
554}
555
556#[component]
557async fn new_issue_form(handle: &str, name: &str, title: &str, body: &str, error: &str) -> Result {
558 // `max-w-3xl` without `mx-auto`: a centred column inside the full-width repository
559 // header leaves the two edges out of line, and the header is what the eye anchors on.
560 view! {
561 <div class="max-w-3xl">
562 <h1 class="text-lg font-semibold tracking-tight">"New issue"</h1>
563
564 if !error.is_empty() {
565 <div class="mt-4">
566 flash(kind: FlashKind::Error, (error))
567 </div>
568 }
569
570 <form method="post" action=(issues_url(handle, name)) class="mt-5 space-y-4">
571 csrf_field()
572 <div class="space-y-2">
573 label(attrs: attributes! { for="title" }, "Title")
574 input(attrs: attributes! {
575 id="title"
576 name="title"
577 type="text"
578 value=(title)
579 placeholder="Blame is slow on large files"
580 required=(true)
581 autofocus=(true)
582 })
583 </div>
584
585 <div class="space-y-2">
586 label(attrs: attributes! { for="body" }, "Description")
587 textarea(
588 attrs: attributes! {
589 id="body"
590 name="body"
591 rows="12"
592 placeholder="Markdown is supported."
593 },
594 (body)
595 )
596 <p class="text-xs text-muted-foreground">
597 "Markdown. HTML in the source is shown as text, never rendered."
598 </p>
599 </div>
600
601 <div class="flex items-center gap-3">
602 button(attrs: attributes! { type="submit" }, "Open issue")
603 <a
604 href=(issues_url(handle, name))
605 class="text-sm text-muted-foreground hover:text-foreground"
606 >"Cancel"</a>
607 </div>
608 </form>
609 </div>
610 }
611}
612
613// --- One issue ------------------------------------------------------------------------
614
615#[page("/{handle}/repos/{name}/issues/{number}")]
616async fn issue_page(cx: &Cx) -> Result {
617 let repo = repo_for(cx).await?;
618
619 let issue = match detail(cx, &repo, number_for(cx)?).await? {
620 Found::Here(issue) => issue,
621 Found::Elsewhere(url) => {
622 return view! { (StatusCode::SEE_OTHER) (location(&url)?) };
623 }
624 };
625
626 view! {
627 wide(
628 repo_header(
629 repo: &repo,
630 rev: "",
631 active: Tab::Issues,
632 )
633 issue_view(
634 handle: repo.handle.as_str(),
635 name: repo.name.as_str(),
636 issue: &issue,
637 error: "",
638 comment: "",
639 editing: false,
640 )
641 )
642 }
643}
644
645/// Adds a comment, then redirects so a reload cannot repeat it.
646///
647/// Named `add_comment` rather than `comment` because `#[page]` turns a handler's name
648/// into a unit struct in module scope, which would shadow every `comment` binding in
649/// this file — including the loop variable that renders one.
650#[page(POST "/{handle}/repos/{name}/issues/{number}/comments")]
651async fn add_comment(cx: &Cx, Form(submitted): Form<CommentForm>) -> Result {
652 csrf_verified(cx, &submitted.csrf_token).await?;
653
654 let repo = owned_repo(cx).await?;
655 let number = number_for(cx)?;
656
657 let outcome = comment_on_issue(
658 &current_actor(cx).await?,
659 &repo.handle,
660 &repo.name,
661 number,
662 &submitted.body,
663 SystemTime::now(),
664 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
665 &issues(cx),
666 )
667 .await;
668
669 let message = match outcome {
670 Ok(()) => {
671 return view! {
672 (StatusCode::SEE_OTHER)
673 (location(&issue_url(repo.handle.as_str(), repo.name.as_str(), number))?)
674 };
675 }
676 Err(error) => write_failure(error)?,
677 };
678
679 let issue = reread(cx, &repo, number).await?;
680
681 view! {
682 wide(
683 repo_header(
684 repo: &repo,
685 rev: "",
686 active: Tab::Issues,
687 )
688 issue_view(
689 handle: repo.handle.as_str(),
690 name: repo.name.as_str(),
691 issue: &issue,
692 error: message.as_str(),
693 // Kept, so a rejected comment is not silently thrown away.
694 comment: submitted.body.as_str(),
695 editing: false,
696 )
697 )
698 }
699}
700
701/// Closes or reopens, then redirects.
702///
703/// One route for both, because the two differ only in the value posted — and because
704/// two routes would mean two copies of the same guard.
705#[page(POST "/{handle}/repos/{name}/issues/{number}/state")]
706async fn set_state(cx: &Cx, Form(submitted): Form<StateForm>) -> Result {
707 csrf_verified(cx, &submitted.csrf_token).await?;
708
709 let repo = owned_repo(cx).await?;
710 let number = number_for(cx)?;
711
712 // A value neither state can name is a tampered form, not something to default:
713 // defaulting would close an issue somebody asked to reopen.
714 let state = submitted
715 .state
716 .parse::<IssueState>()
717 .map_err(|_| bad_request("unknown issue state"))?;
718
719 match set_issue_state(
720 &current_actor(cx).await?,
721 &repo.handle,
722 &repo.name,
723 number,
724 state,
725 SystemTime::now(),
726 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
727 &issues(cx),
728 )
729 .await
730 {
731 Ok(()) => {}
732 // Nothing typed to preserve and nothing to explain, so a failure here is a
733 // response rather than a re-render.
734 Err(error) => {
735 write_failure(error)?;
736 }
737 }
738
739 view! {
740 (StatusCode::SEE_OTHER)
741 (location(&issue_url(repo.handle.as_str(), repo.name.as_str(), number))?)
742 }
743}
744
745/// Changes the title and body, then redirects.
746#[page(POST "/{handle}/repos/{name}/issues/{number}/edit")]
747async fn edit(cx: &Cx, Form(submitted): Form<IssueForm>) -> Result {
748 csrf_verified(cx, &submitted.csrf_token).await?;
749
750 let repo = owned_repo(cx).await?;
751 let number = number_for(cx)?;
752
753 let outcome = edit_issue(
754 &current_actor(cx).await?,
755 &repo.handle,
756 &repo.name,
757 number,
758 &IssueEdit {
759 title: submitted.title.clone(),
760 body: submitted.body.clone(),
761 },
762 SystemTime::now(),
763 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
764 &issues(cx),
765 )
766 .await;
767
768 let message = match outcome {
769 Ok(()) => {
770 return view! {
771 (StatusCode::SEE_OTHER)
772 (location(&issue_url(repo.handle.as_str(), repo.name.as_str(), number))?)
773 };
774 }
775 Err(error) => write_failure(error)?,
776 };
777
778 // The stored issue, with the rejected edit put back into the form and the panel
779 // opened on it — otherwise the reason is visible but what caused it is hidden
780 // behind a closed disclosure.
781 let stored = reread(cx, &repo, number).await?;
782 let attempted = IssueDetail {
783 title: submitted.title.clone(),
784 body: submitted.body.clone(),
785 ..*stored
786 };
787
788 view! {
789 wide(
790 repo_header(
791 repo: &repo,
792 rev: "",
793 active: Tab::Issues,
794 )
795 issue_view(
796 handle: repo.handle.as_str(),
797 name: repo.name.as_str(),
798 issue: &attempted,
799 error: message.as_str(),
800 comment: "",
801 editing: true,
802 )
803 )
804 }
805}
806
807#[component]
808async fn issue_view(
809 handle: &str,
810 name: &str,
811 issue: &IssueDetail,
812 error: &str,
813 /// A comment that was rejected, put back in the box.
814 comment: &str,
815 /// Whether the edit panel starts open — true only when an edit was refused.
816 editing: bool,
817) -> Result {
818 let author = issue
819 .author
820 .as_ref()
821 .map(OrgName::as_str)
822 .unwrap_or("someone");
823 let url = issue_url(handle, name, issue.number);
824
825 view! {
826 <div class="max-w-3xl">
827 <div class="flex items-start gap-3">
828 <h1 class="min-w-0 flex-1 text-xl font-semibold tracking-tight">
829 (issue.title.as_str())
830 <span class="ml-2 font-mono text-lg font-normal text-muted-foreground">
831 "#" (issue.number.get())
832 </span>
833 </h1>
834 </div>
835
836 // Straight after the title, on their own line: the labels say what kind of
837 // thing this is, which is read with the title rather than with the metadata.
838 if !issue.labels.is_empty() {
839 <div class="mt-2 flex flex-wrap items-center gap-1.5">
840 for pill in &issue.labels {
841 label_pill(handle: handle, name: name, pill: pill)
842 }
843 </div>
844 }
845
846 <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-muted-foreground">
847 // The badge is the only coloured thing here, because whether the issue is
848 // still open is the only live fact on the page.
849 badge(
850 variant: if issue.state.is_open() {
851 BadgeVariant::Primary
852 } else {
853 BadgeVariant::Secondary
854 },
855 if issue.state.is_open() { "Open" } else { "Closed" }
856 )
857 <span>
858 <span class="font-mono">"@" (author)</span>
859 " opened this "
860 <span title=(timestamp(issue.created_at))>(ago(issue.created_at))</span>
861 </span>
862 if let Some(closed_at) = issue.closed_at {
863 <span>
864 "closed " <span title=(timestamp(closed_at))>(ago(closed_at))</span>
865 </span>
866 }
867 <span>
868 (issue.comments.len())
869 if issue.comments.len() == 1 { " comment" } else { " comments" }
870 </span>
871 </div>
872
873 if !error.is_empty() {
874 <div class="mt-4">
875 flash(kind: FlashKind::Error, (error))
876 </div>
877 }
878
879 <div class="mt-4 space-y-3">
880 <article class="rounded-lg border border-border">
881 <header class="flex items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground">
882 <span class="font-mono">"@" (author)</span>
883 <span title=(timestamp(issue.created_at))>(ago(issue.created_at))</span>
884 </header>
885 body_panel(
886 handle: handle,
887 name: name,
888 source: issue.body.as_str(),
889 empty: "No description given.",
890 )
891 </article>
892
893 for comment in &issue.comments {
894 issue_comment(handle: handle, name: name, comment: comment)
895 }
896 </div>
897
898 if issue.viewer_is_owner {
899 // The two actions sit on one row under the textarea, per ui.md's
900 // density rule: they are the two things to do here and stacking them
901 // spent a whole row on each.
902 //
903 // They are still **two forms** — a form cannot nest, and posting a draft
904 // comment along with a close would be a surprise either way. The Comment
905 // button reaches its own form by id rather than by containment, which is
906 // what lets the row hold both without the textarea joining it.
907 <form
908 id="comment-form"
909 method="post"
910 action=(format!("{url}/comments"))
911 class="mt-5 space-y-2"
912 >
913 csrf_field()
914 label(attrs: attributes! { for="comment-body" }, "Comment")
915 textarea(
916 attrs: attributes! {
917 id="comment-body"
918 name="body"
919 rows="5"
920 placeholder="Markdown is supported."
921 },
922 (comment)
923 )
924 </form>
925
926 <div class="mt-3 flex flex-wrap items-center gap-2">
927 button(
928 size: ButtonSize::Sm,
929 attrs: attributes! { type="submit" form="comment-form" },
930 "Comment"
931 )
932
933 // The state posted is the one being moved *to*.
934 <form method="post" action=(format!("{url}/state"))>
935 csrf_field()
936 <input
937 type="hidden"
938 name="state"
939 value=(if issue.state.is_open() { "closed" } else { "open" })
940 />
941 button(
942 variant: ButtonVariant::Outline,
943 size: ButtonSize::Sm,
944 attrs: attributes! { type="submit" },
945 if issue.state.is_open() { "Close issue" } else { "Reopen issue" }
946 )
947 </form>
948 </div>
949
950 // On its own row for the same reason the edit panel is: the form it
951 // reveals is full width, and nesting it in a flex row indents it all.
952 <details class="mt-3">
953 <summary class="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
954 "Labels"
955 </summary>
956
957 if issue.repo_labels.is_empty() {
958 <p class="mt-2 text-xs text-muted-foreground">
959 "This repository has no labels yet. "
960 <a href=(labels_url(handle, name)) class="underline hover:text-foreground">
961 "Make one"
962 </a>
963 "."
964 </p>
965 } else {
966 // Every box, ticked or not, submitted as the whole set — which is
967 // what makes pressing Save twice mean the same thing as once.
968 <form method="post" action=(format!("{url}/labels")) class="mt-2 space-y-2">
969 csrf_field()
970 <div class="flex flex-wrap gap-x-4 gap-y-2">
971 for choice in &issue.repo_labels {
972 <label class="flex items-center gap-2 text-xs">
973 <input
974 type="checkbox"
975 name="label"
976 value=(choice.id.as_str())
977 checked=(issue.labels.iter().any(|on| on.id == choice.id))
978 />
979 <span class=(format!("label-pill label-{}", choice.colour))>
980 (choice.name.as_str())
981 </span>
982 </label>
983 }
984 </div>
985 <div class="flex items-center gap-3 pt-1">
986 button(
987 size: ButtonSize::Sm,
988 variant: ButtonVariant::Outline,
989 attrs: attributes! { type="submit" },
990 "Save labels"
991 )
992 <a
993 href=(labels_url(handle, name))
994 class="text-xs text-muted-foreground hover:text-foreground"
995 >"Manage labels"</a>
996 </div>
997 </form>
998 }
999 </details>
1000
1001 // On its own row rather than beside the button: the form it reveals is
1002 // full width, and nesting it in a flex row indents the whole thing.
1003 <details class="mt-3" open=(editing)>
1004 <summary class="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
1005 "Edit title and description"
1006 </summary>
1007
1008 <form method="post" action=(format!("{url}/edit")) class="mt-3 space-y-4">
1009 csrf_field()
1010 <div class="space-y-2">
1011 label(attrs: attributes! { for="edit-title" }, "Title")
1012 input(attrs: attributes! {
1013 id="edit-title"
1014 name="title"
1015 type="text"
1016 value=(issue.title.as_str())
1017 required=(true)
1018 })
1019 </div>
1020 <div class="space-y-2">
1021 label(attrs: attributes! { for="edit-body" }, "Description")
1022 textarea(
1023 attrs: attributes! { id="edit-body" name="body" rows="10" },
1024 (issue.body.as_str())
1025 )
1026 </div>
1027 button(
1028 size: ButtonSize::Sm,
1029 attrs: attributes! { type="submit" },
1030 "Save changes"
1031 )
1032 </form>
1033 </details>
1034 } else {
1035 <p class="mt-5 rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground">
1036 "Only the owner can comment on or change this issue."
1037 </p>
1038 }
1039
1040 <p class="mt-8">
1041 <a
1042 href=(issues_url(handle, name))
1043 class="text-sm text-muted-foreground hover:text-foreground"
1044 >"Back to issues"</a>
1045 </p>
1046 </div>
1047 }
1048}
1049
1050#[component]
1051async fn issue_comment(handle: &str, name: &str, comment: &CommentView) -> Result {
1052 let author = comment
1053 .author
1054 .as_ref()
1055 .map(OrgName::as_str)
1056 .unwrap_or("someone");
1057
1058 view! {
1059 <article class="rounded-lg border border-border">
1060 <header class="flex items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground">
1061 <span class="font-mono">"@" (author)</span>
1062 <span title=(timestamp(comment.created_at))>(ago(comment.created_at))</span>
1063 </header>
1064 body_panel(handle: handle, name: name, source: comment.body.as_str(), empty: "")
1065 </article>
1066 }
1067}
1068
1069/// A markdown body inside a panel.
1070///
1071/// Split out so the one `view!` holding rendered markdown is a single line. Safe
1072/// because [`markdown`](super::markdown) writes every tag itself and never passes
1073/// source HTML through — the same reason the README can be rendered.
1074#[component]
1075async fn body_panel(handle: &str, name: &str, source: &str, empty: &str) -> Result {
1076 view! {
1077 if source.is_empty() {
1078 <p class="px-4 py-3 text-sm text-muted-foreground italic">(empty)</p>
1079 } else {
1080 <div class="overflow-x-auto px-4 py-2 text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
1081 (markdown::render(
1082 source,
1083 Some(markdown::Crossref {
1084 handle,
1085 name,
1086 origin: None,
1087 }),
1088 ))
1089 </div>
1090 }
1091 }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::*;
1097
1098 #[test]
1099 fn the_first_page_of_the_open_list_is_the_bare_url() {
1100 assert_eq!(
1101 filtered_url("ada", "steid", IssueState::Open, "", 1),
1102 "/ada/repos/steid/issues"
1103 );
1104 }
1105
1106 #[test]
1107 fn a_page_link_carries_the_state_and_the_label_with_it() {
1108 // The property the list depends on: paging out of a filter would silently widen
1109 // the list somebody is reading.
1110 assert_eq!(
1111 filtered_url("ada", "steid", IssueState::Closed, "needs review", 3),
1112 "/ada/repos/steid/issues?state=closed&label=needs%20review&page=3"
1113 );
1114 }
1115
1116 #[test]
1117 fn a_state_link_keeps_the_label_and_drops_the_page() {
1118 // Page 4 of the open issues is not page 4 of the closed ones, so switching
1119 // state starts again at the first page — with the filter intact.
1120 assert_eq!(
1121 filtered_url("ada", "steid", IssueState::Closed, "bug", 1),
1122 "/ada/repos/steid/issues?state=closed&label=bug"
1123 );
1124 }
1125}