OXYGENHUB

Proposal page: centred timeline with avatars, inline editing, labels and projects a1b9869565aa

Asked for by Andy. The conversation is centred at the normal width (the
diff tabs stay wide) and is one timeline in time order: the description,
comments, reviews and events, each beside its author's avatar. The title,
target and description are edited inline from an Edit button, not the
sidebar; ready/draft/close/reopen sit under the comment box and show only
when they apply, which also removes the empty box a landed proposal had.
Labels are editable, or point at the labels page when there are none;
proposals and issues can be added to and taken off the owner's projects.
Every proposal action redirects back, so a reload never repeats it.

closes #53
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 16:52 UTC
Signature
✔ signed by Andy McCall
Hash
a1b9869565aad8c8b391208f64eba146178677c739c7374a95f0b2be8f44723d
Depends on
145306044896 18e381ea88c2 1f1eb06a9009 38833ed78707 78001759604c 9198231cee96 93be9ce4653e 9df855f29948 9e5ef6c23006 b64e2537d702 d23464c0ac60 de505df5cf8d e1ec9fbf538e fd958fbc06fb
Depended on by
0bc95135954d 33d98147511b 2a8b621676b4 b5a0a69b3622 ffb67a81188c 6ae3fa0af4f6 206c1fc1b06a 7d2eb590051f 9e50caedf4db 4f37afcc3583 8e96499f2705 cb71e18eb6f9 0b20f1ec4007
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
59 atoms, 0 tree operations, 11 files

Browse the tree at this change

modified crates/hub/src/web/issues.rs +4 −0
…
316 unchanged lines
317 317
    projects: Vec<(crate::db::projects::Project, String)>,
318 318
    /// Proposals whose description closes it.
319 319
    closed_by: Vec<super::chain::Link>,
320 +
    /// Projects the viewer may add it to.
321 +
    project_choices: Option<Vec<crate::db::projects::Project>>,
320 322
    notice: Option<String>,
321 323
    error: Option<String>,
322 324
}
…
51 unchanged lines
374 376
    let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage;
375 377
    let projects = super::projects::placements_for(app, viewer, &row, i.number).await;
376 378
    let closed_by = super::chain::proposals_closing(app, &row, i.number).await;
379 +
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
377 380
    Ok(Page(IssuePage {
378 381
        shell,
379 382
        repo: RepoShell::new(app, &row, "issues").with_role(role),
380 383
        i,
381 384
        closed_by,
385 +
        project_choices,
382 386
        body_html,
383 387
        timeline: timeline.into_iter().map(|(_, it)| it).collect(),
384 388
        comments,
…
625 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
141 unchanged lines
142 142
        .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state))
143 143
        .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land))
144 144
        .route("/{owner}/{name}/proposal/{number}/labels", post(proposals::set_labels))
145 +
        .route("/{owner}/{name}/proposal/{number}/project", post(projects::place_proposal))
146 +
        .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue))
145 147
        .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events))
146 148
        .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks))
147 149
        .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries))
…
454 unchanged lines
modified crates/hub/src/web/projects.rs +103 −0
…
456 unchanged lines
457 457
        assert_eq!(parse_reference("hub", ""), None);
458 458
    }
459 459
}
460 +
461 +
#[derive(Deserialize)]
462 +
pub struct PlaceForm {
463 +
    #[serde(rename = "_csrf")]
464 +
    csrf_token: String,
465 +
    /// The project's number among its owner's.
466 +
    project: i64,
467 +
    /// `add` or `remove`.
468 +
    action: String,
469 +
}
470 +
471 +
/// Put an issue or proposal on one of its repository owner's projects (in
472 +
/// the first column), or take it off, from its own page.
473 +
async fn place(
474 +
    app: Shared,
475 +
    viewer: Viewer,
476 +
    owner: String,
477 +
    name: String,
478 +
    kind: &'static str,
479 +
    number: i64,
480 +
    f: PlaceForm,
481 +
) -> Result<Response, Error> {
482 +
    let page = format!("/{owner}/{name}/{kind}/{number}");
483 +
    let shell = Shell::new(&app, &page, &viewer);
484 +
    csrf(&viewer, &f.csrf_token, &shell)?;
485 +
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
486 +
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
487 +
    if !r.edit {
488 +
        return Err(Error::not_found(shell));
489 +
    }
490 +
    let e = |e| Error::from_db(shell.clone(), e);
491 +
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
492 +
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
493 +
    let mut error = None;
494 +
    match f.action.as_str() {
495 +
        "add" => {
496 +
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
497 +
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
498 +
            if let Err(msg) =
499 +
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
500 +
                    .await
501 +
                    .map_err(e)?
502 +
            {
503 +
                error = Some(msg);
504 +
            }
505 +
        }
506 +
        "remove" => {
507 +
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
508 +
                if c.repo_id == Some(row.id) && c.number == Some(number) {
509 +
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
510 +
                }
511 +
            }
512 +
        }
513 +
        _ => return Err(Error::bad(shell, "add or remove")),
514 +
    }
