From 1d8a70768f2479138994f391e5471f1a71fa31fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Thu, 24 Oct 2024 22:32:42 +0000 Subject: [PATCH] integrate-gitea (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://gitea.champs-libres.be/julienfastre/cl-cli/pulls/2 Co-authored-by: Julien Fastré Co-committed-by: Julien Fastré --- src/config.rs | 7 ++ src/debug.rs | 14 ++++ src/error.rs | 19 +++++ src/gitea/action.rs | 63 +++++++++++++++++ src/gitea/client.rs | 98 ++++++++++++++++++++++++++ src/gitea/issue.rs | 56 +++++++++++++++ src/gitea/mod.rs | 4 ++ src/gitea/repository.rs | 9 +++ src/gitlab/action.rs | 139 +++++++++++++++++++++++++++++++++++++ src/gitlab/client.rs | 67 +++++++++++------- src/gitlab/mod.rs | 1 + src/main.rs | 1 + src/openproject/user.rs | 3 + src/openproject/work.rs | 12 ++++ src/planning/issue2work.rs | 136 +++++------------------------------- src/planning/mod.rs | 13 ++++ 16 files changed, 496 insertions(+), 146 deletions(-) create mode 100644 src/gitea/action.rs create mode 100644 src/gitea/client.rs create mode 100644 src/gitea/issue.rs create mode 100644 src/gitea/mod.rs create mode 100644 src/gitea/repository.rs create mode 100644 src/gitlab/action.rs diff --git a/src/config.rs b/src/config.rs index 468a551..9cd9fdd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,7 @@ use serde::Deserialize; #[derive(Deserialize, Debug)] pub(crate) struct Config { pub gitlab: Vec, + pub gitea: Vec, pub openproject: OpenProjectConfig, } @@ -12,6 +13,12 @@ pub(crate) struct GitlabConfig { pub domain: String, } +#[derive(Deserialize, Debug)] +pub(crate) struct GiteaConfig { + pub token: String, + pub domain: String, +} + #[derive(Deserialize, Debug)] pub(crate) struct OpenProjectConfig { pub token: String, diff --git a/src/debug.rs b/src/debug.rs index 68362b1..790abea 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -1,12 +1,26 @@ use crate::config::Config; use crate::error::GeneralError; +use crate::gitea::issue::Issue; use crate::openproject::client::Client; use crate::openproject::root::RootClient; use crate::openproject::user::GetMe; +use url::Url; pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> { println!("test"); + let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); + let issue: Issue = gitea_client + .get( + Url::parse("https://gitea.champs-libres.be/api/v1/repos/julienfastre/test/issues/6") + .unwrap(), + ) + .await?; + + println!("issue: {:?}", issue); + + return Ok(()); + let open_project_client = Client::from_config(&config.openproject); println!("base_url: {}", open_project_client.base_url); println!("base_url: will get root"); diff --git a/src/error.rs b/src/error.rs index c39d1aa..fdf9bc0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,23 @@ +use reqwest::header::InvalidHeaderValue; +use reqwest::Error; + #[derive(Debug)] pub struct GeneralError { pub(crate) description: String, } + +impl From for GeneralError { + fn from(value: InvalidHeaderValue) -> Self { + GeneralError { + description: "Unable to convert the token into header value".to_string(), + } + } +} + +impl From for GeneralError { + fn from(value: Error) -> Self { + GeneralError { + description: format!("Unable to perform a request: {}", value.to_string()), + } + } +} diff --git a/src/gitea/action.rs b/src/gitea/action.rs new file mode 100644 index 0000000..c93a473 --- /dev/null +++ b/src/gitea/action.rs @@ -0,0 +1,63 @@ +use url::Url; +use crate::cli::Issue2Work; +use crate::config::Config; +use crate::error::GeneralError; +use crate::gitea::issue::{issue_html_url_to_api, Issue}; +use crate::gitea::client::has_client_for_url; +use crate::openproject::user::{GetMe, User}; +use crate::openproject::work::{WorkPackage, WorkPackageWriter, WorkPackageWriterAssignee}; +use crate::planning::Issue2WorkActionTrait; + +pub(crate) struct GiteaAction {} + +impl Issue2WorkActionTrait for GiteaAction { + async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> { + let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); + let issue: Issue = gitea_client + .get(issue_html_url_to_api(url)?) + .await?; + let open_project_client = crate::openproject::client::Client::from_config(&config.openproject); + + let work_package = create_work_package_from_issue( + &issue, + match args.assign_to_me { + true => { + let u = open_project_client.me().await?; + Some(u) + } + false => None, + } + ); + + let work_package = open_project_client + .create_work_package(&work_package, &args.project_id) + .await?; + + println!( + "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", + work_package.subject, config.openproject.base_url, args.project_id, work_package.id + ); + + Ok(()) + } + + fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool { + has_client_for_url(&url, &config) + } +} + +fn create_work_package_from_issue(issue: &Issue, assignee: Option) -> WorkPackageWriter { + WorkPackageWriter { + subject: format!( + "{} ({})", + issue.title, + issue.repository.full_name + ), + work_type: "TASK".into(), + description: crate::openproject::work::DescriptionWriter { + format: "markdown".into(), + raw: format!("From Gitea issue: {} \n\n{}", issue.html_url, issue.body), + }, + assignee: assignee.into() + } +} \ No newline at end of file diff --git a/src/gitea/client.rs b/src/gitea/client.rs new file mode 100644 index 0000000..65dc5f9 --- /dev/null +++ b/src/gitea/client.rs @@ -0,0 +1,98 @@ +use crate::config::{Config, GiteaConfig, GitlabConfig}; +use crate::error::GeneralError; +use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; +use reqwest::{ClientBuilder, StatusCode}; +use serde::de::DeserializeOwned; +use url::Url; + +#[derive(Debug)] +pub struct Client { + token: String, + base_uri: String, +} + +fn is_client_for_url(url: &Url, config: &GiteaConfig) -> bool { + if url.domain() == Some(config.domain.as_str()) { + return true; + } + false +} + +pub(crate) fn has_client_for_url(url: &Url, config: &Config) -> bool { + for c in &config.gitea { + if is_client_for_url(url, c) { + return true; + } + } + + false +} + +fn client_for_url(url: &Url, config: &Config) -> Result { + for c in &config.gitea { + if is_client_for_url(url, c) { + return Ok(Client::from_config(&c)); + } + } + + Err(GeneralError { + description: format!("No gitea client found for url {}", url), + }) +} + +impl Client { + pub fn from_config(config: &GiteaConfig) -> Self { + Self::new(&config.token, &config.domain) + } + + pub fn new(token: &String, domain: &String) -> Self { + Client { + token: token.clone(), + base_uri: format!("https://{}", domain.clone()), + } + } + + pub async fn get(&self, url: Url) -> Result { + let mut headers = HeaderMap::new(); + headers.append(AUTHORIZATION, self.token.parse()?); + headers.append(ACCEPT, "application/json".parse()?); + + let client = ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap(); + + let response = client.get(url.clone()).send().await?; + + match response.status() { + StatusCode::OK => { + let result: T = response.json().await?; + + Ok(result) + } + _ => Err(GeneralError { + description: format!( + "Could not call GET on {:?}, error code {}", + url, + response.status() + ), + }), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_is_client_for_url() { + let config = GiteaConfig { + domain: "gitea.champs-libres.be".into(), + token: "".into(), + }; + + assert_eq!(is_client_for_url(&Url::parse("https://gitea.champs-libres.be/something/somewhere").unwrap(), &config), true); + assert_eq!(is_client_for_url(&Url::parse("https://somewhere.else/something/somewhere").unwrap(), &config), false); + } +} diff --git a/src/gitea/issue.rs b/src/gitea/issue.rs new file mode 100644 index 0000000..e087049 --- /dev/null +++ b/src/gitea/issue.rs @@ -0,0 +1,56 @@ +use crate::gitea::client::Client; +use crate::gitea::repository::Repository; +use serde::Deserialize; +use url::Url; +use crate::error::GeneralError; + +#[derive(Debug, Deserialize)] +pub struct Issue { + pub id: u64, + number: u64, + pub title: String, + pub body: String, + pub repository: Repository, + pub html_url: String, +} + +pub trait IssueClient { + fn get_issue(owner_or_organisation: &String, repo: &String, number: &u64) -> Option; +} + +impl IssueClient for Client { + fn get_issue(_owner_or_organisation: &String, _repo: &String, number: &u64) -> Option { + todo!() + } +} + +pub fn issue_html_url_to_api(url: &Url) -> Result { + let mut parts = url.path_segments().unwrap(); + let domain = parts.next().unwrap(); + let repo = parts.next().unwrap(); + let issue = parts.next().unwrap(); + let iid = parts.next().unwrap(); + + if (!issue.eq("issues")) { + return Err(GeneralError { + description: format!("Issue url is not valid: {}", url), + }); + } + + let url = Url::parse(format!("{}://{}/api/v1/repos/{}/{}/issues/{}", url.scheme(), url.host().unwrap(), domain, repo, iid).as_str()).unwrap(); + + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_issue_html_url_to_api() { + let url = Url::parse("https://gitea.champs-libres.be/champs-libres/test/issues/1").unwrap(); + let result = issue_html_url_to_api(&url).unwrap(); + + assert_eq!(result.as_str(), "https://gitea.champs-libres.be/api/v1/repos/champs-libres/test/issues/1"); + } +} diff --git a/src/gitea/mod.rs b/src/gitea/mod.rs new file mode 100644 index 0000000..32c6c76 --- /dev/null +++ b/src/gitea/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod issue; +pub mod repository; +pub(crate) mod action; diff --git a/src/gitea/repository.rs b/src/gitea/repository.rs new file mode 100644 index 0000000..80d50db --- /dev/null +++ b/src/gitea/repository.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Repository { + id: u64, + name: String, + owner: String, + pub full_name: String, +} diff --git a/src/gitlab/action.rs b/src/gitlab/action.rs new file mode 100644 index 0000000..ef3c4d7 --- /dev/null +++ b/src/gitlab/action.rs @@ -0,0 +1,139 @@ +use crate::cli::Issue2Work; +use crate::config::Config; +use crate::error::GeneralError; +use crate::gitlab::client::{client_for_url, has_client_for_url}; +use crate::gitlab::issue::IssueBundle; +use crate::openproject::client::Client; +use crate::openproject::user::{GetMe, User}; +use crate::openproject::work::WorkPackageWriterAssignee; +use crate::planning::Issue2WorkActionTrait; +use gitlab::api::{issues, projects, AsyncQuery}; +use gitlab::{Issue, Project}; +use url::Url; + +#[derive(Debug)] +struct IssueInfo { + project: String, + iid: u64, +} + +/// details on how to create a work package from various informations +#[derive(Debug)] +struct Issue2WorkPackageDTO { + pub issue: IssueBundle, + pub assign_to: Option, +} + +pub(crate) struct GitlabAction {} + +impl Issue2WorkActionTrait for GitlabAction { + async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> { + let client = client_for_url(&url, &config).await?; + let data = extract_issue_info(&url).unwrap(); + + let endpoint = issues::ProjectIssues::builder() + .iid(data.iid) + .project(String::from(data.project)) + .build() + .unwrap(); + + let issues: Vec = endpoint.query_async(&client).await.unwrap(); + let issue = issues.first().unwrap(); + + let project_endpoint = projects::Project::builder() + .project(issue.project_id.value()) + .build() + .unwrap(); + + let project: Project = project_endpoint.query_async(&client).await.unwrap(); + + let issue_bundle = IssueBundle::new(&issue, &project); + + let open_project_client = Client::from_config(&config.openproject); + + let dto = Issue2WorkPackageDTO { + issue: issue_bundle, + assign_to: match args.assign_to_me { + true => { + let u = open_project_client.me().await?; + Some(u) + } + false => None, + }, + }; + + let work_package = open_project_client + .create_work_package(&(&dto).into(), &args.project_id) + .await?; + + println!( + "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", + work_package.subject, config.openproject.base_url, args.project_id, work_package.id + ); + + Ok(()) + } + + fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool { + has_client_for_url(&url, &config) + } +} + +fn extract_issue_info(url: &Url) -> Option { + let parts = url + .path_segments() + .expect("Could not parse path segment of given url"); + let mut project_url: Vec = Vec::with_capacity(3); + let mut iid: Option = None; + + let mut project_found = false; + + for el in parts { + if el == "-" { + project_found = true; + continue; + } + if el == "issues" { + continue; + } + if !project_found { + project_url.push(String::from(el)); + } else { + // must be the id + iid = Some(String::from(el)); + break; + } + } + + Some(IssueInfo { + project: project_url.join("/"), + iid: iid + .expect("iid of the issue not found") + .parse() + .expect("could not transform issue id to u64"), + }) +} + +impl From<&Issue2WorkPackageDTO> for crate::openproject::work::WorkPackageWriter { + fn from(value: &Issue2WorkPackageDTO) -> Self { + crate::openproject::work::WorkPackageWriter { + subject: format!( + "{} ({}/{})", + value.issue.issue.title, + value.issue.project.name_with_namespace, + value.issue.issue.iid + ), + work_type: "TASK".into(), + description: crate::openproject::work::DescriptionWriter { + format: "markdown".into(), + raw: format!("From gitlab: {}", value.issue.issue.web_url), + }, + assignee: WorkPackageWriterAssignee { + href: match &value.assign_to { + None => None, + Some(w) => Some(w.clone().d_links.d_self.href), + }, + }, + } + } +} diff --git a/src/gitlab/client.rs b/src/gitlab/client.rs index d84979b..4b3d2a6 100644 --- a/src/gitlab/client.rs +++ b/src/gitlab/client.rs @@ -1,35 +1,48 @@ -use crate::config::Config; +use crate::config::{Config, GitlabConfig}; use crate::error::GeneralError; -use gitlab::AsyncGitlab; -use gitlab::GitlabBuilder; +use gitlab::{AsyncGitlab, GitlabBuilder}; use url::Url; -pub trait ClientProviderTrait { - async fn client_for_url(url: &Url, config: &Config) -> Result; -} - pub struct ClientProvider {} -impl ClientProviderTrait for ClientProvider { - async fn client_for_url(url: &Url, config: &Config) -> Result { - for c in &config.gitlab { - if url.domain() == Some(c.domain.as_str()) { - let client = GitlabBuilder::new("gitlab.com", c.token.clone()) - .build_async() - .await; +impl ClientProvider {} - return match client { - Ok(new_client) => Ok(new_client), - Err(e) => { - let new_error = e.into(); - Err(new_error) - } - }; - } - } - - Err(GeneralError { - description: format!("No client available for this domain: {:?}", url.domain()), - }) +fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool { + if url.domain() == Some(config.domain.as_str()) { + return true; } + + false +} + +pub async fn client_for_url(url: &Url, config: &Config) -> Result { + for c in &config.gitlab { + if is_client_for_url(url, c) { + let client = GitlabBuilder::new("gitlab.com", c.token.clone()) + .build_async() + .await; + + return match client { + Ok(new_client) => Ok(new_client), + Err(e) => { + let new_error = e.into(); + Err(new_error) + } + }; + } + } + + Err(GeneralError { + description: format!("No client available for this domain: {:?}", url.domain()), + }) +} + +pub fn has_client_for_url(url: &Url, config: &Config) -> bool { + for c in &config.gitlab { + if is_client_for_url(url, c) { + return true; + } + } + + false } diff --git a/src/gitlab/mod.rs b/src/gitlab/mod.rs index e278a81..b26b243 100644 --- a/src/gitlab/mod.rs +++ b/src/gitlab/mod.rs @@ -1,3 +1,4 @@ +pub mod action; pub mod client; pub mod issue; diff --git a/src/main.rs b/src/main.rs index 19789f7..fc7ff5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod cli; mod config; mod debug; mod error; +mod gitea; mod gitlab; mod openproject; mod planning; diff --git a/src/openproject/user.rs b/src/openproject/user.rs index 3e29a3f..54965a1 100644 --- a/src/openproject/user.rs +++ b/src/openproject/user.rs @@ -11,8 +11,11 @@ pub struct UserLink { #[derive(Deserialize, Debug, Clone)] pub struct User { #[serde(rename = "_type")] + #[allow(unused_variables)] pub d_type: String, + #[allow(unused_variables)] pub id: u64, + #[allow(unused_variables)] pub name: String, #[serde(rename = "_links")] pub d_links: UserLink, diff --git a/src/openproject/work.rs b/src/openproject/work.rs index d9aca46..5520698 100644 --- a/src/openproject/work.rs +++ b/src/openproject/work.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use crate::openproject::user::User; #[derive(Serialize, Debug)] pub struct WorkPackageWriterAssignee { @@ -24,3 +25,14 @@ pub struct WorkPackage { pub id: u64, pub subject: String, } + +impl From> for WorkPackageWriterAssignee { + fn from(value: Option) -> Self { + WorkPackageWriterAssignee { + href: match value { + None => None, + Some(w) => Some(w.clone().d_links.d_self.href), + }, + } + } +} diff --git a/src/planning/issue2work.rs b/src/planning/issue2work.rs index 7c19cc5..2e1189b 100644 --- a/src/planning/issue2work.rs +++ b/src/planning/issue2work.rs @@ -1,132 +1,30 @@ use crate::cli::Issue2Work; use crate::config::Config; use crate::error::GeneralError; -use crate::gitlab::client::{ClientProvider, ClientProviderTrait}; -use crate::gitlab::issue::IssueBundle; -use crate::openproject::client::Client; -use crate::openproject::user::{GetMe, User}; -use crate::openproject::work::WorkPackageWriterAssignee; -use gitlab::api::{issues, projects, AsyncQuery}; -use gitlab::{Issue, Project}; +use crate::gitea::action::GiteaAction; +use crate::gitlab::action::GitlabAction; +use crate::planning::Issue2WorkActionTrait; use url::Url; -#[derive(Debug)] -struct IssueInfo { - project: String, - iid: u64, -} - -/// details on how to create a work package from various informations -#[derive(Debug)] -struct Issue2WorkPackageDTO { - pub issue: IssueBundle, - pub assign_to: Option, -} - -impl From<&Issue2WorkPackageDTO> for crate::openproject::work::WorkPackageWriter { - fn from(value: &Issue2WorkPackageDTO) -> Self { - crate::openproject::work::WorkPackageWriter { - subject: format!( - "{} ({}/{})", - value.issue.issue.title, - value.issue.project.name_with_namespace, - value.issue.issue.iid - ), - work_type: "TASK".into(), - description: crate::openproject::work::DescriptionWriter { - format: "markdown".into(), - raw: format!("From gitlab: {}", value.issue.issue.web_url), - }, - assignee: WorkPackageWriterAssignee { - href: match &value.assign_to { - None => None, - Some(w) => Some(w.clone().d_links.d_self.href), - }, - }, - } - } +struct App { + gitlab_issue2work_action: GitlabAction, + gitea_issue2work_action: GiteaAction, } pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> { let url = Url::parse(&*args.issue_url).expect("issue_url is not valid"); - let data = extract_issue_info(&url).unwrap(); - - let client = ClientProvider::client_for_url(&url, &config).await?; - - let endpoint = issues::ProjectIssues::builder() - .iid(data.iid) - .project(String::from(data.project)) - .build() - .unwrap(); - - let issues: Vec = endpoint.query_async(&client).await.unwrap(); - let issue = issues.first().unwrap(); - - let project_endpoint = projects::Project::builder() - .project(issue.project_id.value()) - .build() - .unwrap(); - - let project: Project = project_endpoint.query_async(&client).await.unwrap(); - - let issue_bundle = IssueBundle::new(&issue, &project); - - let open_project_client = Client::from_config(&config.openproject); - - let dto = Issue2WorkPackageDTO { - issue: issue_bundle, - assign_to: match args.assign_to_me { - true => { - let u = open_project_client.me().await?; - Some(u) - } - false => None, - }, + let app = App { + gitlab_issue2work_action: GitlabAction {}, + gitea_issue2work_action: GiteaAction {}, }; - let work_package = open_project_client - .create_work_package(&(&dto).into(), &args.project_id) - .await?; - - println!( - "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", - work_package.subject, config.openproject.base_url, args.project_id, work_package.id - ); - - Ok(()) -} - -fn extract_issue_info(url: &Url) -> Option { - let parts = url - .path_segments() - .expect("Could not parse path segment of given url"); - let mut project_url: Vec = Vec::with_capacity(3); - let mut iid: Option = None; - - let mut project_found = false; - - for el in parts { - if el == "-" { - project_found = true; - continue; - } - if el == "issues" { - continue; - } - if !project_found { - project_url.push(String::from(el)); - } else { - // must be the id - iid = Some(String::from(el)); - break; - } + if app.gitlab_issue2work_action.supports(&url, &config, args) { + app.gitlab_issue2work_action.run(&url, &config, args).await + } else if app.gitea_issue2work_action.supports(&url, &config, args) { + app.gitea_issue2work_action.run(&url, &config, args).await + } else { + Err(GeneralError { + description: format!("This action is not supported for this url: {}", url), + }) } - - Some(IssueInfo { - project: project_url.join("/"), - iid: iid - .expect("iid of the issue not found") - .parse() - .expect("could not transform issue id to u64"), - }) } diff --git a/src/planning/mod.rs b/src/planning/mod.rs index dd9b3f9..23561e9 100644 --- a/src/planning/mod.rs +++ b/src/planning/mod.rs @@ -1 +1,14 @@ +use crate::cli::Issue2Work; +use crate::config::Config; +use crate::error::GeneralError; +use crate::gitlab::client::ClientProvider; +use gitlab::{AsyncGitlab, GitlabBuilder}; +use url::Url; + pub(crate) mod issue2work; + +pub trait Issue2WorkActionTrait { + async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>; + + fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool; +}