Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +49 −5
| @@ -176,6 +176,31 @@ code, pre, .mono { | |||
| 176 | 176 | color: var(--fb-fg); | |
| 177 | 177 | white-space: nowrap; | |
| 178 | 178 | } | |
| 179 | + | /* Multi-label dropdown on the new-issue form. */ | |
| 180 | + | .label-dropdown { | |
| 181 | + | display: inline-block; | |
| 182 | + | position: relative; | |
| 183 | + | } | |
| 184 | + | .label-dropdown > summary { | |
| 185 | + | list-style: none; | |
| 186 | + | width: auto; | |
| 187 | + | cursor: pointer; | |
| 188 | + | } | |
| 189 | + | .label-dropdown > summary::-webkit-details-marker { | |
| 190 | + | display: none; | |
| 191 | + | } | |
| 192 | + | .label-menu { | |
| 193 | + | left: 0; | |
| 194 | + | right: auto; | |
| 195 | + | display: flex; | |
| 196 | + | flex-direction: column; | |
| 197 | + | gap: 0.35rem; | |
| 198 | + | max-height: 16rem; | |
| 199 | + | overflow-y: auto; | |
| 200 | + | } | |
| 201 | + | .label-menu .checkbox { | |
| 202 | + | margin: 0; | |
| 203 | + | } | |
| 179 | 204 | .menu-item:hover { | |
| 180 | 205 | background: var(--fb-bg-inset); | |
| 181 | 206 | text-decoration: none; | |
| @@ -742,16 +767,25 @@ body.drawer-open .drawer-backdrop { | |||
| 742 | 767 | /* Issue/PR dependency lists in the sidebar. */ | |
| 743 | 768 | .dep-list { | |
| 744 | 769 | list-style: none; | |
| 770 | + | padding: 0; | |
| 745 | 771 | display: flex; | |
| 746 | 772 | flex-direction: column; | |
| 747 | - | gap: 0.35rem; | |
| 748 | - | margin-bottom: 0.5rem; | |
| 773 | + | gap: 0.25rem; | |
| 774 | + | margin: 0 0 0.75rem; | |
| 749 | 775 | } | |
| 750 | 776 | .dep-row { | |
| 751 | 777 | display: flex; | |
| 752 | 778 | align-items: center; | |
| 753 | - | gap: 0.4rem; | |
| 779 | + | gap: 0.45rem; | |
| 754 | 780 | font-size: 0.9em; | |
| 781 | + | padding: 0.3rem 0.5rem; | |
| 782 | + | border-radius: var(--fb-radius); | |
| 783 | + | } | |
| 784 | + | .dep-row:hover { | |
| 785 | + | background: var(--fb-bg-inset); | |
| 786 | + | } | |
| 787 | + | .dep-row > .icon { | |
| 788 | + | flex: none; | |
| 755 | 789 | } | |
| 756 | 790 | .dep-row > a { | |
| 757 | 791 | flex: 1; | |
| @@ -761,14 +795,23 @@ body.drawer-open .drawer-backdrop { | |||
| 761 | 795 | text-overflow: ellipsis; | |
| 762 | 796 | white-space: nowrap; | |
| 763 | 797 | } | |
| 798 | + | .dep-row > a:hover { | |
| 799 | + | text-decoration: none; | |
| 800 | + | color: var(--fb-accent); | |
| 801 | + | } | |
| 764 | 802 | .dep-row .icon-btn { | |
| 765 | - | width: 1.5rem; | |
| 766 | - | height: 1.5rem; | |
| 803 | + | width: 1.4rem; | |
| 804 | + | height: 1.4rem; | |
| 767 | 805 | flex: none; | |
| 806 | + | color: var(--fb-fg-muted); | |
| 807 | + | } | |
| 808 | + | .dep-row .icon-btn:hover { | |
| 809 | + | color: var(--fb-danger); | |
| 768 | 810 | } | |
| 769 | 811 | .dep-add { | |
| 770 | 812 | display: flex; | |
| 771 | 813 | gap: 0.4rem; | |
| 814 | + | margin-top: 0.25rem; | |
| 772 | 815 | } | |
| 773 | 816 | .dep-add input { | |
| 774 | 817 | flex: 1; | |
| @@ -909,6 +952,7 @@ input[type="password"], | |||
| 909 | 952 | input[type="email"], | |
| 910 | 953 | input[type="search"], | |
| 911 | 954 | input[type="url"], | |
| 955 | + | input[type="number"], | |
| 912 | 956 | textarea, | |
| 913 | 957 | select { | |
| 914 | 958 | width: 100%; | |
assets/fabrica.js +6 −6
| @@ -56,12 +56,12 @@ | |||
| 56 | 56 | }); | |
| 57 | 57 | }); | |
| 58 | 58 | ||
| 59 | - | // ---- Auto-submitting selects ---- | |
| 60 | - | // A <select data-autosubmit> submits its form on change; without JS a noscript | |
| 61 | - | // Apply button does the same. | |
| 62 | - | document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) { | |
| 63 | - | sel.addEventListener("change", function () { | |
| 64 | - | if (sel.form) sel.form.submit(); | |
| 59 | + | // ---- Auto-submitting controls ---- | |
| 60 | + | // Any [data-autosubmit] control (select or checkbox) submits its form on | |
| 61 | + | // change; without JS a noscript button does the same. | |
| 62 | + | document.querySelectorAll("[data-autosubmit]").forEach(function (el) { | |
| 63 | + | el.addEventListener("change", function () { | |
| 64 | + | if (el.form) el.form.submit(); | |
| 65 | 65 | }); | |
| 66 | 66 | }); | |
| 67 | 67 | ||
crates/web/src/issues.rs +74 −32
| @@ -172,6 +172,7 @@ pub(crate) async fn new_form( | |||
| 172 | 172 | return Ok(Redirect::to("/login").into_response()); | |
| 173 | 173 | } | |
| 174 | 174 | let branches = crate::repo::branch_names(state, &ctx.repo.id).await; | |
| 175 | + | let repo_labels = state.store.list_labels(&ctx.repo.id).await?; | |
| 175 | 176 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 176 | 177 | let body = html! { | |
| 177 | 178 | (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) | |
| @@ -183,6 +184,10 @@ pub(crate) async fn new_form( | |||
| 183 | 184 | input type="text" id="title" name="title" required autofocus; | |
| 184 | 185 | label for="body" { "Description" } | |
| 185 | 186 | textarea id="body" name="body" rows="8" placeholder="Markdown supported" {} | |
| 187 | + | @if !repo_labels.is_empty() { | |
| 188 | + | label { "Labels" } | |
| 189 | + | (label_dropdown(&repo_labels)) | |
| 190 | + | } | |
| 186 | 191 | button class="btn btn-primary" type="submit" { "Create " (kind.noun) } | |
| 187 | 192 | } | |
| 188 | 193 | } | |
| @@ -250,17 +255,19 @@ pub(crate) async fn view( | |||
| 250 | 255 | @for (c, cauthor) in &comment_rows { | |
| 251 | 256 | (comment_card(cauthor, &c.body, c.created_at)) | |
| 252 | 257 | } | |
| 253 | - | @if can_write { | |
| 258 | + | @if can_write || is_author { | |
| 259 | + | @let comment_form = format!("comment-{}", issue.id); | |
| 254 | 260 | div class="card comment-form" { | |
| 255 | - | form method="post" action=(format!("/issue/{}/comment", issue.id)) { | |
| 261 | + | // The comment form holds only the textarea; the Comment | |
| 262 | + | // button and the (separate) state form are siblings in | |
| 263 | + | // the actions row — never nested forms. | |
| 264 | + | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { | |
| 256 | 265 | input type="hidden" name="_csrf" value=(csrf); | |
| 257 | 266 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | |
| 258 | - | div class="comment-actions" { | |
| 259 | - | @if can_write || is_author { | |
| 260 | - | (state_button(&issue, &csrf, kind)) | |
| 261 | - | } | |
| 262 | - | button class="btn btn-primary" type="submit" { "Comment" } | |
| 263 | - | } | |
| 267 | + | } | |
| 268 | + | div class="comment-actions" { | |
| 269 | + | (state_button(&issue, &csrf, kind)) | |
| 270 | + | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } | |
| 264 | 271 | } | |
| 265 | 272 | } | |
| 266 | 273 | } | |
| @@ -339,7 +346,7 @@ pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup { | |||
| 339 | 346 | form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" { | |
| 340 | 347 | input type="hidden" name="_csrf" value=(csrf); | |
| 341 | 348 | input type="hidden" name="state" value=(target); | |
| 342 | - | button class="btn" type="submit" { (label) } | |
| 349 | + | button class="btn inline-btn" type="submit" { (label) } | |
| 343 | 350 | } | |
| 344 | 351 | } | |
| 345 | 352 | } | |
| @@ -367,7 +374,7 @@ pub(crate) async fn assignee_form( | |||
| 367 | 374 | option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) } | |
| 368 | 375 | } | |
| 369 | 376 | } | |
| 370 | - | button class="btn" type="submit" { "Set" } | |
| 377 | + | button class="btn inline-btn" type="submit" { "Set" } | |
| 371 | 378 | } | |
| 372 | 379 | } | |
| 373 | 380 | } | |
| @@ -381,16 +388,38 @@ pub(crate) fn label_form( | |||
| 381 | 388 | ) -> Markup { | |
| 382 | 389 | let is_on = |id: &str| applied.iter().any(|l| l.id == id); | |
| 383 | 390 | html! { | |
| 391 | + | // Each checkbox auto-submits the whole set (the handler replaces the | |
| 392 | + | // applied labels); a noscript Apply button keeps it working without JS. | |
| 384 | 393 | form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" { | |
| 385 | 394 | input type="hidden" name="_csrf" value=(csrf); | |
| 386 | 395 | @for l in repo_labels { | |
| 387 | 396 | label class="checkbox" { | |
| 388 | - | input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)]; | |
| 397 | + | input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)] | |
| 398 | + | data-autosubmit; | |
| 389 | 399 | " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} | |
| 390 | 400 | " " (l.name) | |
| 391 | 401 | } | |
| 392 | 402 | } | |
| 393 | - | button class="btn" type="submit" { "Apply labels" } | |
| 403 | + | noscript { button class="btn inline-btn" type="submit" { "Apply labels" } } | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /// A `<details>` dropdown of label checkboxes, for selecting several labels when | |
| 409 | + | /// opening an issue. The checkboxes submit with the surrounding form. | |
| 410 | + | fn label_dropdown(repo_labels: &[Label]) -> Markup { | |
| 411 | + | html! { | |
| 412 | + | details class="menu label-dropdown" data-menu { | |
| 413 | + | summary class="btn" { "Select labels" (icon(Icon::ChevronDown)) } | |
| 414 | + | div class="menu-popover label-menu" { | |
| 415 | + | @for l in repo_labels { | |
| 416 | + | label class="checkbox" { | |
| 417 | + | input type="checkbox" name="label" value=(l.id); | |
| 418 | + | " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} | |
| 419 | + | " " (l.name) | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | } | |
| 394 | 423 | } | |
| 395 | 424 | } | |
| 396 | 425 | } | |
| @@ -418,7 +447,7 @@ pub(crate) fn deps_section( | |||
| 418 | 447 | form method="post" action=(format!("/issue/{}/deps/remove", issue.id)) class="dep-remove" { | |
| 419 | 448 | input type="hidden" name="_csrf" value=(csrf); | |
| 420 | 449 | input type="hidden" name="depends_on" value=(dep.id); | |
| 421 | - | button class="icon-btn" type="submit" aria-label="Remove dependency" { (icon(Icon::X)) } | |
| 450 | + | button class="icon-btn inline-btn" type="submit" aria-label="Remove dependency" { (icon(Icon::X)) } | |
| 422 | 451 | } | |
| 423 | 452 | } | |
| 424 | 453 | } | |
| @@ -436,7 +465,7 @@ pub(crate) fn deps_section( | |||
| 436 | 465 | form method="post" action=(format!("/issue/{}/deps/add", issue.id)) class="side-form dep-add" { | |
| 437 | 466 | input type="hidden" name="_csrf" value=(csrf); | |
| 438 | 467 | input type="number" name="number" min="1" placeholder="#123" aria-label="Depends on number" required; | |
| 439 | - | button class="btn" type="submit" { "Add" } | |
| 468 | + | button class="btn inline-btn" type="submit" { "Add" } | |
| 440 | 469 | } | |
| 441 | 470 | } | |
| 442 | 471 | @if !dependents.is_empty() { | |
| @@ -508,20 +537,6 @@ pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issu | |||
| 508 | 537 | format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number) | |
| 509 | 538 | } | |
| 510 | 539 | ||
| 511 | - | /// New-issue form fields + a query flag for `is_pull`. | |
| 512 | - | #[derive(Debug, Deserialize)] | |
| 513 | - | pub struct NewIssueForm { | |
| 514 | - | /// Title. | |
| 515 | - | #[serde(default)] | |
| 516 | - | title: String, | |
| 517 | - | /// Markdown body. | |
| 518 | - | #[serde(default)] | |
| 519 | - | body: String, | |
| 520 | - | /// CSRF token. | |
| 521 | - | #[serde(rename = "_csrf")] | |
| 522 | - | csrf: String, | |
| 523 | - | } | |
| 524 | - | ||
| 525 | 540 | /// `?is_pull=` query for the create endpoint. | |
| 526 | 541 | #[derive(Debug, Default, Deserialize)] | |
| 527 | 542 | pub struct CreateQuery { | |
| @@ -531,6 +546,9 @@ pub struct CreateQuery { | |||
| 531 | 546 | } | |
| 532 | 547 | ||
| 533 | 548 | /// `POST /issue-new/{repo_id}` — create an issue (PRs use their own flow). | |
| 549 | + | /// | |
| 550 | + | /// The body is parsed manually because it carries repeated `label` fields, which | |
| 551 | + | /// `serde_urlencoded` cannot represent. | |
| 534 | 552 | pub async fn create( | |
| 535 | 553 | State(state): State<AppState>, | |
| 536 | 554 | MaybeUser(viewer): MaybeUser, | |
| @@ -538,9 +556,20 @@ pub async fn create( | |||
| 538 | 556 | headers: HeaderMap, | |
| 539 | 557 | Path(repo_id): Path<String>, | |
| 540 | 558 | Query(q): Query<CreateQuery>, | |
| 541 | - | Form(form): Form<NewIssueForm>, | |
| 559 | + | body: axum::body::Bytes, | |
| 542 | 560 | ) -> Response { | |
| 543 | - | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 561 | + | let (mut title, mut desc, mut csrf) = (String::new(), String::new(), String::new()); | |
| 562 | + | let mut chosen: Vec<String> = Vec::new(); | |
| 563 | + | for (k, v) in url::form_urlencoded::parse(&body) { | |
| 564 | + | match k.as_ref() { | |
| 565 | + | "title" => title = v.into_owned(), | |
| 566 | + | "body" => desc = v.into_owned(), | |
| 567 | + | "_csrf" => csrf = v.into_owned(), | |
| 568 | + | "label" => chosen.push(v.into_owned()), | |
| 569 | + | _ => {} | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | if verify_csrf(&jar, &headers, Some(&csrf)).is_err() { | |
| 544 | 573 | return AppError::Forbidden.into_response(); | |
| 545 | 574 | } | |
| 546 | 575 | let result = async { | |
| @@ -557,14 +586,27 @@ pub async fn create( | |||
| 557 | 586 | if access_for(&state, Some(&user), &repo).await? < auth::Access::Read { | |
| 558 | 587 | return Err(AppError::NotFound); | |
| 559 | 588 | } | |
| 560 | - | let title = form.title.trim(); | |
| 589 | + | let title = title.trim(); | |
| 561 | 590 | if title.is_empty() { | |
| 562 | 591 | return Err(AppError::BadRequest("A title is required.".to_string())); | |
| 563 | 592 | } | |
| 564 | 593 | let issue = state | |
| 565 | 594 | .store | |
| 566 | - | .create_issue(&repo.id, &user.id, title, form.body.trim(), false) | |
| 595 | + | .create_issue(&repo.id, &user.id, title, desc.trim(), false) | |
| 567 | 596 | .await?; | |
| 597 | + | // Apply any selected labels that belong to this repo. | |
| 598 | + | if !chosen.is_empty() { | |
| 599 | + | let valid: std::collections::HashSet<String> = state | |
| 600 | + | .store | |
| 601 | + | .list_labels(&repo.id) | |
| 602 | + | .await? | |
| 603 | + | .into_iter() | |
| 604 | + | .map(|l| l.id) | |
| 605 | + | .collect(); | |
| 606 | + | for id in chosen.iter().filter(|id| valid.contains(*id)) { | |
| 607 | + | state.store.add_issue_label(&issue.id, id).await?; | |
| 608 | + | } | |
| 609 | + | } | |
| 568 | 610 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | |
| 569 | 611 | } | |
| 570 | 612 | .await; | |
crates/web/src/pulls.rs +7 −8
| @@ -154,17 +154,16 @@ pub(crate) async fn view( | |||
| 154 | 154 | (comment_card(cauthor, &c.body, c.created_at)) | |
| 155 | 155 | } | |
| 156 | 156 | (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) | |
| 157 | - | @if can_write { | |
| 157 | + | @if can_write || is_author { | |
| 158 | + | @let comment_form = format!("comment-{}", issue.id); | |
| 158 | 159 | div class="card comment-form" { | |
| 159 | - | form method="post" action=(format!("/issue/{}/comment", issue.id)) { | |
| 160 | + | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { | |
| 160 | 161 | input type="hidden" name="_csrf" value=(csrf); | |
| 161 | 162 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | |
| 162 | - | div class="comment-actions" { | |
| 163 | - | @if can_write || is_author { | |
| 164 | - | (state_button(&issue, &csrf, Kind::pulls())) | |
| 165 | - | } | |
| 166 | - | button class="btn btn-primary" type="submit" { "Comment" } | |
| 167 | - | } | |
| 163 | + | } | |
| 164 | + | div class="comment-actions" { | |
| 165 | + | (state_button(&issue, &csrf, Kind::pulls())) | |
| 166 | + | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } | |
| 168 | 167 | } | |
| 169 | 168 | } | |
| 170 | 169 | } | |
crates/web/src/tests.rs +73 −2
| @@ -697,14 +697,85 @@ async fn issues_open_create_and_view() { | |||
| 697 | 697 | // View it. | |
| 698 | 698 | let req = Request::builder() | |
| 699 | 699 | .uri("/ada/proj/-/issues/1") | |
| 700 | - | .header(header::COOKIE, cookie) | |
| 700 | + | .header(header::COOKIE, cookie.clone()) | |
| 701 | 701 | .body(Body::empty()) | |
| 702 | 702 | .unwrap(); | |
| 703 | - | let res = app.oneshot(req).await.unwrap(); | |
| 703 | + | let res = app.clone().oneshot(req).await.unwrap(); | |
| 704 | 704 | assert_eq!(res.status(), StatusCode::OK); | |
| 705 | 705 | let html = body_string(res).await; | |
| 706 | 706 | assert!(html.contains("First bug"), "issue title shown"); | |
| 707 | 707 | assert!(html.contains("#1"), "issue number shown"); | |
| 708 | + | ||
| 709 | + | // Commenting works (regression: nested state form used to duplicate _csrf). | |
| 710 | + | let issue = state | |
| 711 | + | .store | |
| 712 | + | .issue_by_number(&repo.id, 1, false) | |
| 713 | + | .await | |
| 714 | + | .unwrap() | |
| 715 | + | .unwrap(); | |
| 716 | + | let req = Request::builder() | |
| 717 | + | .method("POST") | |
| 718 | + | .uri(format!("/issue/{}/comment", issue.id)) | |
| 719 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 720 | + | .header(header::COOKIE, cookie) | |
| 721 | + | .body(Body::from(format!("body=a+comment&_csrf={token}"))) | |
| 722 | + | .unwrap(); | |
| 723 | + | let res = app.clone().oneshot(req).await.unwrap(); | |
| 724 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER, "comment accepted"); | |
| 725 | + | assert_eq!(state.store.list_comments(&issue.id).await.unwrap().len(), 1); | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | #[tokio::test] | |
| 729 | + | async fn new_issue_can_apply_labels() { | |
| 730 | + | let (state, app) = app().await; | |
| 731 | + | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |
| 732 | + | let repo = state | |
| 733 | + | .store | |
| 734 | + | .create_repo(NewRepo { | |
| 735 | + | owner_id: ada.id.clone(), | |
| 736 | + | group_id: None, | |
| 737 | + | name: "proj".to_string(), | |
| 738 | + | path: "proj".to_string(), | |
| 739 | + | description: None, | |
| 740 | + | visibility: model::Visibility::Public, | |
| 741 | + | default_branch: "main".to_string(), | |
| 742 | + | }) | |
| 743 | + | .await | |
| 744 | + | .unwrap(); | |
| 745 | + | let bug = state | |
| 746 | + | .store | |
| 747 | + | .create_label(&repo.id, "bug", "#ff0000", None) | |
| 748 | + | .await | |
| 749 | + | .unwrap(); | |
| 750 | + | ||
| 751 | + | let cookie = login(&app).await; | |
| 752 | + | let token = cookie | |
| 753 | + | .split("fabrica_csrf=") | |
| 754 | + | .nth(1) | |
| 755 | + | .and_then(|s| s.split(';').next()) | |
| 756 | + | .unwrap() | |
| 757 | + | .to_string(); | |
| 758 | + | let req = Request::builder() | |
| 759 | + | .method("POST") | |
| 760 | + | .uri(format!("/issue-new/{}", repo.id)) | |
| 761 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 762 | + | .header(header::COOKIE, cookie) | |
| 763 | + | .body(Body::from(format!( | |
| 764 | + | "title=Tagged&body=&label={}&_csrf={token}", | |
| 765 | + | bug.id | |
| 766 | + | ))) | |
| 767 | + | .unwrap(); | |
| 768 | + | let res = app.oneshot(req).await.unwrap(); | |
| 769 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER); | |
| 770 | + | let issue = state | |
| 771 | + | .store | |
| 772 | + | .issue_by_number(&repo.id, 1, false) | |
| 773 | + | .await | |
| 774 | + | .unwrap() | |
| 775 | + | .unwrap(); | |
| 776 | + | let labels = state.store.issue_labels(&issue.id).await.unwrap(); | |
| 777 | + | assert_eq!(labels.len(), 1, "label applied on create"); | |
| 778 | + | assert_eq!(labels[0].name, "bug"); | |
| 708 | 779 | } | |
| 709 | 780 | ||
| 710 | 781 | #[tokio::test] | |