515 +
    let to = match error {
516 +
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
517 +
        None => page,
518 +
    };
519 +
    Ok(Redirect::to(&to).into_response())
520 +
}
521 +
522 +
/// `POST /{owner}/{name}/proposal/{number}/project`.
523 +
pub async fn place_proposal(
524 +
    State(app): State<Shared>,
525 +
    viewer: Viewer,
526 +
    Path((owner, name, number)): Path<(String, String, i64)>,
527 +
    Form(f): Form<PlaceForm>,
528 +
) -> Result<Response, Error> {
529 +
    place(app, viewer, owner, name, "proposal", number, f).await
530 +
}
531 +
532 +
/// `POST /{owner}/{name}/issue/{number}/project`.
533 +
pub async fn place_issue(
534 +
    State(app): State<Shared>,
535 +
    viewer: Viewer,
536 +
    Path((owner, name, number)): Path<(String, String, i64)>,
537 +
    Form(f): Form<PlaceForm>,
538 +
) -> Result<Response, Error> {
539 +
    place(app, viewer, owner, name, "issue", number, f).await
540 +
}
541 +
542 +
/// The owner's open projects `viewer` may add an item to that it is not on
543 +
/// yet; `None` when the viewer may not change the owner's projects at all.
544 +
pub async fn project_choices(
545 +
    app: &Shared,
546 +
    viewer: &Viewer,
547 +
    repo: &db::repos::RepoRow,
548 +
    placed: &[(pdb::Project, String)],
549 +
) -> Option<Vec<pdb::Project>> {
550 +
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
551 +
    if !r.edit {
552 +
        return None;
553 +
    }
554 +
    Some(
555 +
        pdb::of_owner(&app.db, &repo.owner)
556 +
            .await
557 +
            .unwrap_or_default()
558 +
            .into_iter()
559 +
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
560 +
            .collect(),
561 +
    )
562 +
}
modified crates/hub/src/web/proposals.rs +68 −40
…
347 unchanged lines
348 348
    pub line: Option<usize>,
349 349
}
350 350
351 +
/// One entry of a proposal's conversation, in time order: what kind it
352 +
/// is and its index in `comments`, `reviews` or `events`.
353 +
pub struct Entry {
354 +
    pub kind: &'static str,
355 +
    pub index: usize,
356 +
}
357 +
351 358
pub struct ReviewView {
352 359
    pub row: pdb::ReviewRow,
353 360
    pub body_html: String,
…
194 unchanged lines
548 555
    p: pdb::ProposalRow,
549 556
    /// Issues it closes, from its description and its changes.
550 557
    closes: Vec<super::chain::Link>,
558 +
    /// Projects the viewer may add it to.
559 +
    project_choices: Option<Vec<db::projects::Project>>,
560 +
    /// Comments (not anchored to a line), reviews and events, oldest first.
561 +
    timeline: Vec<Entry>,
562 +
    /// The viewer may set labels (triage or above).
563 +
    can_triage: bool,
551 564
    body_html: String,
552 565
    tab: String,
553 566
    changes: Vec<ChangeView>,
…
36 unchanged lines
590 603
#[derive(Deserialize)]
591 604
pub struct DetailQuery {
592 605
    tab: Option<String>,
606 +
    /// What an action said, after it redirected here.
593 607
    #[serde(default)]
608 +
    notice: Option<String>,
609 +
    #[serde(default)]
610 +
    error: Option<String>,
611 +
    #[serde(default)]
594 612
    w: Option<String>,
595 613
}
596 614
…
74 unchanged lines
671 689
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
672 690
    let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect();
673 691
    let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await;
692 +
    let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await;
693 +
    let mut dated: Vec<(i64, Entry)> = Vec::new();
694 +
    for (index, c) in comments.iter().enumerate() {
695 +
        if c.row.anchor_change.is_empty() {
696 +
            dated.push((c.row.created_ms, Entry { kind: "comment", index }));
697 +
        }
698 +
    }
699 +
    for (index, r) in v.reviews.iter().enumerate() {
700 +
        dated.push((r.row.created_ms, Entry { kind: "review", index }));
701 +
    }
702 +
    for (index, e) in events.iter().enumerate() {
703 +
        dated.push((e.at_ms, Entry { kind: "event", index }));
704 +
    }
705 +
    dated.sort_by_key(|(at, _)| *at);
706 +
    let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect();
674 707
    Ok(Page(ProposalPage {
708 +
        can_triage: role >= Role::Triage,
709 +
        timeline,
675 710
        closes,
711 +
        project_choices,
676 712
        projects,
677 713
        shell,
678 714
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
…
34 unchanged lines
713 749
    }))
714 750
}
715 751
752 +
/// Back to the proposal after an action, with what to say: a redirect, so
753 +
/// reloading the page (the checks script does) never repeats the action.
754 +
fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option<String>, error: Option<String>) -> Response {
755 +
    let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}");
756 +
    if let Some(n) = notice {
757 +
        url.push_str(&format!("&notice={}", super::settings::urlencode(&n)));
758 +
    }
