OXYGENHUB

Proposal page: the issue page's sidebar 8e96499f2705

Sections with headings; Reviewers and Labels as gear menus, filterable,
applied at once (the reviewers form takes action=set with the whole
list; the people offered are those who can be assigned, less the
author); the gear icon shared by both pages. Test.

closes #84
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 18:19 UTC
Signature
✔ signed by Andy McCall
Hash
8e96499f270589313852b3f68508c023ece9f0f4593ff17d570be42232d612e0
Depends on
145306044896 18e381ea88c2 38833ed78707 4da8b828a822 749d9a5fcd9f a1b9869565aa b5668108487f b64e2537d702 cb71e18eb6f9 cc1cd197df1d d23464c0ac60 de505df5cf8d e1ec9fbf538e
Depended on by
a2ef53c5db26 b5a0a69b3622 ffb67a81188c 3ea0b8af2d46 206c1fc1b06a 7d2eb590051f 991b55638aaf 9e50caedf4db 4f37afcc3583 ea85398a51bc
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.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
24 atoms, 1 tree operation, 7 files

Browse the tree at this change

added templates/gear.html +1 −0
1 +
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **The proposal page's sidebar matches the issue page's (#84).** Sections with headings; Reviewers and Labels open filterable menus from a gear (people with access to the repository, with avatars; labels with colour and description) that apply in one go; requested reviewers show with avatars; Landing, Checks, Approvals, Projects, Code owners and Depends on as sections. The reviewers form takes the whole list (`action=set`).
8 +
7 9
- **Device sign-in says it is done (#83).** After Approve, `/device` shows "Signed in: oxy on saturn is now signed in as you; return to it, there is nothing to copy or enter" with a link to its token, instead of the code form again, which read as if the code were needed a second time. Denying shows a matching "Denied" card.
8 10
9 11
- **The issue page's sidebar, laid out as GitHub's (#82).** Assignees, Labels and Milestone each show what is set under their heading (avatars, coloured label pills) with a gear opening a menu: a filterable checklist (people who can be assigned, with avatars; labels with their colour and description; a milestone) that applies in one go. "Assign yourself" when nobody is assigned. Close, Not planned, Lock and Pin are quiet rows with icons at the foot, evenly spaced, and the comment box is as wide as the conversation.
…
39 unchanged lines
modified crates/hub/src/web/proposals.rs +37 −8
…
634 unchanged lines
635 635
    projects: Vec<(db::projects::Project, String)>,
636 636
    /// Asked to review and not yet reviewing.
637 637
    requested: Vec<String>,
638 +
    /// Who can be asked to review, for the sidebar's menu (oxygenhub#84).
639 +
    reviewable: Vec<String>,
638 640
    /// The viewer may ask for reviews (the author, or a writer).
639 641
    can_request: bool,
640 642
}
…
67 unchanged lines
708 710
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
709 711
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
710 712
    let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write);
713 +
    let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default();
714 +
    let mut reviewable: Vec<String> = if can_request {
715 +
        accounts::assignable(&app.db, row.id, &row.owner).await.unwrap_or_default().into_iter().filter(|u| *u != p.author).collect()
716 +
    } else {
717 +
        Vec::new()
718 +
    };
719 +
    for r in &requested {
720 +
        if !reviewable.iter().any(|x| x.eq_ignore_ascii_case(r)) {
721 +
            reviewable.push(r.clone());
722 +
        }
723 +
    }
711 724
    let delegated = match viewer.account() {
712 725
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
713 726
        None => false,
…
72 unchanged lines
786 799
        all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(),
787 800
        checks: db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(),
788 801
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
789 -
        requested: pdb::review_requests(&app.db, loaded.row.id).await.unwrap_or_default(),
802 +
        requested,
803 +
        reviewable,
790 804
        can_request,
791 805
    }))
792 806
}
…
561 unchanged lines
1354 1368
pub struct ReviewersForm {
1355 1369
    #[serde(rename = "_csrf")]
1356 1370
    csrf_token: String,
1357 -
    /// `add` or `remove`.
1371 +
    /// `add`, `remove`, or `set` (the menu: exactly these are asked).
1358 1372
    action: String,
1359 -
    username: String,
1373 +
    #[serde(default)]
1374 +
    username: Vec<String>,
1360 1375
}
1361 1376
1362 1377
/// `POST /{owner}/{name}/proposal/{number}/reviewers`: the author or a
…
2 unchanged lines
1365 1380
    State(app): State<Shared>,
