1use 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
32pub const PULLS_KIND: &str = "pulls";
34pub const LABELS_PER_ITEM: usize = 20;
36
37pub const DEFAULT_LABELS: [(&str, &str); 5] = [
39 ("bug", "d73a4a"),
40 ("enhancement", "a2eeef"),
41 ("documentation", "0075ca"),
42 ("question", "d876e3"),
43 ("wontfix", "ffffff"),
44];
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ItemKind {
49 Issue,
51 Pull,
53}
54
55#[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#[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#[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#[must_use]
110pub fn effective_labels(idx: &IssueIndex) -> Vec<Label> {
111 idx.labels.clone().unwrap_or_else(default_labels)
112}
113
114pub fn ensure_labels(idx: &mut IssueIndex) -> &mut Vec<Label> {
116 idx.labels.get_or_insert_with(default_labels)
117}
118
119#[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#[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#[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
190pub 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
202pub 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 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 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 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 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 #[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 #[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 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 #[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 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 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 #[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 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 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 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 #[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("<x>"));
1020 assert!(!chips.contains("onload"), "corrupt colours cannot inject");
1021 }
1022}