OXYGENHUB

Board cards show labels and assignees, and the board filters by them (refs #293) 6a306cd03fc1

#293's fourth slice: a card shows its issue's or proposal's labels, each
a link to the board filtered by it, and an issue's assignees; the filter
takes label:, assignee: and state: with words, as the issue list does.
The board reads what its cards show once per repository instead of one
query per card. The API's ProjectCard gains labels and assignees
(additive), and oh project view prints them.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key bbce979e61ca
When
· 2026-10-25 08:04 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
6a306cd03fc12d18a52869bd400e9541b4c7392afdc914963dc81492f0c00fdd
Depends on
17a3a84b00d1 18e381ea88c2 5c6e92fd8d71 657e7d5c9da9 69e4f27c5633 a7e6e8e6094c b5668108487f b64e2537d702 c79a1111dd56 d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
Depended on by
f2d2776b2a14 0bc95135954d 046e3352cdfd 439ecd8708a1
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
25 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Board cards show their labels and assignees, and the board filters by them (#293, filters):** an issue's or proposal's card shows its labels (each a link to the board filtered by it) and an issue's assignees; the filter takes `label:NAME`, `assignee:NAME` and `state:STATE` with words, as the issue list does, every term holding. The board reads what its cards show once per repository rather than once per card (a board of 300 cards made 300 queries). The API's cards and `oh project view` carry the labels and assignees too.
8 +
7 9
## 0.8.0 (2026-10-25)
8 10
9 11
- **Built on oxy 0.6.0 and OxygenFlow 0.3.1:** tag withdrawals, keyless grants that survive a key rotation, working depth-0 clones of big repositories (oxygen#134, #135, #137; oxygenhub#301).
…
295 unchanged lines
modified crates/hub-api/src/lib.rs +6 −0
…
770 unchanged lines
771 771
    pub title: String,
772 772
    /// The issue's or proposal's state; empty for a note.
773 773
    pub state: String,
774 +
    /// The issue's or proposal's label names (oxygenhub#293).
775 +
    #[serde(default)]
776 +
    pub labels: Vec<String>,
777 +
    /// An issue's assignees' usernames.
778 +
    #[serde(default)]
779 +
    pub assignees: Vec<String>,
774 780
}
775 781
776 782
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
682 unchanged lines
modified crates/hub/src/api/projects.rs +9 −1
…
23 unchanged lines
24 24
}
25 25
26 26
fn card_of(v: &wp::CardView) -> ProjectCard {
27 -
    ProjectCard { id: v.card.id, kind: v.card.kind.clone(), reference: v.reference.clone(), title: v.title.clone(), state: v.state.clone() }
27 +
    ProjectCard {
28 +
        id: v.card.id,
29 +
        kind: v.card.kind.clone(),
30 +
        reference: v.reference.clone(),
31 +
        title: v.title.clone(),
32 +
        state: v.state.clone(),
33 +
        labels: v.labels.iter().map(|l| l.name.clone()).collect(),
34 +
        assignees: v.assignees.clone(),
35 +
    }
28 36
}
29 37
30 38
async fn rights(app: &Shared, c: &Caller, owner: &str) -> Result<wp::Rights, ApiError> {
…
235 unchanged lines
modified crates/hub/src/web/projects.rs +104 −21
…
3 unchanged lines
4 4
use super::settings::csrf;
5 5
use super::{filters, Error, Page, Shell};
6 6
use crate::auth::{roles, Access, Viewer};
7 -
use crate::db::{self, projects as pdb};
7 +
use crate::db::{self, issues as idb, projects as pdb};
8 8
use crate::Shared;
9 9
use askama::Template;
10 10
use axum::extract::{Form, Path, Query, State};
…
172 unchanged lines
183 183
    pub title: String,
184 184
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
185 185
    pub state: String,
186 +
    /// The issue's or proposal's labels, and an issue's assignees (#293).
187 +
    pub labels: Vec<idb::Label>,
188 +
    pub assignees: Vec<String>,
186 189
}
187 190
188 191
pub struct ColumnView {
…
15 unchanged lines
204 207
    /// The column shown whole.
205 208
    #[serde(default)]
206 209
    all: Option<i64>,
207 -
    /// Only cards whose title or reference holds this.
210 +
    /// Only cards whose title or reference holds these words, and with
211 +
    /// `label:NAME`, `assignee:NAME` or `state:STATE` as the issue list takes.
208 212
    #[serde(default)]
209 213
    q: Option<String>,
210 214
    /// Only cards in this state (`open`, `closed`, `landed`…).
…
10 unchanged lines
221 225
    }
222 226
}
223 227
228 +
/// The board filter's `label:`, `assignee:` and `state:` terms, and its words.
229 +
#[derive(Default)]
230 +
struct CardFilter {
231 +
    labels: Vec<String>,
232 +
    assignees: Vec<String>,
233 +
    state: String,
234 +
    words: Vec<String>,
235 +
}
236 +
237 +
impl CardFilter {
238 +
    fn of(view: &BoardQuery) -> CardFilter {
239 +
        let mut f = CardFilter { state: view.state(), ..Default::default() };
240 +
        for tok in view.text().split_whitespace() {
241 +
            match tok.split_once(':') {
242 +
                Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()),
243 +
                Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()),
244 +
                Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(),
245 +
                _ => f.words.push(tok.to_lowercase()),
246 +
            }
247 +
        }