759 +
    if let Some(e) = error {
760 +
        url.push_str(&format!("&error={}", super::settings::urlencode(&e)));
761 +
    }
762 +
    Redirect::to(&url).into_response()
763 +
}
764 +
716 765
pub async fn show(
717 766
    State(app): State<Shared>,
718 767
    viewer: Viewer,
…
1 unchanged lines
720 769
    Query(q): Query<DetailQuery>,
721 770
) -> Result<Page<ProposalPage>, Error> {
722 771
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
723 -
    let mut page = detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await?;
772 +
    let mut page =
773 +
        detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?;
724 774
    if q.w.as_deref() == Some("1") {
725 775
        // Hide whitespace-only changes: the same hunks, rendered again.
726 776
        page.0.files =
…
21 unchanged lines
748 798
    viewer: Viewer,
749 799
    Path((owner, name, number)): Path<(String, String, i64)>,
750 800
    Form(f): Form<CommentForm>,
751 -
) -> Result<Page<ProposalPage>, Error> {
801 +
) -> Result<Response, Error> {
752 802
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
753 803
    let me = require(&viewer, &shell)?;
754 804
    csrf(&viewer, &f.csrf_token, &shell)?;
…
3 unchanged lines
758 808
        .map_err(|e| Error::from_db(shell.clone(), e))?
759 809
        .ok_or_else(|| Error::not_found(shell.clone()))?;
760 810
    if f.body.trim().is_empty() {
761 -
        return detail(
762 -
            &app,
763 -
            shell,
764 -
            &viewer,
765 -
            &owner,
766 -
            &name,
767 -
            number,
768 -
            "conversation".into(),
769 -
            None,
770 -
            Some("An empty comment says nothing.".into()),
771 -
        )
772 -
        .await;
811 +
        return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into())));
773 812
    }
774 813
    let anchor = match (f.path.trim(), f.line) {
775 814
        ("", _) | (_, None) => None,
…
14 unchanged lines
790 829
    )
791 830
    .await
792 831
    .map_err(|e| Error::from_db(shell.clone(), e))?;
793 -
    detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await
832 +
    Ok(back(&owner, &name, number, tab, None, None))
794 833
}
795 834
796 835
#[derive(Deserialize)]
…
10 unchanged lines
807 846
    viewer: Viewer,
808 847
    Path((owner, name, number)): Path<(String, String, i64)>,
809 848
    Form(f): Form<ReviewForm>,
810 -
) -> Result<Page<ProposalPage>, Error> {
849 +
) -> Result<Response, Error> {
811 850
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
812 851
    let me = require(&viewer, &shell)?;
813 852
    csrf(&viewer, &f.csrf_token, &shell)?;
…
6 unchanged lines
820 859
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
821 860
    }
822 861
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
823 -
        return detail(
824 -
            &app,
825 -
            shell,
826 -
            &viewer,
827 -
            &owner,
828 -
            &name,
829 -
            number,
830 -
            "conversation".into(),
831 -
            None,
832 -
            Some("You cannot approve your own proposal.".into()),
833 -
        )
834 -
        .await;
862 +
        return Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into())));
835 863
    }
836 864
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
837 865
        .await
…
8 unchanged lines
846 874
    } else {
847 875
        None
848 876
    };
849 -
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await
877 +
    Ok(back(&owner, &name, number, "conversation", notice, None))
850 878
}
851 879
852 880
#[derive(Deserialize)]
…
11 unchanged lines
864 892
    viewer: Viewer,
865 893
    Path((owner, name, number)): Path<(String, String, i64)>,
866 894
    Form(f): Form<EditForm>,
867 -
) -> Result<Page<ProposalPage>, Error> {
895 +
) -> Result<Response, Error> {
868 896
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
869 897
    let me = require(&viewer, &shell)?;
870 898
    csrf(&viewer, &f.csrf_token, &shell)?;
…
8 unchanged lines
879 907
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
880 908
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
881 909
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
882 -
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await
910 +
    Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None))
883 911
}
884 912
885 913
#[derive(Deserialize)]
…
8 unchanged lines
894 922
    viewer: Viewer,
895 923
    Path((owner, name, number)): Path<(String, String, i64)>,
896 924
    Form(f): Form<StateForm>,
897 -
) -> Result<Page<ProposalPage>, Error> {
925 +
) -> Result<Response, Error> {
898 926
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
899 927
    let me = require(&viewer, &shell)?;
900 928
    csrf(&viewer, &f.csrf_token, &shell)?;
…
15 unchanged lines
916 944
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
917 945
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
918 946
    crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await;
919 -
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
947 +
    Ok(back(&owner, &name, number, "conversation", None, None))
920 948
}
921 949
922 950
pub async fn resolve_comment(
…
1 unchanged lines
924 952
    viewer: Viewer,
925 953
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
926 954
    Form(f): Form<super::session::CsrfForm>,
927 -
) -> Result<Page<ProposalPage>, Error> {
955 +
) -> Result<Response, Error> {
928 956
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
929 957
    require(&viewer, &shell)?;
930 958
    csrf(&viewer, &f.csrf, &shell)?;
…
11 unchanged lines
942 970
    if let Some(r) = current {
943 971
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
944 972
    }
945 -
    detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await
973 +
    Ok(back(&owner, &name, number, "files", None, None))
946 974
}
947 975
948 976
/// Land from the hub: only when the repository has no trust policy (the
…
3 unchanged lines
952 980
    viewer: Viewer,
