Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial support for AzureDevOps repositories. #15

Merged
merged 5 commits into from
Nov 3, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub enum Error {
},
GitLabDiffNotSupported,
BitbucketDiffNotSupported,
AzureDevOpsNotSupported,
NoUserInPath {
path: String,
},
Expand Down Expand Up @@ -115,6 +116,7 @@ impl fmt::Display for Error {
Error::OpenUrlFailure {url, msg} => write!(f, "{}: Cannot open URL {}", msg, url),
Error::GitLabDiffNotSupported => write!(f, "GitLab does not support '..' for comparing diff between commits. Please use '...'"),
Error::BitbucketDiffNotSupported => write!(f, "BitBucket does not support diff between commits (see https://bitbucket.org/site/master/issues/4779/ability-to-diff-between-any-two-commits)"),
Error::AzureDevOpsNotSupported => write!(f, "Azure Devops does not currently support this operation"),
Error::NoUserInPath{path} => write!(f, "Can't detect user name from path {}", path),
Error::NoRepoInPath{path} => write!(f, "Can't detect repository name from path {}", path),
Error::UnknownHostingService {url} => write!(f, "Unknown hosting service for URL {}. If you want to use custom URL for GitHub Enterprise, please set $GIT_BRWS_GHE_URL_HOST", url),
Expand Down
138 changes: 133 additions & 5 deletions src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,46 +263,174 @@ fn build_bitbucket_url(user: &str, repo: &str, cfg: &Config, page: &Page) -> Res
}
}

fn build_azdevops_url(team: &str, repo: &str, cfg: &Config, page: &Page) -> Result<String> {
match page {
Page::Open {
pull_request: true, ..
} => {
if let Some(ref b) = cfg.branch {
Ok(format!("https://dev.azure.com/{}/_git/{}/pullrequestcreate?sourceRef={}&targetRef=master", team, repo, b))
} else {
Err(Error::NoLocalRepoFound {
operation: "opening a pull request without specifying branch".to_string(),
})
}
}

Page::Open { .. } => {
if let Some(ref b) = cfg.branch {
Ok(format!(
"https://dev.azure.com/{}/_git/{}?version=GB{}",
team, repo, b
))
} else {
Ok(format!("https://dev.azure.com/{}/{}", team, repo))
}
}

Page::Commit { ref hash } => Ok(format!(
"https://dev.azure.com/{}/_git/{}/commit/{}",
team, repo, hash
)),

Page::Tag { ref tagname, .. } => Ok(format!(
"https://dev.azure.com/{}/_git/{}?version=GT{}",
team, repo, tagname
)),

Page::FileOrDir {
ref relative_path,
ref hash,
line: None,
blame,
} => Ok(format!(
"https://dev.azure.com/{}/_git/{}/commit/{}?path={}{}",
team,
repo,
hash,
to_slash(relative_path),
if *blame { "?_a=annotate" } else { "" },
)),

Page::Issue { number } => Ok(format!(
"https://dev.azure.com/{}/{}/_workitems/edit/{}",
team, repo, number
)),

_ => Err(Error::AzureDevOpsNotSupported),
}
}

fn is_azdevops(host: &str) -> bool {
match host {
"visualstudio.com" => true,
"vs-ssh.visualstudio.com" => true,
"dev.azure.com" => true,
"ssh.dev.azure.com" => true,
_ => false,
}
}

// Note: Parse '/team/_git/repo' or '/team/repo' into 'team' and 'repo'
pub fn azdevops_slug_from_path<'a>(path: &'a str) -> Result<(&'a str, &'a str)> {
let mut split = path.split('/').skip_while(|s| s.is_empty());

let mut team = split.next().ok_or_else(|| Error::NoUserInPath {
path: path.to_string(),
})?;

// Strip off v3 from Azure DevOps ssh:// paths.
// See: preprocess_repo_to_url
//
// Example: ssh://git@ssh.dev.azure.com:v3/team/repo/repo
//
if team == "v3" {
team = split.next().ok_or_else(|| Error::NoRepoInPath {
path: path.to_string(),
})?;
}

let mut repo = split.next().ok_or_else(|| Error::NoRepoInPath {
path: path.to_string(),
})?;

if repo.ends_with("_git") {
repo = split.next().ok_or_else(|| Error::NoRepoInPath {
path: path.to_string(),
})?;
bgianfo marked this conversation as resolved.
Show resolved Hide resolved
}
Ok((team, repo))
}

// Note: Parse '/user/repo.git' or '/user/repo' or 'user/repo' into 'user' and 'repo'
pub fn slug_from_path<'a>(path: &'a str) -> Result<(&'a str, &'a str)> {
let mut split = path.split('/').skip_while(|s| s.is_empty());
let user = split.next().ok_or_else(|| Error::NoUserInPath {
path: path.to_string(),
})?;

let mut repo = split.next().ok_or_else(|| Error::NoRepoInPath {
path: path.to_string(),
})?;

if repo.ends_with(".git") {
// Slice '.git' from 'repo.git'
repo = &repo[0..repo.len() - 4];
}

Ok((user, repo))
}

