use std::path::Path;
use std::time::Duration;
use trusty_common::github_path::GithubPath;
use crate::core::gh_account::run_bounded;
use crate::core::policy_labels::{self, PolicyLabel};
use crate::core::trusty_tools_config::{ResolvedTicketing, TrustyToolsConfig, resolve_ticketing};
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 Some(label) = policy_labels::workstream_label(session_name) else {
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!(?skip, "workstream label: repo not resolvable — skipping");
return skip;
}
};
let repo = gh_path.rel_path();
if create_policy_label(runner, &repo, &label) {
tracing::info!(repo = %repo, label = %label.name, "workstream label ensured");
LabelOutcome::Ensured
} else {
tracing::debug!(
repo = %repo,
label = %label.name,
"workstream label: `gh label create` failed/unavailable — skipping (non-fatal)"
);
LabelOutcome::GhFailed
}
}
fn create_policy_label<R: GhLabelRunner>(runner: &R, repo: &str, label: &PolicyLabel) -> bool {
let force = policy_labels::is_owned_namespace(&label.name);
runner.create_label(repo, label, force)
}
fn ensure_launch_labels_with<R: GhLabelRunner>(
runner: &R,
ticketing: &ResolvedTicketing,
repo_url: Option<&str>,
workspace_dir: &Path,
session_name: &str,
) {
if !ticketing.ensure_labels {
tracing::debug!("launch labels: agents.ticketing.ensure_labels is false — skipping");
return;
}
let _ = ensure_workstream_label_with(runner, repo_url, workspace_dir, session_name);
let Ok(gh_path) = resolve_github_repo(repo_url, workspace_dir) else {
return;
};
let repo = gh_path.rel_path();
for label in policy_labels::policy_labels_configured(ticketing, Some(session_name)) {
if policy_labels::is_owned_namespace(&label.name) {
continue;
}
if create_policy_label(runner, &repo, &label) {
tracing::info!(repo = %repo, label = %label.name, "policy label created");
} else {
tracing::debug!(
repo = %repo,
label = %label.name,
"policy label not created (already exists, or `gh` unavailable) — non-fatal"
);
}
}
}
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 || {
let ticketing = resolve_ticketing(&TrustyToolsConfig::load()).unwrap_or_else(|e| {
tracing::warn!("agents.ticketing is invalid ({e}); using the built-in standard");
ResolvedTicketing::default()
});
ensure_launch_labels_with(
&RealGhRunner,
&ticketing,
repo_url.as_deref(),
&workspace_dir,
&session_name,
)
})
.await
{
tracing::debug!("workstream label ensure task failed to join: {e}");
}
});
}
fn origin_url_from_workspace(dir: &Path) -> Option<String> {
let output = trusty_common::git::command_in(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)
}
trait GhLabelRunner {
fn create_label(&self, repo: &str, label: &PolicyLabel, force: bool) -> bool;
}
struct RealGhRunner;
impl GhLabelRunner for RealGhRunner {
fn create_label(&self, repo: &str, label: &PolicyLabel, force: bool) -> bool {
let argv = policy_labels::create_label_argv(label, Some(repo), force);
run_bounded(GH_LABEL_TIMEOUT, move || {
let borrowed: Vec<&str> = argv.iter().map(String::as_str).collect();
trusty_common::gh::GhCommand::new(borrowed)
.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());
}
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, label: &PolicyLabel, force: bool) -> bool {
self.calls.borrow_mut().push((
repo.to_string(),
label.name.clone(),
label.color.clone(),
label.description.clone(),
force,
));
self.succeed && !self.fail_for.contains(&label.name.as_str())
}
}
#[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 launch_labels_ensure_the_policy_set() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(runner.label_names(), ["ws/tm-tcode-01", "trusty-mpm"]);
}
#[test]
fn launch_labels_never_create_the_retired_lifecycle_pair() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
let names = runner.label_names();
assert!(
!names.iter().any(|n| n == "in-progress" || n == "blocked"),
"launch must not create the retired pair; got {names:?}"
);
}
#[test]
fn convention_label_is_created_without_force() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
let calls = runner.calls.borrow();
let (repo, name, color, description, force) = calls
.iter()
.find(|(_, name, ..)| name == "trusty-mpm")
.expect("the convention label is ensured at launch");
assert_eq!(repo, "bobmatnyc/trusty-tools");
assert_eq!(name, "trusty-mpm");
assert_eq!(color, &policy_labels::convention_label().color);
assert_eq!(
description,
&policy_labels::convention_label().description,
"description must come from the shared policy table"
);
assert!(
!force,
"an ordinary repo label — --force would rewrite a project's own \
color/description on every launch"
);
}
#[test]
fn convention_label_runs_even_when_workstream_label_fails() {
let runner = FakeGhRunner::failing_for(&["ws/tm-tcode-01"]);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(runner.label_names(), ["ws/tm-tcode-01", "trusty-mpm"]);
}
#[test]
fn workstream_label_runs_even_when_convention_label_fails() {
let runner = FakeGhRunner::failing_for(&["trusty-mpm"]);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(
runner.label_names(),
["ws/tm-tcode-01", "trusty-mpm"],
"a policy-label failure must not suppress the ws/ label"
);
}
#[test]
fn launch_labels_survive_total_gh_failure() {
let runner = FakeGhRunner::new(false);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(runner.label_names(), ["ws/tm-tcode-01", "trusty-mpm"]);
}
#[test]
fn launch_labels_skip_cleanly_on_non_github_remote() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
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_convention_label_when_session_name_is_blank() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
" ",
);
assert_eq!(runner.label_names(), ["trusty-mpm"]);
}
#[test]
fn launch_labels_match_builtin_when_the_block_is_absent() {
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ResolvedTicketing::default(),
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(runner.label_names(), ["ws/tm-tcode-01", "trusty-mpm"]);
}
#[test]
fn launch_labels_include_configured_extra_labels() {
let ticketing = ResolvedTicketing::default().with_extra_labels(vec![PolicyLabel::new(
"area/cli",
"0E8A16",
"CLI surface",
)]);
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ticketing,
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert_eq!(
runner.label_names(),
["ws/tm-tcode-01", "trusty-mpm", "area/cli"]
);
}
#[test]
fn launch_labels_skip_entirely_when_ensure_labels_is_false() {
let ticketing = ResolvedTicketing::default().with_ensure_labels(false);
let runner = FakeGhRunner::new(true);
ensure_launch_labels_with(
&runner,
&ticketing,
Some(REPO_URL),
Path::new("/nonexistent"),
"tm-tcode-01",
);
assert!(
runner.calls.borrow().is_empty(),
"ensure_labels: false turns the launch-time ensure off"
);
}
}