248 +
        f
249 +
    }
250 +
251 +
    fn is_empty(&self) -> bool {
252 +
        self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty()
253 +
    }
254 +
255 +
    /// Every term holds: each word in the title or reference, each label
256 +
    /// and assignee among the card's, and its state.
257 +
    fn keeps(&self, c: &CardView) -> bool {
258 +
        let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase());
259 +
        self.words.iter().all(|w| title.contains(w) || reference.contains(w))
260 +
            && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l)))
261 +
            && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a)))
262 +
            && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state))
263 +
    }
264 +
}
265 +
224 266
/// Filter and cut the columns as `view` says.
225 267
fn shape(columns: &mut [ColumnView], view: &BoardQuery) {
226 -
    let (q, state) = (view.text().to_lowercase(), view.state());
268 +
    let filter = CardFilter::of(view);
227 269
    for col in columns.iter_mut() {
228 270
        col.total = col.cards.len();
229 -
        if !q.is_empty() || !state.is_empty() {
230 -
            col.cards.retain(|c| {
231 -
                (q.is_empty() || c.title.to_lowercase().contains(&q) || c.reference.to_lowercase().contains(&q))
232 -
                    && (state.is_empty() || c.state.eq_ignore_ascii_case(&state))
233 -
            });
271 +
        if !filter.is_empty() {
272 +
            col.cards.retain(|c| filter.keeps(c));
234 273
        } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) {
235 274
            // Cards join a column at its end: the newest are the last.
236 275
            let cut = col.cards.len() - COLUMN_SHOWN;
…
75 unchanged lines
312 351
        }
313 352
    }
314 353
    let cards = pdb::cards(&app.db, project.id).await?;
354 +
    // What the cards show, read once per repository, not per card: a
355 +
    // board of 300 cards made 300 queries.
356 +
    let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default();
357 +
    let mut repo_ids: Vec<(i64, bool)> =
358 +
        cards.iter().filter_map(|c| Some((c.repo_id?, c.kind == "issue"))).filter(|(rid, _)| readable.contains_key(rid)).collect();
359 +
    repo_ids.sort();
360 +
    repo_ids.dedup();
361 +
    for (rid, issues) in repo_ids {
362 +
        shown.extend(shown_in(app, rid, issues).await?.into_iter().map(|(n, s)| ((rid, issues, n), s)));
363 +
    }
315 364
    let mut columns = Vec::new();
316 365
    for column in pdb::columns(&app.db, project.id).await? {
317 366
        let mut views = Vec::new();
…
5 unchanged lines
323 372
                    url: String::new(),
324 373
                    title: card.note.clone(),
325 374
                    state: String::new(),
375 +
                    labels: Vec::new(),
376 +
                    assignees: Vec::new(),
326 377
                }),
