Compare commits

..

2 Commits

Author SHA1 Message Date
2b37c5b18c
Add Gitea Action support for issue-to-work conversion
Implemented Gitea actions to support converting Gitea issues into work packages in OpenProject. Modified various modules to include necessary structs, functions, and tests to handle Gitea issues, ensure URL validation, and adapt work package creation based on Gitea issues.
2024-10-25 00:29:34 +02:00
9ef98e5044
Refactor the issue2work action
- separation the logic to handle issues from gitlab into the gitlab mod
- create a kind of visitor pattern to check between the different providers
2024-10-24 23:14:14 +02:00
13 changed files with 385 additions and 160 deletions

View File

@ -1,17 +1,21 @@
use url::Url;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::issue::Issue; use crate::gitea::issue::Issue;
use crate::openproject::client::Client; use crate::openproject::client::Client;
use crate::openproject::root::RootClient; use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe; use crate::openproject::user::GetMe;
use url::Url;
pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> { pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
println!("test"); println!("test");
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap()); 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?; 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); println!("issue: {:?}", issue);

63
src/gitea/action.rs Normal file
View File

@ -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<User>) -> 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()
}
}

View File

@ -1,9 +1,9 @@
use crate::config::{Config, GiteaConfig, GitlabConfig};
use crate::error::GeneralError; use crate::error::GeneralError;
use reqwest::header::{HeaderMap, AUTHORIZATION, ACCEPT}; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::{ClientBuilder, StatusCode}; use reqwest::{ClientBuilder, StatusCode};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use url::Url; use url::Url;
use crate::config::GiteaConfig;
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
@ -11,8 +11,36 @@ pub struct Client {
base_uri: String, base_uri: String,
} }
impl Client { 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<Client, GeneralError> {
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 { pub fn from_config(config: &GiteaConfig) -> Self {
Self::new(&config.token, &config.domain) Self::new(&config.token, &config.domain)
} }
@ -40,7 +68,7 @@ impl Client {
StatusCode::OK => { StatusCode::OK => {
let result: T = response.json().await?; let result: T = response.json().await?;
return Ok(result); Ok(result)
} }
_ => Err(GeneralError { _ => Err(GeneralError {
description: format!( description: format!(
@ -52,3 +80,19 @@ impl Client {
} }
} }
} }
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_is_client_for_url() {
let config = GiteaConfig {
domain: "gitea.champs-libres.be".into(),
token: "<PASSWORD>".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);
}
}

View File

@ -1,14 +1,17 @@
use crate::gitea::client::Client; use crate::gitea::client::Client;
use serde::Deserialize;
use crate::gitea::repository::Repository; use crate::gitea::repository::Repository;
use serde::Deserialize;
use url::Url;
use crate::error::GeneralError;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Issue { pub struct Issue {
id: u64, pub id: u64,
number: u64, number: u64,
title: String, pub title: String,
body: String, pub body: String,
repository: Repository pub repository: Repository,
pub html_url: String,
} }
pub trait IssueClient { pub trait IssueClient {
@ -16,7 +19,38 @@ pub trait IssueClient {
} }
impl IssueClient for Client { impl IssueClient for Client {
fn get_issue(owner_or_organisation: &String, repo: &String, number: &u64) -> Option<Issue> { fn get_issue(_owner_or_organisation: &String, _repo: &String, number: &u64) -> Option<Issue> {
todo!() todo!()
} }
} }
pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {
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");
}
}

View File

@ -1,3 +1,4 @@
pub mod client; pub mod client;
pub mod issue; pub mod issue;
pub mod repository; pub mod repository;
pub(crate) mod action;

View File

@ -5,5 +5,5 @@ pub struct Repository {
id: u64, id: u64,
name: String, name: String,
owner: String, owner: String,
full_name: String, pub full_name: String,
} }

139
src/gitlab/action.rs Normal file
View File

@ -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<User>,
}
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<Issue> = 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<IssueInfo> {
let parts = url
.path_segments()
.expect("Could not parse path segment of given url");
let mut project_url: Vec<String> = Vec::with_capacity(3);
let mut iid: Option<String> = 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),
},
},
}
}
}

View File