953 981
    Path((owner, name, number)): Path<(String, String, i64)>,
954 982
    Form(f): Form<super::session::CsrfForm>,
955 -
) -> Result<Page<ProposalPage>, Error> {
983 +
) -> Result<Response, Error> {
956 984
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
957 985
    let me = require(&viewer, &shell)?;
958 986
    csrf(&viewer, &f.csrf, &shell)?;
959 987
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
960 988
    match land_as(&app, &repo, &row, &me.account, number).await {
961 -
        Ok(_) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await,
989 +
        Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)),
962 990
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
963 -
        Err(Landing::Refused(m)) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(m)).await,
991 +
        Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))),
964 992
    }
965 993
}
966 994
…
109 unchanged lines
1076 1104
    viewer: Viewer,
1077 1105
    Path((owner, name, number)): Path<(String, String, i64)>,
1078 1106
    axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>,
1079 -
) -> Result<Page<ProposalPage>, Error> {
1107 +
) -> Result<Response, Error> {
1080 1108
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1081 1109
    require(&viewer, &shell)?;
1082 1110
    csrf(&viewer, &f.csrf_token, &shell)?;
…
8 unchanged lines
1091 1119
    for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1092 1120
        db::issues::set_proposal_label(&app.db, p.id, l.id, f.label.contains(&l.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1093 1121
    }
1094 -
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
1122 +
    Ok(back(&owner, &name, number, "conversation", None, None))
1095 1123
}
1096 1124
1097 1125
/// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for
…
91 unchanged lines
modified crates/hub/tests/api.rs +19 −0
…
1107 unchanged lines
1108 1108
    assert!(i.contains(">issue #1</small>"));
1109 1109
    let chain = &i[i.find("class=\"chain\"").expect("a chain on the issue")..];
1110 1110
    assert!(chain.contains("closed by") && chain.contains("proposal #3</span> Faster, safer"), "{chain}");
1111 +
    // The conversation is a timeline with avatars; editing sits with the
1112 +
    // content, not in the sidebar, and labels and projects are editable.
1113 +
    let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body;
1114 +
    assert!(p.contains("class=\"tl-avatar\" src=\"/avatar/"), "avatars");
1115 +
    assert!(p.contains("id=\"edit\"") && !p.contains("<h3>Edit</h3>"), "the editor is inline, not in the sidebar");
1116 +
    assert!(p.contains("no labels yet") && p.contains("/andy/demo/labels"), "a way to make labels");
1117 +
    let (st, _) = api(app, "POST", "/api/v1/projects/andy", Some("site"), Some(r#"{"title":"Board"}"#)).await;
1118 +
    assert!(st.is_success(), "{st}");
1119 +
    let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body;
1120 +
    assert!(p.contains("<option value=\"1\">Board</option>"), "the owner's projects to add to");
1121 +
    let csrf = csrf_of(&p);
1122 +
    call(app, "POST", "/andy/demo/proposal/3/project", None, Some(&andy), None, Some(&format!("_csrf={csrf}&project=1&action=add"))).await;
1123 +
    let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body;
1124 +
    assert!(p.contains("/projects/andy/1\">Board</a>") && p.contains("value=\"remove\""), "on the project, removable");
1125 +
    call(app, "POST", "/andy/demo/proposal/3/project", None, Some(&andy), None, Some(&format!("_csrf={csrf}&project=1&action=remove")))
1126 +
        .await;
1127 +
    let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body;
1128 +
    assert!(!p.contains("/projects/andy/1\">Board</a>"), "taken off again");
1129 +
1111 1130
    // Closed by the proposal's change, not its description: still shown.
1112 1131
    let two = call(app, "GET", "/andy/demo/issue/2", None, None, None, None).await.body;
1113 1132
    let chain = &two[two.find("class=\"chain\"").expect("a chain on issue 2")..];
…
2 unchanged lines
modified crates/hub/tests/edits.rs +7 −4
…
233 unchanged lines
234 234
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await;
235 235
    assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body);
236 236
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
237 -
    assert!(r.body.contains("Landed."), "{}", r.body);
237 +
    // The action redirects back (so a reload never repeats it), saying so.
238 +
    let back = r.location.clone().unwrap_or_default();
239 +
    assert!(back.starts_with("/andy/demo/proposal/2?") && back.contains("notice=Landed."), "{back}");
240 +
    assert!(send(app, "GET", &back, Some(&andy), None).await.body.contains("Landed."));
238 241
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "ONE\ntwo\nthree\n");
239 242
240 243
    // A conflict: two proposals edit the same line from the same base; landing both conflicts main.
…
19 unchanged lines
260 263
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
261 264
        let r =
262 265
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
263 -
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
266 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?}", r.location);
264 267
    }