327 378
                (kind, Some(rid), Some(n)) => {
328 379
                    // Cards of repositories the viewer cannot read are not shown at all.
329 380
                    let Some(row) = readable.get(&rid) else { continue };
330 -
                    let (title, state) = if kind == "issue" {
331 -
                        match db::issues::get(&app.db, rid, n).await? {
332 -
                            Some(i) => (i.title, i.state),
333 -
                            None => continue,
334 -
                        }
335 -
                    } else {
336 -
                        match db::proposals::get(&app.db, rid, n).await? {
337 -
                            Some(p) => (p.title, p.state),
338 -
                            None => continue,
339 -
                        }
340 -
                    };
381 +
                    let Some(s) = shown.get(&(rid, kind == "issue", n)) else { continue };
341 382
                    let slug = format!("{}/{}", row.owner, row.name);
342 383
                    views.push(CardView {
343 384
                        card: card.clone(),
344 385
                        reference: format!("{slug}#{n}"),
345 386
                        url: format!("/{slug}/{kind}/{n}"),
346 -
                        title,
347 -
                        state,
387 +
                        title: s.title.clone(),
388 +
                        state: s.state.clone(),
389 +
                        labels: s.labels.clone(),
390 +
                        assignees: s.assignees.clone(),
348 391
                    });
349 392
                }
350 393
                _ => {}
…
5 unchanged lines
356 399
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
357 400
    repos.sort();
358 401
    Ok((columns, repos))
402 +
}
403 +
404 +
/// What a card shows of an issue or a proposal.
405 +
struct Shown {
406 +
    title: String,
407 +
    state: String,
408 +
    labels: Vec<idb::Label>,
409 +
    assignees: Vec<String>,
410 +
}
411 +
412 +
/// A repository's issues (or proposals) as cards show them, by number.
413 +
async fn shown_in(app: &Shared, repo_id: i64, issues: bool) -> sqlx::Result<Vec<(i64, Shown)>> {
414 +
    use std::collections::HashMap;
415 +
    let mut out = Vec::new();
416 +
    if issues {
417 +
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
418 +
        for (id, l) in idb::labels_by_issue(&app.db, repo_id).await? {
419 +
            labels.entry(id).or_default().push(l);
420 +
        }
421 +
        let mut assignees: HashMap<i64, Vec<String>> = HashMap::new();
422 +
        for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? {
423 +
            assignees.entry(id).or_default().push(u);
424 +
        }
425 +
        for i in idb::list(&app.db, repo_id).await? {
426 +
            let mut a = assignees.remove(&i.id).unwrap_or_default();
427 +
            a.sort();
428 +
            let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state };
429 +
            out.push((i.number, s));
430 +
        }
431 +
    } else {
432 +
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
433 +
        for (id, l) in idb::labels_by_proposal(&app.db, repo_id).await? {
434 +
            labels.entry(id).or_default().push(l);
435 +
        }
436 +
        for p in db::proposals::list(&app.db, repo_id, &[]).await? {
437 +
            let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state };
438 +
            out.push((p.number, s));
439 +
        }
440 +
    }
441 +
    Ok(out)
359 442
}
360 443
361 444
pub async fn show(
…
374 unchanged lines
modified crates/hub/tests/api.rs +1 −0
…
923 unchanged lines
924 924
        (card["kind"].as_str(), card["reference"].as_str(), card["title"].as_str(), card["state"].as_str()),
925 925
        (Some("issue"), Some("andy/demo#1"), Some("Crash"), Some("open"))
926 926
    );
927 +
    assert_eq!((card["labels"].as_array().map(Vec::len), card["assignees"].as_array().map(Vec::len)), (Some(0), Some(0)), "{card}");
927 928
    let (st, v) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1"}"#)).await;
928 929
    assert_eq!(st, StatusCode::BAD_REQUEST);
929 930
    assert!(v["detail"].as_str().unwrap().contains("already on this project"));
…
812 unchanged lines
modified crates/hub/tests/issues.rs +61 −0
…
1021 unchanged lines
1022 1022
    assert!(!open.contains("card-0"), "notes have no state");