1366 1381
    viewer: Viewer,
1367 1382
    Path((owner, name, number)): Path<(String, String, i64)>,
1368 -
    Form(f): Form<ReviewersForm>,
1383 +
    axum_extra::extract::Form(f): axum_extra::extract::Form<ReviewersForm>,
1369 1384
) -> Result<Response, Error> {
1370 1385
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1371 1386
    let me = require(&viewer, &shell)?;
…
6 unchanged lines
1378 1393
    if Some(me.account.id) != p.author_id && role < Role::Write {
1379 1394
        return Err(Error::not_found(shell));
1380 1395
    }
1381 -
    let who = f.username.trim().trim_start_matches('@').to_string();
1382 -
    if f.action == "remove" {
1383 -
        if let Some(a) = accounts::account_by_name(&app.db, &who).await.ok().flatten() {
1396 +
    let names: Vec<String> = f.username.iter().map(|n| n.trim().trim_start_matches('@').to_string()).filter(|n| !n.is_empty()).collect();
1397 +
    let current = pdb::review_requests(&app.db, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1398 +
    // Withdrawn: those named (remove), or those the menu left out (set).
1399 +
    let withdraw: Vec<String> = match f.action.as_str() {
1400 +
        "remove" => names.clone(),
1401 +
        "set" => current.iter().filter(|c| !names.iter().any(|n| n.eq_ignore_ascii_case(c))).cloned().collect(),
1402 +
        _ => Vec::new(),
1403 +
    };
1404 +
    for who in &withdraw {
1405 +
        if let Some(a) = accounts::account_by_name(&app.db, who).await.ok().flatten() {
1384 1406
            if pdb::unrequest_review(&app.db, p.id, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1385 1407
                pdb::add_event(&app.db, p.id, &me.account.username, "review request withdrawn", &a.username).await.ok();
1386 1408
            }
1387 1409
        }
1410 +
    }
1411 +
    let ask: Vec<String> = match f.action.as_str() {
1412 +
        "add" => names,
1413 +
        "set" => names.into_iter().filter(|n| !current.iter().any(|c| c.eq_ignore_ascii_case(n))).collect(),
1414 +
        _ => return Ok(back(&owner, &name, number, "conversation", None, None)),
1415 +
    };
1416 +
    if ask.is_empty() {
1388 1417
        return Ok(back(&owner, &name, number, "conversation", None, None));
1389 1418
    }
1390 -
    match request_reviewers(&app, &row, &p, &me.account, &[who]).await {
1419 +
    match request_reviewers(&app, &row, &p, &me.account, &ask).await {
1391 1420
        Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)),
1392 1421
        Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)),
1393 1422
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))),
…
2 unchanged lines
modified crates/hub/tests/api.rs +14 −0
…
408 unchanged lines
409 409
    assert_eq!(st, StatusCode::NO_CONTENT);
410 410
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
411 411
    assert_eq!(v["requested_reviewers"], serde_json::json!([]));
412 +
    // oxygenhub#84: the page's menu sets who is asked, all at once.
413 +
    let page = call(app, "GET", "/andy/demo/proposal/2", None, Some(&bob), None, None).await;
414 +
    assert!(
415 +
        page.body.contains("Request reviews") && page.body.contains("name=\"username\" value=\"andy\""),
416 +
        "the reviewers menu lists andy"
417 +
    );
418 +
    let csrf = csrf_of(&page.body);
419 +
    let form = |list: &str| format!("_csrf={csrf}&action=set{list}");
420 +
    call(app, "POST", "/andy/demo/proposal/2/reviewers", None, Some(&bob), None, Some(&form("&username=andy"))).await;
421 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
422 +
    assert_eq!(v["requested_reviewers"], serde_json::json!(["andy"]));
423 +
    call(app, "POST", "/andy/demo/proposal/2/reviewers", None, Some(&bob), None, Some(&form(""))).await;
424 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await;
425 +
    assert_eq!(v["requested_reviewers"], serde_json::json!([]), "an empty menu withdraws them");
412 426
413 427
    // A source bookmark with `/` in its name, as a feature/N-name workflow
414 428
    // makes them: by name, and as this repository's owner/name:bookmark.
