Skip to main content

solid_pod_rs_forge/
labels.rs

1//! Issue (and pull request) labels: GitHub's default set, owner-only CRUD,
2//! per-item assignment, list filtering, rename / delete cascades and the
3//! luminance-aware chip helpers.
4//!
5//! Follows the JSS `forge` behaviour (`labelDefs`, `ensureLabels`,
6//! `resolveLabels`, `apiLabel*`, `apiItemLabels`, `labelTextColor`,
7//! `labelChips`), cited by name only. The per-repo label **set** lives in the
8//! issue index file ([`crate::spine::issues::IssueIndex::labels`]); the
9//! defaults apply until the first label write materialises them. Items store
10//! label **names**, so a recolour needs no cascade; rename and delete cascade
11//! over issues *and* pulls.
12//!
13//! # Reuse by pull requests
14//!
15//! Pull requests keep their own index (`pulls/<owner>/<repo>.json`, kind
16//! [`PULLS_KIND`]) whose items carry a `labels` name array. The cascade and the
17//! `PUT …/labels` handler therefore work on that file as raw JSON, with no
18//! dependency on a pull-request type: a pull-request route only has to call
19//! [`ForgeService::put_item_labels`] with [`ItemKind::Pull`], and the label
20//! CRUD routes already keep pull labels consistent. Pull writes take
21//! [`crate::locks::ForgeLocks::pulls`] **after** the issue lock.
22
23use serde_json::{json, Value};
24
25use crate::api::{api_err, json_resp, read_json_body, unauthorised};
26use crate::error::ForgeError;
27use crate::ownership::ForgeAgent;
28use crate::request::{ForgeRequest, ForgeResponse};
29use crate::spine::issues::{load_issue_index, save_issue_index, IssueIndex, Label};
30use crate::ForgeService;
31
32/// Spine kind of the pull-request index.
33pub const PULLS_KIND: &str = "pulls";
34/// Most labels one item may carry.
35pub const LABELS_PER_ITEM: usize = 20;
36
37/// GitHub's default label set, in JSS order.
38pub const DEFAULT_LABELS: [(&str, &str); 5] = [
39    ("bug", "d73a4a"),
40    ("enhancement", "a2eeef"),
41    ("documentation", "0075ca"),
42    ("question", "d876e3"),
43    ("wontfix", "ffffff"),
44];
45
46/// Which index an item label operation targets.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ItemKind {
49    /// An issue (typed spine).
50    Issue,
51    /// A pull request (raw JSON spine, see the module docs).
52    Pull,
53}
54
55/// The default set as [`Label`]s.
56///
57/// # Examples
58///
59/// ```
60/// let d = solid_pod_rs_forge::labels::default_labels();
61/// assert_eq!(d[0].name, "bug");
62/// assert_eq!(d[0].color, "d73a4a");
63/// assert_eq!(d.len(), 5);
64/// ```
65#[must_use]
66pub fn default_labels() -> Vec<Label> {
67    DEFAULT_LABELS
68        .iter()
69        .map(|(n, c)| Label {
70            name: (*n).to_string(),
71            color: (*c).to_string(),
72        })
73        .collect()
74}
75
76/// 1 to 50 characters, none of them control characters.
77///
78/// # Examples
79///
80/// ```
81/// use solid_pod_rs_forge::labels::valid_label_name;
82/// assert!(valid_label_name("needs triage"));
83/// assert!(!valid_label_name(""));
84/// assert!(!valid_label_name(&"x".repeat(51)));
85/// assert!(!valid_label_name("a\nb"));
86/// ```
87#[must_use]
88pub fn valid_label_name(s: &str) -> bool {
89    let n = s.chars().count();
90    (1..=50).contains(&n) && !s.chars().any(|c| c.is_control())
91}
92
93/// Exactly six hex digits.
94///
95/// # Examples
96///
97/// ```
98/// use solid_pod_rs_forge::labels::valid_label_color;
99/// assert!(valid_label_color("00FF00"));
100/// assert!(!valid_label_color("red"));
101/// assert!(!valid_label_color("#00ff00"));
102/// ```
103#[must_use]
104pub fn valid_label_color(s: &str) -> bool {
105    s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit())
106}
107
108/// The effective label set: the materialised one, or the defaults.
109#[must_use]
110pub fn effective_labels(idx: &IssueIndex) -> Vec<Label> {
111    idx.labels.clone().unwrap_or_else(default_labels)
112}
113
114/// First label **write** materialises the defaults into the index.
115pub fn ensure_labels(idx: &mut IssueIndex) -> &mut Vec<Label> {
116    idx.labels.get_or_insert_with(default_labels)
117}
118
119/// Item label names to `{name, color}` against `defs`; a name with no
120/// definition keeps the neutral `ededed`.
121///
122/// # Examples
123///
124/// ```
125/// use solid_pod_rs_forge::labels::{default_labels, resolve_labels};
126/// let r = resolve_labels(&["bug".into(), "gone".into()], &default_labels());
127/// assert_eq!(r[0].color, "d73a4a");
128/// assert_eq!(r[1].color, "ededed");
129/// ```
130#[must_use]
131pub fn resolve_labels(names: &[String], defs: &[Label]) -> Vec<Label> {
132    names
133        .iter()
134        .map(|n| {
135            defs.iter()
136                .find(|d| &d.name == n)
137                .cloned()
138                .unwrap_or(Label {
139                    name: n.clone(),
140                    color: "ededed".into(),
141                })
142        })
143        .collect()
144}
145
146/// Perceptual-luminance text colour for a chip (GitHub style): dark text on
147/// bright backgrounds, white on dark ones. `hex` must be six hex digits.
148///
149/// # Examples
150///
151/// ```
152/// use solid_pod_rs_forge::labels::text_color;
153/// assert_eq!(text_color("d73a4a"), "#ffffff");
154/// assert_eq!(text_color("00ff00"), "#1f2328");
155/// ```
156#[must_use]
157pub fn text_color(hex: &str) -> &'static str {
158    let c = |i: usize| u32::from_str_radix(hex.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0);
159    let lum = 0.299 * f64::from(c(0)) + 0.587 * f64::from(c(2)) + 0.114 * f64::from(c(4));
160    if lum > 140.0 {
161        "#1f2328"
162    } else {
163        "#ffffff"
164    }
165}
166
167/// Rounded chips for resolved labels (everything escaped; the colour is
168/// re-validated so a corrupt index cannot inject into the style attribute).
169#[must_use]
170pub fn chips_html(labels: &[Label]) -> String {
171    labels
172        .iter()
173        .map(|l| {
174            let color = if valid_label_color(&l.color) {
175                l.color.as_str()
176            } else {
177                "ededed"
178            };
179            format!(
180                "<span class=\"label-chip\" style=\"background:#{};color:{}\">{}</span>",
181                color,
182                text_color(color),
183                crate::request::esc(&l.name)
184            )
185        })
186        .collect::<Vec<_>>()
187        .join(" ")
188}
189
190/// Rename `old` to `new` in a name list; true when something changed.
191pub fn rename_in(names: &mut [String], old: &str, new: &str) -> bool {
192    let mut touched = false;
193    for n in names.iter_mut() {
194        if n == old {
195            *n = new.to_string();
196            touched = true;
197        }
198    }
199    touched
200}
201
202/// Drop `name` from a name list; true when something changed.
203pub fn remove_from(names: &mut Vec<String>, name: &str) -> bool {
204    let before = names.len();
205    names.retain(|n| n != name);
206    names.len() != before
207}
208
209fn names_of(item: &Value) -> Vec<String> {
210    item.get("labels")
211        .and_then(Value::as_array)
212        .map(|a| {
213            a.iter()
214                .filter_map(|x| x.as_str().map(str::to_string))
215                .collect()
216        })
217        .unwrap_or_default()
218}
219
220fn set_names(item: &mut Value, names: &[String]) {
221    item["labels"] = json!(names);
222}
223
224impl ForgeService {
225    /// The repo's effective label set.
226    ///
227    /// # Errors
228    ///
229    /// A corrupt issue index is a backend error.
230    pub async fn label_defs(&self, owner: &str, repo: &str) -> Result<Vec<Label>, ForgeError> {
231        let idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
232        Ok(effective_labels(&idx))
233    }
234
235    /// Apply `f` to every pull request's label name list; saves only when
236    /// something changed. The caller already holds the issue lock.
237    async fn cascade_pulls<F>(&self, owner: &str, repo: &str, f: F) -> Result<(), ForgeError>
238    where
239        F: Fn(&mut Vec<String>) -> bool,
240    {
241        let _g = self.locks.pulls.lock(owner, repo).await;
242        let Some(bytes) = self.spine.load(PULLS_KIND, owner, repo).await? else {
243            return Ok(());
244        };
245        let mut doc: Value = serde_json::from_slice(&bytes)?;
246        let mut touched = false;
247        if let Some(pulls) = doc.get_mut("pulls").and_then(Value::as_object_mut) {
248            for pr in pulls.values_mut() {
249                let mut names = names_of(pr);
250                if !names.is_empty() && f(&mut names) {
251                    set_names(pr, &names);
252                    touched = true;
253                }
254            }
255        }
256        if touched {
257            self.spine
258                .store(PULLS_KIND, owner, repo, &serde_json::to_vec_pretty(&doc)?)
259                .await?;
260        }
261        Ok(())
262    }
263
264    /// `/api/repos/<o>/<r>/labels[/<name>]` (JSS `apiLabelsHandler`).
265    pub(crate) async fn h_api_labels(
266        &self,
267        req: &ForgeRequest,
268        agent: &ForgeAgent,
269        owner: &str,
270        repo: &str,
271        tail: &[String],
272    ) -> Result<ForgeResponse, ForgeError> {
273        let m = req.method.to_ascii_uppercase();
274        match tail {
275            [] => match m.as_str() {
276                "GET" | "HEAD" => {
277                    let defs = self.label_defs(owner, repo).await?;
278                    Ok(json_resp(200, &json!({ "labels": defs })))
279                }
280                "POST" => self.api_label_create(req, agent, owner, repo).await,
281                _ => Ok(api_err(405, "method not allowed")),
282            },
283            [name] => match m.as_str() {
284                "PATCH" => self.api_label_patch(req, agent, owner, repo, name).await,
285                "DELETE" => self.api_label_delete(agent, owner, repo, name).await,
286                _ => Ok(api_err(405, "method not allowed")),
287            },
288            _ => Ok(api_err(404, "not found")),
289        }
290    }
291
292    fn label_owner_gate(agent: &ForgeAgent, owner: &str) -> Option<ForgeResponse> {
293        if matches!(agent, ForgeAgent::Anonymous) {
294            return Some(unauthorised());
295        }
296        if agent.owner() != Some(owner) {
297            return Some(api_err(403, "only the repo owner may manage labels"));
298        }
299        None
300    }
301
302    async fn api_label_create(
303        &self,
304        req: &ForgeRequest,
305        agent: &ForgeAgent,
306        owner: &str,
307        repo: &str,
308    ) -> Result<ForgeResponse, ForgeError> {
309        let body = read_json_body(req);
310        if let Some(r) = Self::label_owner_gate(agent, owner) {
311            return Ok(r);
312        }
313        let Some(p) = body else {
314            return Ok(api_err(400, "invalid JSON body"));
315        };
316        let lname = p.get("name").and_then(Value::as_str).map_or("", str::trim);
317        if !valid_label_name(lname) {
318            return Ok(api_err(422, "label name required (1-50 printable chars)"));
319        }
320        let Some(color) = p
321            .get("color")
322            .and_then(Value::as_str)
323            .filter(|c| valid_label_color(c))
324        else {
325            return Ok(api_err(422, "color must be 6 hex digits"));
326        };
327        let color = color.to_ascii_lowercase();
328        let _g = self.locks.issues.lock(owner, repo).await;
329        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
330        let defs = ensure_labels(&mut idx);
331        if defs
332            .iter()
333            .any(|d| d.name.to_lowercase() == lname.to_lowercase())
334        {
335            return Ok(api_err(409, "label exists"));
336        }
337        defs.push(Label {
338            name: lname.to_string(),
339            color: color.clone(),
340        });
341        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
342        Ok(json_resp(201, &json!({"name": lname, "color": color})))
343    }
344
345    async fn api_label_patch(
346        &self,
347        req: &ForgeRequest,
348        agent: &ForgeAgent,
349        owner: &str,
350        repo: &str,
351        label: &str,
352    ) -> Result<ForgeResponse, ForgeError> {
353        let body = read_json_body(req);
354        if let Some(r) = Self::label_owner_gate(agent, owner) {
355            return Ok(r);
356        }
357        let Some(p) = body else {
358            return Ok(api_err(400, "invalid JSON body"));
359        };
360        let _g = self.locks.issues.lock(owner, repo).await;
361        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
362        let defs = ensure_labels(&mut idx);
363        let Some(pos) = defs.iter().position(|d| d.name == label) else {
364            return Ok(api_err(404, "not found"));
365        };
366        let mut new_name = defs[pos].name.clone();
367        if let Some(n) = p.get("name") {
368            let n = n.as_str().map_or("", str::trim);
369            if !valid_label_name(n) {
370                return Ok(api_err(422, "label name required (1-50 printable chars)"));
371            }
372            if n != defs[pos].name
373                && defs
374                    .iter()
375                    .enumerate()
376                    .any(|(i, d)| i != pos && d.name.to_lowercase() == n.to_lowercase())
377            {
378                return Ok(api_err(409, "label exists"));
379            }
380            new_name = n.to_string();
381        }
382        let mut new_color = defs[pos].color.clone();
383        if let Some(c) = p.get("color") {
384            let Some(c) = c.as_str().filter(|c| valid_label_color(c)) else {
385                return Ok(api_err(422, "color must be 6 hex digits"));
386            };
387            new_color = c.to_ascii_lowercase();
388        }
389        let old_name = std::mem::replace(&mut defs[pos].name, new_name.clone());
390        defs[pos].color = new_color.clone();
391        if new_name != old_name {
392            for i in idx.issues.values_mut() {
393                rename_in(&mut i.labels, &old_name, &new_name);
394            }
395        }
396        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
397        if new_name != old_name {
398            let (o, n) = (old_name, new_name.clone());
399            self.cascade_pulls(owner, repo, move |names| rename_in(names, &o, &n))
400                .await?;
401        }
402        Ok(json_resp(
403            200,
404            &json!({"name": new_name, "color": new_color}),
405        ))
406    }
407
408    async fn api_label_delete(
409        &self,
410        agent: &ForgeAgent,
411        owner: &str,
412        repo: &str,
413        label: &str,
414    ) -> Result<ForgeResponse, ForgeError> {
415        if let Some(r) = Self::label_owner_gate(agent, owner) {
416            return Ok(r);
417        }
418        let _g = self.locks.issues.lock(owner, repo).await;
419        let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
420        let defs = ensure_labels(&mut idx);
421        let Some(pos) = defs.iter().position(|d| d.name == label) else {
422            return Ok(api_err(404, "not found"));
423        };
424        defs.remove(pos);
425        for i in idx.issues.values_mut() {
426            remove_from(&mut i.labels, label);
427        }
428        save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
429        let l = label.to_string();
430        self.cascade_pulls(owner, repo, move |names| remove_from(names, &l))
431            .await?;
432        Ok(json_resp(200, &json!({"name": label, "deleted": true})))
433    }
434
435    /// `PUT …/{issues,pulls}/<n>/labels {labels: [names]}`: replace an item's
436    /// label set. Repo owner or the item's author; every name must exist in
437    /// the repo's label set (names are validated, never invented).
438    ///
439    /// Pull-request routes call this with [`ItemKind::Pull`].
440    ///
441    /// # Errors
442    ///
443    /// Spine I/O and corruption errors; request problems are returned as
444    /// `Ok` JSON error responses (401, 400, 422, 404, 403).
445    pub async fn put_item_labels(
446        &self,
447        req: &ForgeRequest,
448        agent: &ForgeAgent,
449        owner: &str,
450        repo: &str,
451        number: u64,
452        kind: ItemKind,
453    ) -> Result<ForgeResponse, ForgeError> {
454        let body = read_json_body(req);
455        if matches!(agent, ForgeAgent::Anonymous) {
456            return Ok(unauthorised());
457        }
458        let Some(arr) = body
459            .as_ref()
460            .and_then(|b| b.get("labels"))
461            .and_then(Value::as_array)
462        else {
463            return Ok(api_err(400, "body must be { labels: [names] }"));
464        };
465        if arr.len() > LABELS_PER_ITEM {
466            return Ok(api_err(
467                422,
468                &format!("at most {LABELS_PER_ITEM} labels per item"),
469            ));
470        }
471        let defs = self.label_defs(owner, repo).await?;
472        let mut names: Vec<String> = Vec::new();
473        for n in arr {
474            match n.as_str().filter(|s| defs.iter().any(|d| d.name == *s)) {
475                Some(s) => {
476                    if !names.iter().any(|x| x == s) {
477                        names.push(s.to_string());
478                    }
479                }
480                None => {
481                    let shown: String = n
482                        .as_str()
483                        .map_or_else(|| n.to_string(), str::to_string)
484                        .chars()
485                        .take(60)
486                        .collect();
487                    return Ok(api_err(422, &format!("unknown label: {shown}")));
488                }
489            }
490        }
491        let may = |author: &str| agent.owner() == Some(owner) || agent.author_id() == author;
492        match kind {
493            ItemKind::Issue => {
494                let _g = self.locks.issues.lock(owner, repo).await;
495                let mut idx = load_issue_index(self.spine.as_ref(), owner, repo).await?;
496                let Some(item) = idx.issues.get_mut(&number) else {
497                    return Ok(api_err(404, "not found"));
498                };
499                if !may(&item.author) {
500                    return Ok(api_err(
501                        403,
502                        "only the repo owner or the item author may set labels",
503                    ));
504                }
505                item.labels = names.clone();
506                save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
507            }
508            ItemKind::Pull => {
509                let _g = self.locks.pulls.lock(owner, repo).await;
510                let Some(bytes) = self.spine.load(PULLS_KIND, owner, repo).await? else {
511                    return Ok(api_err(404, "not found"));
512                };
513                let mut doc: Value = serde_json::from_slice(&bytes)?;
514                let Some(item) = doc
515                    .get_mut("pulls")
516                    .and_then(|p| p.get_mut(number.to_string()))
517                else {
518                    return Ok(api_err(404, "not found"));
519                };
520                let author = item.get("author").and_then(Value::as_str).unwrap_or("");
521                if !may(author) {
522                    return Ok(api_err(
523                        403,
524                        "only the repo owner or the item author may set labels",
525                    ));
526                }
527                set_names(item, &names);
528                self.spine
529                    .store(PULLS_KIND, owner, repo, &serde_json::to_vec_pretty(&doc)?)
530                    .await?;
531            }
532        }
533        Ok(json_resp(200, &json!({"number": number, "labels": names})))
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::api::tests::{casey, dana, fixture, get_json, rq};
541    use crate::spine::issues::{IssueEntry, IssueState};
542
543    async fn call(
544        svc: &ForgeService,
545        method: &str,
546        path: &str,
547        body: &str,
548        agent: ForgeAgent,
549    ) -> (u16, Value) {
550        let r = svc.handle(rq(method, path, "", body), agent).await.unwrap();
551        (
552            r.status,
553            serde_json::from_slice(&r.body).unwrap_or(Value::Null),
554        )
555    }
556
557    async fn seed_issues(svc: &ForgeService) {
558        let mut idx = load_issue_index(svc.spine.as_ref(), "casey", "demo")
559            .await
560            .unwrap();
561        for (title, author) in [
562            ("Clone fails", "https://pod.example/dana/profile/card#me"),
563            ("Second", "https://pod.example/casey/profile/card#me"),
564            ("Docs typo", "https://pod.example/dana/profile/card#me"),
565        ] {
566            idx.allocate(IssueEntry {
567                number: 0,
568                title: title.into(),
569                state: IssueState::Open,
570                author: author.into(),
571                created_at: 1,
572                thread: vec![],
573                labels: vec![],
574            });
575        }
576        save_issue_index(svc.spine.as_ref(), "casey", "demo", &idx)
577            .await
578            .unwrap();
579    }
580
581    // JSS "GitHub's default label set is served before any label was ever touched"
582    #[tokio::test]
583    async fn default_set_is_served_untouched() {
584        let (_td, svc, _) = fixture();
585        let (st, v) = get_json(
586            &svc,
587            "/forge/api/repos/casey/demo/labels",
588            "",
589            ForgeAgent::Anonymous,
590        )
591        .await;
592        assert_eq!(st, 200);
593        assert_eq!(
594            v["labels"],
595            json!([
596                {"name":"bug","color":"d73a4a"},
597                {"name":"enhancement","color":"a2eeef"},
598                {"name":"documentation","color":"0075ca"},
599                {"name":"question","color":"d876e3"},
600                {"name":"wontfix","color":"ffffff"},
601            ])
602        );
603    }
604
605    // JSS "label CRUD is owner-only; create validates color and 409s duplicates"
606    #[tokio::test]
607    async fn crud_is_owner_only_validates_and_409s() {
608        let (_td, svc, _) = fixture();
609        let url = "/forge/api/repos/casey/demo/labels";
610        let b = r#"{"name":"triage","color":"00ff00"}"#;
611        assert_eq!(
612            call(&svc, "POST", url, b, ForgeAgent::Anonymous).await.0,
613            401
614        );
615        assert_eq!(call(&svc, "POST", url, b, dana()).await.0, 403);
616        let (st, v) = call(&svc, "POST", url, b, casey()).await;
617        assert_eq!(st, 201);
618        assert_eq!(v, json!({"name":"triage","color":"00ff00"}));
619        assert_eq!(call(&svc, "POST", url, b, casey()).await.0, 409);
620        assert_eq!(
621            call(&svc, "POST", url, r#"{"name":"x","color":"red"}"#, casey())
622                .await
623                .0,
624            422
625        );
626        assert_eq!(
627            call(
628                &svc,
629                "POST",
630                url,
631                r#"{"name":"","color":"00ff00"}"#,
632                casey()
633            )
634            .await
635            .0,
636            422
637        );
638        assert_eq!(call(&svc, "POST", url, "garbage", casey()).await.0, 400);
639        // upper-case duplicate is a duplicate too
640        assert_eq!(
641            call(
642                &svc,
643                "POST",
644                url,
645                r#"{"name":"TRIAGE","color":"00ff00"}"#,
646                casey()
647            )
648            .await
649            .0,
650            409
651        );
652        let (_, l) = get_json(&svc, url, "", ForgeAgent::Anonymous).await;
653        let names: Vec<&str> = l["labels"]
654            .as_array()
655            .unwrap()
656            .iter()
657            .map(|x| x["name"].as_str().unwrap())
658            .collect();
659        assert_eq!(
660            names,
661            vec![
662                "bug",
663                "enhancement",
664                "documentation",
665                "question",
666                "wontfix",
667                "triage"
668            ]
669        );
670    }
671
672    // JSS "owner labels an issue ... JSON is additive" + "?label= filters" + "a non-owner non-author cannot set labels"
673    #[tokio::test]
674    async fn assignment_resolution_filter_and_authorisation() {
675        let (_td, svc, _) = fixture();
676        seed_issues(&svc).await;
677        call(
678            &svc,
679            "POST",
680            "/forge/api/repos/casey/demo/labels",
681            r#"{"name":"triage","color":"00ff00"}"#,
682            casey(),
683        )
684        .await;
685        let put = |n: u32, body: &'static str, a: ForgeAgent| {
686            let svc = svc.clone();
687            async move {
688                call(
689                    &svc,
690                    "PUT",
691                    &format!("/forge/api/repos/casey/demo/issues/{n}/labels"),
692                    body,
693                    a,
694                )
695                .await
696            }
697        };
698        let (st, v) = put(2, r#"{"labels":["bug","triage"]}"#, casey()).await;
699        assert_eq!(st, 200);
700        assert_eq!(v, json!({"number":2,"labels":["bug","triage"]}));
701        let (_, t) = get_json(
702            &svc,
703            "/forge/api/repos/casey/demo/issues/2",
704            "",
705            ForgeAgent::Anonymous,
706        )
707        .await;
708        assert_eq!(
709            t["labels"],
710            json!([{"name":"bug","color":"d73a4a"},{"name":"triage","color":"00ff00"}])
711        );
712        let (_, li) = get_json(
713            &svc,
714            "/forge/api/repos/casey/demo/issues",
715            "label=triage",
716            ForgeAgent::Anonymous,
717        )
718        .await;
719        let nums: Vec<u64> = li["issues"]
720            .as_array()
721            .unwrap()
722            .iter()
723            .map(|i| i["number"].as_u64().unwrap())
724            .collect();
725        assert_eq!(nums, vec![2]);
726        let (_, none) = get_json(
727            &svc,
728            "/forge/api/repos/casey/demo/issues",
729            "state=closed&label=triage",
730            ForgeAgent::Anonymous,
731        )
732        .await;
733        assert_eq!(none["issues"], json!([]));
734        // authorisation
735        assert_eq!(put(2, r#"{"labels":["bug"]}"#, dana()).await.0, 403);
736        assert_eq!(
737            put(2, r#"{"labels":["bug"]}"#, ForgeAgent::Anonymous)
738                .await
739                .0,
740            401
741        );
742        assert_eq!(
743            put(2, r#"{"labels":["no-such-label"]}"#, casey()).await.0,
744            422
745        );
746        assert_eq!(put(2, r#"{"nope":1}"#, casey()).await.0, 400);
747        assert_eq!(put(99, r#"{"labels":["bug"]}"#, casey()).await.0, 404);
748        let too_many = format!(r#"{{"labels":[{}]}}"#, vec!["\"bug\""; 21].join(","));
749        assert_eq!(
750            put(2, Box::leak(too_many.into_boxed_str()), casey())
751                .await
752                .0,
753            422
754        );
755        // the item author may label without owning the repo (dana authored #3)
756        assert_eq!(
757            put(3, r#"{"labels":["question","question"]}"#, dana())
758                .await
759                .0,
760            200
761        );
762        let (_, t3) = get_json(
763            &svc,
764            "/forge/api/repos/casey/demo/issues/3",
765            "",
766            ForgeAgent::Anonymous,
767        )
768        .await;
769        assert_eq!(
770            t3["labels"].as_array().unwrap().len(),
771            1,
772            "duplicates collapse"
773        );
774    }
775
776    // JSS "label rename cascades onto items; delete cascades OFF issues and pulls"
777    #[tokio::test]
778    async fn rename_and_delete_cascade_over_issues_and_pulls() {
779        let (_td, svc, _) = fixture();
780        seed_issues(&svc).await;
781        let base = "/forge/api/repos/casey/demo";
782        call(
783            &svc,
784            "POST",
785            &format!("{base}/labels"),
786            r#"{"name":"triage","color":"00ff00"}"#,
787            casey(),
788        )
789        .await;
790        call(
791            &svc,
792            "PUT",
793            &format!("{base}/issues/2/labels"),
794            r#"{"labels":["bug","triage"]}"#,
795            casey(),
796        )
797        .await;
798        // a pull request index, as another feature would write it
799        svc.spine
800            .store(
801                PULLS_KIND,
802                "casey",
803                "demo",
804                br#"{"next":6,"pulls":{"5":{"number":5,"author":"https://pod.example/dana/profile/card#me","labels":["enhancement","triage"]}}}"#,
805            )
806            .await
807            .unwrap();
808        let (st, v) = call(
809            &svc,
810            "PATCH",
811            &format!("{base}/labels/triage"),
812            r#"{"name":"needs-triage","color":"112233"}"#,
813            casey(),
814        )
815        .await;
816        assert_eq!(st, 200);
817        assert_eq!(v, json!({"name":"needs-triage","color":"112233"}));
818        let (_, t) = get_json(&svc, &format!("{base}/issues/2"), "", ForgeAgent::Anonymous).await;
819        let names: Vec<&str> = t["labels"]
820            .as_array()
821            .unwrap()
822            .iter()
823            .map(|l| l["name"].as_str().unwrap())
824            .collect();
825        assert_eq!(
826            names,
827            vec!["bug", "needs-triage"],
828            "rename followed the assignment"
829        );
830        let pulls: Value = serde_json::from_slice(
831            &svc.spine
832                .load(PULLS_KIND, "casey", "demo")
833                .await
834                .unwrap()
835                .unwrap(),
836        )
837        .unwrap();
838        assert_eq!(
839            pulls["pulls"]["5"]["labels"],
840            json!(["enhancement", "needs-triage"])
841        );
842        // rename conflicts and unknown labels
843        assert_eq!(
844            call(
845                &svc,
846                "PATCH",
847                &format!("{base}/labels/needs-triage"),
848                r#"{"name":"BUG"}"#,
849                casey()
850            )
851            .await
852            .0,
853            409
854        );
855        assert_eq!(
856            call(
857                &svc,
858                "PATCH",
859                &format!("{base}/labels/needs-triage"),
860                r#"{"color":"zzz"}"#,
861                casey()
862            )
863            .await
864            .0,
865            422
866        );
867        assert_eq!(
868            call(&svc, "PATCH", &format!("{base}/labels/nope"), "{}", casey())
869                .await
870                .0,
871            404
872        );
873        assert_eq!(
874            call(
875                &svc,
876                "PATCH",
877                &format!("{base}/labels/needs-triage"),
878                "{}",
879                dana()
880            )
881            .await
882            .0,
883            403
884        );
885        // delete cascades
886        let (st, _) = call(
887            &svc,
888            "DELETE",
889            &format!("{base}/labels/needs-triage"),
890            "",
891            casey(),
892        )
893        .await;
894        assert_eq!(st, 200);
895        let (_, t) = get_json(&svc, &format!("{base}/issues/2"), "", ForgeAgent::Anonymous).await;
896        assert_eq!(t["labels"].as_array().unwrap().len(), 1);
897        assert_eq!(
898            call(
899                &svc,
900                "DELETE",
901                &format!("{base}/labels/needs-triage"),
902                "",
903                casey()
904            )
905            .await
906            .0,
907            404,
908            "second delete is a clean 404"
909        );
910        assert_eq!(
911            call(
912                &svc,
913                "DELETE",
914                &format!("{base}/labels/enhancement"),
915                "",
916                casey()
917            )
918            .await
919            .0,
920            200
921        );
922        let pulls: Value = serde_json::from_slice(
923            &svc.spine
924                .load(PULLS_KIND, "casey", "demo")
925                .await
926                .unwrap()
927                .unwrap(),
928        )
929        .unwrap();
930        assert_eq!(
931            pulls["pulls"]["5"]["labels"],
932            json!([]),
933            "delete cascades off pull requests too"
934        );
935        assert_eq!(
936            call(
937                &svc,
938                "DELETE",
939                &format!("{base}/labels/bug"),
940                "",
941                ForgeAgent::Anonymous
942            )
943            .await
944            .0,
945            401
946        );
947    }
948
949    // JSS "owner labels a pull request" via the reusable entry point
950    #[tokio::test]
951    async fn pull_labels_via_put_item_labels() {
952        let (_td, svc, _) = fixture();
953        svc.spine
954            .store(PULLS_KIND, "casey", "demo", br#"{"next":6,"pulls":{"5":{"number":5,"author":"https://pod.example/dana/profile/card#me"}}}"#)
955            .await
956            .unwrap();
957        let req = rq("PUT", "/x", "", r#"{"labels":["enhancement"]}"#);
958        let r = svc
959            .put_item_labels(&req, &casey(), "casey", "demo", 5, ItemKind::Pull)
960            .await
961            .unwrap();
962        assert_eq!(r.status, 200);
963        let r = svc
964            .put_item_labels(
965                &req,
966                &ForgeAgent::Anonymous,
967                "casey",
968                "demo",
969                5,
970                ItemKind::Pull,
971            )
972            .await
973            .unwrap();
974        assert_eq!(r.status, 401);
975        let stranger = ForgeAgent::Pod {
976            webid: "https://pod.example/eve/profile/card#me".into(),
977            username: "eve".into(),
978        };
979        let r = svc
980            .put_item_labels(&req, &stranger, "casey", "demo", 5, ItemKind::Pull)
981            .await
982            .unwrap();
983        assert_eq!(r.status, 403);
984        let r = svc
985            .put_item_labels(&req, &casey(), "casey", "demo", 6, ItemKind::Pull)
986            .await
987            .unwrap();
988        assert_eq!(r.status, 404);
989        let pulls: Value = serde_json::from_slice(
990            &svc.spine
991                .load(PULLS_KIND, "casey", "demo")
992                .await
993                .unwrap()
994                .unwrap(),
995        )
996        .unwrap();
997        assert_eq!(pulls["pulls"]["5"]["labels"], json!(["enhancement"]));
998    }
999
1000    #[test]
1001    fn chips_use_luminance_text_and_escape() {
1002        let chips = chips_html(&[
1003            Label {
1004                name: "bug".into(),
1005                color: "d73a4a".into(),
1006            },
1007            Label {
1008                name: "triage".into(),
1009                color: "00ff00".into(),
1010            },
1011            Label {
1012                name: "<x>".into(),
1013                color: "\"onload=".into(),
1014            },
1015        ]);
1016        assert!(chips.contains("background:#d73a4a;color:#ffffff"));
1017        assert!(chips.contains("background:#00ff00;color:#1f2328"));
1018        assert!(chips.contains(">bug</span>"));
1019        assert!(chips.contains("&lt;x&gt;"));
1020        assert!(!chips.contains("onload"), "corrupt colours cannot inject");
1021    }
1022}