1023 1023
}
1024 1024
1025 +
/// #293: a card shows its issue's labels and assignees, each a link to
1026 +
/// the board filtered by it, and the filter takes `label:`, `assignee:`
1027 +
/// and `state:` with words, every term holding.
1025 1028
#[tokio::test(flavor = "multi_thread")]
1029 +
async fn a_board_filters_by_label_and_assignee() {
1030 +
    let h = hub().await;
1031 +
    let app = &h.app;
1032 +
    let andy = register(app, "andy").await;
1033 +
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
1034 +
    post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await;
1035 +
    app.repos.create(&RepoName::parse("andy/game").unwrap(), None).unwrap();
1036 +
    let row = db::repos::create(&app.db, "andy", "game", "", hub_api::Visibility::Public).await.unwrap();
1037 +
    let mut ids = Vec::new();
1038 +
    for title in ["Sound crackles", "Jump is floaty", "Title screen"] {
1039 +
        let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: row.id, title, body: "", author_id: None, author: "andy" })
1040 +
            .await
1041 +
            .unwrap();
1042 +
        ids.push(i.id);
1043 +
    }
1044 +
    let bug = db::issues::upsert_label(&app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap();
1045 +
    let feel = db::issues::upsert_label(&app.db, row.id, "feel", "#0e8a16", "").await.unwrap();
1046 +
    db::issues::set_issue_label(&app.db, ids[0], bug.id, true).await.unwrap();
1047 +
    db::issues::set_issue_label(&app.db, ids[1], bug.id, true).await.unwrap();
1048 +
    db::issues::set_issue_label(&app.db, ids[1], feel.id, true).await.unwrap();
1049 +
    let (andy_id,): (i64,) = sqlx::query_as("SELECT id FROM accounts WHERE username = 'andy'").fetch_one(&app.db).await.unwrap();
1050 +
    db::issues::set_assignee(&app.db, ids[1], andy_id, true).await.unwrap();
1051 +
    let (project, todo): (i64, i64) =
1052 +
        sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id ORDER BY c.position LIMIT 1")
1053 +
            .fetch_one(&app.db)
1054 +
            .await
1055 +
            .unwrap();
1056 +
    for n in 1..=3i64 {
1057 +
        sqlx::query(
1058 +
            "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, created_ms) VALUES ($1, $2, $3, 'issue', $4, $5, '', 0)",
1059 +
        )
1060 +
        .bind(project)
1061 +
        .bind(todo)
1062 +
        .bind(n)
1063 +
        .bind(row.id)
1064 +
        .bind(n)
1065 +
        .execute(&app.db)
1066 +
        .await
1067 +
        .unwrap();
1068 +
    }
1069 +
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
1070 +
    assert!(board.contains("href=\"/projects/andy/1?q=label:bug\""), "{board}");
1071 +
    assert!(board.contains("title=\"Something is wrong\">bug</a>"), "a label's description is its title");
1072 +
    assert!(board.contains(">@andy</a>"), "{board}");
1073 +
    let bugs = get(app, "/projects/andy/1?q=label:bug", Some(&andy)).await.body;
1074 +
    assert!(bugs.contains("Sound crackles") && bugs.contains("Jump is floaty") && !bugs.contains("Title screen"), "{bugs}");
1075 +
    assert!(bugs.contains("2 of 3"));
1076 +
    let both = get(app, "/projects/andy/1?q=label:BUG+label:feel", Some(&andy)).await.body;
1077 +
    assert!(!both.contains("Sound crackles") && both.contains("Jump is floaty"), "every label must hold");
1078 +
    let mine = get(app, "/projects/andy/1?q=assignee:%40andy", Some(&andy)).await.body;
1079 +
    assert!(mine.contains("Jump is floaty") && !mine.contains("Sound crackles"), "{mine}");
1080 +
    let words = get(app, "/projects/andy/1?q=label:bug+sound", Some(&andy)).await.body;
1081 +
    assert!(words.contains("Sound crackles") && !words.contains("Jump is floaty"));
1082 +
    let closed = get(app, "/projects/andy/1?q=state:closed", Some(&andy)).await.body;
1083 +
    assert!(closed.contains("0 of 3"), "{closed}");
1084 +
}
1085 +
1086 +
#[tokio::test(flavor = "multi_thread")]
1026 1087
async fn issue_markdown_follows_the_default_bookmark() {
1027 1088
    let h = hub().await;
1028 1089
    let app = &h.app;
…
242 unchanged lines
modified crates/oh/src/main.rs +4 −1
…
1748 unchanged lines
1749 1749
                for card in &col.cards {
1750 1750
                    let what = if card.reference.is_empty() { ui::dim("note") } else { card.reference.clone() };
1751 1751
                    let st = if card.state.is_empty() { String::new() } else { format!(" {}", ui::dim(&card.state)) };
1752 -
                    println!("  {} {what}{st}  {}", ui::dim(&format!("{:>4}", card.id)), card.title);
1752 +
                    let mut tags: Vec<String> = card.labels.iter().map(|l| format!("[{l}]")).collect();
1753 +
                    tags.extend(card.assignees.iter().map(|a| format!("@{a}")));
1754 +
                    let tags = if tags.is_empty() { String::new() } else { format!("  {}", ui::dim(&tags.join(" "))) };
1755 +
                    println!("  {} {what}{st}  {}{tags}", ui::dim(&format!("{:>4}", card.id)), card.title);
1753 1756
                }
1754 1757
            }
