pub mod azure;
pub mod gitea;
pub mod github;
pub mod gitlab;
mod info;
pub use azure::AzureDevOpsProvider;
pub use gitea::GiteaProvider;
pub use github::GitHubProvider;
pub use gitlab::GitLabProvider;
pub use info::{PlatformData, RemoteRefInfo};
use std::io::ErrorKind;
use std::path::Path;
use std::process::Output;
use anyhow::{Context, bail};
use crate::git::error::GitError;
use crate::git::{RefType, Repository};
use crate::shell_exec::Cmd;
pub trait RemoteRefProvider {
fn ref_type(&self) -> RefType;
fn platform_label(&self) -> &'static str;
fn fetch_info(&self, number: u32, repo: &Repository) -> anyhow::Result<RemoteRefInfo>;
fn ref_path(&self, number: u32) -> String;
fn tracking_ref(&self, number: u32) -> String {
format!("refs/{}", self.ref_path(number))
}
}
pub(super) struct CliApiRequest<'a> {
pub tool: &'a str,
pub args: &'a [&'a str],
pub repo_root: &'a Path,
pub prompt_env: (&'a str, &'a str),
pub install_hint: &'a str,
pub run_context: &'a str,
}
pub(super) fn run_cli_api(request: CliApiRequest<'_>) -> anyhow::Result<Output> {
match Cmd::new(request.tool)
.args(request.args.iter().copied())
.current_dir(request.repo_root)
.env(request.prompt_env.0, request.prompt_env.1)
.run()
{
Ok(output) => Ok(output),
Err(error) => {
if error.kind() == ErrorKind::NotFound {
bail!("{}", request.install_hint);
}
Err(anyhow::Error::from(error).context(request.run_context.to_string()))
}
}
}
pub(super) fn cli_api_error_details(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr.trim().to_string()
}
}
pub(super) fn cli_api_error(ref_type: RefType, message: String, output: &Output) -> anyhow::Error {
GitError::CliApiError {
ref_type,
message,
stderr: cli_api_error_details(output),
}
.into()
}
pub(super) fn extract_host_from_html_url(html_url: &str) -> anyhow::Result<String> {
html_url
.strip_prefix("https://")
.or_else(|| html_url.strip_prefix("http://"))
.and_then(|s| s.split('/').next())
.filter(|h| !h.is_empty())
.map(String::from)
.with_context(|| format!("Failed to parse host from PR URL: {html_url}"))
}
pub(super) fn cli_config_value(tool: &str, key: &str) -> Option<String> {
Cmd::new(tool)
.args(["config", "get", key])
.run()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn find_remote(repo: &Repository, info: &RemoteRefInfo) -> Result<String, GitError> {
let (matched, owner, repo_name) = match &info.platform_data {
PlatformData::GitHub {
base_owner,
base_repo,
..
}
| PlatformData::Gitea {
base_owner,
base_repo,
..
}
| PlatformData::GitLab {
base_owner,
base_repo,
..
} => (
repo.find_remote_for_repo(None, base_owner, base_repo),
base_owner.as_str(),
base_repo.as_str(),
),
PlatformData::AzureDevOps {
organization,
project,
repo_name,
..
} => (
repo.find_remote_for_azure(organization, project, repo_name),
organization.as_str(),
repo_name.as_str(),
),
};
matched.ok_or_else(|| {
let suggested_url = match &info.platform_data {
PlatformData::GitHub {
host,
base_owner,
base_repo,
..
} => github::fork_remote_url(host, base_owner, base_repo),
PlatformData::Gitea {
host,
base_owner,
base_repo,
..
} => gitea::fork_remote_url(host, base_owner, base_repo),
PlatformData::GitLab {
host,
base_owner,
base_repo,
..
} => gitlab::fork_remote_url(host, base_owner, base_repo),
PlatformData::AzureDevOps {
host,
organization,
project,
repo_name,
} => azure::fork_remote_url(host, organization, project, repo_name),
};
GitError::NoRemoteForRepo {
owner: owner.to_string(),
repo: repo_name.to_string(),
suggested_url,
}
})
}
pub fn branch_tracks_ref(
repo_root: &Path,
branch: &str,
provider: &dyn RemoteRefProvider,
number: u32,
expected_remote: Option<&str>,
) -> Option<bool> {
let expected_ref = provider.tracking_ref(number);
crate::git::branch_tracks_ref(repo_root, branch, &expected_ref, expected_remote)
}
pub fn local_branch_name(info: &RemoteRefInfo) -> String {
info.source_branch.clone()
}
struct RefUrlParts<'a> {
scheme: &'a str,
segments: Vec<&'a str>,
marker_index: usize,
kind: &'static str,
number: u32,
}
fn parse_ref_url_parts(input: &str) -> Option<RefUrlParts<'_>> {
let trimmed = input.trim();
let scheme_end = trimmed.find("://")?;
let scheme = &trimmed[..scheme_end];
if scheme != "https" && scheme != "http" {
return None;
}
let rest = &trimmed[scheme_end + 3..];
let path = rest.split(['?', '#']).next()?;
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 5 {
return None;
}
for (marker_index, pair) in segments.windows(2).enumerate() {
let Ok(number) = pair[1].parse::<u32>() else {
continue;
};
let kind = match pair[0] {
"pull" | "pulls" | "pullrequest" => "pr",
"merge_requests" => "mr",
_ => continue,
};
return Some(RefUrlParts {
scheme,
segments,
marker_index,
kind,
number,
});
}
None
}
pub fn parse_ref_url(input: &str) -> Option<String> {
let parts = parse_ref_url_parts(input)?;
Some(format!("{}:{}", parts.kind, parts.number))
}
pub fn repo_url_from_ref_url(input: &str) -> Option<String> {
let parts = parse_ref_url_parts(input)?;
let mut repo_segments = &parts.segments[..parts.marker_index];
if repo_segments.last() == Some(&"-") {
repo_segments = &repo_segments[..repo_segments.len() - 1];
}
if repo_segments.len() < 3 {
return None;
}
Some(format!("{}://{}", parts.scheme, repo_segments.join("/")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ref_paths() {
let gh = GitHubProvider;
assert_eq!(gh.ref_path(123), "pull/123/head");
assert_eq!(gh.tracking_ref(123), "refs/pull/123/head");
let ge = GiteaProvider;
assert_eq!(ge.ref_path(7), "pull/7/head");
assert_eq!(ge.tracking_ref(7), "refs/pull/7/head");
let gl = GitLabProvider;
assert_eq!(gl.ref_path(42), "merge-requests/42/head");
assert_eq!(gl.tracking_ref(42), "refs/merge-requests/42/head");
}
#[test]
fn parse_ref_url_github() {
assert_eq!(
parse_ref_url("https://github.com/owner/repo/pull/123").as_deref(),
Some("pr:123")
);
assert_eq!(
parse_ref_url("https://github.acme.com/team/repo/pull/9").as_deref(),
Some("pr:9")
);
assert_eq!(
parse_ref_url("https://github.com/owner/repo/pull/2895/files").as_deref(),
Some("pr:2895")
);
assert_eq!(
parse_ref_url("https://github.com/owner/repo/pull/77#discussion_r1").as_deref(),
Some("pr:77")
);
assert_eq!(
parse_ref_url("http://github.com/owner/repo/pull/1").as_deref(),
Some("pr:1")
);
}
#[test]
fn parse_ref_url_gitlab() {
assert_eq!(
parse_ref_url("https://gitlab.com/group/repo/-/merge_requests/42").as_deref(),
Some("mr:42")
);
assert_eq!(
parse_ref_url("https://gitlab.com/group/sub/repo/-/merge_requests/7").as_deref(),
Some("mr:7")
);
assert_eq!(
parse_ref_url("https://gitlab.example.com/team/repo/-/merge_requests/12/diffs")
.as_deref(),
Some("mr:12")
);
}
#[test]
fn parse_ref_url_gitea() {
assert_eq!(
parse_ref_url("https://codeberg.org/owner/repo/pulls/55").as_deref(),
Some("pr:55")
);
assert_eq!(
parse_ref_url("https://gitea.example.com/team/repo/pulls/3").as_deref(),
Some("pr:3")
);
}
#[test]
fn parse_ref_url_azure_devops() {
assert_eq!(
parse_ref_url("https://dev.azure.com/org/project/_git/repo/pullrequest/9").as_deref(),
Some("pr:9")
);
assert_eq!(
parse_ref_url("https://myorg.visualstudio.com/myproject/_git/repo/pullrequest/9")
.as_deref(),
Some("pr:9")
);
}
#[test]
fn parse_ref_url_rejects_non_urls() {
assert_eq!(parse_ref_url("pr:123"), None);
assert_eq!(parse_ref_url("feature/pull/7"), None);
assert_eq!(parse_ref_url("pull/123"), None);
assert_eq!(parse_ref_url("https://example.com/pull/1"), None);
assert_eq!(parse_ref_url("https://github.com/o/r/issues/5"), None);
assert_eq!(parse_ref_url("https://github.com/o/r/pull/new"), None);
assert_eq!(parse_ref_url(""), None);
assert_eq!(parse_ref_url(" "), None);
}
#[test]
fn repo_url_from_ref_url_per_forge() {
let cases = [
(
"https://github.com/upstream/repo/pull/123",
"https://github.com/upstream/repo",
),
(
"https://github.com/owner/repo/pull/2895/files",
"https://github.com/owner/repo",
),
(
"https://github.com/owner/repo/pull/77#discussion_r1",
"https://github.com/owner/repo",
),
(
"https://github.acme.com/team/repo/pull/9",
"https://github.acme.com/team/repo",
),
(
"https://gitlab.com/group/sub/repo/-/merge_requests/7/diffs",
"https://gitlab.com/group/sub/repo",
),
(
"https://codeberg.org/owner/repo/pulls/55",
"https://codeberg.org/owner/repo",
),
(
"https://dev.azure.com/org/project/_git/repo/pullrequest/9",
"https://dev.azure.com/org/project/_git/repo",
),
(
"http://github.com/owner/repo/pull/1",
"http://github.com/owner/repo",
),
];
for (input, expected) in cases {
assert_eq!(
repo_url_from_ref_url(input).as_deref(),
Some(expected),
"input: {input}"
);
}
}
#[test]
fn repo_url_from_ref_url_rejects_non_pr_urls() {
assert_eq!(repo_url_from_ref_url("https://github.com/owner/repo"), None);
assert_eq!(
repo_url_from_ref_url("https://github.com/o/r/issues/5"),
None
);
assert_eq!(repo_url_from_ref_url("https://example.com/pull/1"), None);
assert_eq!(repo_url_from_ref_url("pr:123"), None);
assert_eq!(repo_url_from_ref_url(""), None);
}
}