265 268
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
266 269
    assert!(conflicts.contains("resolve region by region") && conflicts.contains("/andy/demo/resolve/main/a.txt"), "{conflicts}");
…
102 unchanged lines
369 372
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
370 373
        let r =
371 374
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
372 -
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
375 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
373 376
    }
374 377
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
375 378
    assert!(conflicts.contains("choose which to keep") && conflicts.contains("/andy/demo/resolve/main/new.txt"), "{conflicts}");
…
50 unchanged lines
426 429
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
427 430
        let r =
428 431
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
429 -
        assert!(r.body.contains("Landed."), "#{n}: {}", r.body);
432 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
430 433
    }
431 434
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
432 435
    assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}");
…
40 unchanged lines
modified crates/hub/tests/proposals.rs +19 −6
…
91 unchanged lines
92 92
    send(app, "POST", path, cookie, Some(form), None).await
93 93
}
94 94
95 +
/// POST a proposal action and follow its redirect back to the page, as a
96 +
/// browser does (actions redirect so a reload never repeats them).
97 +
async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
98 +
    let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
99 +
    if let Some(c) = cookie {
100 +
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
101 +
    }
102 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap();
103 +
    assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back");
104 +
    let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
105 +
    get(app, &to, cookie).await
106 +
}
107 +
95 108
async fn register(app: &Shared, user: &str) -> String {
96 109
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
97 110
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
…
65 unchanged lines
163 176
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await;
164 177
    assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body);
165 178
    let csrf_b = csrf_of(&files.body);
166 -
    let r = post(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await;
179 +
    let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await;
167 180
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
168 181
    assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body);
169 182
    let c = db::proposals::comments(&app.db, 1).await.unwrap();
…
14 unchanged lines
184 197
185 198
    // Bob cannot approve his own; Andy approves, which is pending until sealed.
186 199
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body);
187 -
    let r = post(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await;
200 +
    let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await;
188 201
    assert!(r.body.contains("cannot approve your own"), "{}", r.body);
189 -
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await;
202 +
    let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await;
190 203
    assert!(r.body.contains("seal pending"), "{}", r.body);
191 204
    assert!(r.body.contains("oxy seal --proposal"));
192 205
…
26 unchanged lines
219 232
220 233
    // Land from the hub (no policy): main moves to the projected heads.
221 234
    let csrf_a = csrf_of(&page.body);
222 -
    let r = post(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await;
235 +
    let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await;
223 236
    assert!(r.body.contains("Landed."), "{}", r.body);
224 237
    let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap();
225 238
    assert_eq!(heads, vec![feat]);
…
38 unchanged lines
264 277
    );
265 278
    // Closing and reopening.
266 279
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body);
267 -
    let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await;
280 +
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await;
268 281
    assert!(r.body.contains(">closed<"), "{}", r.body);
269 -
    let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
282 +
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
270 283
    assert!(r.body.contains(">open<"), "{}", r.body);
271 284
}
modified docs/spec.md +10 −0
…
375 unchanged lines
376 376
377 377
Per-repository numbered issues sharing the number space with proposals (as GitHub does; so every number is shown with its kind, "issue #1" or "proposal #2", in page headers and in the links `#N` becomes), with title, Markdown body, author, assignees, labels (name, colour, description), a milestone (title, due date, description, open/closed), state (`open`, `closed` with reason `completed` or `not planned`), comments, lock, and pin. Issue templates come from `.oxyhub/ISSUE_TEMPLATE/*.md` in the default state.
378 378
379 +
**The proposal page.** The conversation is centred at the normal page width; the changes and files tabs are wide for diffs.
380 +
- **Timeline:** the description, comments, reviews and events are one timeline in time order, each entry beside its author's avatar.
381 +
- **Editing:** the title, target and description are edited inline (an Edit button beside the title opens the editor below the timeline), not from the sidebar.
382 +
- **State actions:** ready for review, back to draft, close and reopen sit under the comment box, and only when they apply.
383 +
- **Sidebar:**
384 +
  - Landing, Closes, Labels and Projects.
385 +
  - Labels: those with triage edit them, or are pointed at the repository's labels page when it has none.
386 +
  - Projects: editors of the owner's projects add the proposal to one (its first column) or take it off. Issues have the same project control.