@ -1,19 +1,23 @@
use crate::config::Config; use crate::config::{Config, GitlabConfig};
use crate::error::GeneralError; use crate::error::GeneralError;
use gitlab::AsyncGitlab; use gitlab::{AsyncGitlab, GitlabBuilder};
use gitlab::GitlabBuilder;
use url::Url; use url::Url;
pub trait ClientProviderTrait {
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError>;
}
pub struct ClientProvider {} pub struct ClientProvider {}
impl ClientProviderTrait for ClientProvider { impl ClientProvider {}
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError> {
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<AsyncGitlab, GeneralError> {
for c in &config.gitlab { for c in &config.gitlab {
if url.domain() == Some(c.domain.as_str()) { if is_client_for_url(url, c) {
let client = GitlabBuilder::new("gitlab.com", c.token.clone()) let client = GitlabBuilder::new("gitlab.com", c.token.clone())
.build_async() .build_async()
.await; .await;
@ -31,5 +35,14 @@ impl ClientProviderTrait for ClientProvider {
Err(GeneralError { Err(GeneralError {
description: format!("No client available for this domain: {:?}", url.domain()), 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
} }

View File

@ -1,3 +1,4 @@
pub mod action;
pub mod client; pub mod client;
pub mod issue; pub mod issue;

View File

@ -11,8 +11,11 @@ pub struct UserLink {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct User { pub struct User {
#[serde(rename = "_type")] #[serde(rename = "_type")]
#[allow(unused_variables)]
pub d_type: String, pub d_type: String,
#[allow(unused_variables)]
pub id: u64, pub id: u64,
#[allow(unused_variables)]
pub name: String, pub name: String,
#[serde(rename = "_links")] #[serde(rename = "_links")]
pub d_links: UserLink, pub d_links: UserLink,

View File

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::openproject::user::User;
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct WorkPackageWriterAssignee { pub struct WorkPackageWriterAssignee {
@ -24,3 +25,14 @@ pub struct WorkPackage {
pub id: u64, pub id: u64,
pub subject: String, pub subject: String,
} }
impl From<Option<User>> for WorkPackageWriterAssignee {
fn from(value: Option<User>) -> Self {
WorkPackageWriterAssignee {
href: match value {
None => None,
Some(w) => Some(w.clone().d_links.d_self.href),
},
}
}
}

View File

@ -1,132 +1,30 @@
use crate::cli::Issue2Work; use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitlab::client::{ClientProvider, ClientProviderTrait}; use crate::gitea::action::GiteaAction;
use crate::gitlab::issue::IssueBundle; use crate::gitlab::action::GitlabAction;
use crate::openproject::client::Client; use crate::planning::Issue2WorkActionTrait;
use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriterAssignee;
use gitlab::api::{issues, projects, AsyncQuery};
use gitlab::{Issue, Project};
use url::Url; use url::Url;
#[derive(Debug)] struct App {
struct IssueInfo { gitlab_issue2work_action: GitlabAction,
project: String, gitea_issue2work_action: GiteaAction,
iid: u64,
}
/// details on how to create a work package from various informations
#[derive(Debug)]
struct Issue2WorkPackageDTO {
pub issue: IssueBundle,
pub assign_to: Option<User>,
}
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),
},
},
}
}
} }
pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> { 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 url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
let data = extract_issue_info(&url).unwrap(); let app = App {
gitlab_issue2work_action: GitlabAction {},
let client = ClientProvider::client_for_url(&url, &config).await?; gitea_issue2work_action: GiteaAction {},
let endpoint = issues::ProjectIssues::builder()
.iid(data.iid)
.project(String::from(data.project))
.build()
.unwrap();
let issues: Vec<Issue> = 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 if app.gitlab_issue2work_action.supports(&url, &config, args) {
.create_work_package(&(&dto).into(), &args.project_id) app.gitlab_issue2work_action.run(&url, &config, args).await
.await?; } else if app.gitea_issue2work_action.supports(&url, &config, args) {
app.gitea_issue2work_action.run(&url, &config, args).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<IssueInfo> {
let parts = url
.path_segments()
.expect("Could not parse path segment of given url");
let mut project_url: Vec<String> = Vec::with_capacity(3);
let mut iid: Option<String> = 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 { } else {
// must be the id Err(GeneralError {
iid = Some(String::from(el)); description: format!("This action is not supported for this url: {}", url),
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"),
}) })
}
} }

View File

@ -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(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;
}