1755 1758
            println!("{}", ui::dim(&format!("\n{}", b.project.url)));
…
307 unchanged lines
modified templates/project.html +3 −2
…
6 unchanged lines
7 7
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 9
<form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search">
10 -
  <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter cards">
10 +
  <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter: words, label:bug, assignee:andy" title="Words in the title or reference; label:NAME, assignee:NAME and state:STATE as on the issue list">
11 11
  <label class="visually-hidden" for="board-state">State</label><select id="board-state" name="state"><option value="">any state</option>{% for s in ["open", "closed", "landed", "draft"] %}<option{% if state == *s %} selected{% endif %}>{{ s }}</option>{% endfor %}</select>
12 12
  <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}">clear</a>{% endif %}
13 13
</form>
…
8 unchanged lines
22 22
    <li class="card-item{% if c.card.kind == "note" %} note{% endif %}">
23 23
      {% if c.card.kind == "note" %}<p>{{ c.title }}</p>
24 24
      {% else %}<a href="{{ c.url }}">{{ c.title }}</a>
25 -
      <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span></p>{% endif %}
25 +
      <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span>{% for a in c.assignees %} · <a class="card-who" href="/projects/{{ project.owner }}/{{ project.number }}?q=assignee:{{ a|urlencode }}" title="Cards assigned to {{ a }}">@{{ a }}</a>{% endfor %}</p>
26 +
      {% if !c.labels.is_empty() %}<p class="card-labels">{% for l in c.labels %}<a class="label" style="--c: {{ l.colour }}" href="/projects/{{ project.owner }}/{{ project.number }}?q=label:{{ l.name|urlencode }}" title="{% if l.description.is_empty() %}Cards labelled {{ l.name }}{% else %}{{ l.description }}{% endif %}">{{ l.name }}</a>{% endfor %}</p>{% endif %}{% endif %}
26 27
      {% if can_edit %}
27 28
      <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}">
28 29
        <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label>
…
47 unchanged lines
modified templates/style.css +4 −0
…
621 unchanged lines
622 622
.card-item { background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; }
623 623
.card-item.note { border-left: 3px solid var(--warn); }
624 624
.card-item p { margin: 4px 0 0; }
625 +
.card-item .card-labels { display: flex; flex-wrap: wrap; gap: 4px; }
626 +
.card-item .card-labels .label { margin-right: 0; text-decoration: none; }
627 +
.card-item .card-labels .label:hover, .card-item .card-labels .label:focus-visible { background: color-mix(in srgb, var(--c, var(--acc)) 14%, transparent); }
628 +
.card-item .card-who { color: inherit; }
625 629
.card-item .card-actions { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
626 630
.card-item .card-actions select { width: auto; margin: 0; padding: 1px 4px; font-size: 12px; }
627 631
.board .add-card { margin-top: 8px; }
…
816 unchanged lines