387 +
- **Redirects:** every action redirects back to the page, so reloading it (the checks script does, when a check arrives) never repeats the action.
388 +
379 389
**The chain of work.** A proposal shows the issues it closes (`closes`, `fixes` or `resolves #N` in its description or in any of its changes' messages) in a strip under its title: each issue with its state, then the proposal, then its target bookmark, marked landed once it is. A Closes list sits in its sidebar. An issue shows the proposals that close it, by the same rule, with their states. So the path from a report to the change that landed it can be followed from either end.
380 390
381 391
Relative links and images in issue, comment and proposal text resolve against the repository's default bookmark, and in release notes against the release's tag. Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark ("closed by change"). Any other `#12` in a pushed change's message, on any bookmark, puts "referenced by change <hash> on <bookmark>" on that issue's or proposal's timeline, the hash linking to the change; other repositories' `owner/name#12` are left alone.
…
255 unchanged lines
modified templates/issue.html +2 −1
…
55 unchanged lines
56 56
  {% if let Some(ms) = milestone %}<p><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="mute small">None.</p>{% endif %}
57 57
  {% if can_triage && !milestones.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/milestone"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="milestone"><option value="">none</option>{% for (ms, sel) in milestones %}<option value="{{ ms.id }}"{% if *sel %} selected{% endif %}>{{ ms.title }}</option>{% endfor %}</select> <button type="submit" class="small">SET</button></form>{% endif %}
58 58
  <h3>Projects</h3>
59 -
  {% if projects.is_empty() %}<p class="mute small">None. <a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% else %}<ul class="plain small">{% for (p, col) in projects %}<li><a href="/projects/{{ p.owner }}/{{ p.number }}">{{ p.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %}
59 +
  {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="tiny" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
60 +
  {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="small mute"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %}
60 61
  {% if can_triage %}
61 62
  <h3>Moderation</h3>
62 63
  <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
9 unchanged lines
modified templates/proposal.html +75 −40
…
2 unchanged lines
3 3
{% block title %}Proposal #{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %}
6 -
{% block mainclass %} class="wide"{% endblock %}
6 +
{% block mainclass %}{% if tab != "conversation" %} class="wide"{% endif %}{% endblock %}
7 7
{% block content %}
8 -
<div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small>
8 +
<div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small>{% if can_edit %} <a class="go small head-edit" href="#edit" onclick="document.getElementById('edit').open=true">Edit</a>{% endif %}
9 9
  <small>
10 10
    <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span>
11 11
    {{ p.author }} wants <span class="lbl lbl-bm">{{ source_label }}</span> into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>
…
62 unchanged lines
74 74
75 75
{% else %}
76 76
<div class="side-layout conversation">
77 -
<div>
78 -
  {% if !p.body.is_empty() %}<article class="doc">{{ body_html|safe }}</article>{% endif %}
79 -
  {% for c in comments %}{% if c.row.anchor_change.is_empty() %}
80 -
  <div class="comment"><span class="who">{{ c.row.author }}</span> <time class="mute" title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time><div class="body">{{ c.body_html|safe }}</div></div>
81 -
  {% endif %}{% endfor %}
82 -
  {% for r in reviews %}
83 -
  <div class="comment review-{{ r.row.verdict }}"><span class="who">{{ r.row.author }}</span> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}commented{% endif %} <time class="mute" title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time>{% if !r.row.body.is_empty() %}<div class="body">{{ r.body_html|safe }}</div>{% endif %}</div>
77 +
<div class="timeline">
78 +
  <div class="tl-item">
79 +
    <img class="tl-avatar" src="/avatar/{{ p.author }}" alt="" width="36" height="36">
80 +
    <div class="tl-card">
81 +
      <div class="tl-head"><a href="/{{ p.author }}"><strong>{{ p.author }}</strong></a> opened this proposal <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time>{% if can_edit %} <a class="tl-edit small" href="#edit">edit</a>{% endif %}</div>
82 +
      {% if p.body.is_empty() %}<p class="tl-empty mute">No description.</p>{% else %}<article class="doc tl-doc">{{ body_html|safe }}</article>{% endif %}
83 +
    </div>
84 +
  </div>
85 +
  {% for t in timeline %}
86 +
  {% if t.kind == "comment" %}{% let c = comments[t.index] %}
87 +
  <div class="tl-item">
88 +
    <img class="tl-avatar" src="/avatar/{{ c.row.author }}" alt="" width="36" height="36">
89 +
    <div class="tl-card">
90 +
      <div class="tl-head"><a href="/{{ c.row.author }}"><strong>{{ c.row.author }}</strong></a> commented <time title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time></div>
91 +
      <div class="tl-text">{{ c.body_html|safe }}</div>
92 +
    </div>
93 +
  </div>
94 +
  {% else if t.kind == "review" %}{% let r = reviews[t.index] %}
95 +
  <div class="tl-item review-{{ r.row.verdict }}">
96 +
    <img class="tl-avatar" src="/avatar/{{ r.row.author }}" alt="" width="36" height="36">
97 +
    <div class="tl-card">
98 +
      <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div>
99 +
      {% if !r.row.body.is_empty() %}<div class="tl-text">{{ r.body_html|safe }}</div>{% endif %}
100 +
    </div>
101 +
  </div>
102 +
  {% else %}{% let e = events[t.index] %}
103 +
  <p class="tl-event"><span class="tl-dot"></span><img class="tl-mini" src="/avatar/{{ e.actor }}" alt="" width="18" height="18"> <strong>{{ e.actor }}</strong> {{ e.event }}{% if e.event.ends_with("by change") && !e.detail.is_empty() %}{% let hash = e.detail.split(' ').next().unwrap_or("") %} <a href="{{ repo.url }}/change/{{ hash }}"><code>{{ hash }}</code></a>{{ e.detail[hash.len()..] }}{% else if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} <time class="mute" title="{{ e.at_ms|when_i }}">{{ e.at_ms|ago_i }}</time></p>
104 +
  {% endif %}
84 105
  {% endfor %}
85 -
  {% for e in events %}<p class="small mute">{{ e.actor }} {{ e.event }}{% if e.event.ends_with("by change") && !e.detail.is_empty() %}{% let hash = e.detail.split(' ').next().unwrap_or("") %} <a href="{{ repo.url }}/change/{{ hash }}"><code>{{ hash }}</code></a>{{ e.detail[hash.len()..] }}{% else if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} · {{ e.at_ms|ago_i }}</p>{% endfor %}
86 -
  {% if shell.user.is_some() %}
87 -
  <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment">
88 -
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
89 -
    <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="4" required></textarea>
90 -
    <button type="submit">COMMENT</button>
91 -
  </form>
106 +
  {% if let Some(me) = shell.user %}
107 +
  <div class="tl-item">
108 +
    <img class="tl-avatar" src="/avatar/{{ me }}" alt="" width="36" height="36">
109 +
    <div class="tl-card">
110 +
      <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment">
111 +
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
112 +
        <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="4" required></textarea>
113 +
        <div class="tl-actions"><button type="submit" class="primary-btn">Comment</button></div>
114 +
      </form>
115 +
      {% if can_edit && (p.state == "draft" || p.is_open() || p.state == "closed") %}
116 +
      <form class="tl-actions" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state">
117 +
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
118 +
        {% if p.state == "draft" %}<button type="submit" name="action" value="ready">Ready for review</button>{% endif %}
119 +
        {% if p.state == "open" %}<button type="submit" name="action" value="draft">Back to draft</button>{% endif %}
120 +
        {% if p.is_open() %}<button type="submit" name="action" value="close" class="danger-btn">Close proposal</button>{% endif %}
121 +
        {% if p.state == "closed" %}<button type="submit" name="action" value="reopen">Reopen</button>{% endif %}
122 +
      </form>
123 +
      {% endif %}
124 +
    </div>
125 +
  </div>
92 126
  {% if p.is_open() %}
93 -
  <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review">
127 +
  <form class="card tl-review" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review">
94 128
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
95 129
    <h2>Review</h2>
96 130
    <label for="rbody">Summary (optional)</label><textarea id="rbody" name="body" rows="3"></textarea>
…
7 unchanged lines
104 138
  </form>
105 139
  {% endif %}
106 140
  {% endif %}
141 +
  {% if can_edit %}
142 +
  <details class="panel tl-editbox" id="edit">
143 +
    <summary>Edit title, target and description</summary>
144 +
    <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit">
145 +
      <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
146 +
      <label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}">
147 +
      <label for="etarget">Target bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}">
