diff --git a/.claude/skills/bflow/SKILL.md b/.claude/skills/bflow/SKILL.md index 18edc37..3ac3850 100644 --- a/.claude/skills/bflow/SKILL.md +++ b/.claude/skills/bflow/SKILL.md @@ -7,7 +7,7 @@ description: ALWAYS load this skill if you interact with GIT branches! This skil ## Hard Rule -**NEVER** use `git branch`, `git merge`, `git tag`, `gh pr create`, or any other raw git/gh command for branch lifecycle operations. **ALL** branch creation, merging, tagging, PR creation, and version bumping MUST go through `bflow`. +**NEVER** use `git branch`, `git merge`, `git tag`, `gh pr create`, `az repos pr create`, or any other raw git/gh/az command for branch lifecycle operations. **ALL** branch creation, merging, tagging, PR creation, and version bumping MUST go through `bflow`. **ONLY EXCEPTION:** The user explicitly asks you to bypass bflow. @@ -78,7 +78,7 @@ PR bodies resolve from `.github/pr-templates/bflow-.md`, most-specific firs 1. Branch-specific: `bflow-.md` (e.g. `bflow-release-fix.md`) 2. Group: the fix family (`fix`, `release-fix`, `hotfix-fix`) shares `bflow-fix.md`; other types' group == their own name 3. `bflow-default.md` -4. Repo's git default (`.github/PULL_REQUEST_TEMPLATE.md` etc.), else empty body +4. Repo's git default (`.github/PULL_REQUEST_TEMPLATE.md` etc. on GitHub; `.azuredevops/pull_request_template.md` etc. on Azure DevOps), else empty body Opt-in: with no `.github/pr-templates/`, behavior is unchanged. @@ -143,6 +143,8 @@ Not available for `start release`. ## Prerequisites bflow runs preflight checks automatically: -- `git` and `gh` must be installed -- `gh auth login` must be completed +- `git` must be installed +- The hosting provider is auto-detected from the origin remote URL (`dev.azure.com` / `*.visualstudio.com` → Azure DevOps, else GitHub); override with `git config bflow.hosting.provider github|devops` +- GitHub repos: `gh` installed + `gh auth login` completed +- Azure DevOps repos: `az` installed + `az extension add --name azure-devops` + authenticated (`az login`, or a PAT via `az devops login`) - Uncommitted changes are auto-stashed and restored after the operation diff --git a/README.md b/README.md index 8734190..f92cff4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,18 @@ cargo install --path . ### Requirements - [git](https://git-scm.com/) -- [gh](https://cli.github.com/) (GitHub CLI, authenticated via `gh auth login`) +- For GitHub repos: [gh](https://cli.github.com/) (GitHub CLI, authenticated via `gh auth login`) +- For Azure DevOps repos: [az](https://learn.microsoft.com/cli/azure/) (Azure CLI) with the `azure-devops` extension (`az extension add --name azure-devops`), authenticated via `az login` or a PAT via `az devops login` + +### Hosting provider + +bflow auto-detects the hosting provider from the `origin` remote URL: `dev.azure.com` and `*.visualstudio.com` remotes use Azure DevOps (org/project/repo are parsed from the URL), everything else uses GitHub. Only the CLI of the detected provider needs to be installed. + +To override detection (e.g. a GitHub Enterprise domain), set: + +```bash +git config bflow.hosting.provider github # or: devops (requires an Azure DevOps origin URL) +``` ## Branch Model @@ -219,7 +230,7 @@ When `bflow finish` opens a PR, it picks the body template by branch type. Place 1. **Branch-specific** — `bflow-.md` (e.g. `bflow-release-fix.md`) 2. **Group** — the fix family (`fix`, `release-fix`, `hotfix-fix`) shares `bflow-fix.md`; every other type's group equals its own name 3. **Default** — `bflow-default.md` -4. **Git default** — the repo's own `.github/PULL_REQUEST_TEMPLATE.md` (and the other paths `gh` recognizes), else an empty body +4. **Git default** — the repo's own default template, else an empty body. On GitHub: `.github/PULL_REQUEST_TEMPLATE.md` and the other paths `gh` recognizes. On Azure DevOps: `.azuredevops/pull_request_template.md` and the other ADO default paths. | File | Applies to | |------|-----------| @@ -230,7 +241,7 @@ When `bflow finish` opens a PR, it picks the body template by branch type. Place | `bflow-chore.md` / `bflow-docs.md` / `bflow-refactor.md` | `chore/*` / `docs/*` / `refactor/*` | | `bflow-default.md` | any PR with no more specific match | -The feature is opt-in: with no `.github/pr-templates/` directory, bflow falls back to the existing git default behavior unchanged. +The feature is opt-in: with no `.github/pr-templates/` directory, bflow falls back to the existing git default behavior unchanged. bflow templates live in `.github/pr-templates/` on every hosting provider, including Azure DevOps. ### Release-only commands @@ -564,7 +575,9 @@ src/ │ └── branch.rs — Branch type detection and parsing ├── hosting/ │ ├── mod.rs — Hosting platform trait -│ └── github.rs — GitHub implementation via gh CLI +│ ├── detect.rs — Provider detection from the origin remote URL +│ ├── github.rs — GitHub implementation via gh CLI +│ └── devops.rs — Azure DevOps implementation via az CLI ├── flows/ │ ├── start.rs — Start work/release-fix/hotfix-fix │ ├── finish_work.rs — PR creation for work branches @@ -577,7 +590,7 @@ src/ └── worktree.rs — Worktree config, path resolution, and setup wizard ``` -The `Git` and `HostingPlatform` traits enable future extensibility (e.g. GitLab, Bitbucket) and testability. +The `Git` and `HostingPlatform` traits enable multiple hosting providers (GitHub and Azure DevOps today, GitLab/Bitbucket-ready) and testability. ## License diff --git a/src/git/mod.rs b/src/git/mod.rs index 46ae7b9..24a712e 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -41,6 +41,10 @@ pub trait Git { fn git_dir(&self) -> Result; fn rev_parse(&self, refname: &str) -> Result; + /// URL of the `origin` remote (`git remote get-url origin`). Errors when the + /// remote does not exist; callers decide how to handle a missing remote. + fn remote_url(&self) -> Result; + // Worktree / config primitives /// Read a git config value (`git config --get `). Returns `None` when unset. fn get_config(&self, key: &str) -> Result>; @@ -237,6 +241,10 @@ impl Git for GitCli { self.run(&["rev-parse", refname]) } + fn remote_url(&self) -> Result { + self.run(&["remote", "get-url", "origin"]) + } + fn get_config(&self, key: &str) -> Result> { self.run_config(&["config", "--get", key]) } diff --git a/src/hosting/detect.rs b/src/hosting/detect.rs new file mode 100644 index 0000000..4f42657 --- /dev/null +++ b/src/hosting/detect.rs @@ -0,0 +1,228 @@ +//! Hosting-provider detection. +//! +//! The provider is auto-detected from the `origin` remote URL. The git config +//! key `bflow.hosting.provider` (`github` | `devops`) overrides detection for +//! edge cases (e.g. GitHub Enterprise domains). + +use crate::git::Git; +use super::Result; + +const PROVIDER_KEY: &str = "bflow.hosting.provider"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Provider { + GitHub, + AzureDevOps { org: String, project: String, repo: String }, +} + +/// Detect the hosting provider from the `bflow.hosting.provider` override and +/// the `origin` remote URL. +pub fn detect(git: &dyn Git) -> Result { + let override_val = git.get_config(PROVIDER_KEY)?; + let remote = git.remote_url().ok(); + resolve(remote.as_deref(), override_val.as_deref()) +} + +/// Pure resolution core (unit-tested without a git repo). +/// +/// - Override `github` → GitHub unconditionally. +/// - Override `devops` → the remote must be an Azure DevOps URL (az needs the +/// org/project/repo coordinates) or an error is returned. +/// - No override → parse the remote; unrecognized hosts and missing remotes +/// fall back to GitHub (preserves pre-detection behavior). +fn resolve(remote_url: Option<&str>, override_val: Option<&str>) -> Result { + match override_val.map(str::trim) { + Some("github") => Ok(Provider::GitHub), + Some("devops") => match remote_url.and_then(parse_remote) { + Some(p @ Provider::AzureDevOps { .. }) => Ok(p), + _ => Err(format!( + "{PROVIDER_KEY} is 'devops' but the Azure DevOps organization/project/repository could not be determined from the origin remote URL." + )), + }, + Some(other) => Err(format!( + "Invalid {PROVIDER_KEY} value '{other}'. Valid values: github, devops." + )), + None => Ok(remote_url.and_then(parse_remote).unwrap_or(Provider::GitHub)), + } +} + +/// Parse a remote URL into a provider. Returns `None` for unrecognized hosts. +fn parse_remote(url: &str) -> Option { + let url = url.trim().trim_end_matches('/'); + let url = url.strip_suffix(".git").unwrap_or(url); + + // SSH scp-like syntax: git@host:path + if let Some(rest) = url.strip_prefix("git@") { + let (host, path) = rest.split_once(':')?; + return parse_host_path(host, path); + } + + // URL syntax: scheme://[user@]host/path + let rest = url.split_once("://").map(|(_, r)| r)?; + let rest = rest.rsplit_once('@').map_or(rest, |(_, r)| r); + let (host, path) = rest.split_once('/')?; + parse_host_path(host, path) +} + +fn parse_host_path(host: &str, path: &str) -> Option { + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + + match host { + "github.com" => Some(Provider::GitHub), + // https://dev.azure.com/{org}/{project}/_git/{repo} + "dev.azure.com" => match segments.as_slice() { + [org, project, "_git", repo] => Some(azure(org, project, repo)), + _ => None, + }, + // git@ssh.dev.azure.com:v3/{org}/{project}/{repo} + "ssh.dev.azure.com" => match segments.as_slice() { + ["v3", org, project, repo] => Some(azure(org, project, repo)), + _ => None, + }, + // Legacy https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo} + _ => { + let org = host.strip_suffix(".visualstudio.com")?; + match segments.as_slice() { + [project, "_git", repo] | ["DefaultCollection", project, "_git", repo] => { + Some(azure(org, project, repo)) + } + _ => None, + } + } + } +} + +fn azure(org: &str, project: &str, repo: &str) -> Provider { + Provider::AzureDevOps { + org: org.to_string(), + // https remotes percent-encode spaces in project names; az wants the + // display name. Spaces are the only realistic case in ADO names. + project: project.replace("%20", " "), + repo: repo.replace("%20", " "), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ado(org: &str, project: &str, repo: &str) -> Provider { + Provider::AzureDevOps { + org: org.to_string(), + project: project.to_string(), + repo: repo.to_string(), + } + } + + #[test] + fn github_https() { + assert_eq!(parse_remote("https://github.com/acme/repo.git"), Some(Provider::GitHub)); + assert_eq!(parse_remote("https://github.com/acme/repo"), Some(Provider::GitHub)); + } + + #[test] + fn github_ssh() { + assert_eq!(parse_remote("git@github.com:acme/repo.git"), Some(Provider::GitHub)); + assert_eq!(parse_remote("ssh://git@github.com/acme/repo.git"), Some(Provider::GitHub)); + } + + #[test] + fn devops_https() { + assert_eq!( + parse_remote("https://dev.azure.com/beans/Shop/_git/backend"), + Some(ado("beans", "Shop", "backend")), + ); + } + + #[test] + fn devops_https_with_user() { + assert_eq!( + parse_remote("https://beans@dev.azure.com/beans/Shop/_git/backend"), + Some(ado("beans", "Shop", "backend")), + ); + } + + #[test] + fn devops_https_percent_encoded_project() { + assert_eq!( + parse_remote("https://dev.azure.com/beans/My%20Shop/_git/backend"), + Some(ado("beans", "My Shop", "backend")), + ); + } + + #[test] + fn devops_ssh_v3() { + assert_eq!( + parse_remote("git@ssh.dev.azure.com:v3/beans/Shop/backend"), + Some(ado("beans", "Shop", "backend")), + ); + assert_eq!( + parse_remote("ssh://git@ssh.dev.azure.com/v3/beans/Shop/backend"), + Some(ado("beans", "Shop", "backend")), + ); + } + + #[test] + fn devops_legacy_visualstudio() { + assert_eq!( + parse_remote("https://beans.visualstudio.com/Shop/_git/backend"), + Some(ado("beans", "Shop", "backend")), + ); + assert_eq!( + parse_remote("https://beans.visualstudio.com/DefaultCollection/Shop/_git/backend"), + Some(ado("beans", "Shop", "backend")), + ); + } + + #[test] + fn trailing_slash_and_git_suffix_stripped() { + assert_eq!( + parse_remote("https://dev.azure.com/beans/Shop/_git/backend.git/"), + Some(ado("beans", "Shop", "backend")), + ); + } + + #[test] + fn unrecognized_hosts_yield_none() { + assert_eq!(parse_remote("https://gitlab.com/acme/repo.git"), None); + assert_eq!(parse_remote("https://github.enterprise.corp/acme/repo"), None); + assert_eq!(parse_remote("not-a-url"), None); + assert_eq!(parse_remote("https://dev.azure.com/beans/backend"), None); + } + + #[test] + fn no_override_unrecognized_or_missing_remote_defaults_to_github() { + assert_eq!(resolve(Some("https://gitlab.com/a/b"), None), Ok(Provider::GitHub)); + assert_eq!(resolve(None, None), Ok(Provider::GitHub)); + } + + #[test] + fn no_override_ado_remote_detected() { + assert_eq!( + resolve(Some("https://dev.azure.com/beans/Shop/_git/backend"), None), + Ok(ado("beans", "Shop", "backend")), + ); + } + + #[test] + fn override_github_wins_over_ado_remote() { + assert_eq!( + resolve(Some("https://dev.azure.com/beans/Shop/_git/backend"), Some("github")), + Ok(Provider::GitHub), + ); + } + + #[test] + fn override_devops_requires_parsable_ado_remote() { + let err = resolve(Some("https://github.com/acme/repo"), Some("devops")).unwrap_err(); + assert!(err.contains("could not be determined"), "{err}"); + let err = resolve(None, Some("devops")).unwrap_err(); + assert!(err.contains("could not be determined"), "{err}"); + } + + #[test] + fn override_invalid_value_errors() { + let err = resolve(Some("https://github.com/acme/repo"), Some("gitlab")).unwrap_err(); + assert!(err.contains("Valid values: github, devops"), "{err}"); + } +} diff --git a/src/hosting/devops.rs b/src/hosting/devops.rs new file mode 100644 index 0000000..dcb087f --- /dev/null +++ b/src/hosting/devops.rs @@ -0,0 +1,173 @@ +use super::{run_cli, HostingPlatform, Result}; + +pub struct AzureDevOps { + org: String, + project: String, + repo: String, +} + +impl AzureDevOps { + pub fn new(org: String, project: String, repo: String) -> Self { + Self { org, project, repo } + } + + fn org_url(&self) -> String { + // Valid for legacy {org}.visualstudio.com organizations too. + format!("https://dev.azure.com/{}", self.org) + } + + /// Canonical PR web URL, built from the coordinates parsed out of the remote. + /// az's `repository.webUrl` is unreliable (absent in `pr list` responses, + /// legacy-format for visualstudio.com orgs), so it is never used. + fn pr_url(&self, id: &str) -> String { + format!( + "{}/{}/_git/{}/pullrequest/{id}", + self.org_url(), + encode_segment(&self.project), + encode_segment(&self.repo), + ) + } + + fn run_az(&self, args: &[String]) -> Result { + run_cli("az", args) + } + + fn repo_args(&self) -> Vec { + vec![ + "--organization".into(), self.org_url(), + "--project".into(), self.project.clone(), + "--repository".into(), self.repo.clone(), + ] + } +} + +/// URL-encode a path segment. Spaces (decoded during remote-URL detection) are +/// the only realistic case in ADO project/repo names. +fn encode_segment(s: &str) -> String { + s.replace(' ', "%20") +} + +/// Validate a `--query pullRequestId -o tsv` result: a single line of digits. +/// Anything else (empty, az's "None" for null) is a hard error. +fn validate_pr_id(id: &str) -> Result<&str> { + let id = id.trim(); + if !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) { + Ok(id) + } else { + Err(format!("Unexpected az pull request id: '{id}'")) + } +} + +/// az's `--description` is a list argument where each element becomes one line. +fn description_args(body: &str) -> Vec { + let mut args = vec!["--description".to_string()]; + args.extend(body.split('\n').map(|l| l.to_string())); + args +} + +impl HostingPlatform for AzureDevOps { + fn create_or_get_pr(&self, head: &str, base: &str, title: &str, template: Option<&str>) -> Result { + // Return the existing active PR for this head/base if there is one. + let mut list_args: Vec = vec!["repos".into(), "pr".into(), "list".into()]; + list_args.extend(self.repo_args()); + list_args.extend([ + "--source-branch".into(), head.into(), + "--target-branch".into(), base.into(), + "--status".into(), "active".into(), + "--query".into(), "[0].pullRequestId".into(), + "-o".into(), "tsv".into(), + ]); + let existing = self.run_az(&list_args)?; + if !existing.is_empty() { + return Ok(self.pr_url(validate_pr_id(&existing)?)); + } + + // A bflow-resolved template (branch-specific/group/default) wins; otherwise fall + // back to the repository's own default PR template, then to an empty body. + let default_paths = [ + ".azuredevops/pull_request_template.md", + ".azuredevops/PULL_REQUEST_TEMPLATE.md", + ".vsts/pull_request_template.md", + "pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.md", + ]; + let body_file = template + .map(|p| p.to_string()) + .or_else(|| default_paths.iter().find(|p| std::path::Path::new(p).exists()).map(|p| p.to_string())); + let body = match body_file { + Some(path) => std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read PR template '{path}': {e}"))?, + None => String::new(), + }; + + let mut create_args: Vec = vec!["repos".into(), "pr".into(), "create".into()]; + create_args.extend(self.repo_args()); + create_args.extend([ + "--source-branch".into(), head.into(), + "--target-branch".into(), base.into(), + "--title".into(), title.into(), + ]); + create_args.extend(description_args(&body)); + create_args.extend([ + "--query".into(), "pullRequestId".into(), + "-o".into(), "tsv".into(), + ]); + let created = self.run_az(&create_args)?; + Ok(self.pr_url(validate_pr_id(&created)?)) + } + + fn check_auth(&self) -> Result<()> { + // Explicit extension check first: it also prevents az's interactive + // dynamic-install prompt from firing inside a non-tty command later. + self.run_az(&["extension".into(), "show".into(), "--name".into(), "azure-devops".into()]) + .map_err(|e| format!("Azure DevOps CLI extension is missing. Run 'az extension add --name azure-devops'.\n{e}"))?; + // Probe actual repo access rather than `az account show`: PAT auth via + // `az devops login` (or AZURE_DEVOPS_EXT_PAT) works without an `az login` + // session, which `account show` would wrongly report as unauthenticated. + let mut args: Vec = vec!["repos".into(), "show".into()]; + args.extend(self.repo_args()); + args.extend(["--query".into(), "id".into(), "-o".into(), "tsv".into()]); + self.run_az(&args).map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pr_url_is_canonical_dev_azure_form() { + // Legacy visualstudio.com orgs get the canonical URL too — it redirects. + let ado = AzureDevOps::new("wilko".into(), "Shuttel".into(), "Shuttel".into()); + assert_eq!(ado.pr_url("2662"), "https://dev.azure.com/wilko/Shuttel/_git/Shuttel/pullrequest/2662"); + } + + #[test] + fn pr_url_encodes_spaces_in_project_and_repo() { + let ado = AzureDevOps::new("beans".into(), "My Shop".into(), "the repo".into()); + assert_eq!(ado.pr_url("7"), "https://dev.azure.com/beans/My%20Shop/_git/the%20repo/pullrequest/7"); + } + + #[test] + fn validate_pr_id_accepts_digits_only() { + assert_eq!(validate_pr_id("2662"), Ok("2662")); + assert_eq!(validate_pr_id(" 42\n"), Ok("42")); + assert!(validate_pr_id("").is_err()); + assert!(validate_pr_id("None").is_err()); + assert!(validate_pr_id("https://x\t42").is_err()); + } + + #[test] + fn description_args_one_arg_per_line() { + assert_eq!( + description_args("line one\nline two\n\nline four"), + vec!["--description", "line one", "line two", "", "line four"], + ); + } + + #[test] + fn description_args_empty_body_is_single_empty_line() { + assert_eq!(description_args(""), vec!["--description", ""]); + } +} diff --git a/src/hosting/github.rs b/src/hosting/github.rs index b855059..1a5f4e3 100644 --- a/src/hosting/github.rs +++ b/src/hosting/github.rs @@ -1,20 +1,16 @@ -use std::process::Command; -use super::{HostingPlatform, Result}; +use super::{run_cli, HostingPlatform, Result}; pub struct GitHub; +impl Default for GitHub { + fn default() -> Self { Self::new() } +} + impl GitHub { pub fn new() -> Self { Self } fn run_gh(&self, args: &[&str]) -> Result { - let output = Command::new("gh").args(args).output() - .map_err(|e| format!("Failed to run gh: {e}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(format!("gh {} failed: {}", args.join(" "), stderr)) - } + run_cli("gh", args) } } @@ -45,17 +41,6 @@ impl HostingPlatform for GitHub { } } - fn open_url(&self, url: &str) -> Result<()> { - #[cfg(target_os = "macos")] - let result = Command::new("open").arg(url).output(); - #[cfg(target_os = "windows")] - let result = Command::new("cmd").args(["/C", "start", "", url]).output(); - #[cfg(target_os = "linux")] - let result = Command::new("xdg-open").arg(url).output(); - result.map_err(|e| format!("Failed to open URL: {e}"))?; - Ok(()) - } - fn check_auth(&self) -> Result<()> { self.run_gh(&["auth", "status"]).map(|_| ()) } diff --git a/src/hosting/mod.rs b/src/hosting/mod.rs index 8ed38f2..f26df9f 100644 --- a/src/hosting/mod.rs +++ b/src/hosting/mod.rs @@ -1,3 +1,5 @@ +pub mod detect; +pub mod devops; pub mod github; pub mod template; @@ -8,6 +10,45 @@ pub trait HostingPlatform { /// `Some`, its contents become the PR body; when `None`, the platform falls back to /// the repository's own default template. fn create_or_get_pr(&self, head: &str, base: &str, title: &str, template: Option<&str>) -> Result; - fn open_url(&self, url: &str) -> Result<()>; + fn open_url(&self, url: &str) -> Result<()> { + open_in_browser(url) + } fn check_auth(&self) -> Result<()>; } + +/// Run a hosting CLI (`gh`, `az`, ...), returning trimmed stdout on success or a +/// `" failed: "` error. Shared by all provider implementations. +fn run_cli(program: &str, args: &[impl AsRef]) -> Result { + use std::process::Command; + let output = Command::new(program).args(args.iter().map(|a| a.as_ref())).output() + .map_err(|e| format!("Failed to run {program}: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let joined = args.iter().map(|a| a.as_ref()).collect::>().join(" "); + Err(format!("{program} {joined} failed: {stderr}")) + } +} + +/// Open a URL in the OS default browser (platform dispatch, provider-agnostic). +pub fn open_in_browser(url: &str) -> Result<()> { + use std::process::Command; + #[cfg(target_os = "macos")] + let result = Command::new("open").arg(url).output(); + #[cfg(target_os = "windows")] + let result = Command::new("cmd").args(["/C", "start", "", url]).output(); + #[cfg(target_os = "linux")] + let result = Command::new("xdg-open").arg(url).output(); + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + let result: std::io::Result = + Err(std::io::Error::other("no browser-opener known for this platform")); + + let output = result.map_err(|e| format!("Failed to open URL: {e}"))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(format!("Failed to open URL: {stderr}")) + } +} diff --git a/src/main.rs b/src/main.rs index 2904003..5c3f587 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ use bflow::cli::{Commands, WorktreeAction, resolve_action}; use bflow::git::GitCli; use bflow::git::Git; use bflow::git::branch::BranchType; +use bflow::hosting::detect::{self, Provider}; +use bflow::hosting::devops::AzureDevOps; use bflow::hosting::github::GitHub; use bflow::hosting::HostingPlatform; use bflow::menu::{self, Action}; @@ -44,16 +46,12 @@ fn run(command: Option) -> Result<(), String> { other => other, }; - check_command_exists("gh")?; - let hosting = GitHub::new(); - - hosting.check_auth().map_err(|e| { - format!("GitHub CLI is not authenticated. Run 'gh auth login' first.\n{e}") - })?; - + // Provider detection reads the origin remote, so the repo check comes first. let branch_name = git.current_branch().map_err(|_| { "Not in a git repository.".to_string() })?; + + let hosting = create_hosting(&git)?; let git_dir = git.git_dir()?; // One-time upgrade of any pre-2.4 global state file into the per-branch folder. @@ -126,7 +124,7 @@ fn run(command: Option) -> Result<(), String> { write_state_for_action(&action, &branch_type, &git_dir, stash_msg.clone())?; } - let result = run_flow(&git, &hosting, &branch_type, &branch_name, &action, no_checkout, worktree_active, &wt_config, &editor, resume_state.as_ref()); + let result = run_flow(&git, &*hosting, &branch_type, &branch_name, &action, no_checkout, worktree_active, &wt_config, &editor, resume_state.as_ref()); // Lifecycle: clear state on success of a release/hotfix finish. Both a fresh // finish and a resume run on the source branch, so its identity is available. @@ -287,7 +285,7 @@ fn unresolved_merge_message(resume_state: Option<&FinishState>) -> String { #[allow(clippy::too_many_arguments)] fn run_flow( git: &GitCli, - hosting: &GitHub, + hosting: &dyn HostingPlatform, branch_type: &BranchType, branch_name: &str, action: &Action, @@ -376,6 +374,29 @@ fn run_flow( Ok(()) } +/// Detect the hosting provider for this repo and return a ready-to-use, +/// preflighted (CLI installed + authenticated) hosting backend. +fn create_hosting(git: &dyn Git) -> Result, String> { + match detect::detect(git)? { + Provider::GitHub => { + check_command_exists("gh")?; + let hosting = GitHub::new(); + hosting.check_auth().map_err(|e| { + format!("GitHub CLI is not authenticated. Run 'gh auth login' first.\n{e}") + })?; + Ok(Box::new(hosting)) + } + Provider::AzureDevOps { org, project, repo } => { + check_command_exists("az")?; + let hosting = AzureDevOps::new(org, project, repo); + hosting.check_auth().map_err(|e| { + format!("Azure CLI is not ready for Azure DevOps. Run 'az login' (or 'az devops login' with a PAT).\n{e}") + })?; + Ok(Box::new(hosting)) + } + } +} + fn run_worktree_config(git: &GitCli, action: Option, local: bool) -> Result<(), String> { match action { None => worktree::wizard(git, local), diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f299eac..073b917 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -41,6 +41,8 @@ pub struct MockGit { pub stashes: RefCell>, /// git config values returned by `get_config` (key -> value). pub config: HashMap, + /// URL returned by `remote_url`. + pub remote_url: String, /// Value returned by `repo_root`. pub repo_root: PathBuf, } @@ -71,6 +73,7 @@ impl MockGit { git_dir: PathBuf::from(".git"), stashes: RefCell::new(Vec::new()), config: HashMap::new(), + remote_url: "https://github.com/acme/repo.git".to_string(), repo_root: PathBuf::from("/repos/beans-gitflow"), } } @@ -249,6 +252,11 @@ impl Git for MockGit { Ok("abc123".to_string()) } + fn remote_url(&self) -> Result { + self.calls.borrow_mut().push("remote_url".to_string()); + Ok(self.remote_url.clone()) + } + fn get_config(&self, key: &str) -> Result, String> { self.calls.borrow_mut().push(format!("get_config:{key}")); Ok(self.config.get(key).cloned())