…
916 unchanged lines
modified templates/issue.html +3 −3
…
46 unchanged lines
47 47
  {% endif %}
48 48
  <section class="side-sec">
49 49
    <div class="side-head"><h3>Assignees</h3>
50 -
    {% if can_triage %}<details class="menu"><summary class="gear" title="Edit assignees" aria-label="Edit assignees"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg></summary>
50 +
    {% if can_triage %}<details class="menu"><summary class="gear" title="Edit assignees" aria-label="Edit assignees">{% include "gear.html" %}</summary>
51 51
      <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/assign"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="set">
52 52
        <p class="menu-title">Assign people to this issue</p>
53 53
        <input class="menu-filter" type="search" placeholder="Filter people" aria-label="Filter people" data-menu-filter>
…
6 unchanged lines
60 60
  </section>
61 61
  <section class="side-sec">
62 62
    <div class="side-head"><h3>Labels</h3>
63 -
    {% if can_triage %}<details class="menu"><summary class="gear" title="Edit labels" aria-label="Edit labels"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg></summary>
63 +
    {% if can_triage %}<details class="menu"><summary class="gear" title="Edit labels" aria-label="Edit labels">{% include "gear.html" %}</summary>
64 64
      <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
65 65
        <p class="menu-title">Apply labels to this issue</p>
66 66
        {% if !all_labels.is_empty() %}<input class="menu-filter" type="search" placeholder="Filter labels" aria-label="Filter labels" data-menu-filter>{% endif %}
…
5 unchanged lines
72 72
  </section>
73 73
  <section class="side-sec">
74 74
    <div class="side-head"><h3>Milestone</h3>
75 -
    {% if can_triage && !milestones.is_empty() %}<details class="menu"><summary class="gear" title="Set milestone" aria-label="Set milestone"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0a8.2 8.2 0 0 1 .7.03c.4.04.7.34.76.73l.2 1.37c.33.11.65.24.96.4l1.12-.83a.85.85 0 0 1 1.07.06c.34.3.65.63.94.97.25.3.27.73.06 1.06l-.83 1.12c.16.3.29.63.4.96l1.37.2c.39.06.69.37.73.76a8 8 0 0 1 0 1.4.85.85 0 0 1-.73.76l-1.37.2c-.11.33-.24.65-.4.96l.83 1.12c.21.33.19.76-.06 1.06-.29.34-.6.67-.94.97a.85.85 0 0 1-1.07.06l-1.12-.83c-.3.16-.63.29-.96.4l-.2 1.37a.85.85 0 0 1-.76.73 8 8 0 0 1-1.4 0 .85.85 0 0 1-.76-.73l-.2-1.37a5.6 5.6 0 0 1-.96-.4l-1.12.83a.85.85 0 0 1-1.07-.06 8 8 0 0 1-.94-.97.85.85 0 0 1-.06-1.06l.83-1.12a5.6 5.6 0 0 1-.4-.96l-1.37-.2A.85.85 0 0 1 0 8.7a8 8 0 0 1 0-1.4c.04-.39.34-.7.73-.76l1.37-.2c.11-.33.24-.65.4-.96l-.83-1.12a.85.85 0 0 1 .06-1.06c.29-.34.6-.67.94-.97a.85.85 0 0 1 1.07-.06l1.12.83c.3-.16.63-.29.96-.4l.2-1.37A.85.85 0 0 1 7.3.03 8 8 0 0 1 8 0Zm0 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg></summary>
75 +
    {% if can_triage && !milestones.is_empty() %}<details class="menu"><summary class="gear" title="Set milestone" aria-label="Set milestone">{% include "gear.html" %}</summary>
76 76
      <form class="menu-pop" method="post" action="{{ repo.url }}/issue/{{ i.number }}/milestone"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
77 77
        <p class="menu-title">Set milestone</p>
78 78
        <div class="menu-list"><label class="menu-item"><input type="radio" name="milestone" value=""{% if milestone.is_none() %} checked{% endif %}> <span class="menu-name">No milestone</span></label>
…
23 unchanged lines
modified templates/proposal.html +44 −25
…
150 unchanged lines
151 151
  </details>
152 152
  {% endif %}
153 153
</div>
154 -
<aside class="side">
155 -
  <h3>Landing</h3>
154 +
<aside class="side side-sections">
155 +
  <section class="side-sec">