148 +
      <label for="ebody">Description (Markdown)</label><textarea id="ebody" name="body" class="md" rows="8">{{ p.body }}</textarea>
149 +
      <div class="tl-actions"><button type="submit" class="primary-btn">Save</button></div>
150 +
    </form>
151 +
  </details>
152 +
  {% endif %}
107 153
</div>
108 154
<aside class="side">
109 155
  <h3>Landing</h3>
…
7 unchanged lines
117 163
    {% endif %}
118 164
  {% endif %}
119 165
  {% if !closes.is_empty() %}<h3>Closes</h3><ul class="plain">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="small mute">{{ c.state }}</span></li>{% endfor %}</ul>{% endif %}
120 -
  <h3>Labels</h3>
121 -
  {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %}
122 -
  {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %}<button type="submit" class="small">SET</button></form>{% endif %}
166 +
  <h3>Labels{% if can_triage %} <a class="side-edit" href="{{ repo.url }}/labels" title="Manage this repository's labels">manage</a>{% endif %}</h3>
167 +
  {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %}
168 +
  {% if can_triage %}
169 +
  {% if all_labels.is_empty() %}<p class="small mute">This repository has no labels yet: <a href="{{ repo.url }}/labels">create some</a>.</p>
170 +
  {% else %}<details class="side-picker"><summary>Edit labels</summary>
171 +
  <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
172 +
    {% for l in all_labels %}<label class="side-check"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> <span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span></label>{% endfor %}
173 +
    <button type="submit" class="small">Apply</button>
174 +
  </form></details>{% endif %}
175 +
  {% endif %}
123 176
  <h3>Projects</h3>
124 -
  {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %}
177 +
  {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="tiny" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
178 +
  {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="small mute"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %}
125 179
  <h3>Checks</h3>
126 180
  <div id="checks">
127 181
  {% if checks.is_empty() %}<p class="mute small">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %}
…
5 unchanged lines
133 187
  <ul class="plain">{% for a in approvals %}<li>{% if a.sealed %}<span class="sig sig-valid">◈</span>{% else %}<span class="sig sig-unsigned">○</span>{% endif %} <a href="/{{ a.account }}">{{ a.account }}</a>{% if !a.sealed %} <span class="mute small">seal pending</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
