forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
46.4 KiB
1//! Pages: the issue list at `/{owner}/{repo}/issues` and the new-issue form at
2//! `/{owner}/{repo}/issues/new` (GET shows the form, POST creates the issue and redirects to it).
3//! Filters come from the query string. Invalid values fall back to the defaults, never an error.
4
5use std::collections::HashMap;
6
7use forge_core::tz::format_in_tz;
8use forge_core::{
9 IssueFilter, IssueListItem, IssueSort, IssueState, validate_body, validate_title,
10};
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::RawForm;
14use topcoat::router::error::{forbidden, see_other};
15use topcoat::router::{StatusCode, page, path_param, query_params};
16use topcoat::view::{View, component, view};
17
18use crate::auth::{require_csrf, require_user};
19use crate::i18n::issues::{self as g, K};
20use crate::i18n::{self, Lang};
21use crate::notify::{self, Activity};
22use crate::pages::issue_meta::{can_triage, form_all, form_field, form_pairs};
23use crate::pages::repo::repo_head;
24use crate::pages::repo_ctx::{RepoHead, load_repo};
25use crate::state::AppState;
26use crate::templates::{self, Template};
27use crate::views::{
28 alert, avatar_url, csrf_field, enc, iso_time, now_unix, relative_time, repo_href, shell,
29 user_facing_message, user_link,
30};
31use crate::webhooks::emit::{Sender, emit_issue};
32
33path_param!(owner);
34path_param!(repo);
35
36/// Issues per list page.
37const PER_PAGE: u32 = 25;
38/// Highest accepted `?page=` value. Anything else falls back to page 1.
39const MAX_PAGE: u32 = 1000;
40/// Longest accepted filter value, in characters. Longer values are cut.
41const MAX_FILTER_CHARS: usize = 256;
42
43#[query_params(error = bad_request)]
44pub(crate) struct ListQuery {
45 state: Option<String>,
46 label: Option<String>,
47 milestone: Option<String>,
48 author: Option<String>,
49 assignee: Option<String>,
50 q: Option<String>,
51 sort: Option<String>,
52 page: Option<String>,
53}
54
55/// The list's filters as the page shows them. Every value here is already a valid choice.
56pub(crate) struct Selection {
57 /// `open`, `closed` or `all`.
58 pub(crate) state: &'static str,
59 pub(crate) label: String,
60 pub(crate) milestone: String,
61 pub(crate) author: String,
62 pub(crate) assignee: String,
63 pub(crate) query: String,
64 /// `newest`, `oldest` or `updated`.
65 pub(crate) sort: &'static str,
66 pub(crate) page: u32,
67}
68
69/// Trimmed and cut to `MAX_FILTER_CHARS`. A missing value is empty.
70fn clean(value: Option<&str>) -> String {
71 value
72 .unwrap_or_default()
73 .trim()
74 .chars()
75 .take(MAX_FILTER_CHARS)
76 .collect()
77}
78
79/// `Some` for a non-empty filter value.
80fn non_empty(value: &str) -> Option<String> {
81 (!value.is_empty()).then(|| value.to_owned())
82}
83
84impl Selection {
85 pub(crate) fn parse(query: &ListQuery) -> Self {
86 Self {
87 state: match query.state.as_deref() {
88 Some("closed") => "closed",
89 Some("all") => "all",
90 _ => "open",
91 },
92 label: clean(query.label.as_deref()),
93 milestone: clean(query.milestone.as_deref()),
94 author: clean(query.author.as_deref()),
95 assignee: clean(query.assignee.as_deref()),
96 query: clean(query.q.as_deref()),
97 sort: match query.sort.as_deref() {
98 Some("oldest") => "oldest",
99 Some("updated") => "updated",
100 _ => "newest",
101 },
102 page: query
103 .page
104 .as_deref()
105 .and_then(|p| p.trim().parse::<u32>().ok())
106 .filter(|p| (1..=MAX_PAGE).contains(p))
107 .unwrap_or(1),
108 }
109 }
110
111 pub(crate) fn filter(&self) -> IssueFilter {
112 IssueFilter {
113 state: match self.state {
114 "closed" => Some(IssueState::Closed),
115 "all" => None,
116 _ => Some(IssueState::Open),
117 },
118 label: non_empty(&self.label),
119 milestone: non_empty(&self.milestone),
120 author: non_empty(&self.author),
121 assignee: non_empty(&self.assignee),
122 query: non_empty(&self.query),
123 sort: match self.sort {
124 "oldest" => IssueSort::Oldest,
125 "updated" => IssueSort::RecentlyUpdated,
126 _ => IssueSort::Newest,
127 },
128 page: self.page,
129 per_page: PER_PAGE,
130 }
131 }
132
133 /// Whether any filter besides the state is set.
134 pub(crate) fn has_filters(&self) -> bool {
135 !(self.label.is_empty()
136 && self.milestone.is_empty()
137 && self.author.is_empty()
138 && self.assignee.is_empty()
139 && self.query.is_empty())
140 }
141
142 /// The list URL with this selection, but a different state and page.
143 pub(crate) fn href(&self, base: &str, state: &str, page: u32) -> String {
144 let page_text = if page > 1 {
145 page.to_string()
146 } else {
147 String::new()
148 };
149 let sort = if self.sort == "newest" { "" } else { self.sort };
150 list_href(
151 base,
152 &[
153 ("state", state),
154 ("label", self.label.as_str()),
155 ("milestone", self.milestone.as_str()),
156 ("author", self.author.as_str()),
157 ("assignee", self.assignee.as_str()),
158 ("q", self.query.as_str()),
159 ("sort", sort),
160 ("page", page_text.as_str()),
161 ],
162 )
163 }
164}
165
166/// `base` followed by a query string holding the non-empty parameters, percent-encoded.
167pub(crate) fn list_href(base: &str, params: &[(&str, &str)]) -> String {
168 let query: Vec<String> = params
169 .iter()
170 .filter(|(_, value)| !value.is_empty())
171 .map(|(key, value)| format!("{key}={}", enc(value)))
172 .collect();
173 if query.is_empty() {
174 base.to_owned()
175 } else {
176 format!("{base}?{}", query.join("&"))
177 }
178}
179
180/// Unix seconds of a stored RFC 3339 stamp. An unreadable stamp gives 0.
181pub(crate) fn stamp_unix(stamp: &str) -> i64 {
182 time::OffsetDateTime::parse(stamp, &time::format_description::well_known::Rfc3339)
183 .map_or(0, |t| t.unix_timestamp())
184}
185
186/// Tab class for the state links.
187pub(crate) fn state_tab_class(on: bool) -> &'static str {
188 if on { "issue-tab current" } else { "issue-tab" }
189}
190
191pub(crate) struct AssigneeView {
192 pub(crate) name: String,
193 pub(crate) avatar: String,
194}
195
196pub(crate) struct LabelView {
197 pub(crate) class: String,
198 pub(crate) name: String,
199 pub(crate) href: String,
200}
201
202#[derive(Clone)]
203pub(crate) struct MilestoneView {
204 pub(crate) title: String,
205 pub(crate) href: String,
206}
207
208/// One issue row, with every string the template shows already built.
209struct RowView {
210 state_class: &'static str,
211 state_label: &'static str,
212 number_text: String,
213 href: String,
214 title: String,
215 labels: Vec<LabelView>,
216 author: String,
217 relative: String,
218 absolute: String,
219 iso: String,
220 milestone: Option<MilestoneView>,
221 assignees: Vec<AssigneeView>,
222 has_comments: bool,
223 comments_text: String,
224}
225
226/// What every row of one list page shares: the language, the repo, the state filter, the
227/// milestone titles by id, and the clock and timezone for relative times.
228#[derive(Clone, Copy)]
229struct RowCtx<'a> {
230 lang: Lang,
231 owner: &'a str,
232 repo: &'a str,
233 state: &'a str,
234 milestones: &'a HashMap<String, String>,
235 now: i64,
236 tz: &'a str,
237}
238
239fn row_view(row: RowCtx<'_>, item: &IssueListItem) -> RowView {
240 let RowCtx {
241 lang,
242 owner,
243 repo,
244 state,
245 milestones,
246 now,
247 tz,
248 } = row;
249 let issue = &item.issue;
250 let base = repo_href(owner, repo, "issues", "");
251 let unix = stamp_unix(&issue.created_at);
252 let open = matches!(issue.state, IssueState::Open);
253 let comments = issue.comment_count.max(0);
254 RowView {
255 state_class: if open {
256 "issue-state issue-state-open"
257 } else {
258 "issue-state issue-state-closed"
259 },
260 state_label: if open {
261 g::t(lang, K::OpenIssue)
262 } else {
263 g::t(lang, K::ClosedIssue)
264 },
265 number_text: format!("#{}", issue.number),
266 href: repo_href(owner, repo, "issues", &issue.number.to_string()),
267 title: issue.title.clone(),
268 labels: item
269 .labels
270 .iter()
271 .map(|label| LabelView {
272 class: format!("label-chip label-{}", label.id),
273 name: label.name.clone(),
274 href: list_href(&base, &[("state", state), ("label", label.name.as_str())]),
275 })
276 .collect(),
277 author: item.author.clone(),
278 relative: relative_time(row.lang, unix, now),
279 absolute: format_in_tz(unix, tz),
280 iso: iso_time(unix),
281 milestone: issue
282 .milestone_id
283 .as_ref()
284 .and_then(|id| milestones.get(id))
285 .map(|title| MilestoneView {
286 title: title.clone(),
287 href: list_href(&base, &[("state", state), ("milestone", title.as_str())]),
288 }),
289 assignees: item
290 .assignees
291 .iter()
292 .map(|name| AssigneeView {
293 name: name.clone(),
294 avatar: avatar_url(name, None),
295 })
296 .collect(),
297 has_comments: comments > 0,
298 comments_text: g::comment_count(lang, comments),
299 }
300}
301
302/// One pinned issue card, with the strings the template shows already built.
303struct PinnedCard {
304 state_class: &'static str,
305 state_label: &'static str,
306 number_text: String,
307 href: String,
308 title: String,
309 comments_text: String,
310}
311
312fn pinned_card(lang: Lang, owner: &str, repo: &str, issue: &forge_core::Issue) -> PinnedCard {
313 let open = matches!(issue.state, IssueState::Open);
314 let comments = issue.comment_count.max(0);
315 PinnedCard {
316 state_class: if open {
317 "issue-state issue-state-open"
318 } else {
319 "issue-state issue-state-closed"
320 },
321 state_label: if open {
322 g::t(lang, K::OpenIssue)
323 } else {
324 g::t(lang, K::ClosedIssue)
325 },
326 number_text: format!("#{}", issue.number),
327 href: repo_href(owner, repo, "issues", &issue.number.to_string()),
328 title: issue.title.clone(),
329 comments_text: g::comment_count(lang, comments),
330 }
331}
332
333/// One option of the sort select.
334pub(crate) struct SortOption {
335 pub(crate) value: &'static str,
336 pub(crate) label: &'static str,
337 pub(crate) selected: bool,
338}
339
340#[page("/{owner}/{repo}/issues")]
341async fn issues_list_page(cx: &Cx) -> Result<impl View> {
342 let owner = path_param::<Owner>(cx);
343 let name = path_param::<Repo>(cx);
344 let query = query_params::<ListQuery>(cx)?;
345 let ctx = load_repo(cx, owner, name).await?;
346 let state = app_context::<AppState>(cx);
347 let sel = Selection::parse(query);
348 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u));
349
350 let (items, total) = state.store.list_issues(&ctx.repo.id, &sel.filter()).await?;
351 let pinned: Vec<PinnedCard> = state
352 .store
353 .pinned_issues(&ctx.repo.id)
354 .await?
355 .iter()
356 .map(|issue| pinned_card(lang, &ctx.repo.owner_name, &ctx.repo.name, issue))
357 .collect();
358 let (open_count, closed_count) = state.store.issue_counts(&ctx.repo.id).await?;
359 let milestones: HashMap<String, String> = state
360 .store
361 .list_milestones(&ctx.repo.id, None)
362 .await?
363 .into_iter()
364 .map(|m| (m.id, m.title))
365 .collect();
366
367 let owner_name = ctx.repo.owner_name.clone();
368 let repo_name = ctx.repo.name.clone();
369 let base = repo_href(&owner_name, &repo_name, "issues", "");
370 let now = now_unix();
371 let tz = ctx.viewer_tz().to_owned();
372 let row = RowCtx {
373 lang,
374 owner: &owner_name,
375 repo: &repo_name,
376 state: sel.state,
377 milestones: &milestones,
378 now,
379 tz: &tz,
380 };
381 let rows: Vec<RowView> = items.iter().map(|item| row_view(row, item)).collect();
382
383 let last_page = total.div_ceil(u64::from(PER_PAGE)).max(1);
384 let has_newer = sel.page > 1;
385 let has_older = u64::from(sel.page) < last_page;
386 let newer_href = sel.href(&base, sel.state, sel.page.saturating_sub(1));
387 let older_href = sel.href(&base, sel.state, sel.page + 1);
388 let page_label = g::page_label(lang, sel.page);
389
390 let open_href = sel.href(&base, "open", 1);
391 let closed_href = sel.href(&base, "closed", 1);
392 let all_href = sel.href(&base, "all", 1);
393 let open_text = g::tab_open(lang, open_count);
394 let closed_text = g::tab_closed(lang, closed_count);
395 let all_text = g::tab_all(lang, open_count + closed_count);
396 let clear_href = list_href(&base, &[("state", sel.state)]);
397 let has_filters = sel.has_filters();
398
399 let sort_options: Vec<SortOption> = [
400 ("newest", g::t(lang, K::SortNewest)),
401 ("oldest", g::t(lang, K::SortOldest)),
402 ("updated", g::t(lang, K::SortUpdated)),
403 ]
404 .into_iter()
405 .map(|(value, label)| SortOption {
406 value,
407 label,
408 selected: value == sel.sort,
409 })
410 .collect();
411
412 let can_create = ctx.viewer.is_some() && !ctx.repo.archived;
413 let new_href = repo_href(&owner_name, &repo_name, "issues/new", "");
414 let labels_href = repo_href(&owner_name, &repo_name, "labels.css", "");
415 let labels_page_href = repo_href(&owner_name, &repo_name, "labels", "");
416 let milestones_href = repo_href(&owner_name, &repo_name, "milestones", "");
417 let title = g::issues_title(lang, &format!("{owner_name}/{repo_name}"));
418 let empty_text = g::t(lang, K::NoIssuesMatch);
419 let state_word = sel.state;
420
421 let header = ctx.head(state, "issues").await?;
422 Ok(view! {
423 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), extra_stylesheet: Some(labels_href.as_str()),
424 repo_head(head: header)
425 <div class="issue-toolbar">
426 <nav class="issue-tabs" aria-label=(g::t(lang, K::IssuesByState))>
427 <a class=(state_tab_class(state_word == "open")) href=(open_href.as_str())>(open_text.as_str())</a>
428 <a class=(state_tab_class(state_word == "closed")) href=(closed_href.as_str())>(closed_text.as_str())</a>
429 <a class=(state_tab_class(state_word == "all")) href=(all_href.as_str())>(all_text.as_str())</a>
430 </nav>
431 <div class="issue-tools">
432 <nav aria-label=(g::t(lang, K::LabelsAndMilestones))>
433 <a class="button" href=(labels_page_href.as_str())>(g::t(lang, K::Labels))</a>
434 <a class="button" href=(milestones_href.as_str())>(g::t(lang, K::Milestones))</a>
435 </nav>
436 if can_create {
437 <a class="button" href=(new_href.as_str())>(g::t(lang, K::NewIssue))</a>
438 }
439 </div>
440 </div>
441 <form method="get" action=(base.as_str()) class="issue-search">
442 <input type="hidden" name="state" value=(sel.state)>
443 <input type="hidden" name="label" value=(sel.label.as_str())>
444 <input type="hidden" name="milestone" value=(sel.milestone.as_str())>
445 <input type="hidden" name="author" value=(sel.author.as_str())>
446 <input type="hidden" name="assignee" value=(sel.assignee.as_str())>
447 <input type="search" name="q" value=(sel.query.as_str()) placeholder=(g::t(lang, K::SearchTitles)) aria-label=(g::t(lang, K::SearchTitles)) maxlength="256">
448 <select name="sort" aria-label=(g::t(lang, K::Sort))>
449 for option in sort_options.iter() {
450 <option value=(option.value) selected=(option.selected)>(option.label)</option>
451 }
452 </select>
453 <button type="submit">(g::t(lang, K::Search))</button>
454 </form>
455 if has_filters {
456 <p class="muted small">(g::t(lang, K::FiltersApplied)) <a href=(clear_href.as_str())>(g::t(lang, K::ClearFilters))</a></p>
457 }
458 if !pinned.is_empty() {
459 <section class="pinned-issues" aria-label=(g::t(lang, K::PinnedIssues))>
460 <h2>(g::t(lang, K::Pinned))</h2>
461 <ul class="pinned-list">
462 for card in pinned.iter() {
463 <li class="pinned-card">
464 <div class="issue-meta muted small">
465 <span class=(card.state_class) role="img" aria-label=(card.state_label)></span>
466 <span>(card.number_text.as_str())</span>
467 <span>(card.comments_text.as_str())</span>
468 </div>
469 <a class="pinned-title" href=(card.href.as_str())>(card.title.as_str())</a>
470 </li>
471 }
472 </ul>
473 </section>
474 }
475 if rows.is_empty() {
476 <p class="muted">(empty_text)</p>
477 } else {
478 <ul class="issue-list">
479 for row in rows.iter() {
480 <li class="issue-row">
481 <span class=(row.state_class) role="img" aria-label=(row.state_label)></span>
482 <div class="issue-main">
483 <div class="issue-title-line">
484 <a class="issue-title" href=(row.href.as_str())>(row.title.as_str())</a>
485 for chip in row.labels.iter() {
486 <a class=(chip.class.as_str()) href=(chip.href.as_str())>(chip.name.as_str())</a>
487 }
488 </div>
489 <div class="issue-meta muted small">
490 <span>(row.number_text.as_str())</span>
491 if lang == Lang::Tr {
492 <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span>
493 <span>(g::t(lang, K::By))</span>
494 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
495 <span>(g::t(lang, K::Opened))</span>
496 } else {
497 <span>(g::t(lang, K::Opened))</span>
498 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
499 <span>(g::t(lang, K::By))</span>
500 <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span>
501 }
502 if let Some(milestone) = row.milestone.clone() {
503 <a href=(milestone.href.as_str())>(milestone.title.as_str())</a>
504 }
505 </div>
506 </div>
507 <div class="issue-aside">
508 for assignee in row.assignees.iter() {
509 <img class="avatar avatar-sm" src=(assignee.avatar.as_str()) alt=(assignee.name.as_str()) title=(assignee.name.as_str()) width="20" height="20">
510 }
511 if row.has_comments {
512 <a class="small muted" href=(row.href.as_str())>(row.comments_text.as_str())</a>
513 }
514 </div>
515 </li>
516 }
517 </ul>
518 }
519 <nav class="pager">
520 if has_newer {
521 <a href=(newer_href.as_str())>(g::t(lang, K::Newer))</a>
522 }
523 <span class="muted small">(page_label.as_str())</span>
524 if has_older {
525 <a href=(older_href.as_str())>(g::t(lang, K::Older))</a>
526 }
527 </nav>
528 )
529 })
530}
531
532/// The values the new-issue form shows: what was typed, and the message when validation failed.
533#[derive(Default)]
534struct DraftIssue {
535 title: String,
536 body: String,
537 error: Option<String>,
538}
539
540/// The query of the new-issue page. `template` is a template's stem. `blank` opens an empty
541/// form. Without either, a repo with several templates shows the chooser.
542#[query_params(error = bad_request)]
543struct NewIssueQuery {
544 template: Option<String>,
545 blank: Option<String>,
546}
547
548/// One row of the template chooser.
549struct ChooserRow {
550 href: String,
551 name: String,
552 about: String,
553}
554
555/// One label checkbox on the new-issue form. Only viewers who may triage get them.
556struct LabelOption {
557 id: String,
558 name: String,
559 checked: bool,
560}
561
562/// Checkboxes for the repo's labels, checked where `checked` says so.
563fn label_options(
564 labels: &[forge_core::Label],
565 checked: impl Fn(&forge_core::Label) -> bool,
566) -> Vec<LabelOption> {
567 labels
568 .iter()
569 .map(|label| LabelOption {
570 id: label.id.clone(),
571 name: label.name.clone(),
572 checked: checked(label),
573 })
574 .collect()
575}
576
577/// Whether the template suggests this label. Names compare without case.
578fn suggested(template: Option<&Template>, label: &forge_core::Label) -> bool {
579 template.is_some_and(|t| {
580 t.labels
581 .iter()
582 .any(|name| name.eq_ignore_ascii_case(&label.name))
583 })
584}
585
586/// Validates a title and body. `Ok(Err(message))` is a rejection the form can show. The title is
587/// returned trimmed.
588fn check_draft(title: &str, body: &str) -> forge_core::Result<std::result::Result<String, String>> {
589 let title = match validate_title(title) {
590 Ok(title) => title,
591 Err(e) => return user_facing_message(&e).map(Err).ok_or(e),
592 };
593 match validate_body(body) {
594 Ok(()) => Ok(Ok(title)),
595 Err(e) => user_facing_message(&e).map(Err).ok_or(e),
596 }
597}
598
599/// The body of the new-issue page: the repo header with Issues active, then either the template
600/// chooser (`chooser` is `Some`) or the form.
601#[component]
602async fn new_issue_body(
603 lang: Lang,
604 head: RepoHead,
605 csrf: String,
606 draft: DraftIssue,
607 labels: Vec<LabelOption>,
608 chooser: Option<Vec<ChooserRow>>,
609) -> Result<impl View> {
610 let action = repo_href(&head.owner, &head.name, "issues/new", "");
611 let cancel = repo_href(&head.owner, &head.name, "issues", "");
612 let blank = format!("{action}?blank=1");
613 let chooser_mode = chooser.is_some();
614 let rows = chooser.unwrap_or_default();
615 let has_labels = !labels.is_empty();
616 Ok(view! {
617 repo_head(head: head)
618 <section class="card issue-new">
619 <h2>(g::t(lang, K::NewIssue))</h2>
620 if chooser_mode {
621 <p class="muted">(g::t(lang, K::ChooseTemplate))</p>
622 <ul class="issue-list">
623 for row in rows.iter() {
624 <li class="issue-row">
625 <div class="issue-main">
626 <a class="issue-title" href=(row.href.as_str())>(row.name.as_str())</a>
627 if !row.about.is_empty() {
628 <div class="muted small">(row.about.as_str())</div>
629 }
630 </div>
631 </li>
632 }
633 </ul>
634 <div class="issue-actions">
635 <a href=(blank.as_str())>(g::t(lang, K::BlankIssue))</a>
636 <a href=(cancel.as_str())>(g::t(lang, K::Cancel))</a>
637 </div>
638 } else {
639 if let Some(message) = draft.error {
640 alert(message: message.as_str())
641 }
642 <form method="post" action=(action.as_str()) class="stack">
643 csrf_field(token: csrf.as_str())
644 <label>
645 <span>(g::t(lang, K::Title))</span>
646 <input type="text" name="title" value=(draft.title.as_str()) required=(true) autocomplete="off">
647 </label>
648 <label>
649 <span>(g::t(lang, K::Description))</span>
650 <textarea name="body" rows="12">(draft.body.as_str())</textarea>
651 <span class="muted small">(g::t(lang, K::MarkdownSupported))</span>
652 </label>
653 if has_labels {
654 <fieldset class="label-picker">
655 <legend>(g::t(lang, K::Labels))</legend>
656 for label in labels.iter() {
657 <label class="inline-form">
658 <input type="checkbox" name="label" value=(label.id.as_str()) checked=(label.checked)>
659 <span>(label.name.as_str())</span>
660 </label>
661 }
662 </fieldset>
663 }
664 <div class="issue-actions">
665 <button type="submit" class="primary">(g::t(lang, K::SubmitNewIssue))</button>
666 <a href=(cancel.as_str())>(g::t(lang, K::Cancel))</a>
667 </div>
668 </form>
669 }
670 </section>
671 })
672}
673
674#[page("/{owner}/{repo}/issues/new")]
675async fn new_issue_page(cx: &Cx) -> Result<impl View> {
676 let owner = path_param::<Owner>(cx);
677 let name = path_param::<Repo>(cx);
678 let query = query_params::<NewIssueQuery>(cx)?;
679 let ctx = load_repo(cx, owner, name).await?;
680 let viewer = require_user(cx).await?;
681 let lang = i18n::lang(cx, Some(&viewer.0));
682 if ctx.repo.archived {
683 return Err(forbidden().into());
684 }
685 let state = app_context::<AppState>(cx);
686 let header = ctx.head(state, "issues").await?;
687 let title = g::new_issue_title(lang, &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name));
688 let csrf = viewer.1.csrf_token.clone();
689 let found =
690 templates::load_issue_templates(ctx.path.clone(), ctx.repo.default_branch.clone()).await?;
691
692 let chooser = if found.len() > 1 && query.template.is_none() && query.blank.is_none() {
693 let base = repo_href(&ctx.repo.owner_name, &ctx.repo.name, "issues/new", "");
694 Some(
695 found
696 .iter()
697 .map(|t| ChooserRow {
698 href: format!("{base}?template={}", enc(&t.stem)),
699 name: t.name.clone(),
700 about: t.about.clone(),
701 })
702 .collect(),
703 )
704 } else {
705 None
706 };
707 let selected = match (query.blank.is_some(), query.template.as_deref()) {
708 (true, _) => None,
709 (false, Some(stem)) => found.iter().find(|t| t.stem == stem),
710 (false, None) => found.first().filter(|_| found.len() == 1),
711 };
712 let draft = selected
713 .map(|t| DraftIssue {
714 title: t.title.clone(),
715 body: t.body.clone(),
716 error: None,
717 })
718 .unwrap_or_default();
719 let labels = if can_triage(&ctx) {
720 let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
721 label_options(&repo_labels, |l| suggested(selected, l))
722 } else {
723 Vec::new()
724 };
725 Ok(view! {
726 shell(title: title.as_str(), viewer: Some(&viewer),
727 new_issue_body(lang: lang, head: header, csrf: csrf, draft: draft, labels: labels, chooser: chooser))
728 })
729}
730
731#[page(POST "/{owner}/{repo}/issues/new")]
732async fn new_issue_submit(cx: &Cx, RawForm(raw): RawForm) -> Result<impl View> {
733 let owner = path_param::<Owner>(cx);
734 let name = path_param::<Repo>(cx);
735 let ctx = load_repo(cx, owner, name).await?;
736 let fields = form_pairs(&raw[..]);
737 let viewer = require_csrf(cx, &form_field(&fields, "csrf")).await?;
738 let lang = i18n::lang(cx, Some(&viewer.0));
739 if ctx.repo.archived {
740 return Err(forbidden().into());
741 }
742 let state = app_context::<AppState>(cx);
743 let typed_title = form_field(&fields, "title");
744 let typed_body = form_field(&fields, "body");
745 let picked = form_all(&fields, "label");
746 match check_draft(&typed_title, &typed_body)? {
747 Ok(title) => {
748 let issue = state
749 .store
750 .create_issue(&ctx.repo.id, &viewer.0.id, &title, &typed_body)
751 .await?;
752 if can_triage(&ctx) && !picked.is_empty() {
753 let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
754 let ids: Vec<String> = repo_labels
755 .iter()
756 .filter(|l| picked.contains(&l.id))
757 .map(|l| l.id.clone())
758 .collect();
759 state
760 .store
761 .set_issue_labels(&issue.id, &viewer.0.id, &ids)
762 .await?;
763 }
764 emit_issue(state, &ctx.repo, &Sender::of(&viewer.0), "opened", &issue).await;
765 notify::dispatch(
766 state,
767 &ctx.repo,
768 &issue,
769 &viewer.0.id,
770 Activity::Opened,
771 Some(&issue.body),
772 )
773 .await;
774 let location = repo_href(
775 &ctx.repo.owner_name,
776 &ctx.repo.name,
777 "issues",
778 &issue.number.to_string(),
779 );
780 Err(see_other(location).into())
781 }
782 Err(message) => {
783 let header = ctx.head(state, "issues").await?;
784 let title =
785 g::new_issue_title(lang, &format!("{}/{}", ctx.repo.owner_name, ctx.repo.name));
786 let csrf = viewer.1.csrf_token.clone();
787 let draft = DraftIssue {
788 title: typed_title,
789 body: typed_body,
790 error: Some(message),
791 };
792 let labels = if can_triage(&ctx) {
793 let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
794 label_options(&repo_labels, |l| picked.contains(&l.id))
795 } else {
796 Vec::new()
797 };
798 Ok(view! {
799 (StatusCode::UNPROCESSABLE_ENTITY)
800 shell(title: title.as_str(), viewer: Some(&viewer),
801 new_issue_body(lang: lang, head: header, csrf: csrf, draft: draft, labels: labels, chooser: None))
802 })
803 }
804 }
805}
806
807/// Shared test fixture for the issue and label pages: a store with one owner, one repo and a
808/// signed-in session for the owner. The pages need no git data, so no repository is created.
809#[cfg(test)]
810pub(crate) mod test_util {
811 use forge_core::{Repo, Store, User, Visibility};
812 use http::HeaderMap;
813 use topcoat::router::request::Request;
814 use topcoat::router::{Body, Router, to_bytes};
815
816 use crate::auth::SESSION_COOKIE;
817
818 pub const OWNER: &str = "alice";
819 pub const REPO: &str = "demo";
820 pub const TOKEN: &str = "issues-test-session";
821 pub const CSRF: &str = "issues-test-csrf";
822
823 pub struct Fixture {
824 pub router: Router,
825 pub store: Store,
826 pub owner: User,
827 pub repo: Repo,
828 _dir: tempfile::TempDir,
829 }
830
831 pub async fn fixture(visibility: Visibility) -> Fixture {
832 let dir = tempfile::tempdir().expect("tempdir");
833 let path = dir.path().to_path_buf();
834 let state = crate::test_support::test_state(&path).await;
835 let store = state.store.clone();
836 let router = crate::test_support::app_router(state);
837 let owner = store
838 .create_user(OWNER, None, "not-a-real-hash", false)
839 .await
840 .unwrap();
841 let repo = store
842 .create_repo(&owner.id, REPO, "", visibility, "main")
843 .await
844 .unwrap();
845 crate::test_support::create_session(&store, &owner.id, TOKEN, CSRF).await;
846 Fixture {
847 router,
848 store,
849 owner,
850 repo,
851 _dir: dir,
852 }
853 }
854
855 /// One response: status, headers and body text.
856 pub struct Reply {
857 pub status: u16,
858 pub headers: HeaderMap,
859 pub body: String,
860 }
861
862 impl Reply {
863 pub fn header(&self, name: &str) -> Option<String> {
864 self.headers
865 .get(name)
866 .and_then(|v| v.to_str().ok())
867 .map(str::to_owned)
868 }
869
870 pub fn text(&self) -> String {
871 self.body.clone()
872 }
873 }
874
875 /// Sends one request through the router. `signed_in` adds the owner's session cookie, and
876 /// `form` is sent as a urlencoded body.
877 pub async fn send(
878 router: &Router,
879 method: &str,
880 uri: &str,
881 signed_in: bool,
882 form: Option<&str>,
883 headers: &[(&str, &str)],
884 ) -> Reply {
885 let mut builder = Request::builder().method(method).uri(uri);
886 if signed_in {
887 builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={TOKEN}"));
888 }
889 for (name, value) in headers {
890 builder = builder.header(*name, *value);
891 }
892 let body = match form {
893 Some(form) => {
894 builder = builder.header("content-type", "application/x-www-form-urlencoded");
895 form.as_bytes().to_vec()
896 }
897 None => Vec::new(),
898 };
899 let res = router.handle(builder.body(Body::from(body)).unwrap()).await;
900 let status = res.status().as_u16();
901 let headers = res.headers().clone();
902 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
903 Reply {
904 status,
905 headers,
906 body: String::from_utf8_lossy(&bytes).into_owned(),
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use forge_core::{IssueState, StateReason, Visibility};
914
915 use super::test_util::{OWNER, REPO, fixture, send};
916
917 fn base() -> String {
918 format!("/{OWNER}/{REPO}/issues")
919 }
920
921 /// Number of issue rows in a page.
922 fn rows(html: &str) -> usize {
923 html.matches("class=\"issue-row\"").count()
924 }
925
926 #[tokio::test]
927 async fn toolbar_links_to_labels_and_milestones() {
928 let fx = fixture(Visibility::Public).await;
929 let labels = format!("href=\"/{OWNER}/{REPO}/labels\"");
930 let milestones = format!("href=\"/{OWNER}/{REPO}/milestones\"");
931 for signed_in in [true, false] {
932 let html = send(&fx.router, "GET", &base(), signed_in, None, &[])
933 .await
934 .text();
935 assert!(html.contains(&labels), "signed_in={signed_in}");
936 assert!(html.contains(&milestones), "signed_in={signed_in}");
937 }
938 }
939
940 #[tokio::test]
941 async fn empty_list_says_so() {
942 let fx = fixture(Visibility::Public).await;
943 let reply = send(&fx.router, "GET", &base(), true, None, &[]).await;
944 assert_eq!(reply.status, 200);
945 assert!(reply.text().contains("No issues match these filters."));
946 assert_eq!(rows(&reply.text()), 0);
947 }
948
949 #[tokio::test]
950 async fn newest_first_and_post_creates_an_issue() {
951 let fx = fixture(Visibility::Public).await;
952 let reply = send(
953 &fx.router,
954 "POST",
955 &format!("{}/new", base()),
956 true,
957 Some("csrf=issues-test-csrf&title=+first+issue+&body=hello+**there**"),
958 &[],
959 )
960 .await;
961 assert_eq!(reply.status, 303);
962 assert_eq!(
963 reply.header("location").as_deref(),
964 Some(format!("{}/1", base()).as_str())
965 );
966 let repo_id = fx.repo.id.clone();
967 fx.store
968 .create_issue(&repo_id, &fx.owner.id, "second issue", "")
969 .await
970 .unwrap();
971 fx.store
972 .create_issue(&repo_id, &fx.owner.id, "third issue", "")
973 .await
974 .unwrap();
975
976 let html = send(&fx.router, "GET", &base(), true, None, &[])
977 .await
978 .text();
979 assert_eq!(rows(&html), 3);
980 let third = html.find("third issue").unwrap();
981 let second = html.find("second issue").unwrap();
982 let first = html.find("first issue").unwrap();
983 assert!(third < second && second < first, "{html}");
984 }
985
986 #[tokio::test]
987 async fn pagination_with_thirty_issues() {
988 let fx = fixture(Visibility::Public).await;
989 let repo_id = fx.repo.id.clone();
990 for n in 1..=30 {
991 fx.store
992 .create_issue(&repo_id, &fx.owner.id, &format!("bulk {n:02}"), "")
993 .await
994 .unwrap();
995 }
996 let page1 = send(&fx.router, "GET", &base(), true, None, &[])
997 .await
998 .text();
999 assert_eq!(rows(&page1), 25);
1000 assert!(page1.contains(">Older<"));
1001 assert!(!page1.contains(">Newer<"));
1002
1003 let page2 = send(
1004 &fx.router,
1005 "GET",
1006 &format!("{}?page=2", base()),
1007 true,
1008 None,
1009 &[],
1010 )
1011 .await
1012 .text();
1013 assert_eq!(rows(&page2), 5);
1014 assert!(page2.contains(">Newer<"));
1015 assert!(!page2.contains(">Older<"));
1016 assert!(page2.contains("bulk 01"));
1017 }
1018
1019 #[tokio::test]
1020 async fn state_filter_and_counts() {
1021 let fx = fixture(Visibility::Public).await;
1022 let repo_id = fx.repo.id.clone();
1023 let open = fx
1024 .store
1025 .create_issue(&repo_id, &fx.owner.id, "still open", "")
1026 .await
1027 .unwrap();
1028 let done = fx
1029 .store
1030 .create_issue(&repo_id, &fx.owner.id, "already done", "")
1031 .await
1032 .unwrap();
1033 fx.store
1034 .set_issue_state(
1035 &done.id,
1036 Some(fx.owner.id.as_str()),
1037 IssueState::Closed,
1038 Some(StateReason::Completed),
1039 serde_json::json!({}),
1040 )
1041 .await
1042 .unwrap();
1043 assert_eq!(open.number, 1);
1044
1045 let default = send(&fx.router, "GET", &base(), true, None, &[])
1046 .await
1047 .text();
1048 assert!(default.contains("still open"));
1049 assert!(!default.contains("already done"));
1050 assert!(default.contains("Closed 1"), "{default}");
1051
1052 let closed = send(
1053 &fx.router,
1054 "GET",
1055 &format!("{}?state=closed", base()),
1056 true,
1057 None,
1058 &[],
1059 )
1060 .await
1061 .text();
1062 assert!(closed.contains("already done"));
1063 assert!(!closed.contains("still open"));
1064
1065 let all = send(
1066 &fx.router,
1067 "GET",
1068 &format!("{}?state=all", base()),
1069 true,
1070 None,
1071 &[],
1072 )
1073 .await
1074 .text();
1075 assert!(all.contains("already done") && all.contains("still open"));
1076
1077 let bogus = send(
1078 &fx.router,
1079 "GET",
1080 &format!("{}?state=bogus", base()),
1081 true,
1082 None,
1083 &[],
1084 )
1085 .await;
1086 assert_eq!(bogus.status, 200);
1087 assert!(bogus.text().contains("still open"));
1088 }
1089
1090 #[tokio::test]
1091 async fn label_filter_uses_store_labels() {
1092 let fx = fixture(Visibility::Public).await;
1093 let repo_id = fx.repo.id.clone();
1094 let bug = fx
1095 .store
1096 .create_label(&repo_id, "bug", "#d73a4a", "")
1097 .await
1098 .unwrap();
1099 let tagged = fx
1100 .store
1101 .create_issue(&repo_id, &fx.owner.id, "tagged issue", "")
1102 .await
1103 .unwrap();
1104 fx.store
1105 .create_issue(&repo_id, &fx.owner.id, "plain issue", "")
1106 .await
1107 .unwrap();
1108 fx.store
1109 .set_issue_labels(&tagged.id, &fx.owner.id, std::slice::from_ref(&bug.id))
1110 .await
1111 .unwrap();
1112
1113 let filtered = send(
1114 &fx.router,
1115 "GET",
1116 &format!("{}?label=bug", base()),
1117 true,
1118 None,
1119 &[],
1120 )
1121 .await
1122 .text();
1123 assert!(filtered.contains("tagged issue"));
1124 assert!(!filtered.contains("plain issue"));
1125 assert!(filtered.contains(&format!("label-chip label-{}", bug.id)));
1126 assert!(filtered.contains(&format!("/{OWNER}/{REPO}/labels.css")));
1127 }
1128
1129 #[tokio::test]
1130 async fn search_treats_percent_as_a_literal() {
1131 let fx = fixture(Visibility::Public).await;
1132 let repo_id = fx.repo.id.clone();
1133 for title in ["100% done", "100 done", "other thing"] {
1134 fx.store
1135 .create_issue(&repo_id, &fx.owner.id, title, "")
1136 .await
1137 .unwrap();
1138 }
1139 let html = send(
1140 &fx.router,
1141 "GET",
1142 &format!("{}?q=%25", base()),
1143 true,
1144 None,
1145 &[],
1146 )
1147 .await
1148 .text();
1149 assert!(html.contains(">100% done</a>"), "{html}");
1150 assert!(!html.contains(">100 done</a>"), "{html}");
1151 assert!(!html.contains(">other thing</a>"), "{html}");
1152
1153 let raw = send(
1154 &fx.router,
1155 "GET",
1156 &format!("{}?q=100%", base()),
1157 true,
1158 None,
1159 &[],
1160 )
1161 .await;
1162 assert_eq!(raw.status, 200);
1163 }
1164
1165 #[tokio::test]
1166 async fn anonymous_reads_public_but_cannot_create() {
1167 let fx = fixture(Visibility::Public).await;
1168 let repo_id = fx.repo.id.clone();
1169 fx.store
1170 .create_issue(&repo_id, &fx.owner.id, "public issue", "")
1171 .await
1172 .unwrap();
1173 let list = send(&fx.router, "GET", &base(), false, None, &[]).await;
1174 assert_eq!(list.status, 200);
1175 assert!(list.text().contains("public issue"));
1176 assert!(!list.text().contains("New issue</a>"));
1177
1178 let form = send(
1179 &fx.router,
1180 "GET",
1181 &format!("{}/new", base()),
1182 false,
1183 None,
1184 &[],
1185 )
1186 .await;
1187 assert_eq!(form.status, 303);
1188 assert!(form.header("location").unwrap().starts_with("/login"));
1189
1190 let post = send(
1191 &fx.router,
1192 "POST",
1193 &format!("{}/new", base()),
1194 false,
1195 Some("csrf=&title=sneaky"),
1196 &[],
1197 )
1198 .await;
1199 assert_eq!(post.status, 303);
1200 assert!(post.header("location").unwrap().starts_with("/login"));
1201 let (total, _) = fx.store.issue_counts(&repo_id).await.unwrap();
1202 assert_eq!(total, 1);
1203 }
1204
1205 #[tokio::test]
1206 async fn private_repo_is_404_for_anonymous() {
1207 let fx = fixture(Visibility::Private).await;
1208 let list = send(&fx.router, "GET", &base(), false, None, &[]).await;
1209 assert_eq!(list.status, 404);
1210 let owner = send(&fx.router, "GET", &base(), true, None, &[]).await;
1211 assert_eq!(owner.status, 200);
1212 }
1213
1214 #[tokio::test]
1215 async fn invalid_query_values_fall_back_to_defaults() {
1216 let fx = fixture(Visibility::Public).await;
1217 for query in [
1218 "page=abc",
1219 "page=0",
1220 "page=99999",
1221 "sort=sideways&state=&page=-1",
1222 ] {
1223 let reply = send(
1224 &fx.router,
1225 "GET",
1226 &format!("{}?{query}", base()),
1227 true,
1228 None,
1229 &[],
1230 )
1231 .await;
1232 assert_eq!(reply.status, 200, "{query}");
1233 assert!(reply.text().contains("Page 1"), "{query}");
1234 }
1235 }
1236
1237 #[tokio::test]
1238 async fn new_issue_route_wins_over_issue_number() {
1239 let fx = fixture(Visibility::Public).await;
1240 let reply = send(
1241 &fx.router,
1242 "GET",
1243 &format!("{}/new", base()),
1244 true,
1245 None,
1246 &[],
1247 )
1248 .await;
1249 assert_eq!(reply.status, 200);
1250 assert!(reply.text().contains(&format!("action=\"{}/new\"", base())));
1251 }
1252
1253 #[tokio::test]
1254 async fn invalid_draft_keeps_values_and_creates_nothing() {
1255 let fx = fixture(Visibility::Public).await;
1256 let reply = send(
1257 &fx.router,
1258 "POST",
1259 &format!("{}/new", base()),
1260 true,
1261 Some("csrf=issues-test-csrf&title=+++&body=kept+body"),
1262 &[],
1263 )
1264 .await;
1265 assert_eq!(reply.status, 422);
1266 assert!(reply.text().contains("kept body"));
1267 let (total, _) = fx.store.issue_counts(&fx.repo.id).await.unwrap();
1268 assert_eq!(total, 0);
1269 }
1270
1271 #[tokio::test]
1272 async fn bad_csrf_is_403_and_archived_repo_is_403() {
1273 let fx = fixture(Visibility::Public).await;
1274 let bad = send(
1275 &fx.router,
1276 "POST",
1277 &format!("{}/new", base()),
1278 true,
1279 Some("csrf=wrong&title=hi"),
1280 &[],
1281 )
1282 .await;
1283 assert_eq!(bad.status, 403);
1284
1285 fx.store.set_repo_archived(&fx.repo.id, true).await.unwrap();
1286 let archived = send(
1287 &fx.router,
1288 "POST",
1289 &format!("{}/new", base()),
1290 true,
1291 Some("csrf=issues-test-csrf&title=hi"),
1292 &[],
1293 )
1294 .await;
1295 assert_eq!(archived.status, 403);
1296 let form = send(
1297 &fx.router,
1298 "GET",
1299 &format!("{}/new", base()),
1300 true,
1301 None,
1302 &[],
1303 )
1304 .await;
1305 assert_eq!(form.status, 403);
1306 }
1307
1308 #[tokio::test]
1309 async fn turkish_viewer_sees_turkish_list() {
1310 let fx = fixture(Visibility::Public).await;
1311 let reply = send(
1312 &fx.router,
1313 "GET",
1314 &base(),
1315 true,
1316 None,
1317 &[("accept-language", "tr")],
1318 )
1319 .await;
1320 assert_eq!(reply.status, 200);
1321 let html = reply.text();
1322 assert!(html.contains(">Yeni sorun</a>"), "{html}");
1323 assert!(html.contains("Bu filtrelerle eşleşen sorun yok."), "{html}");
1324 }
1325
1326 #[tokio::test]
1327 async fn turkish_viewer_sees_relative_times_in_turkish() {
1328 let fx = fixture(Visibility::Public).await;
1329 fx.store
1330 .create_issue(&fx.repo.id, &fx.owner.id, "fresh issue", "")
1331 .await
1332 .unwrap();
1333 let html = send(
1334 &fx.router,
1335 "GET",
1336 &base(),
1337 true,
1338 None,
1339 &[("accept-language", "tr")],
1340 )
1341 .await
1342 .text();
1343 assert!(html.contains("az önce"), "{html}");
1344 assert!(html.contains("açıldı"), "{html}");
1345 assert!(!html.contains("just now"), "{html}");
1346 }
1347}