156 +
    <div class="side-head"><h3>Landing</h3></div>
156 157
  {% if p.state == "landed" %}<p><span class="sig sig-valid">✔</span> landed{% if !p.landed_state.is_empty() %} at <code>{{ p.landed_state|short }}</code>{% endif %}</p>
157 158
  {% if !p.landed_by.is_empty() %}<p class="small mute">by <a href="/{{ p.landed_by }}">{{ p.landed_by }}</a>, {{ p.landed_via }}, {{ p.landed_ms|ago }}</p>{% endif %}
158 159
  {% else if p.state == "closed" %}<p class="mute">closed</p>
…
6 unchanged lines
165 166
      {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %}
166 167
    {% endif %}
167 168
  {% endif %}
168 -
  {% 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 %}
169 -
  <h3>Labels{% if can_triage %} <a class="side-edit" href="{{ repo.url }}/labels" title="Manage this repository's labels">manage</a>{% endif %}</h3>
170 -
  {% 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 %}
171 -
  {% if can_triage %}
172 -
  {% if all_labels.is_empty() %}<p class="small mute">This repository has no labels yet: <a href="{{ repo.url }}/labels">create some</a>.</p>
173 -
  {% else %}<details class="side-picker"><summary>Edit labels</summary>
174 -
  <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
175 -
    {% 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 %}
176 -
    <button type="submit" class="small">Apply</button>
177 -
  </form></details>{% endif %}
178 -
  {% endif %}
179 -
  <h3>Projects</h3>
180 -
  {% 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 %}
181 -
  {% 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 %}
182 -
  <h3>Checks</h3>
169 +
  </section>
170 +
  {% if !closes.is_empty() %}<section class="side-sec"><div class="side-head"><h3>Closes</h3></div><ul class="plain small side-list">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="mute">{{ c.state }}</span></li>{% endfor %}</ul></section>{% endif %}
171 +
  <section class="side-sec">
172 +
    <div class="side-head"><h3>Reviewers</h3>
173 +
    {% if can_request %}<details class="menu"><summary class="gear" title="Request reviews" aria-label="Request reviews">{% include "gear.html" %}</summary>
174 +
      <form class="menu-pop" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/reviewers"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="set">
175 +
        <p class="menu-title">Request reviews</p>
176 +
        <input class="menu-filter" type="search" placeholder="Filter people" aria-label="Filter people" data-menu-filter>
177 +
        <div class="menu-list">{% for u in reviewable %}<label class="menu-item"><input type="checkbox" name="username" value="{{ u }}"{% if requested.contains(u) %} checked{% endif %}> <img class="avatar xs" src="/avatar/{{ u }}" alt="" loading="lazy"> <span class="menu-name">{{ u }}</span></label>{% endfor %}
178 +
        {% if reviewable.is_empty() %}<p class="menu-empty">Nobody else with access to this repository yet.</p>{% endif %}</div>
179 +
        <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div>
180 +
      </form></details>{% endif %}</div>
181 +
    {% if requested.is_empty() %}<p class="side-empty">No reviews requested</p>{% else %}<ul class="side-people">{% for r in requested %}<li><a href="/{{ r }}"><img class="avatar xs" src="/avatar/{{ r }}" alt="" loading="lazy"> {{ r }}</a> <span class="side-note-inline">awaiting review</span></li>{% endfor %}</ul>{% endif %}
182 +
  </section>
183 +
  <section class="side-sec">
184 +
    <div class="side-head"><h3>Labels</h3>
185 +
    {% if can_triage %}<details class="menu"><summary class="gear" title="Edit labels" aria-label="Edit labels">{% include "gear.html" %}</summary>
186 +
      <form class="menu-pop" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
187 +
        <p class="menu-title">Apply labels to this proposal</p>
188 +
        {% if !all_labels.is_empty() %}<input class="menu-filter" type="search" placeholder="Filter labels" aria-label="Filter labels" data-menu-filter>{% endif %}
189 +
        <div class="menu-list">{% for l in all_labels %}<label class="menu-item"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> <span class="swatch" style="--c: {{ l.colour }}"></span> <span class="menu-name">{{ l.name }}{% if !l.description.is_empty() %}<small>{{ l.description }}</small>{% endif %}</span></label>{% endfor %}
190 +
        {% if all_labels.is_empty() %}<p class="menu-empty">This repository has no labels yet.</p>{% endif %}</div>