134 188
  {% if !owners.is_empty() %}<h3>Code owners</h3><ul class="plain">{% for o in owners %}<li>{{ o }}</li>{% endfor %}</ul>{% endif %}
135 189
  {% if !depends_on.is_empty() %}<h3>Depends on</h3><ul class="plain">{% for n in depends_on %}<li><a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a></li>{% endfor %}</ul>{% endif %}
136 -
  {% if can_edit %}
137 -
  <h3>Edit</h3>
138 -
  <form class="card" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit">
139 -
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
140 -
    <label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}">
141 -
    <label for="etarget">Target</label><input type="text" id="etarget" name="target" value="{{ p.target }}">
142 -
    <label for="ebody">Description</label><textarea id="ebody" name="body" rows="4">{{ p.body }}</textarea>
143 -
    <button type="submit">SAVE</button>
144 -
  </form>
145 -
  <form class="card" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state">
146 -
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
147 -
    <div class="choice">
148 -
    {% if p.state == "draft" %}<button type="submit" name="action" value="ready">READY FOR REVIEW</button>{% endif %}
149 -
    {% if p.state == "open" %}<button type="submit" name="action" value="draft">BACK TO DRAFT</button>{% endif %}
150 -
    {% if p.is_open() %}<button type="submit" name="action" value="close">CLOSE</button>{% endif %}
151 -
    {% if p.state == "closed" %}<button type="submit" name="action" value="reopen">REOPEN</button>{% endif %}
152 -
    </div>
153 -
  </form>
154 -
  {% endif %}
155 190
</aside>
156 191
</div>
157 192
{% endif %}
…
1 unchanged lines
modified templates/style.css +36 −0
…
600 unchanged lines
601 601
.st-closed .chain-state { color: var(--mute); }
602 602
.chain-arrow { color: var(--acc); font-weight: 700; }
603 603
.chain-and { color: var(--mute); }
604 +
605 +
/* A proposal's conversation as a timeline (hub spec §7): avatars on a rail,
606 +
   each entry a card with its author and time, events as quiet lines. */
607 +
.timeline { position: relative; min-width: 0; }
608 +
.timeline::before { content: ""; position: absolute; left: 17px; top: 12px; bottom: 12px; border-left: 2px solid var(--line); z-index: 0; }
609 +
.tl-item { position: relative; display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 14px; margin: 0 0 18px; z-index: 1; }
610 +
.tl-avatar { width: 36px; height: 36px; border-radius: 50%; border: 2px solid var(--bg); background: var(--panel); }
611 +
.tl-card { min-width: 0; background: var(--panel); border: 1px solid var(--line); }
612 +
.tl-head { padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--soft); font-size: 13px; color: var(--mute); }
613 +
.tl-head strong { color: var(--ink); }
614 +
.tl-head .tl-edit { float: right; }
615 +
.tl-card article.doc.tl-doc { max-width: none; margin: 0; border: 0; }
616 +
.tl-text { padding: 10px 14px; overflow-wrap: anywhere; }
617 +
.tl-empty { padding: 10px 14px; margin: 0; }
618 +
.tl-card form { padding: 10px 14px; margin: 0; }
619 +
.tl-card textarea { width: 100%; box-sizing: border-box; }
620 +
.tl-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; padding-top: 0; }
621 +
form.tl-actions { padding: 0 14px 12px; }
622 +
.tl-item.review-approve .tl-card { border-color: var(--ok); }
623 +
.tl-item.review-changes .tl-card { border-color: var(--bad); }
624 +
.tl-event { position: relative; z-index: 1; display: flex; align-items: center; gap: 6px; margin: -6px 0 16px 10px; font-size: 13px; color: var(--mute); }
625 +
.tl-event strong { color: var(--ink); font-weight: 600; }
626 +
.tl-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--soft); border: 2px solid var(--line); flex: none; }
627 +
.tl-mini { width: 18px; height: 18px; border-radius: 50%; }
628 +
.timeline form.card.tl-review { max-width: none; margin-left: 50px; }
629 +
.tl-editbox { margin-left: 50px; }
630 +
.tl-editbox summary { cursor: pointer; font-weight: 600; }
631 +
.tl-editbox input[type=text], .tl-editbox textarea { width: 100%; box-sizing: border-box; }
632 +
.head-edit { margin-left: 8px; vertical-align: middle; }
633 +
aside.side h3 .side-edit { float: right; font-size: 11px; font-weight: 400; text-transform: none; letter-spacing: 0; }
634 +
.side-picker summary { cursor: pointer; font-size: 12px; color: var(--acc); }
635 +
.side-picker form { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
636 +
.side-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
637 +
.side-picker-inline { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
638 +
.side-picker-inline select { flex: 1; min-width: 0; }
639 +
@media (max-width: 760px) { .timeline::before { display: none; } .tl-item { grid-template-columns: 1fr; } .tl-avatar { display: none; } .timeline form.card.tl-review, .tl-editbox { margin-left: 0; } }