Compare commits

..

3 Commits

Author SHA1 Message Date
1ad8b56892
WIP 2024-10-18 10:16:27 +02:00
2d103e2295
First impl of planning load 2024-05-29 23:12:52 +02:00
b49c08aed3
Added planning load module and updated dependencies
A new module 'planning_load' has been added to the 'planning' directory, handling workload planning tasks. Updates have also been made to project dependencies in Cargo.lock and Cargo.toml. The added dependencies are necessary for the new functionalities in the 'planning_load' module. Also, a few adjustments were made to allow better access and error handling across packages.
2024-05-22 23:05:43 +02:00
29 changed files with 589 additions and 535 deletions

View File

@ -0,0 +1,4 @@
kind: Added
body: Add an option `--assign-to-me` to automatically assign the openproject's user
to the newly created work package
time: 2024-03-17T21:35:42.724855046+01:00

View File

@ -0,0 +1,3 @@
kind: Added
body: Allow to configure multiple gitlab instances
time: 2024-03-17T21:35:56.447907065+01:00

View File

@ -1,5 +0,0 @@
## v0.2.0 - 2024-11-14
### Added
* Add an option `--assign-to-me` to automatically assign the openproject's user to the newly created work package
* Allow to configure multiple gitlab instances
* Create work pakcage from gitea

View File

@ -6,12 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v0.2.0 - 2024-11-14
### Added
* Add an option `--assign-to-me` to automatically assign the openproject's user to the newly created work package
* Allow to configure multiple gitlab instances
* Create work pakcage from gitea
## v0.1.0 - 2024-01-08 ## v0.1.0 - 2024-01-08
### Added ### Added
* Initiate changie versioning * Initiate changie versioning

83
Cargo.lock generated
View File

