forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.5 KiB
1//! Page: the pull request list at `/{owner}/{repo}/pulls`. Filters, sorting and paging work like
2//! the issue list (`issues_list.rs`), and the helpers come from there. Pull requests are issues
3//! with `kind = 'pull'`, so the store lists only those. Closed pull requests that were merged
4//! show a purple "Merged" badge instead of "Closed".
5
6use std::collections::HashMap;
7
8use forge_core::tz::format_in_tz;
9use forge_core::{IssueListItem, IssueState};
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::{page, path_param, query_params};
13use topcoat::view::{View, view};
14
15use crate::i18n::pulls::{self as g, K};
16use crate::i18n::{self, Lang};
17use crate::pages::issues_list::{
18 AssigneeView, LabelView, ListQuery, MilestoneView, Selection, SortOption, list_href,
19 stamp_unix, state_tab_class,
20};
21use crate::pages::repo::repo_head;
22use crate::pages::repo_ctx::load_repo;
23use crate::state::AppState;
24use crate::views::{avatar_url, iso_time, now_unix, relative_time, repo_href, shell, user_link};
25
26path_param!(owner);
27path_param!(repo);
28
29/// Pull requests per list page. Same as the issue list.
30const PER_PAGE: u32 = 25;
31
32/// One pull request row, with every string the template shows already built.
33struct PullRowView {
34 state_class: &'static str,
35 state_label: &'static str,
36 badge_class: &'static str,
37 badge_label: &'static str,
38 number_text: String,
39 href: String,
40 title: String,
41 labels: Vec<LabelView>,
42 author: String,
43 relative: String,
44 absolute: String,
45 iso: String,
46 milestone: Option<MilestoneView>,
47 assignees: Vec<AssigneeView>,
48 has_comments: bool,
49 comments_text: String,
50}
51
52/// The row's icon and badge: green for open, purple for merged, red for closed without a merge.
53fn state_parts(
54 lang: Lang,
55 open: bool,
56 merged: bool,
57) -> (&'static str, &'static str, &'static str, &'static str) {
58 if open {
59 (
60 "issue-state issue-state-open",
61 g::t(lang, K::PrStateOpenLabel),
62 "issue-badge issue-badge-open",
63 g::t(lang, K::StateOpen),
64 )
65 } else if merged {
66 (
67 "issue-state pull-state-merged",
68 g::t(lang, K::PrStateMergedLabel),
69 "issue-badge issue-badge-merged",
70 g::t(lang, K::StateMerged),
71 )
72 } else {
73 (
74 "issue-state pull-state-closed",
75 g::t(lang, K::PrStateClosedLabel),
76 "issue-badge issue-badge-closed",
77 g::t(lang, K::StateClosed),
78 )
79 }
80}
81
82/// The values every row of one list page shares.
83struct RowEnv<'a> {
84 lang: Lang,
85 /// The list URL (`/{owner}/{repo}/pulls`); each item links below it.
86 base: &'a str,
87 state: &'a str,
88 milestones: &'a HashMap<String, String>,
89 now: i64,
90 tz: &'a str,
91}
92
93/// One row, built from the page's shared values and the item's own `merged` flag.
94fn row_view(env: &RowEnv<'_>, item: &IssueListItem, merged: bool) -> PullRowView {
95 let RowEnv {
96 lang,
97 base,
98 state,
99 milestones,
100 now,
101 tz,
102 } = *env;
103 let issue = &item.issue;
104 let unix = stamp_unix(&issue.created_at);
105 let open = matches!(issue.state, IssueState::Open);
106 let comments = issue.comment_count.max(0);
107 let (state_class, state_label, badge_class, badge_label) = state_parts(lang, open, merged);
108 PullRowView {
109 state_class,
110 state_label,
111 badge_class,
112 badge_label,
113 number_text: format!("#{}", issue.number),
114 href: format!("{base}/{}", issue.number),
115 title: issue.title.clone(),
116 labels: item
117 .labels
118 .iter()
119 .map(|label| LabelView {
120 class: format!("label-chip label-{}", label.id),
121 name: label.name.clone(),
122 href: list_href(base, &[("state", state), ("label", label.name.as_str())]),
123 })
124 .collect(),
125 author: item.author.clone(),
126 relative: relative_time(env.lang, unix, now),
127 absolute: format_in_tz(unix, tz),
128 iso: iso_time(unix),
129 milestone: issue
130 .milestone_id
131 .as_ref()
132 .and_then(|id| milestones.get(id))
133 .map(|title| MilestoneView {
134 title: title.clone(),
135 href: list_href(base, &[("state", state), ("milestone", title.as_str())]),
136 }),
137 assignees: item
138 .assignees
139 .iter()
140 .map(|name| AssigneeView {
141 name: name.clone(),
142 avatar: avatar_url(name, None),
143 })
144 .collect(),
145 has_comments: comments > 0,
146 comments_text: g::comments(lang, comments),
147 }
148}
149
150#[page("/{owner}/{repo}/pulls")]
151async fn pulls_list_page(cx: &Cx) -> Result<impl View> {
152 let owner = path_param::<Owner>(cx);
153 let name = path_param::<Repo>(cx);
154 let query = query_params::<ListQuery>(cx)?;
155 let ctx = load_repo(cx, owner, name).await?;
156 let state = app_context::<AppState>(cx);
157 let sel = Selection::parse(query);
158 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
159
160 let (items, total) = state.store.list_pulls(&ctx.repo.id, &sel.filter()).await?;
161 let (open_count, closed_count) = state.store.pull_counts(&ctx.repo.id).await?;
162 let numbers: Vec<i64> = items.iter().map(|item| item.issue.number).collect();
163 let merged = state.store.merged_numbers(&ctx.repo.id, &numbers).await?;
164 let milestones: HashMap<String, String> = state
165 .store
166 .list_milestones(&ctx.repo.id, None)
167 .await?
168 .into_iter()
169 .map(|m| (m.id, m.title))
170 .collect();
171
172 let owner_name = ctx.repo.owner_name.clone();
173 let repo_name = ctx.repo.name.clone();
174 let base = repo_href(&owner_name, &repo_name, "pulls", "");
175 let now = now_unix();
176 let tz = ctx.viewer_tz().to_owned();
177 let env = RowEnv {
178 lang,
179 base: &base,
180 state: sel.state,
181 milestones: &milestones,
182 now,
183 tz: &tz,
184 };
185 let rows: Vec<PullRowView> = items
186 .iter()
187 .map(|item| row_view(&env, item, merged.contains(&item.issue.number)))
188 .collect();
189
190 let last_page = total.div_ceil(u64::from(PER_PAGE)).max(1);
191 let has_newer = sel.page > 1;
192 let has_older = u64::from(sel.page) < last_page;
193 let newer_href = sel.href(&base, sel.state, sel.page.saturating_sub(1));
194 let older_href = sel.href(&base, sel.state, sel.page + 1);
195 let page_label = g::page_number(lang, sel.page);
196
197 let open_href = sel.href(&base, "open", 1);
198 let closed_href = sel.href(&base, "closed", 1);
199 let all_href = sel.href(&base, "all", 1);
200 let open_text = g::tab_open(lang, open_count);
201 let closed_text = g::tab_closed(lang, closed_count);
202 let all_text = g::tab_all(lang, open_count + closed_count);
203 let clear_href = list_href(&base, &[("state", sel.state)]);
204 let has_filters = sel.has_filters();
205
206 let sort_options: Vec<SortOption> = [
207 ("newest", g::t(lang, K::SortNewest)),
208 ("oldest", g::t(lang, K::SortOldest)),
209 ("updated", g::t(lang, K::SortUpdated)),
210 ]
211 .into_iter()
212 .map(|(value, label)| SortOption {
213 value,
214 label,
215 selected: value == sel.sort,
216 })
217 .collect();
218
219 let can_create = ctx.viewer.is_some() && !ctx.repo.archived;
220 let compare_href = repo_href(&owner_name, &repo_name, "compare", "");
221 let labels_href = repo_href(&owner_name, &repo_name, "labels.css", "");
222 let title = g::list_title(lang, &owner_name, &repo_name);
223 let empty_text = if open_count + closed_count == 0 {
224 g::t(lang, K::NoPullRequestsYet)
225 } else {
226 g::t(lang, K::NoPullRequestsMatch)
227 };
228 let state_word = sel.state;
229
230 let header = ctx.head(state, "pulls").await?;
231 Ok(view! {
232 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), extra_stylesheet: Some(labels_href.as_str()),
233 repo_head(head: header)
234 <div class="issue-toolbar">
235 <nav class="issue-tabs" aria-label=(g::t(lang, K::ListStateNavLabel))>
236 <a class=(state_tab_class(state_word == "open")) href=(open_href.as_str())>(open_text.as_str())</a>
237 <a class=(state_tab_class(state_word == "closed")) href=(closed_href.as_str())>(closed_text.as_str())</a>
238 <a class=(state_tab_class(state_word == "all")) href=(all_href.as_str())>(all_text.as_str())</a>
239 </nav>
240 <div class="issue-tools">
241 if can_create {
242 <a class="button" href=(compare_href.as_str())>(g::t(lang, K::NewPullRequest))</a>
243 }
244 </div>
245 </div>
246 <form method="get" action=(base.as_str()) class="issue-search">
247 <input type="hidden" name="state" value=(sel.state)>
248 <input type="hidden" name="label" value=(sel.label.as_str())>
249 <input type="hidden" name="milestone" value=(sel.milestone.as_str())>
250 <input type="hidden" name="author" value=(sel.author.as_str())>
251 <input type="hidden" name="assignee" value=(sel.assignee.as_str())>
252 <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">
253 <select name="sort" aria-label=(g::t(lang, K::SortLabel))>
254 for option in sort_options.iter() {
255 <option value=(option.value) selected=(option.selected)>(option.label)</option>
256 }
257 </select>
258 <button type="submit">(g::t(lang, K::Search))</button>
259 </form>
260 if has_filters {
261 <p class="muted small">(g::t(lang, K::FiltersApplied)) <a href=(clear_href.as_str())>(g::t(lang, K::ClearFilters))</a></p>
262 }
263 if rows.is_empty() {
264 <p class="muted">(empty_text)</p>
265 } else {
266 <ul class="issue-list">
267 for row in rows.iter() {
268 <li class="issue-row">
269 <span class=(row.state_class) role="img" aria-label=(row.state_label)></span>
270 <div class="issue-main">
271 <div class="issue-title-line">
272 <a class="issue-title" href=(row.href.as_str())>(row.title.as_str())</a>
273 <span class=(row.badge_class)>(row.badge_label)</span>
274 for chip in row.labels.iter() {
275 <a class=(chip.class.as_str()) href=(chip.href.as_str())>(chip.name.as_str())</a>
276 }
277 </div>
278 <div class="issue-meta muted small">
279 <span>(row.number_text.as_str())</span>
280 if lang == Lang::Tr {
281 <span>(g::t(lang, K::MetaBeforeTime))</span>
282 <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span>
283 <span>(g::t(lang, K::MetaAfterTime))</span>
284 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
285 } else {
286 <span>(g::t(lang, K::MetaBeforeTime))</span>
287 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
288 <span>(g::t(lang, K::MetaAfterTime))</span>
289 <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span>
290 }
291 if let Some(milestone) = row.milestone.clone() {
292 <a href=(milestone.href.as_str())>(milestone.title.as_str())</a>
293 }
294 </div>
295 </div>
296 <div class="issue-aside">
297 for assignee in row.assignees.iter() {
298 <img class="avatar avatar-sm" src=(assignee.avatar.as_str()) alt=(assignee.name.as_str()) title=(assignee.name.as_str()) width="20" height="20">
299 }
300 if row.has_comments {
301 <a class="small muted" href=(row.href.as_str())>(row.comments_text.as_str())</a>
302 }
303 </div>
304 </li>
305 }
306 </ul>
307 }
308 <nav class="pager">
309 if has_newer {
310 <a href=(newer_href.as_str())>(g::t(lang, K::Newer))</a>
311 }
312 <span class="muted small">(page_label.as_str())</span>
313 if has_older {
314 <a href=(older_href.as_str())>(g::t(lang, K::Older))</a>
315 }
316 </nav>
317 )
318 })
319}
320
321#[cfg(test)]
322mod tests {
323 use forge_core::{IssueState, StateReason, Visibility};
324
325 use crate::pages::issues_list::test_util::{OWNER, REPO, fixture, send};
326
327 fn base() -> String {
328 format!("/{OWNER}/{REPO}/pulls")
329 }
330
331 fn rows(html: &str) -> usize {
332 html.matches("class=\"issue-row\"").count()
333 }
334
335 const HEAD_OID: &str = "1111111111111111111111111111111111111111";
336 const BASE_OID: &str = "2222222222222222222222222222222222222222";
337
338 #[tokio::test]
339 async fn empty_repo_says_no_pull_requests_yet() {
340 let fx = fixture(Visibility::Public).await;
341 let reply = send(&fx.router, "GET", &base(), true, None, &[]).await;
342 assert_eq!(reply.status, 200);
343 assert!(reply.text().contains("No pull requests yet"));
344 assert_eq!(rows(&reply.text()), 0);
345 }
346
347 #[tokio::test]
348 async fn list_shows_pulls_not_issues() {
349 let fx = fixture(Visibility::Public).await;
350 let repo_id = fx.repo.id.clone();
351 fx.store
352 .create_issue(&repo_id, &fx.owner.id, "plain issue title", "")
353 .await
354 .unwrap();
355 fx.store
356 .create_pull(
357 &repo_id,
358 &fx.owner.id,
359 "pull request title",
360 "",
361 "main",
362 &repo_id,
363 "feature",
364 HEAD_OID,
365 BASE_OID,
366 )
367 .await
368 .unwrap();
369
370 let html = send(&fx.router, "GET", &base(), true, None, &[])
371 .await
372 .text();
373 assert_eq!(rows(&html), 1, "{html}");
374 assert!(html.contains("pull request title"));
375 assert!(!html.contains("plain issue title"));
376 assert!(
377 html.contains(&format!("href=\"/{OWNER}/{REPO}/pulls/2\"")),
378 "{html}"
379 );
380 assert!(html.contains("issue-badge-open\">Open<"));
381 assert!(html.contains(&format!("href=\"/{OWNER}/{REPO}/compare\"")));
382 }
383
384 #[tokio::test]
385 async fn closed_filter_shows_merged_and_closed_badges() {
386 let fx = fixture(Visibility::Public).await;
387 let repo_id = fx.repo.id.clone();
388 let merged = fx
389 .store
390 .create_pull(
391 &repo_id,
392 &fx.owner.id,
393 "merged pull",
394 "",
395 "main",
396 &repo_id,
397 "merged-branch",
398 HEAD_OID,
399 BASE_OID,
400 )
401 .await
402 .unwrap();
403 fx.store
404 .mark_merged(&merged.issue.id, &fx.owner.id, BASE_OID)
405 .await
406 .unwrap();
407 let dropped = fx
408 .store
409 .create_pull(
410 &repo_id,
411 &fx.owner.id,
412 "dropped pull",
413 "",
414 "main",
415 &repo_id,
416 "dropped-branch",
417 HEAD_OID,
418 BASE_OID,
419 )
420 .await
421 .unwrap();
422 fx.store
423 .set_issue_state(
424 &dropped.issue.id,
425 Some(fx.owner.id.as_str()),
426 IssueState::Closed,
427 Some(StateReason::NotPlanned),
428 serde_json::json!({}),
429 )
430 .await
431 .unwrap();
432 fx.store
433 .create_pull(
434 &repo_id,
435 &fx.owner.id,
436 "still open pull",
437 "",
438 "main",
439 &repo_id,
440 "open-branch",
441 HEAD_OID,
442 BASE_OID,
443 )
444 .await
445 .unwrap();
446
447 let closed = send(
448 &fx.router,
449 "GET",
450 &format!("{}?state=closed", base()),
451 true,
452 None,
453 &[],
454 )
455 .await
456 .text();
457 assert!(closed.contains("merged pull"), "{closed}");
458 assert!(closed.contains("dropped pull"), "{closed}");
459 assert!(!closed.contains("still open pull"), "{closed}");
460 assert!(closed.contains("issue-badge-merged\">Merged<"), "{closed}");
461 assert!(closed.contains("issue-badge-closed\">Closed<"), "{closed}");
462 assert_eq!(closed.matches("issue-badge-merged\">Merged<").count(), 1);
463
464 let open = send(&fx.router, "GET", &base(), true, None, &[])
465 .await
466 .text();
467 assert!(open.contains("still open pull"));
468 assert!(!open.contains("merged pull"));
469 assert!(!open.contains("issue-badge-merged"));
470 }
471
472 #[tokio::test]
473 async fn private_repo_is_404_for_anonymous() {
474 let fx = fixture(Visibility::Private).await;
475 let anon = send(&fx.router, "GET", &base(), false, None, &[]).await;
476 assert_eq!(anon.status, 404);
477 let owner = send(&fx.router, "GET", &base(), true, None, &[]).await;
478 assert_eq!(owner.status, 200);
479 }
480
481 #[tokio::test]
482 async fn new_pull_request_button_only_for_signed_in_viewers() {
483 let fx = fixture(Visibility::Public).await;
484 let button = format!("href=\"/{OWNER}/{REPO}/compare\"");
485 let signed_in = send(&fx.router, "GET", &base(), true, None, &[])
486 .await
487 .text();
488 assert!(signed_in.contains("New pull request"));
489 assert!(signed_in.contains(&button));
490 let anon = send(&fx.router, "GET", &base(), false, None, &[])
491 .await
492 .text();
493 assert!(!anon.contains("New pull request"));
494 }
495
496 #[tokio::test]
497 async fn issues_list_still_excludes_pulls() {
498 let fx = fixture(Visibility::Public).await;
499 let repo_id = fx.repo.id.clone();
500 fx.store
501 .create_issue(&repo_id, &fx.owner.id, "plain issue title", "")
502 .await
503 .unwrap();
504 fx.store
505 .create_pull(
506 &repo_id,
507 &fx.owner.id,
508 "pull request title",
509 "",
510 "main",
511 &repo_id,
512 "feature",
513 HEAD_OID,
514 BASE_OID,
515 )
516 .await
517 .unwrap();
518
519 let html = send(
520 &fx.router,
521 "GET",
522 &format!("/{OWNER}/{REPO}/issues?state=all"),
523 true,
524 None,
525 &[],
526 )
527 .await
528 .text();
529 assert!(html.contains("plain issue title"));
530 assert!(!html.contains("pull request title"), "{html}");
531 }
532}