use std::path::Path;
use std::time::Duration;
use trusty_common::github_path::GithubPath;
use crate::core::gh_account::run_bounded;
const GH_LABEL_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LabelOutcome {
Ensured,
SkippedBlankName,
SkippedNoOrigin,
SkippedNonGithub,
GhFailed,
}
pub fn ensure_workstream_label(
repo_url: Option<&str>,
workspace_dir: &Path,
session_name: &str,
) -> LabelOutcome {
ensure_workstream_label_with(&RealGhRunner, repo_url, workspace_dir, session_name)
}
fn ensure_workstream_label_with<R: GhLabelRunner>(
runner: &R,
repo_url: Option<&str>,
workspace_dir: &Path,
session_name: &str,
) -> LabelOutcome {
let name = session_name.trim();
if name.is_empty() {
tracing::debug!("workstream label: blank session name — skipping");
return LabelOutcome::SkippedBlankName;
}
let origin = repo_url
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| origin_url_from_workspace(workspace_dir));
let Some(origin) = origin else {
tracing::debug!(session = %name, "workstream label: no git origin resolvable — skipping");
return LabelOutcome::SkippedNoOrigin;
};
let Some(gh_path) = owner_repo_from_origin(&origin) else {
tracing::debug!(
session = %name,
origin = %origin,
"workstream label: origin is not a github.com remote — skipping"
);
return LabelOutcome::SkippedNonGithub;
};
let label = label_name_for(name);
let color = label_color_for(name);
let description = format!("trusty-mpm workstream {name}");
let repo = gh_path.rel_path();
if runner.create_label(&repo, &label, &color, &description) {
tracing::info!(session = %name, repo = %repo, label = %label, "workstream label ensured");
LabelOutcome::Ensured
} else {
tracing::debug!(
session = %name,
repo = %repo,
label = %label,
"workstream label: `gh label create` failed/unavailable — skipping (non-fatal)"
);
LabelOutcome::GhFailed
}
}
pub fn spawn_workstream_label_ensure(
repo_url: Option<String>,
workspace_dir: std::path::PathBuf,
session_name: String,
) {
tokio::task::spawn(async move {
if let Err(e) = tokio::task::spawn_blocking(move || {
ensure_workstream_label(repo_url.as_deref(), &workspace_dir, &session_name)
})
.await
{
tracing::debug!("workstream label ensure task failed to join: {e}");
}
});
}
const GITHUB_LABEL_MAX_LEN: usize = 50;
fn label_name_for(session_name: &str) -> String {
let full = format!("ws/{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 - "ws/".len() - 1 - suffix.len();
let truncated = truncate_at_char_boundary(session_name, prefix_budget);
format!("ws/{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 origin_url_from_workspace(dir: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!url.is_empty()).then_some(url)
}
fn owner_repo_from_origin(origin: &str) -> Option<GithubPath> {
let trimmed = origin.trim();
if trimmed.is_empty() {
return None;
}
let after_scheme = trimmed
.find("://")
.map(|i| &trimmed[i + 3..])
.unwrap_or(trimmed);
let host_source = after_scheme
.find('@')
.map(|i| &after_scheme[i + 1..])
.unwrap_or(after_scheme);
let colon = host_source.find(':');
let slash = host_source.find('/');
let host = match (colon, slash) {
(Some(c), s) if s.is_none_or(|s| c < s) => &host_source[..c],
(_, Some(s)) => &host_source[..s],
_ => host_source,
};
if !host.eq_ignore_ascii_case("github.com") {
return None;
}
trusty_common::github_path::parse_github_path(trimmed)
}
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))
}
trait GhLabelRunner {
fn create_label(&self, repo: &str, name: &str, color: &str, description: &str) -> bool;
}
struct RealGhRunner;
impl GhLabelRunner for RealGhRunner {
fn create_label(&self, repo: &str, name: &str, color: &str, description: &str) -> bool {
let repo = repo.to_string();
let name = name.to_string();
let color = color.to_string();
let description = description.to_string();
run_bounded(GH_LABEL_TIMEOUT, move || {
std::process::Command::new("gh")
.args([
"label",
"create",
&name,
"--repo",
&repo,
"--color",
&color,
"--description",
&description,
"--force",
])
.output()
.ok()
.map(|out| out.status.success())
})
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[test]
fn owner_repo_from_ssh_origin() {
let gp = owner_repo_from_origin("git@github.com:bobmatnyc/trusty-tools.git")
.expect("github ssh origin parses");
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
}
#[test]
fn owner_repo_from_https_origin() {
let gp = owner_repo_from_origin("https://github.com/bobmatnyc/trusty-tools.git")
.expect("github https origin parses");
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
}
#[test]
fn owner_repo_from_https_origin_without_dot_git() {
let gp = owner_repo_from_origin("https://github.com/bobmatnyc/trusty-tools")
.expect("github https origin without .git parses");
assert_eq!(gp.owner, "bobmatnyc");
assert_eq!(gp.repo, "trusty-tools");
}
#[test]
fn owner_repo_rejects_non_github_host() {
assert!(owner_repo_from_origin("git@gitlab.com:acme/widget.git").is_none());
assert!(owner_repo_from_origin("https://bitbucket.org/acme/widget.git").is_none());
}
#[test]
fn owner_repo_rejects_empty() {
assert!(owner_repo_from_origin("").is_none());
assert!(owner_repo_from_origin(" ").is_none());
}
#[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 name_a = format!("{base}1111");
let name_b = format!("{base}2222");
let label_a = label_name_for(&name_a);
let label_b = label_name_for(&name_b);
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);
}
struct FakeGhRunner {
succeed: bool,
calls: RefCell<Vec<(String, String, String, String)>>,
}
impl FakeGhRunner {
fn new(succeed: bool) -> Self {
Self {
succeed,
calls: RefCell::new(Vec::new()),
}
}
}
impl GhLabelRunner for FakeGhRunner {
fn create_label(&self, repo: &str, name: &str, color: &str, description: &str) -> bool {
self.calls.borrow_mut().push((
repo.to_string(),
name.to_string(),
color.to_string(),
description.to_string(),
));
self.succeed
}
}
#[test]
fn ensure_skips_when_name_blank() {
let runner = FakeGhRunner::new(true);
let outcome = ensure_workstream_label_with(
&runner,
Some("https://github.com/bobmatnyc/trusty-tools.git"),
Path::new("/nonexistent"),
" ",
);
assert_eq!(outcome, LabelOutcome::SkippedBlankName);
assert!(runner.calls.borrow().is_empty(), "gh must not be invoked");
}
#[test]
fn ensure_skips_when_no_origin() {
let runner = FakeGhRunner::new(true);
let outcome = ensure_workstream_label_with(&runner, None, Path::new("/"), "tm-tcode-01");
assert_eq!(outcome, LabelOutcome::SkippedNoOrigin);
assert!(runner.calls.borrow().is_empty(), "gh must not be invoked");
}
#[test]
fn ensure_skips_when_non_github() {
let runner = FakeGhRunner::new(true);
let outcome = ensure_workstream_label_with(
&runner,
Some("git@gitlab.com:acme/widget.git"),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(outcome, LabelOutcome::SkippedNonGithub);
assert!(runner.calls.borrow().is_empty(), "gh must not be invoked");
}
#[test]
fn ensure_creates_label_via_runner() {
let runner = FakeGhRunner::new(true);
let outcome = ensure_workstream_label_with(
&runner,
Some("https://github.com/bobmatnyc/trusty-tools.git"),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(outcome, LabelOutcome::Ensured);
let calls = runner.calls.borrow();
assert_eq!(calls.len(), 1);
let (repo, name, color, description) = &calls[0];
assert_eq!(repo, "bobmatnyc/trusty-tools");
assert_eq!(name, "ws/tm-tcode-01");
assert_eq!(color.len(), 6);
assert_eq!(description, "trusty-mpm workstream tm-tcode-01");
}
#[test]
fn ensure_reports_runner_failure() {
let runner = FakeGhRunner::new(false);
let outcome = ensure_workstream_label_with(
&runner,
Some("https://github.com/bobmatnyc/trusty-tools.git"),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(outcome, LabelOutcome::GhFailed);
}
}