use serde::Deserialize;
use crate::core::trusty_tools_config::ResolvedTicketing;
pub const GITHUB_LABEL_MAX_LEN: usize = 50;
pub const CONVENTION_LABEL: &str = "trusty-mpm";
const CONVENTION_LABEL_COLOR: &str = "BFD4F2";
const CONVENTION_LABEL_DESCRIPTION: &str = "trusty-mpm platform and related work";
const WORKSTREAM_PREFIX: &str = "ws/";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PolicyLabel {
pub name: String,
#[serde(default)]
pub color: String,
#[serde(default)]
pub description: String,
}
impl PolicyLabel {
pub fn new(
name: impl Into<String>,
color: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
name: name.into(),
color: color.into(),
description: description.into(),
}
}
}
pub fn convention_label() -> PolicyLabel {
PolicyLabel::new(
CONVENTION_LABEL,
CONVENTION_LABEL_COLOR,
CONVENTION_LABEL_DESCRIPTION,
)
}
pub fn workstream_label(session_name: &str) -> Option<PolicyLabel> {
let name = session_name.trim();
if name.is_empty() {
return None;
}
Some(PolicyLabel::new(
label_name_for(name),
label_color_for(name),
format!("trusty-mpm workstream {name}"),
))
}
pub fn policy_labels(session_name: Option<&str>) -> Vec<PolicyLabel> {
let mut out = vec![convention_label()];
out.extend(session_name.and_then(workstream_label));
out
}
pub fn policy_labels_configured(
ticketing: &ResolvedTicketing,
session_name: Option<&str>,
) -> Vec<PolicyLabel> {
let mut out = policy_labels(session_name);
for label in &ticketing.extra_labels {
match out.iter_mut().find(|existing| existing.name == label.name) {
Some(existing) => *existing = label.clone(),
None => out.push(label.clone()),
}
}
out
}
pub fn is_owned_namespace(name: &str) -> bool {
name.starts_with(WORKSTREAM_PREFIX)
}
pub fn create_label_argv(label: &PolicyLabel, repo: Option<&str>, force: bool) -> Vec<String> {
let mut argv = vec![
"label".to_string(),
"create".to_string(),
label.name.clone(),
];
if !label.color.is_empty() {
argv.push("--color".to_string());
argv.push(label.color.clone());
}
if !label.description.is_empty() {
argv.push("--description".to_string());
argv.push(label.description.clone());
}
if let Some(repo) = repo {
argv.push("--repo".to_string());
argv.push(repo.to_string());
}
if force {
argv.push("--force".to_string());
}
argv
}
pub const LABEL_LIST_LIMIT: usize = 1000;
pub fn list_labels_argv(repo: Option<&str>, limit: usize) -> Vec<String> {
let mut argv = vec![
"label".to_string(),
"list".to_string(),
"--limit".to_string(),
limit.to_string(),
"--json".to_string(),
"name,color,description".to_string(),
];
if let Some(repo) = repo {
argv.push("--repo".to_string());
argv.push(repo.to_string());
}
argv
}
fn label_name_for(session_name: &str) -> String {
let full = format!("{WORKSTREAM_PREFIX}{session_name}");
if full.len() <= GITHUB_LABEL_MAX_LEN {
return full;
}
let suffix = format!("{:08x}", (fnv1a_hash(session_name) >> 32) as u32);
let prefix_budget = GITHUB_LABEL_MAX_LEN - WORKSTREAM_PREFIX.len() - 1 - suffix.len();
let truncated = truncate_at_char_boundary(session_name, prefix_budget);
format!("{WORKSTREAM_PREFIX}{truncated}-{suffix}")
}
fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
fn label_color_for(session_name: &str) -> String {
let hue = (fnv1a_hash(session_name) % 360) as f64;
hsl_to_hex(hue, 0.55, 0.45)
}
fn fnv1a_hash(input: &str) -> u64 {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET_BASIS;
for byte in input.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
fn hsl_to_hex(h: f64, s: f64, l: f64) -> String {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let h_prime = h / 60.0;
let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
let m = l - c / 2.0;
let (r1, g1, b1) = match h_prime as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let to_byte = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
format!("{:02X}{:02X}{:02X}", to_byte(r1), to_byte(g1), to_byte(b1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convention_label_is_stable() {
let l = convention_label();
assert_eq!(l.name, "trusty-mpm");
assert_eq!(l.color.len(), 6);
assert!(l.color.chars().all(|c| c.is_ascii_hexdigit()));
assert!(!l.description.is_empty());
}
#[test]
fn policy_labels_includes_workstream() {
let names: Vec<String> = policy_labels(Some("tm-tcode-01"))
.into_iter()
.map(|l| l.name)
.collect();
assert_eq!(names, ["trusty-mpm", "ws/tm-tcode-01"]);
}
#[test]
fn policy_labels_without_session_skips_workstream() {
let names: Vec<String> = policy_labels(None).into_iter().map(|l| l.name).collect();
assert_eq!(names, ["trusty-mpm"]);
}
#[test]
fn policy_labels_blank_session_skips_workstream() {
let names: Vec<String> = policy_labels(Some(" "))
.into_iter()
.map(|l| l.name)
.collect();
assert_eq!(names, ["trusty-mpm"]);
}
#[test]
fn configured_labels_match_builtin_when_block_absent() {
let default = ResolvedTicketing::default();
for session in [None, Some("tm-tcode-01")] {
assert_eq!(
policy_labels_configured(&default, session),
policy_labels(session),
"session={session:?}"
);
}
}
#[test]
fn configured_labels_append_extra_labels() {
let cfg = ResolvedTicketing::default().with_extra_labels(vec![PolicyLabel::new(
"area/cli",
"0E8A16",
"CLI surface",
)]);
let names: Vec<String> = policy_labels_configured(&cfg, Some("tm-tcode-01"))
.into_iter()
.map(|l| l.name)
.collect();
assert_eq!(names, ["trusty-mpm", "ws/tm-tcode-01", "area/cli"]);
}
#[test]
fn configured_labels_restyle_a_builtin_by_name() {
let cfg = ResolvedTicketing::default().with_extra_labels(vec![PolicyLabel::new(
CONVENTION_LABEL,
"FF0000",
"ours",
)]);
let labels = policy_labels_configured(&cfg, None);
assert_eq!(labels.len(), 1, "restyle must not duplicate: {labels:?}");
assert_eq!(labels[0].color, "FF0000");
}
#[test]
fn policy_labels_carry_no_retired_lifecycle_pair() {
let names: Vec<String> = policy_labels(Some("tm-tcode-01"))
.into_iter()
.map(|l| l.name)
.collect();
assert!(!names.iter().any(|n| n == "in-progress" || n == "blocked"));
}
#[test]
fn owned_namespace_is_ws_only() {
assert!(is_owned_namespace("ws/tm-tcode-01"));
assert!(!is_owned_namespace("trusty-mpm"));
assert!(!is_owned_namespace("status:coded"));
}
#[test]
fn label_color_is_stable_and_valid_hex() {
let a = label_color_for("tm-tcode-01");
let b = label_color_for("tm-tcode-01");
assert_eq!(a, b, "same name must always derive the same color");
assert_eq!(a.len(), 6);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(a, a.to_ascii_uppercase(), "color must be uppercase hex");
}
#[test]
fn label_color_differs_across_names() {
let a = label_color_for("tm-tcode-01");
let b = label_color_for("tm-dogfood-relaunch-01");
assert_ne!(a, b, "distinct names should (overwhelmingly likely) differ");
}
#[test]
fn label_name_short_is_verbatim() {
assert_eq!(label_name_for("tm-tcode-01"), "ws/tm-tcode-01");
}
#[test]
fn label_name_stays_within_github_cap() {
let long_name = "a".repeat(64);
let label = label_name_for(&long_name);
assert!(
label.len() <= GITHUB_LABEL_MAX_LEN,
"label {label:?} ({} chars) exceeds the {GITHUB_LABEL_MAX_LEN}-char GitHub cap",
label.len()
);
}
#[test]
fn label_name_long_is_truncated_with_hash_suffix() {
let long_name = "a".repeat(64);
let label = label_name_for(&long_name);
assert!(label.starts_with("ws/aaaa"), "got {label:?}");
assert_eq!(label.len(), GITHUB_LABEL_MAX_LEN);
let suffix = label.rsplit('-').next().expect("has a suffix segment");
assert_eq!(suffix.len(), 8, "suffix {suffix:?} must be 8 hex chars");
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn label_name_distinct_long_names_get_distinct_labels() {
let base = "x".repeat(60);
let label_a = label_name_for(&format!("{base}1111"));
let label_b = label_name_for(&format!("{base}2222"));
assert_ne!(
label_a, label_b,
"two long names differing only past the truncation point must not collide"
);
assert!(label_a.len() <= GITHUB_LABEL_MAX_LEN);
assert!(label_b.len() <= GITHUB_LABEL_MAX_LEN);
}
#[test]
fn create_label_argv_full() {
let l = PolicyLabel::new("unicorn:done", "0075CA", "Done");
assert_eq!(
create_label_argv(&l, None, false),
[
"label",
"create",
"unicorn:done",
"--color",
"0075CA",
"--description",
"Done"
]
);
}
#[test]
fn create_label_argv_omits_empty_fields() {
let l = PolicyLabel::new("T4", "", "");
assert_eq!(
create_label_argv(&l, None, false),
["label", "create", "T4"]
);
}
#[test]
fn create_label_argv_repo_and_force() {
let l = PolicyLabel::new("ws/x", "0E8A16", "d");
assert_eq!(
create_label_argv(&l, Some("owner/repo"), true),
[
"label",
"create",
"ws/x",
"--color",
"0E8A16",
"--description",
"d",
"--repo",
"owner/repo",
"--force"
]
);
}
#[test]
fn list_labels_argv_full() {
assert_eq!(
list_labels_argv(None, 1000),
[
"label",
"list",
"--limit",
"1000",
"--json",
"name,color,description"
]
);
}
#[test]
fn list_labels_argv_repo() {
assert_eq!(
list_labels_argv(Some("owner/repo"), 7),
[
"label",
"list",
"--limit",
"7",
"--json",
"name,color,description",
"--repo",
"owner/repo"
]
);
}
#[test]
fn list_labels_argv_requests_more_than_the_gh_default() {
const GH_DEFAULT_PAGE: usize = 30;
let argv = list_labels_argv(None, LABEL_LIST_LIMIT);
let limit: usize = argv
.iter()
.position(|a| a == "--limit")
.and_then(|i| argv.get(i + 1))
.expect("`gh label list` argv must carry an explicit --limit")
.parse()
.expect("--limit is a number");
assert_eq!(limit, LABEL_LIST_LIMIT);
assert!(
limit > GH_DEFAULT_PAGE,
"the seed probe must ask for more than gh's default {GH_DEFAULT_PAGE}-label page"
);
}
}