191 +
        <div class="menu-foot"><a class="small" href="{{ repo.url }}/labels">Edit labels</a> <button type="submit" class="small primary-btn">Apply</button></div>
192 +
      </form></details>{% endif %}</div>
193 +
    {% if labels.is_empty() %}<p class="side-empty">None yet</p>{% else %}<p class="side-labels">{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %}
194 +
  </section>
195 +
  <section class="side-sec">
196 +
    <div class="side-head"><h3>Projects</h3></div>
197 +
  {% if projects.is_empty() %}<p class="side-empty">None yet</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="inline" 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 %}
198 +
  {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="side-note"><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 %}
199 +
  </section>
200 +
  <section class="side-sec">
201 +
    <div class="side-head"><h3>Checks</h3></div>
183 202
  <div id="checks">
184 -
  {% 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 %}
203 +
  {% if checks.is_empty() %}<p class="side-empty">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %}
185 204
  <ul class="plain">{% for c in checks %}<li><span class="sig{% if c.status == "passed" %} sig-valid{% else if c.status == "failed" %} sig-invalid{% else %} sig-unsigned{% endif %}">{% if c.status == "passed" %}✔{% else if c.status == "failed" %}✘{% else if c.status == "running" %}…{% else %}○{% endif %}</span> {% if c.url.is_empty() %}{{ c.name }}{% else %}<a href="{{ c.url }}">{{ c.name }}</a>{% endif %} <span class="mute small">{{ c.status }}{% if !c.summary.is_empty() %} · {{ c.summary }}{% endif %}{% if !c.seal.is_empty() %} · sealed{% endif %}</span></li>{% endfor %}</ul>{% endif %}
186 205
  </div>
187 206
  <script>(function(){if(!window.EventSource)return;var es=new EventSource("{{ repo.url }}/proposal/{{ p.number }}/events");es.addEventListener("check",function(){es.close();location.reload();});})();</script>
188 -
  <h3>Reviewers</h3>
189 -
  {% if requested.is_empty() %}<p class="small mute">No review requested.</p>{% else %}<ul class="plain">{% for r in requested %}<li><img class="tl-mini" src="/avatar/{{ r }}" alt="" width="18" height="18"> <a href="/{{ r }}">{{ r }}</a> <span class="small mute">asked</span>{% if can_request %} <form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/reviewers"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="remove"><input type="hidden" name="username" value="{{ r }}"><button title="Withdraw the request">×</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
190 -
  {% if can_request %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/reviewers"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="action" value="add"><input type="text" name="username" placeholder="username" aria-label="Ask someone to review" size="12" required> <button class="small">Ask</button></form>{% endif %}
191 -
  <h3>Approvals</h3>
192 -
  {% if approvals.is_empty() %}<p class="mute small">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %}
207 +
  </section>
208 +
  <section class="side-sec">
209 +
    <div class="side-head"><h3>Approvals</h3></div>
210 +
  {% if approvals.is_empty() %}<p class="side-empty">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %}
193 211
  <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 %}
194 -
  {% if !owners.is_empty() %}<h3>Code owners</h3><ul class="plain">{% for o in owners %}<li>{{ o }}</li>{% endfor %}</ul>{% endif %}
195 -
  {% 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 %}
212 +
  </section>
213 +
  {% if !owners.is_empty() %}<section class="side-sec"><div class="side-head"><h3>Code owners</h3></div><ul class="plain small">{% for o in owners %}<li>{{ o }}</li>{% endfor %}</ul></section>{% endif %}
214 +
  {% if !depends_on.is_empty() %}<section class="side-sec"><div class="side-head"><h3>Depends on</h3></div><ul class="plain small">{% for n in depends_on %}<li><a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a></li>{% endfor %}</ul></section>{% endif %}
196 215
</aside>
197 216
</div>
198 217
{% endif %}
…
1 unchanged lines
modified templates/style.css +2 −0
…
710 unchanged lines
711 711
.device-mark { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; font-size: 16px; color: #fff; }
712 712
.device-done.ok .device-mark { background: var(--ok); }
713 713
.device-done.no .device-mark { background: var(--bad); }
714 +
.side-note-inline { font-size: 12px; color: var(--mute); font-weight: 400; }
715 +
.side-list li { margin: 0 0 4px; }