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.
This commit is contained in:
Julien Fastré 2024-10-25 00:29:34 +02:00
parent 9ef98e5044
commit 2b37c5b18c
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
10 changed files with 172 additions and 22 deletions

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,6 +1,5 @@
use crate::config::{Config, GiteaConfig, GitlabConfig}; use crate::config::{Config, GiteaConfig, GitlabConfig};
use crate::error::GeneralError; use crate::error::GeneralError;
use gitlab::AsyncGitlab;
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::{ClientBuilder, StatusCode}; use reqwest::{ClientBuilder, StatusCode};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
@ -12,6 +11,35 @@ pub struct Client {
base_uri: 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<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 { 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)
@ -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 crate::gitea::repository::Repository; use crate::gitea::repository::Repository;
use serde::Deserialize; 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,
} }

View File

@ -74,7 +74,7 @@ impl Issue2WorkActionTrait for GitlabAction {
Ok(()) Ok(())
} }
fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool { fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {
has_client_for_url(&url, &config) has_client_for_url(&url, &config)
} }
} }

View File

@ -39,7 +39,7 @@ pub async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, G
pub fn has_client_for_url(url: &Url, config: &Config) -> bool { pub fn has_client_for_url(url: &Url, config: &Config) -> bool {
for c in &config.gitlab { for c in &config.gitlab {
if (is_client_for_url(url, c)) { if is_client_for_url(url, c) {
return true; return true;
} }
} }

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,34 +1,27 @@
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::gitea::action::GiteaAction;
use crate::gitlab::action::GitlabAction; use crate::gitlab::action::GitlabAction;
use crate::planning::Issue2WorkActionTrait; use crate::planning::Issue2WorkActionTrait;
use url::Url; use url::Url;
struct App { struct App {
gitlab_issue2work_action: GitlabAction, gitlab_issue2work_action: GitlabAction,
} gitea_issue2work_action: GiteaAction,
trait Issue2WorkPackageAction {
fn issue2work_package_action(
&self,
config: &Config,
args: &Issue2Work,
) -> Result<(), GeneralError>;
} }
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 app = App { let app = App {
gitlab_issue2work_action: GitlabAction {}, gitlab_issue2work_action: GitlabAction {},
gitea_issue2work_action: GiteaAction {},
}; };
if (app if app.gitlab_issue2work_action.supports(&url, &config, args) {
.gitlab_issue2work_action
.supports(&url, &config, args)
)
{
app.gitlab_issue2work_action.run(&url, &config, args).await 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 { } else {
Err(GeneralError { Err(GeneralError {
description: format!("This action is not supported for this url: {}", url), description: format!("This action is not supported for this url: {}", url),