Files
refray/src/provider.rs
T
2026-05-10 11:07:08 +00:00

1314 lines
42 KiB
Rust

use std::collections::HashMap;
use anyhow::{Context, Result, anyhow, bail};
use console::style;
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use serde_json::json;
use url::Url;
use crate::config::{
Config, EndpointConfig, MirrorConfig, NamespaceKind, ProviderKind, RepoNameFilter, SiteConfig,
Visibility,
};
#[derive(Clone, Debug)]
pub struct RemoteRepo {
pub name: String,
pub clone_url: String,
pub private: bool,
pub description: Option<String>,
}
#[derive(Clone, Debug)]
pub struct EndpointRepo {
pub endpoint: EndpointConfig,
pub repo: RemoteRepo,
}
#[derive(Clone, Debug)]
pub struct PullRequestRequest {
pub title: String,
pub body: String,
pub head_branch: String,
pub base_branch: String,
}
#[derive(Clone, Debug)]
pub struct PullRequestInfo {
pub url: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebhookInstallOutcome {
Created,
Existing,
}
pub fn list_mirror_repos(
config: &Config,
mirror: &MirrorConfig,
repo_filter: &RepoNameFilter,
jobs: usize,
) -> Result<Vec<EndpointRepo>> {
let endpoint_jobs = mirror
.endpoints
.iter()
.cloned()
.enumerate()
.collect::<Vec<_>>();
let worker_count = jobs.min(endpoint_jobs.len());
if worker_count > 1 {
crate::logln!(
" {} listing repositories with {} workers",
style("jobs").cyan().bold(),
worker_count
);
}
let mut listed = crate::parallel::map(endpoint_jobs, jobs, |(index, endpoint)| {
let site = config.site(&endpoint.site).unwrap();
let client = ProviderClient::new(site)?;
crate::logln!(
" {} {}",
style("list").cyan().bold(),
style(endpoint.label()).dim()
);
let repos = client
.list_repos(&endpoint)
.with_context(|| format!("failed to list repos for {}", endpoint.label()))?;
let repos = repos
.into_iter()
.filter(|repo| mirror.sync_visibility.matches_private(repo.private))
.filter(|repo| repo_filter.matches(&repo.name))
.map(|repo| EndpointRepo {
endpoint: endpoint.clone(),
repo,
})
.collect::<Vec<_>>();
Ok((index, repos))
})?;
listed.sort_by_key(|(index, _)| *index);
Ok(listed.into_iter().flat_map(|(_, repos)| repos).collect())
}
pub struct ProviderClient<'a> {
site: &'a SiteConfig,
token: String,
http: Client,
}
macro_rules! dispatch_provider {
($provider:expr, github => $github:expr, gitlab => $gitlab:expr, gitea_like => $gitea_like:expr $(,)?) => {
match $provider {
ProviderKind::Github => $github,
ProviderKind::Gitlab => $gitlab,
ProviderKind::Gitea | ProviderKind::Forgejo => $gitea_like,
}
};
}
macro_rules! owned_repos {
($client:expr, $repo:ty, $url:expr, $namespace:expr) => {{
Ok($client
.paged_get::<$repo>($url)?
.into_iter()
.filter(|repo: &$repo| repo.owner.login.eq_ignore_ascii_case($namespace))
.map(Into::into)
.collect())
}};
}
macro_rules! json_method {
($name:ident, $method:literal, $request:ident) => {
fn $name<T>(&self, url: &str, body: &serde_json::Value) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
self.send_json($method, self.http.$request(url), url, body)
}
};
}
impl<'a> ProviderClient<'a> {
pub fn new(site: &'a SiteConfig) -> Result<Self> {
let token = site.token()?;
Ok(Self {
site,
token,
http: Client::builder().build()?,
})
}
pub fn list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
dispatch_provider!(self.site.provider,
github => self.github_list_repos(endpoint),
gitlab => self.gitlab_list_repos(endpoint),
gitea_like => self.gitea_list_repos(endpoint),
)
}
pub fn create_repo(
&self,
endpoint: &EndpointConfig,
name: &str,
visibility: &Visibility,
description: Option<&str>,
) -> Result<RemoteRepo> {
dispatch_provider!(self.site.provider,
github => self.github_create_repo(endpoint, name, visibility, description),
gitlab => self.gitlab_create_repo(endpoint, name, visibility, description),
gitea_like => self.gitea_create_repo(endpoint, name, visibility, description),
)
}
pub fn delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> {
dispatch_provider!(self.site.provider,
github => self.github_delete_repo(endpoint, repo_name),
gitlab => self.gitlab_delete_repo(endpoint, repo_name),
gitea_like => self.gitea_delete_repo(endpoint, repo_name),
)
}
pub fn install_webhook(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
url: &str,
secret: &str,
) -> Result<WebhookInstallOutcome> {
dispatch_provider!(self.site.provider,
github => self.github_install_webhook(endpoint, repo, url, secret),
gitlab => self.gitlab_install_webhook(endpoint, repo, url, secret),
gitea_like => self.gitea_install_webhook(endpoint, repo, url, secret),
)
}
pub fn uninstall_webhook(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
url: &str,
) -> Result<bool> {
dispatch_provider!(self.site.provider,
github => self.github_uninstall_webhook(endpoint, repo_name, url),
gitlab => self.gitlab_uninstall_webhook(endpoint, repo_name, url),
gitea_like => self.gitea_uninstall_webhook(endpoint, repo_name, url),
)
}
pub fn open_pull_request(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
request: &PullRequestRequest,
) -> Result<PullRequestInfo> {
dispatch_provider!(self.site.provider,
github => self.github_open_pull_request(endpoint, repo, request),
gitlab => self.gitlab_open_pull_request(endpoint, repo, request),
gitea_like => self.gitea_open_pull_request(endpoint, repo, request),
)
}
pub fn close_pull_requests_by_head_prefix(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
base_branch: &str,
head_prefix: &str,
) -> Result<usize> {
dispatch_provider!(self.site.provider,
github => self.github_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix),
gitlab => self.gitlab_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix),
gitea_like => self.gitea_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix),
)
}
pub fn validate_token(&self) -> Result<()> {
let url = format!("{}/user", self.site.api_base());
self.get(&url).map(|_| ())
}
pub fn detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
dispatch_provider!(self.site.provider,
github => self.github_detect_namespace_kind(namespace),
gitlab => self.gitlab_detect_namespace_kind(namespace),
gitea_like => self.gitea_detect_namespace_kind(namespace),
)
}
pub fn authenticated_clone_url(&self, clone_url: &str) -> Result<String> {
let mut url = Url::parse(clone_url)
.or_else(|_| Url::parse(&format!("{}/{}", self.site.base_url, clone_url)))
.with_context(|| format!("failed to parse clone URL '{clone_url}'"))?;
if url.scheme() != "https" && url.scheme() != "http" {
bail!("only HTTP(S) clone URLs are supported, got '{clone_url}'");
}
let username = self
.site
.git_username
.clone()
.unwrap_or_else(|| default_git_username(&self.site.provider).to_string());
url.set_username(&username)
.map_err(|_| anyhow!("failed to set username on clone URL"))?;
url.set_password(Some(&self.token))
.map_err(|_| anyhow!("failed to set token on clone URL"))?;
Ok(url.to_string())
}
fn github_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
match endpoint.kind {
NamespaceKind::User => {
let url = format!(
"{}/user/repos?affiliation=owner&visibility=all&per_page=100",
self.site.api_base()
);
owned_repos!(self, GithubRepo, &url, &endpoint.namespace)
}
NamespaceKind::Org => {
let url = format!(
"{}/orgs/{}/repos?type=all&per_page=100",
self.site.api_base(),
endpoint.namespace
);
self.paged_remote_repos::<GithubRepo>(&url)
}
NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"),
}
}
fn github_create_repo(
&self,
endpoint: &EndpointConfig,
name: &str,
visibility: &Visibility,
description: Option<&str>,
) -> Result<RemoteRepo> {
let url = match endpoint.kind {
NamespaceKind::User => format!("{}/user/repos", self.site.api_base()),
NamespaceKind::Org => {
format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace)
}
NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"),
};
let body = json!({
"name": name,
"private": matches!(visibility, Visibility::Private),
"description": description.unwrap_or(""),
});
self.post_json::<GithubRepo>(&url, &body).map(Into::into)
}
fn github_detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
let url = format!("{}/users/{namespace}", self.site.api_base());
let value: serde_json::Value = self.get_json(&url)?;
Ok(match value.get("type").and_then(|value| value.as_str()) {
Some("Organization") => Some(NamespaceKind::Org),
Some("User") => Some(NamespaceKind::User),
_ => None,
})
}
fn github_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> {
let url = self.repo_url(endpoint, repo_name, "GitHub")?;
self.delete(&url).map(|_| ())
}
fn github_install_webhook(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
url: &str,
secret: &str,
) -> Result<WebhookInstallOutcome> {
let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "GitHub")?;
let body = json!({
"name": "web",
"active": true,
"events": ["push"],
"config": {
"url": url,
"content_type": "json",
"secret": secret,
"insecure_ssl": "0",
},
});
self.upsert_hook(&hooks_url, url, &body, false)
}
fn github_uninstall_webhook(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
url: &str,
) -> Result<bool> {
let hooks_url = self.repo_hooks_url(endpoint, repo_name, "GitHub")?;
self.delete_matching_hook(&hooks_url, url)
}
fn github_open_pull_request(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
request: &PullRequestRequest,
) -> Result<PullRequestInfo> {
let url = self.repo_pulls_url(endpoint, &repo.name, "GitHub")?;
if let Some(existing) =
self.github_find_open_pull_request(&url, &request.head_branch, &request.base_branch)?
{
return Ok(PullRequestInfo {
url: existing.url(),
});
}
let body = json!({
"title": request.title,
"body": request.body,
"head": request.head_branch,
"base": request.base_branch,
});
let created: ProviderPullRequest = self.post_json(&url, &body)?;
Ok(PullRequestInfo { url: created.url() })
}
fn github_find_open_pull_request(
&self,
pulls_url: &str,
head_branch: &str,
base_branch: &str,
) -> Result<Option<ProviderPullRequest>> {
let url = format!(
"{pulls_url}?state=open&base={}&per_page=100",
urlencoding(base_branch)
);
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
Ok(pulls
.into_iter()
.find(|pull| pull.head_ref() == Some(head_branch)))
}
fn github_close_pull_requests_by_head_prefix(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
base_branch: &str,
head_prefix: &str,
) -> Result<usize> {
let pulls_url = self.repo_pulls_url(endpoint, &repo.name, "GitHub")?;
let url = format!(
"{pulls_url}?state=open&base={}&per_page=100",
urlencoding(base_branch)
);
let pulls: Vec<ProviderPullRequest> = match self.paged_get(&url) {
Ok(pulls) => pulls,
Err(error) if is_not_found_error(&error) => return Ok(0),
Err(error) => return Err(error),
};
let mut closed = 0;
for pull in pulls.into_iter().filter(|pull| {
pull.head_ref()
.is_some_and(|head| head.starts_with(head_prefix))
}) {
let Some(number) = pull.number else {
continue;
};
let update_url = format!("{pulls_url}/{number}");
self.patch_json::<serde_json::Value>(&update_url, &json!({ "state": "closed" }))?;
closed += 1;
}
Ok(closed)
}
fn gitlab_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
match endpoint.kind {
NamespaceKind::User => {
let url = format!(
"{}/users/{}/projects?simple=true&per_page=100&owned=true",
self.site.api_base(),
endpoint.namespace
);
let mut projects = self.paged_get::<GitlabProject>(&url)?;
let owned_url = format!(
"{}/projects?owned=true&simple=true&per_page=100",
self.site.api_base()
);
for project in self.paged_get::<GitlabProject>(&owned_url)? {
if project.is_in_namespace(&endpoint.namespace)
&& !projects.iter().any(|existing| existing.same_path(&project))
{
projects.push(project);
}
}
Ok(projects.into_iter().map(Into::into).collect())
}
NamespaceKind::Org | NamespaceKind::Group => {
let encoded = urlencoding(&endpoint.namespace);
let url = format!(
"{}/groups/{}/projects?simple=true&include_subgroups=false&per_page=100",
self.site.api_base(),
encoded
);
self.paged_remote_repos::<GitlabProject>(&url)
}
}
}
fn gitlab_create_repo(
&self,
endpoint: &EndpointConfig,
name: &str,
visibility: &Visibility,
description: Option<&str>,
) -> Result<RemoteRepo> {
let mut body = serde_json::Map::from_iter([
("name".to_string(), json!(name)),
("path".to_string(), json!(name)),
(
"visibility".to_string(),
json!(match visibility {
Visibility::Private => "private",
Visibility::Public => "public",
}),
),
("description".to_string(), json!(description.unwrap_or(""))),
]);
if matches!(endpoint.kind, NamespaceKind::Org | NamespaceKind::Group) {
let group = self.gitlab_group(&endpoint.namespace)?;
body.insert("namespace_id".to_string(), json!(group.id));
}
let url = format!("{}/projects", self.site.api_base());
match self.post_json::<GitlabProject>(&url, &serde_json::Value::Object(body)) {
Ok(project) => Ok(project.into()),
Err(error) if is_already_taken_error(&error) => {
let existing_url = self.gitlab_project_url(endpoint, name);
self.get_json::<GitlabProject>(&existing_url)
.map(Into::into)
.with_context(|| {
format!(
"GitLab reported that {name} already exists, but failed to fetch {existing_url}: {error:#}"
)
})
}
Err(error) => Err(error),
}
}
fn gitlab_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> {
let url = self.gitlab_project_url(endpoint, repo_name);
self.delete(&url).map(|_| ())
}
fn gitlab_group(&self, namespace: &str) -> Result<GitlabGroup> {
let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace));
self.get_json(&url)
}
fn gitlab_detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
let group_url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace));
if self.get(&group_url).is_ok() {
return Ok(Some(NamespaceKind::Group));
}
let username = namespace.rsplit('/').next().unwrap_or(namespace);
let user_url = format!(
"{}/users?username={}",
self.site.api_base(),
urlencoding(username)
);
let users: serde_json::Value = self.get_json(&user_url)?;
Ok(users
.as_array()
.is_some_and(|items| !items.is_empty())
.then_some(NamespaceKind::User))
}
fn gitlab_install_webhook(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
url: &str,
secret: &str,
) -> Result<WebhookInstallOutcome> {
let hooks_url = self.gitlab_hooks_url(endpoint, &repo.name);
let body = json!({
"url": url,
"push_events": true,
"tag_push_events": true,
"token": secret,
"enable_ssl_verification": true,
});
self.upsert_hook(&hooks_url, url, &body, true)
}
fn gitlab_uninstall_webhook(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
url: &str,
) -> Result<bool> {
let hooks_url = self.gitlab_hooks_url(endpoint, repo_name);
self.delete_matching_hook(&hooks_url, url)
}
fn gitlab_open_pull_request(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
request: &PullRequestRequest,
) -> Result<PullRequestInfo> {
let url = self.gitlab_merge_requests_url(endpoint, &repo.name);
if let Some(existing) =
self.gitlab_find_open_merge_request(&url, &request.head_branch, &request.base_branch)?
{
return Ok(PullRequestInfo {
url: existing.url(),
});
}
let body = json!({
"title": request.title,
"description": request.body,
"source_branch": request.head_branch,
"target_branch": request.base_branch,
});
let created: ProviderPullRequest = self.post_json(&url, &body)?;
Ok(PullRequestInfo { url: created.url() })
}
fn gitlab_find_open_merge_request(
&self,
merge_requests_url: &str,
source_branch: &str,
target_branch: &str,
) -> Result<Option<ProviderPullRequest>> {
let url = format!(
"{merge_requests_url}?state=opened&source_branch={}&target_branch={}&per_page=100",
urlencoding(source_branch),
urlencoding(target_branch)
);
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
Ok(pulls
.into_iter()
.find(|pull| pull.head_ref() == Some(source_branch)))
}
fn gitlab_close_pull_requests_by_head_prefix(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
base_branch: &str,
head_prefix: &str,
) -> Result<usize> {
let merge_requests_url = self.gitlab_merge_requests_url(endpoint, &repo.name);
let url = format!(
"{merge_requests_url}?state=opened&target_branch={}&per_page=100",
urlencoding(base_branch)
);
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
let mut closed = 0;
for pull in pulls.into_iter().filter(|pull| {
pull.head_ref()
.is_some_and(|head| head.starts_with(head_prefix))
}) {
let Some(number) = pull.iid.or(pull.number) else {
continue;
};
let update_url = format!("{merge_requests_url}/{number}");
self.put_json::<serde_json::Value>(&update_url, &json!({ "state_event": "close" }))?;
closed += 1;
}
Ok(closed)
}
fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
match endpoint.kind {
NamespaceKind::User => {
let url = format!("{}/user/repos", self.site.api_base());
Ok(self
.gitea_paged_get::<GiteaRepo>(&url)?
.into_iter()
.filter(|repo| repo.owner.login.eq_ignore_ascii_case(&endpoint.namespace))
.map(Into::into)
.collect())
}
NamespaceKind::Org => {
let url = format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace);
Ok(self
.gitea_paged_get::<GiteaRepo>(&url)?
.into_iter()
.map(Into::into)
.collect())
}
NamespaceKind::Group => bail!("Gitea/Forgejo endpoints use kind 'user' or 'org'"),
}
}
fn gitea_create_repo(
&self,
endpoint: &EndpointConfig,
name: &str,
visibility: &Visibility,
description: Option<&str>,
) -> Result<RemoteRepo> {
let url = match endpoint.kind {
NamespaceKind::User => format!("{}/user/repos", self.site.api_base()),
NamespaceKind::Org => {
format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace)
}
NamespaceKind::Group => bail!("Gitea/Forgejo endpoints use kind 'user' or 'org'"),
};
let body = json!({
"name": name,
"private": matches!(visibility, Visibility::Private),
"description": description.unwrap_or(""),
"auto_init": false,
});
match self.post_json::<GiteaRepo>(&url, &body) {
Ok(repo) => Ok(repo.into()),
Err(error) if is_conflict_error(&error) => {
let repo_url = self.repo_url(endpoint, name, "Gitea/Forgejo")?;
self.get_json::<GiteaRepo>(&repo_url).map(Into::into)
}
Err(error) => Err(error),
}
}
fn gitea_detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
let org_url = format!("{}/orgs/{namespace}", self.site.api_base());
if self.get(&org_url).is_ok() {
return Ok(Some(NamespaceKind::Org));
}
let user_url = format!("{}/users/{namespace}", self.site.api_base());
if self.get(&user_url).is_ok() {
return Ok(Some(NamespaceKind::User));
}
Ok(None)
}
fn gitea_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> {
let url = self.repo_url(endpoint, repo_name, "Gitea/Forgejo")?;
self.delete(&url).map(|_| ())
}
fn gitea_install_webhook(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
url: &str,
secret: &str,
) -> Result<WebhookInstallOutcome> {
let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "Gitea/Forgejo")?;
let body = json!({
"type": "gitea",
"active": true,
"events": ["push"],
"config": {
"url": url,
"content_type": "json",
"secret": secret,
},
});
self.upsert_hook(&hooks_url, url, &body, false)
}
fn gitea_uninstall_webhook(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
url: &str,
) -> Result<bool> {
let hooks_url = self.repo_hooks_url(endpoint, repo_name, "Gitea/Forgejo")?;
self.delete_matching_hook(&hooks_url, url)
}
fn gitea_open_pull_request(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
request: &PullRequestRequest,
) -> Result<PullRequestInfo> {
let url = self.repo_pulls_url(endpoint, &repo.name, "Gitea/Forgejo")?;
if let Some(existing) =
self.gitea_find_open_pull_request(&url, &request.head_branch, &request.base_branch)?
{
return Ok(PullRequestInfo {
url: existing.url(),
});
}
let body = json!({
"title": request.title,
"body": request.body,
"head": request.head_branch,
"base": request.base_branch,
});
let created: ProviderPullRequest = self.post_json(&url, &body)?;
Ok(PullRequestInfo { url: created.url() })
}
fn gitea_find_open_pull_request(
&self,
pulls_url: &str,
head_branch: &str,
base_branch: &str,
) -> Result<Option<ProviderPullRequest>> {
let url = format!(
"{pulls_url}?state=open&base={}&head={}&limit=50",
urlencoding(base_branch),
urlencoding(head_branch)
);
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
Ok(pulls
.into_iter()
.find(|pull| pull.head_ref() == Some(head_branch)))
}
fn gitea_close_pull_requests_by_head_prefix(
&self,
endpoint: &EndpointConfig,
repo: &RemoteRepo,
base_branch: &str,
head_prefix: &str,
) -> Result<usize> {
let pulls_url = self.repo_pulls_url(endpoint, &repo.name, "Gitea/Forgejo")?;
let url = format!(
"{pulls_url}?state=open&base={}&limit=50",
urlencoding(base_branch)
);
let pulls: Vec<ProviderPullRequest> = match self.paged_get(&url) {
Ok(pulls) => pulls,
Err(error) if is_not_found_error(&error) => return Ok(0),
Err(error) => return Err(error),
};
let mut closed = 0;
for pull in pulls.into_iter().filter(|pull| {
pull.head_ref()
.is_some_and(|head| head.starts_with(head_prefix))
}) {
let Some(number) = pull.number.or(pull.index) else {
continue;
};
let update_url = format!("{pulls_url}/{number}");
self.patch_json::<serde_json::Value>(&update_url, &json!({ "state": "closed" }))?;
closed += 1;
}
Ok(closed)
}
fn repo_url(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
provider: &str,
) -> Result<String> {
if matches!(endpoint.kind, NamespaceKind::Group) {
bail!("{provider} endpoints use kind 'user' or 'org'");
}
Ok(format!(
"{}/repos/{}/{repo_name}",
self.site.api_base(),
endpoint.namespace
))
}
fn repo_hooks_url(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
provider: &str,
) -> Result<String> {
if matches!(endpoint.kind, NamespaceKind::Group) {
bail!("{provider} endpoints use kind 'user' or 'org'");
}
Ok(format!(
"{}/repos/{}/{repo_name}/hooks",
self.site.api_base(),
endpoint.namespace
))
}
fn repo_pulls_url(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
provider: &str,
) -> Result<String> {
if matches!(endpoint.kind, NamespaceKind::Group) {
bail!("{provider} endpoints use kind 'user' or 'org'");
}
Ok(format!(
"{}/repos/{}/{repo_name}/pulls",
self.site.api_base(),
endpoint.namespace
))
}
fn gitlab_hooks_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String {
format!("{}/hooks", self.gitlab_project_url(endpoint, repo_name))
}
fn gitlab_merge_requests_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String {
format!(
"{}/merge_requests",
self.gitlab_project_url(endpoint, repo_name)
)
}
fn gitlab_project_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String {
let project = format!("{}/{repo_name}", endpoint.namespace);
format!(
"{}/projects/{}",
self.site.api_base(),
urlencoding(&project)
)
}
fn find_existing_hook(&self, hooks_url: &str, target_url: &str) -> Result<Option<RepoHook>> {
let hooks: Vec<RepoHook> = self.paged_get(hooks_url)?;
Ok(hooks.into_iter().find(|hook| {
hook.url()
.is_some_and(|hook_url| webhook_urls_match(hook_url, target_url))
}))
}
fn upsert_hook(
&self,
hooks_url: &str,
target_url: &str,
body: &serde_json::Value,
put_on_update: bool,
) -> Result<WebhookInstallOutcome> {
let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else {
self.post_json::<serde_json::Value>(hooks_url, body)?;
return Ok(WebhookInstallOutcome::Created);
};
let update_url = format!("{hooks_url}/{}", hook.id);
if put_on_update {
self.put_json::<serde_json::Value>(&update_url, body)?;
} else {
self.patch_json::<serde_json::Value>(&update_url, body)?;
}
Ok(WebhookInstallOutcome::Existing)
}
fn delete_matching_hook(&self, hooks_url: &str, target_url: &str) -> Result<bool> {
let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else {
return Ok(false);
};
let delete_url = format!("{hooks_url}/{}", hook.id);
self.delete(&delete_url)?;
Ok(true)
}
fn paged_get<T>(&self, first_url: &str) -> Result<Vec<T>>
where
T: for<'de> Deserialize<'de>,
{
let mut output = Vec::new();
let mut next_url = Some(first_url.to_string());
while let Some(url) = next_url.take() {
let response = self.get(&url)?;
next_url = next_link(response.headers());
let mut page: Vec<T> = response
.json()
.with_context(|| format!("invalid JSON from {url}"))?;
output.append(&mut page);
}
Ok(output)
}
fn paged_remote_repos<T>(&self, url: &str) -> Result<Vec<RemoteRepo>>
where
T: for<'de> Deserialize<'de> + Into<RemoteRepo>,
{
Ok(self
.paged_get::<T>(url)?
.into_iter()
.map(Into::into)
.collect())
}
fn gitea_paged_get<T>(&self, base_url: &str) -> Result<Vec<T>>
where
T: for<'de> Deserialize<'de>,
{
const LIMIT: usize = 50;
let mut output = Vec::new();
for page in 1.. {
let separator = if base_url.contains('?') { '&' } else { '?' };
let url = format!("{base_url}{separator}page={page}&limit={LIMIT}");
let mut items: Vec<T> = self
.get(&url)?
.json()
.with_context(|| format!("invalid JSON from {url}"))?;
let count = items.len();
output.append(&mut items);
if count < LIMIT {
break;
}
}
Ok(output)
}
fn get_json<T>(&self, url: &str) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
self.get(url)?
.json()
.with_context(|| format!("invalid JSON from {url}"))
}
json_method!(post_json, "POST", post);
json_method!(put_json, "PUT", put);
json_method!(patch_json, "PATCH", patch);
fn send_json<T>(
&self,
method: &str,
request: reqwest::blocking::RequestBuilder,
url: &str,
body: &serde_json::Value,
) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
self.request_headers(request)?
.json(body)
.send()
.with_context(|| format!("{method} {url} failed"))
.and_then(|response| check_response(method, url, response))?
.json()
.with_context(|| format!("invalid JSON from {url}"))
}
fn get(&self, url: &str) -> Result<Response> {
self.send("GET", self.http.get(url), url)
}
fn delete(&self, url: &str) -> Result<Response> {
self.send("DELETE", self.http.delete(url), url)
}
fn send(
&self,
method: &str,
request: reqwest::blocking::RequestBuilder,
url: &str,
) -> Result<Response> {
self.request_headers(request)?
.send()
.with_context(|| format!("{method} {url} failed"))
.and_then(|response| check_response(method, url, response))
}
fn request_headers(
&self,
request: reqwest::blocking::RequestBuilder,
) -> Result<reqwest::blocking::RequestBuilder> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("refray/0.1"));
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
match self.site.provider {
ProviderKind::Github => {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", self.token))
.context("PAT contains invalid header characters")?,
);
headers.insert(
"X-GitHub-Api-Version",
HeaderValue::from_static("2022-11-28"),
);
}
ProviderKind::Gitlab => {
headers.insert(
"PRIVATE-TOKEN",
HeaderValue::from_str(&self.token)
.context("PAT contains invalid header characters")?,
);
}
ProviderKind::Gitea | ProviderKind::Forgejo => {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("token {}", self.token))
.context("PAT contains invalid header characters")?,
);
}
}
Ok(request.headers(headers))
}
}
fn check_response(method: &str, url: &str, response: Response) -> Result<Response> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
let body = response.text().unwrap_or_default();
bail!("{method} {url} returned {status}: {body}");
}
fn is_not_found_error(error: &anyhow::Error) -> bool {
error.to_string().contains("404 Not Found")
}
fn is_conflict_error(error: &anyhow::Error) -> bool {
error.to_string().contains("409 Conflict")
}
fn is_already_taken_error(error: &anyhow::Error) -> bool {
let text = error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
.to_ascii_lowercase();
text.contains("has already been taken")
}
fn webhook_urls_match(left: &str, right: &str) -> bool {
if left == right {
return true;
}
match (normalize_webhook_url(left), normalize_webhook_url(right)) {
(Some(left), Some(right)) => left == right,
_ => false,
}
}
fn normalize_webhook_url(value: &str) -> Option<String> {
let url = Url::parse(value).ok()?;
let scheme = url.scheme().to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return None;
}
let host = url.host_str()?.to_ascii_lowercase();
let port = url.port_or_known_default()?;
let username = url.username();
let password = url.password().unwrap_or_default();
let path = url.path().trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
let query = url.query().unwrap_or_default();
Some(format!(
"{scheme}://{username}:{password}@{host}:{port}{path}?{query}"
))
}
fn next_link(headers: &HeaderMap) -> Option<String> {
let header = headers.get("link")?.to_str().ok()?;
for part in header.split(',') {
let mut sections = part.trim().split(';');
let url = sections.next()?.trim();
let rel = sections.any(|section| section.trim() == "rel=\"next\"");
if rel {
return url
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.map(ToString::to_string);
}
}
None
}
fn urlencoding(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
fn default_git_username(provider: &ProviderKind) -> &'static str {
match provider {
ProviderKind::Github => "x-access-token",
ProviderKind::Gitlab | ProviderKind::Gitea | ProviderKind::Forgejo => "oauth2",
}
}
#[derive(Deserialize)]
struct GithubRepo {
name: String,
clone_url: String,
private: bool,
description: Option<String>,
owner: GithubOwner,
}
#[derive(Deserialize)]
struct GithubOwner {
login: String,
}
impl From<GithubRepo> for RemoteRepo {
fn from(value: GithubRepo) -> Self {
Self {
name: value.name,
clone_url: value.clone_url,
private: value.private,
description: value.description,
}
}
}
#[derive(Deserialize)]
struct GitlabProject {
name: String,
path: Option<String>,
path_with_namespace: Option<String>,
namespace: Option<GitlabNamespace>,
http_url_to_repo: String,
visibility: String,
description: Option<String>,
}
impl GitlabProject {
fn project_path(&self) -> &str {
self.path.as_deref().unwrap_or(&self.name)
}
fn is_in_namespace(&self, namespace: &str) -> bool {
self.namespace
.as_ref()
.and_then(GitlabNamespace::full_path)
.is_some_and(|path| path.eq_ignore_ascii_case(namespace))
|| self
.path_with_namespace
.as_deref()
.and_then(|path| path.rsplit_once('/').map(|(namespace, _)| namespace))
.is_some_and(|path| path.eq_ignore_ascii_case(namespace))
}
fn same_path(&self, other: &Self) -> bool {
match (&self.path_with_namespace, &other.path_with_namespace) {
(Some(left), Some(right)) => left.eq_ignore_ascii_case(right),
_ => self
.project_path()
.eq_ignore_ascii_case(other.project_path()),
}
}
}
#[derive(Deserialize)]
struct GitlabNamespace {
path: Option<String>,
full_path: Option<String>,
}
impl GitlabNamespace {
fn full_path(&self) -> Option<&str> {
self.full_path.as_deref().or(self.path.as_deref())
}
}
impl From<GitlabProject> for RemoteRepo {
fn from(value: GitlabProject) -> Self {
Self {
name: value.path.unwrap_or(value.name),
clone_url: value.http_url_to_repo,
private: value.visibility != "public",
description: value.description,
}
}
}
#[derive(Deserialize)]
struct GitlabGroup {
id: u64,
}
#[derive(Deserialize)]
struct GiteaRepo {
name: String,
clone_url: String,
private: bool,
description: Option<String>,
owner: GiteaOwner,
}
#[derive(Deserialize)]
struct GiteaOwner {
login: String,
}
impl From<GiteaRepo> for RemoteRepo {
fn from(value: GiteaRepo) -> Self {
Self {
name: value.name,
clone_url: value.clone_url,
private: value.private,
description: value.description,
}
}
}
#[derive(Deserialize)]
struct RepoHook {
id: u64,
#[serde(default)]
url: Option<String>,
#[serde(default)]
config: HashMap<String, String>,
}
#[derive(Deserialize)]
struct ProviderPullRequest {
#[serde(default)]
number: Option<u64>,
#[serde(default)]
iid: Option<u64>,
#[serde(default)]
index: Option<u64>,
#[serde(default)]
html_url: Option<String>,
#[serde(default)]
web_url: Option<String>,
#[serde(default)]
head: Option<PullRequestHead>,
#[serde(default)]
source_branch: Option<String>,
}
impl ProviderPullRequest {
fn url(&self) -> Option<String> {
self.html_url.clone().or_else(|| self.web_url.clone())
}
fn head_ref(&self) -> Option<&str> {
self.head
.as_ref()
.map(|head| head.reference.as_str())
.or(self.source_branch.as_deref())
}
}
#[derive(Deserialize)]
struct PullRequestHead {
#[serde(rename = "ref")]
reference: String,
}
impl RepoHook {
fn url(&self) -> Option<&str> {
self.config
.get("url")
.map(String::as_str)
.or(self.url.as_deref())
}
}
pub fn repos_by_name(repos: Vec<EndpointRepo>) -> HashMap<String, Vec<EndpointRepo>> {
let mut output: HashMap<String, Vec<EndpointRepo>> = HashMap::new();
for repo in repos {
output.entry(repo.repo.name.clone()).or_default().push(repo);
}
output
}
#[cfg(test)]
#[path = "../tests/unit/provider.rs"]
mod tests;