fn preprocess_repo_to_url(repo: &str) -> Result<Url> {
// Workaround Url::parse not being able to parse the SSH urls for AzureDevOps
// as they don't specify a port number, but use the colon syntax. It seems like
// the URL's don't adhere to the RFC? So we force a port number to the default
// SSH port so the Url will parse correctly.
//
// Example: ssh://git@ssh.dev.azure.com:v3/team/repo/repo
//
let processed_repo = if repo.contains("visualstudio.com:v3") || repo.contains("azure.com:v3") {
repo.replace(":v3/", ":22/v3/")
} else {
repo.to_string()
};

Url::parse(&processed_repo).map_err(|e| Error::BrokenUrl {
url: processed_repo,
msg: format!("{}", e),
})
}

// Known URL formats
// 1. https://hosting_service.com/user/repo.git
// 2. git@hosting_service.com:user/repo.git (-> ssh://git@hosting_service.com:22/user/repo.git)
pub fn build_page_url(page: &Page, cfg: &Config) -> Result<String> {
let repo_url = &cfg.repo;
let url = preprocess_repo_to_url(&repo_url)?;
let env = &cfg.env;

let url = Url::parse(repo_url).map_err(|e| Error::BrokenUrl {
url: repo_url.to_string(),
msg: format!("{}", e),
})?;
let path = url.path();
let (user, repo_name) = slug_from_path(path)?;
let host = url.host_str().ok_or_else(|| Error::BrokenUrl {
url: repo_url.to_string(),
msg: "No host in URL".to_string(),
})?;

let (user, repo_name) = if is_azdevops(host) {
azdevops_slug_from_path(path)?
} else {
slug_from_path(path)?
};

match host {
"github.com" => {
build_github_like_url(host, user, repo_name, Some("api.github.com"), cfg, page)
}
"gitlab.com" => build_gitlab_url(host, user, repo_name, cfg, page),
"bitbucket.org" => build_bitbucket_url(user, repo_name, cfg, page),
"visualstudio.com" => build_azdevops_url(user, repo_name, cfg, page),
"vs-ssh.visualstudio.com" => build_azdevops_url(user, repo_name, cfg, page),
"dev.azure.com" => build_azdevops_url(user, repo_name, cfg, page),
"ssh.dev.azure.com" => build_azdevops_url(user, repo_name, cfg, page),
_ => {
let is_gitlab = host.starts_with("gitlab.");
let port = if host.starts_with("github.") {
Expand Down
38 changes: 38 additions & 0 deletions src/test/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ fn convert_ssh_url() {
"ssh://git@bitbucket.org:22/user/repo.git",
"https://bitbucket.org/user/repo",
),
(
"ssh://team@vs-ssh.visualstudio.com:v3/team/repo/repo",
"https://dev.azure.com/team/repo",
),
(
"ssh://git@ssh.dev.azure.com:v3/team/repo/repo",
"https://dev.azure.com/team/repo",
),
] {
let c = config(repo, None, None);
assert_eq!(build_page_url(&OPEN, &c).unwrap(), expected);
Expand All @@ -97,6 +105,10 @@ fn open_page_url() {
"https://gitlab.com/user/repo.git",
"https://gitlab.com/user/repo",
),
(
"https://dev.azure.com/team/repo/_git/repo",
"https://dev.azure.com/team/repo",
),
] {
let c = config(repo, None, None);
assert_eq!(build_page_url(&OPEN, &c).unwrap(), expected);
Expand Down Expand Up @@ -126,6 +138,10 @@ fn open_branch_page_url() {
"https://gitlab.somewhere.com/user/repo.git",
"https://gitlab.somewhere.com/user/repo/tree/dev",
),
(
"https://dev.azure.com/team/_git/repo",
"https://dev.azure.com/team/_git/repo?version=GBdev",
),
] {
let c = config(repo, Some("dev"), None);
assert_eq!(build_page_url(&OPEN, &c).unwrap(), expected);
Expand Down Expand Up @@ -154,6 +170,10 @@ fn commit_page_url() {
"https://gitlab.com/user/repo.git",
"https://gitlab.com/user/repo/commit/90601f1037142605a32426f9ece0c07d479b9cc5",
),
(
"https://dev.azure.com/team/_git/repo",
"https://dev.azure.com/team/_git/repo/commit/90601f1037142605a32426f9ece0c07d479b9cc5",
),
] {
let c = config(repo, None, None);
assert_eq!(build_page_url(&p, &c).unwrap(), expected);
Expand Down Expand Up @@ -227,6 +247,20 @@ fn diff_page_for_bitbucket_url() {
);
}

#[test]
fn diff_page_for_azuredevops_url() {
let p = Page::Diff {
lhs: "561848bad7164d7568658456088b107ec9efd9f3".to_string(),
rhs: "90601f1037142605a32426f9ece0c07d479b9cc5".to_string(),
op: DiffOp::ThreeDots,
};
let c = config("https://dev.azure.com/team/repo/_git/repo", None, None);
assert!(
build_page_url(&p, &c).is_err(),
"azure devops does not support diff page"
);
}

#[test]
fn file_path_with_file_path() {
let relative_path = Path::new("src")
Expand Down Expand Up @@ -409,6 +443,10 @@ fn issue_number_url() {
"https://gitlab.com/user/repo.git",
"https://gitlab.com/user/repo/issues/123",
),
(
"https://dev.azure.com/team/repo/_git/repo",
"https://dev.azure.com/team/repo/_workitems/edit/123",
),
] {
let c = config(repo, None, None);
assert_eq!(build_page_url(&p, &c).unwrap(), expected);
Expand Down