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 gh_path = match resolve_github_repo(repo_url, workspace_dir) {
Ok(gh_path) => gh_path,
Err(skip) => {
tracing::debug!(session = %name, ?skip, "workstream label: repo not resolvable — skipping");
return skip;
}
};
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, true) {
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
}
}
const LIFECYCLE_LABELS: [(&str, &str, &str); 2] = [
(
"in-progress",
"0E8A16",
"Work has started; a session or agent is on it",
),
(
"blocked",
"B60205",
"Blocked on a prerequisite; not scheduled",
),
];
fn ensure_lifecycle_labels_with<R: GhLabelRunner>(runner: &R, repo: &str) {
for (name, color, description) in LIFECYCLE_LABELS {
if runner.create_label(repo, name, color, description, false) {
tracing::info!(repo = %repo, label = %name, "issue lifecycle label created");
} else {
tracing::debug!(
repo = %repo,
label = %name,
"issue lifecycle label not created (already exists, or `gh` unavailable) — non-fatal"
);
}
}
}
fn ensure_launch_labels_with<R: GhLabelRunner>(
runner: &R,
repo_url: Option<&str>,
workspace_dir: &Path,
session_name: &str,
) {
let _ = ensure_workstream_label_with(runner, repo_url, workspace_dir, session_name);
if let Ok(gh_path) = resolve_github_repo(repo_url, workspace_dir) {
ensure_lifecycle_labels_with(runner, &gh_path.rel_path());
}
}
fn resolve_github_repo(
repo_url: Option<&str>,
workspace_dir: &Path,
) -> Result<GithubPath, LabelOutcome> {
let origin = repo_url
.map(str::to_string)
.filter(|s| !s.trim().is_empty())
.or_else(|| origin_url_from_workspace(workspace_dir))
.ok_or(LabelOutcome::SkippedNoOrigin)?;
owner_repo_from_origin(&origin).ok_or(LabelOutcome::SkippedNonGithub)
}
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_launch_labels_with(
&RealGhRunner,
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,
force: bool,
) -> bool;
}
struct RealGhRunner;
impl GhLabelRunner for RealGhRunner {
fn create_label(
&self,
repo: &str,
name: &str,
color: &str,
description: &str,
force: bool,
) -> 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 || {
let mut argv: Vec<&str> = vec![
"label",
"create",
&name,
"--repo",
&repo,
"--color",
&color,
"--description",
&description,
];
if force {
argv.push("--force");
}
trusty_common::gh::GhCommand::new(argv)
.output_blocking()
.ok()
.map(|out| out.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);
}
type RecordedCall = (String, String, String, String, bool);
struct FakeGhRunner {
succeed: bool,
fail_for: Vec<&'static str>,
calls: RefCell<Vec<RecordedCall>>,
}
impl FakeGhRunner {
fn new(succeed: bool) -> Self {
Self {
succeed,
fail_for: Vec::new(),
calls: RefCell::new(Vec::new()),
}
}
fn failing_for(names: &[&'static str]) -> Self {
Self {
succeed: true,
fail_for: names.to_vec(),
calls: RefCell::new(Vec::new()),
}
}
fn label_names(&self) -> Vec<String> {
self.calls
.borrow()
.iter()
.map(|(_, name, ..)| name.clone())
.collect()
}
}
impl GhLabelRunner for FakeGhRunner {
fn create_label(
&self,
repo: &str,
name: &str,
color: &str,
description: &str,
force: bool,
) -> bool {
self.calls.borrow_mut().push((
repo.to_string(),
name.to_string(),
color.to_string(),
description.to_string(),
force,
));
self.succeed && !self.fail_for.contains(&name)
}
}
#[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, force) = &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");
assert!(
force,
"the ws/ label is trusty-mpm's own — force is correct"
);
}
#[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);
}
const REPO_URL: &str = "https://github.com/bobmatnyc/trusty-tools.git";
#[test]
fn lifecycle_label_table_is_valid() {
for (name, color, description) in LIFECYCLE_LABELS {
assert!(!name.is_empty() && name.len() <= GITHUB_LABEL_MAX_LEN);
assert_eq!(color.len(), 6, "{name}'s color must be 6 hex chars");
assert!(color.chars().all(|c| c.is_ascii_hexdigit()));
assert!(!description.is_empty(), "{name} needs a description");
}
let names: Vec<&str> = LIFECYCLE_LABELS.iter().map(|(n, ..)| *n).collect();
assert_eq!(names, ["in-progress", "blocked"]);
}
#[test]
fn lifecycle_labels_are_created_without_force() {
let runner = FakeGhRunner::new(true);
ensure_lifecycle_labels_with(&runner, "bobmatnyc/trusty-tools");
let calls = runner.calls.borrow();
assert_eq!(calls.len(), LIFECYCLE_LABELS.len());
for (repo, name, color, description, force) in calls.iter() {
assert_eq!(repo, "bobmatnyc/trusty-tools");
assert!(
!force,
"{name} is an ordinary repo label — --force would rewrite a \
project's own color/description on every launch"
);
let (_, want_color, want_description) = LIFECYCLE_LABELS
.iter()
.find(|(n, ..)| n == name)
.expect("only table labels are created");
assert_eq!(color, want_color);
assert_eq!(description, want_description);
}
}
#[test]
fn lifecycle_labels_attempt_every_label_when_gh_fails() {
let runner = FakeGhRunner::new(false);
ensure_lifecycle_labels_with(&runner, "bobmatnyc/trusty-tools");
assert_eq!(runner.label_names(), ["in-progress", "blocked"]);
}
#[test]
fn launch_labels_ensure_all_three() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(
runner.label_names(),
["ws/tm-tcode-01", "in-progress", "blocked"]
);
}
#[test]
fn lifecycle_labels_run_even_when_workstream_label_fails() {
let runner = FakeGhRunner::failing_for(&["ws/tm-tcode-01"]);
ensure_launch_labels_with(
&runner,
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(
runner.label_names(),
["ws/tm-tcode-01", "in-progress", "blocked"]
);
}
#[test]
fn workstream_label_runs_even_when_lifecycle_labels_fail() {
let runner = FakeGhRunner::failing_for(&["in-progress", "blocked"]);
ensure_launch_labels_with(
&runner,
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(
runner.label_names(),
["ws/tm-tcode-01", "in-progress", "blocked"],
"a lifecycle-label failure must not suppress the ws/ label or the \
label after it"
);
}
#[test]
fn launch_labels_survive_total_gh_failure() {
let runner = FakeGhRunner::new(false);
ensure_launch_labels_with(
&runner,
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(
runner.label_names(),
["ws/tm-tcode-01", "in-progress", "blocked"]
);
}
#[test]
fn launch_labels_skip_cleanly_on_non_github_remote() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
Some("git@gitlab.com:acme/widget.git"),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert!(
runner.calls.borrow().is_empty(),
"no gh call belongs on a non-github remote"
);
}
#[test]
fn launch_labels_ensure_lifecycle_labels_when_session_name_is_blank() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(&runner, Some(REPO_URL), Path::new("/nonexistent"), " ");
assert_eq!(runner.label_names(), ["in-progress", "blocked"]);
}
}