@ -64,6 +64,12 @@ version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535"
[[package]]
name = "bytecount"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.4.3" version = "1.4.3"
@ -110,10 +116,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"gitlab", "gitlab",
"iso8601",
"log", "log",
"reqwest", "reqwest",
"serde", "serde",
"simple-home-dir", "simple-home-dir",
"tabled",
"tokio", "tokio",
"toml", "toml",
"url", "url",
@ -750,6 +758,16 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "iso8601"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153"
dependencies = [
"nom",
"serde",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.10.5"
@ -959,6 +977,17 @@ version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ceedf44fb00f2d1984b0bc98102627ce622e083e49a5bacdb3e514fa4238e267" checksum = "ceedf44fb00f2d1984b0bc98102627ce622e083e49a5bacdb3e514fa4238e267"
[[package]]
name = "papergrid"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ad43c07024ef767f9160710b3a6773976194758c7919b17e63b863db0bdf7fb"
dependencies = [
"bytecount",
"fnv",
"unicode-width",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.2.0" version = "2.2.0"
@ -983,6 +1012,30 @@ version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a"
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.79" version = "1.0.79"
@ -1335,6 +1388,30 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "tabled"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c998b0c8b921495196a48aabaf1901ff28be0760136e31604f7967b0792050e"
dependencies = [
"papergrid",
"tabled_derive",
"unicode-width",
]
[[package]]
name = "tabled_derive"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c138f99377e5d653a371cdad263615634cfc8467685dfe8e73e2b8e98f44b17"
dependencies = [
"heck",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.9.0" version = "3.9.0"
@ -1586,6 +1663,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]] [[package]]
name = "void" name = "void"
version = "1.0.2" version = "1.0.2"

View File

@ -15,3 +15,5 @@ url = "2.3.1"
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
simple-home-dir = "0.2.1" simple-home-dir = "0.2.1"
log = "0.4.17" log = "0.4.17"
tabled = "0.15.0"
iso8601 = { version = "0.6.1" , features = ["serde"]}

View File

@ -1,11 +1,6 @@
[[gitlab]] [gitlab]
# generate from https://gitlab.com/-/user_settings/personal_access_tokens # generate from https://gitlab.com/-/user_settings/personal_access_tokens
token = "glpat-example" token = "glpat-example"
domain = "gitlab.com"
[[gitea]]
token = "abcdexempletoken"
domain = "gitea.champs-libres.be"
[openproject] [openproject]
# generate api token from https://champs-libres.openproject.com/my/access_token # generate api token from https://champs-libres.openproject.com/my/access_token

View File

@ -21,6 +21,7 @@ pub(crate) enum Commands {
#[derive(Subcommand)] #[derive(Subcommand)]
pub(crate) enum Planning { pub(crate) enum Planning {
I2work(Issue2Work), I2work(Issue2Work),
Load,
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]

View File

@ -3,7 +3,6 @@ use serde::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub(crate) struct Config { pub(crate) struct Config {
pub gitlab: Vec<GitlabConfig>, pub gitlab: Vec<GitlabConfig>,
pub gitea: Vec<GiteaConfig>,
pub openproject: OpenProjectConfig, pub openproject: OpenProjectConfig,
} }
@ -13,12 +12,6 @@ pub(crate) struct GitlabConfig {
pub domain: String, pub domain: String,
} }
#[derive(Deserialize, Debug)]
pub(crate) struct GiteaConfig {
pub token: String,
pub domain: String,
}
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub(crate) struct OpenProjectConfig { pub(crate) struct OpenProjectConfig {
pub token: String, pub token: String,

View File

@ -1,24 +1,12 @@
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::issue::Issue; use crate::openproject::client::Client;
use url::Url; use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe;
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 issue: Issue = gitea_client
.get(
Url::parse("https://gitea.champs-libres.be/api/v1/repos/julienfastre/test/issues/6")
.unwrap(),
)
.await?;
println!("issue: {:?}", issue);
Ok(())
/*
let open_project_client = Client::from_config(&config.openproject); let open_project_client = Client::from_config(&config.openproject);
println!("base_url: {}", open_project_client.base_url); println!("base_url: {}", open_project_client.base_url);
println!("base_url: will get root"); println!("base_url: will get root");
@ -28,6 +16,4 @@ pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
println!("me: {:?}", u); println!("me: {:?}", u);
Ok(()) Ok(())
*/
} }

View File

@ -1,23 +1,4 @@
use reqwest::header::InvalidHeaderValue;
use reqwest::Error;
#[derive(Debug)] #[derive(Debug)]
pub struct GeneralError { pub struct GeneralError {
pub(crate) description: String, pub(crate) description: String,
} }
impl From<InvalidHeaderValue> for GeneralError {
fn from(value: InvalidHeaderValue) -> Self {
GeneralError {
description: "Unable to convert the token into header value".to_string(),
}
}
}
impl From<reqwest::Error> for GeneralError {
fn from(value: Error) -> Self {
GeneralError {
description: format!("Unable to perform a request: {}", value.to_string()),
}
}
}

View File

@ -1,58 +0,0 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use crate::gitea::client::has_client_for_url;
use crate::gitea::issue::{issue_html_url_to_api, Issue};
use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriter;
use crate::planning::Issue2WorkActionTrait;
use url::Url;
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, issue.number),
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,110 +0,0 @@
use crate::config::{Config, GiteaConfig};
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<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 {
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<T: DeserializeOwned>(&self, url: Url) -> Result<T, GeneralError> {
let mut headers = HeaderMap::new();
headers.append(AUTHORIZATION, format!("token {}", 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: "<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,70 +0,0 @@
use crate::error::GeneralError;
use crate::gitea::client::Client;
use crate::gitea::repository::Repository;
use serde::Deserialize;
use url::Url;
#[derive(Debug, Deserialize)]
pub struct Issue {
pub id: u64,
pub 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<Issue>;
}
impl IssueClient for Client {
fn get_issue(_owner_or_organisation: &String, _repo: &String, number: &u64) -> Option<Issue> {
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,4 +0,0 @@
pub(crate) mod action;
pub mod client;
pub mod issue;
pub mod repository;

View File

@ -1,9 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Repository {
id: u64,
name: String,
owner: String,
pub full_name: String,
}

View File

@ -1,139 +0,0 @@
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,48 +1,35 @@
use crate::config::{Config, GitlabConfig}; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use gitlab::{AsyncGitlab, GitlabBuilder}; use gitlab::AsyncGitlab;
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 ClientProvider {} impl ClientProviderTrait for ClientProvider {
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError> {
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;
fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool { return match client {
if url.domain() == Some(config.domain.as_str()) { Ok(new_client) => Ok(new_client),
return true; Err(e) => {
} let new_error = e.into();
Err(new_error)
false }
} };
}
pub async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError> {
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()),
})
} }
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
} }

View File

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

View File

@ -7,10 +7,10 @@ mod cli;
mod config; mod config;
mod debug; mod debug;
mod error; mod error;
mod gitea;
mod gitlab; mod gitlab;
mod openproject; mod openproject;
mod planning; mod planning;
mod utils;
use crate::cli::Commands::{Planning, Test}; use crate::cli::Commands::{Planning, Test};
use crate::cli::Planning::I2work; use crate::cli::Planning::I2work;
@ -49,6 +49,7 @@ async fn main() {
let result = match cli.command { let result = match cli.command {
Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await, Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await,
Some(Planning(cli::Planning::Load)) => planning::planning_load::planning_load(config).await,
Some(Test) => debug::debug(config).await, Some(Test) => debug::debug(config).await,
None => Err(GeneralError { None => Err(GeneralError {
description: "No command launched".to_string(), description: "No command launched".to_string(),

View File

@ -6,8 +6,8 @@ use serde::Deserialize;
use std::error::Error; use std::error::Error;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub(crate) struct OpenProjectError { pub struct OpenProjectError {
pub(crate) description: String, pub description: String,
} }
impl From<reqwest::Error> for OpenProjectError { impl From<reqwest::Error> for OpenProjectError {

View File

@ -3,3 +3,4 @@ mod hal;
pub(crate) mod root; pub(crate) mod root;
pub(crate) mod user; pub(crate) mod user;
pub(crate) mod work; pub(crate) mod work;
pub(crate) mod project;

View File

@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
pub struct ProjectPartial {
href: String,
pub(crate) title: String,
}

View File

@ -8,19 +8,47 @@ pub struct UserLink {
#[serde(rename = "self")] #[serde(rename = "self")]
pub d_self: Link, pub d_self: Link,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct UserPartial {
pub href: Option<String>,
pub title: Option<String>,
}
impl UserPartial {
pub fn is_null(&self) -> bool {
self.href.is_none()
}
}
impl PartialEq<Self> for UserPartial {
fn eq(&self, other: &Self) -> bool {
self.href == other.href
}
}
#[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,
} }
impl PartialEq<Self> for User {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for User {
}
pub trait GetMe { pub trait GetMe {
async fn me(&self) -> Result<User, OpenProjectError>; async fn me(&self) -> Result<User, OpenProjectError>;
} }

View File

@ -1,5 +1,16 @@
use crate::openproject::user::User; use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::Link;
use iso8601::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::openproject::project::ProjectPartial;
use crate::openproject::user::{User, UserLink, UserPartial};
#[derive(Deserialize, Debug, Clone)]
pub struct BudgetPartial {
pub(crate) href: Option<String>,
pub(crate) title: Option<String>,
}
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct WorkPackageWriterAssignee { pub struct WorkPackageWriterAssignee {
@ -20,19 +31,129 @@ pub struct DescriptionWriter {
pub(crate) raw: String, pub(crate) raw: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug, Clone)]
pub struct WorkPackageLinks {
pub(crate) assignee: Option<UserPartial>,
pub(crate) project: ProjectPartial,
pub(crate) budget: BudgetPartial,
}
#[derive(Deserialize, Debug, Clone)]
pub struct WorkPackage { pub struct WorkPackage {
pub id: u64, pub id: u64,
pub subject: String, pub subject: String,
#[serde(alias = "estimatedTime")]
pub estimated_time: Option<Duration>,
#[serde(alias = "spentTime")]
pub spent_time: Option<Duration>,
#[serde(alias = "_links")]
pub links: WorkPackageLinks,
} }
impl From<Option<User>> for WorkPackageWriterAssignee { #[derive(Deserialize, Debug)]
fn from(value: Option<User>) -> Self { pub struct WorkPackageCollectionLinks {
WorkPackageWriterAssignee { #[serde(alias = "nextByOffset")]
href: match value { pub next_by_offset: Option<Link>,
None => None, }
Some(w) => Some(w.clone().d_links.d_self.href),
}, #[derive(Deserialize, Debug)]
} pub struct WorkPackagesElements {
pub elements: Vec<WorkPackage>,
}
#[derive(Deserialize, Debug)]
pub struct WorkPackageCollection {
pub total: u64,
pub count: u64,
pub offset: u64,
#[serde(alias = "pageSize")]
pub page_size: u64,
#[serde(alias = "_links")]
pub links: WorkPackageCollectionLinks,
#[serde(alias = "_embedded")]
pub embedded: WorkPackagesElements,
}
impl WorkPackageCollection {
pub fn has_next(&self) -> bool {
self.links.next_by_offset.is_some()
}
pub fn is_last(&self) -> bool {
!self.has_next()
}
}
pub trait WorkPackageCollectionClient {
async fn work_package(&self) -> Result<Vec<WorkPackage>, OpenProjectError>;
}
trait GetWorkPackageCollectionClient {
async fn first_work_packages_collection(
&self,
) -> Result<WorkPackageCollection, OpenProjectError>;
async fn next_work_packages_collection(
&self,
current: &WorkPackageCollection,
) -> Result<WorkPackageCollection, OpenProjectError>;
}
impl GetWorkPackageCollectionClient for Client {
async fn first_work_packages_collection(
&self,
) -> Result<WorkPackageCollection, OpenProjectError> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/api/v3/work_packages", self.base_url))
.basic_auth("apikey", Some(&self.token))
.send()
.await?;
let collection = handle_response_status(response, "could not find work packages").await?;
Ok(collection)
}
async fn next_work_packages_collection(
&self,
current: &WorkPackageCollection,
) -> Result<WorkPackageCollection, OpenProjectError> {
let client = reqwest::Client::new();
let response = client
.get(format!(
"{}/{}",
self.base_url,
current.links.next_by_offset.clone().unwrap().href
))
.basic_auth("apikey", Some(&self.token))
// .query(&[("filters", r##"[{"startDate":{"operator": "<=", "values": ["2024-06-30"]}, {"endDate":{"operator": ">d", "values": ["2024-06-01"]}]"##)])
.send()
.await?;
let collection = handle_response_status(response, "could not find work packages").await?;
Ok(collection)
}
}
impl WorkPackageCollectionClient for Client {
async fn work_package(&self) -> Result<Vec<WorkPackage>, OpenProjectError> {
let mut work_packages: Vec<WorkPackage> = vec![];
let mut collection = self.first_work_packages_collection().await?;
for w in collection.embedded.elements.iter_mut() {
work_packages.push(w.clone());
}
while !collection.is_last() {
collection = self.next_work_packages_collection(&collection).await?;
for w in collection.embedded.elements.iter() {
work_packages.push(w.clone());
}
}
Ok(work_packages)
} }
} }

View File

@ -1,30 +1,132 @@
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::client::{ClientProvider, ClientProviderTrait};
use crate::gitlab::action::GitlabAction; use crate::gitlab::issue::IssueBundle;
use crate::planning::Issue2WorkActionTrait; 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 url::Url; use url::Url;
struct App { #[derive(Debug)]
gitlab_issue2work_action: GitlabAction, struct IssueInfo {
gitea_issue2work_action: GiteaAction, 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>,
}
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 app = App { let data = extract_issue_info(&url).unwrap();
gitlab_issue2work_action: GitlabAction {},
gitea_issue2work_action: GiteaAction {}, 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<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,
},
}; };
if app.gitlab_issue2work_action.supports(&url, &config, args) { let work_package = open_project_client
app.gitlab_issue2work_action.run(&url, &config, args).await .create_work_package(&(&dto).into(), &args.project_id)
} else if app.gitea_issue2work_action.supports(&url, &config, args) { .await?;
app.gitea_issue2work_action.run(&url, &config, args).await
} else { println!(
Err(GeneralError { "new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
description: format!("This action is not supported for this url: {}", url), 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 {
// 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"),
})
} }

View File

@ -1,12 +1,2 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use url::Url;
pub(crate) mod issue2work; pub(crate) mod issue2work;
pub(crate) mod planning_load;
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;
}

View File

@ -0,0 +1,142 @@
use std::hash::Hash;
use tabled::settings::Style;
use crate::config::Config;
use crate::error::GeneralError;
use crate::openproject::client::Client;
use crate::openproject::user::UserPartial;
use crate::openproject::work::{WorkPackage, WorkPackageCollectionClient};
pub struct PlanningLoadConfig {
}
enum CellContent {
S(String),
N(f64),
}
impl From<CellContent> for String {
fn from(value: CellContent) -> Self {
match value {
CellContent::S(s) => s,
CellContent::N(n) => n.to_string()
}
}
}
struct UserList {
users: Vec<UserPartial>,
}
impl UserList {
fn from_work_packages(work_packages: &Vec<WorkPackage>) -> Self {
let mut users = vec![];
for w in work_packages {
if w.links.assignee.is_none() {
continue;
}
let u: &UserPartial = w.links.assignee.as_ref().unwrap();
if users.contains(u) {
continue;
}
users.push(u.clone());
}
users.sort_by(|a, b| a.href.partial_cmp(&b.href).unwrap());
UserList { users }
}
}
mod table_load {
use tabled::builder::Builder;
use crate::openproject::work::WorkPackage;
use crate::planning::planning_load::{CellContent, UserList};
use crate::utils::duration_to_seconds_f64;
pub struct Table {
work_packages: Vec<WorkPackage>,
user_list: UserList,
}
impl<'a> Table {
pub fn new(mut work_packages: Vec<WorkPackage>) -> Self {
let user_list = UserList::from_work_packages(&work_packages);
work_packages.sort_by(|a, b| {
if a.links.project.title != b.links.project.title {
return a.links.project.title.partial_cmp(&b.links.project.title).unwrap();
}
if a.links.budget.href.as_ref().unwrap_or(&"".to_string()) != b.links.budget.href.as_ref().unwrap_or(&"".to_string()) {
return a.links.budget.href.as_ref().unwrap_or(&"".to_string()).partial_cmp(
b.links.budget.href.as_ref().unwrap_or(&"".to_string())
).unwrap();
}
return a.id.partial_cmp(&b.id).unwrap();
});
Table {work_packages, user_list}
}
pub fn to_rows(&self) -> tabled::Table {
let mut builder = Builder::default();
let mut header = vec![
"Projet".to_string(),
"Buddget".to_string(),
"Tâche".to_string()
];
for u in &self.user_list.users {
header.push(u.title.as_ref().unwrap_or(&"".to_string()).clone());
}
header.push("Non assigné".to_string());
builder.push_record(header);
for work_package in &self.work_packages {
let mut row = Vec::with_capacity(3 + self.user_list.users.len() + 1);
row.push(CellContent::S(work_package.links.project.title.clone()));
row.push(CellContent::S(work_package.links.budget.title.as_ref().unwrap_or(&"".to_string()).clone()));
row.push(CellContent::S(format!("{} ({})", work_package.subject, work_package.id)));
for u in &self.user_list.users {
if work_package.links.assignee.is_some() && work_package.links.assignee.as_ref().unwrap().eq(u) {
row.push({
match work_package.estimated_time {
Some(duration) => CellContent::N(duration_to_seconds_f64(&duration)/3_600_f64),
None => CellContent::S("".to_string())
}
});
} else {
row.push(CellContent::S("".to_string()));
}
if work_package.links.assignee.is_none() {
row.push(CellContent::S("".to_string()));
}
}
builder.push_record(row);
}
builder.build()
}
}
}
pub async fn planning_load(config: Config) -> Result<(), GeneralError> {
let open_project_client = Client::from_config(&config.openproject);
let work_packages = open_project_client.work_package().await?;
let table_data = table_load::Table::new(work_packages);
let mut table = table_data.to_rows();
table.with(Style::markdown());
println!("{}", table);
Ok(())
}

29
src/utils.rs Normal file
View File

@ -0,0 +1,29 @@
use iso8601::Duration;
use iso8601::Duration::{Weeks, YMDHMS};
pub fn duration_to_seconds_f64(duration: &Duration) -> f64 {
if duration.is_zero() {
return 0f64;
}
match duration {
Weeks(w) => f64::from(*w) * 24f64 * 7f64 * 3600f64,
YMDHMS{year, month, day, hour, minute, second, millisecond} =>
f64::from(*day) * 86400f64
+ f64::from(*hour) * 3600f64
+ f64::from(*minute) * 60f64
+ f64::from(*second)
}
}
pub fn empty_duration() -> Duration {
YMDHMS {
year: 0,
month: 0,
day: 0,
hour: 0,
minute: 0,
second: 0,
millisecond: 0
}
}