9 Commits

Author SHA1 Message Date
c5f4f9fcf9 Release v0.3.0
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 1m27s
2024-11-17 21:25:32 +01:00
9f544c66c2 Add "open" crate and automate browser opening for work packages
Added the "open" crate to Cargo.toml and corresponding dependencies to Cargo.lock. Updated the code to automatically open the newly created work package in the browser and handle potential failure cases gracefully.
2024-11-17 21:24:41 +01:00
7c1cdb64ec fix multiple gitlab token possible 2024-11-14 15:23:25 +01:00
4bb787488d example configuation file 2024-11-14 15:22:14 +01:00
ab0df54893 release 0.2.0
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 1m57s
2024-11-14 14:22:56 +01:00
7b6cc33ecb Refactor Gitea client and improve issue handling
Update the authorization header format in the Gitea client. Enhance issue details in work package creation and make `number` field public in the `Issue` struct.
2024-11-14 14:22:39 +01:00
696fd15cfa cargo fixes 2024-10-25 00:42:23 +02:00
957c5b91bc cargo fixes 2024-10-25 00:41:46 +02:00
1d8a70768f integrate-gitea (#2)
Reviewed-on: #2
Co-authored-by: Julien Fastré <julien.fastre@champs-libres.coop>
Co-committed-by: Julien Fastré <julien.fastre@champs-libres.coop>
2024-10-24 22:32:42 +00:00
30 changed files with 579 additions and 580 deletions

View File

@@ -1,4 +0,0 @@
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

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

5
.changes/v0.2.0.md Normal file
View File

@@ -0,0 +1,5 @@
## 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

3
.changes/v0.3.0.md Normal file
View File

@@ -0,0 +1,3 @@
## v0.3.0 - 2024-11-17
### Added
* Open the newly create work package after the wp has been created

View File

@@ -6,6 +6,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
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
### Added
* Initiate changie versioning

104
Cargo.lock generated
View File

@@ -64,12 +64,6 @@ version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535"
[[package]]
name = "bytecount"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce"
[[package]]
name = "byteorder"
version = "1.4.3"
@@ -116,12 +110,11 @@ version = "0.1.0"
dependencies = [
"clap",
"gitlab",
"iso8601",
"log",
"open",
"reqwest",
"serde",
"simple-home-dir",
"tabled",
"tokio",
"toml",
"url",
@@ -746,6 +739,15 @@ version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.5"
@@ -759,13 +761,13 @@ dependencies = [
]
[[package]]
name = "iso8601"
version = "0.6.1"
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"nom",
"serde",
"is-docker",
"once_cell",
]
[[package]]
@@ -927,6 +929,17 @@ version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
[[package]]
name = "open"
version = "5.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.62"
@@ -978,15 +991,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ceedf44fb00f2d1984b0bc98102627ce622e083e49a5bacdb3e514fa4238e267"
[[package]]
name = "papergrid"
version = "0.11.0"
name = "pathdiff"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ad43c07024ef767f9160710b3a6773976194758c7919b17e63b863db0bdf7fb"
dependencies = [
"bytecount",
"fnv",
"unicode-width",
]
checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361"
[[package]]
name = "percent-encoding"
@@ -1012,30 +1020,6 @@ version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "proc-macro2"
version = "1.0.79"
@@ -1388,30 +1372,6 @@ dependencies = [
"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]]
name = "tempfile"
version = "3.9.0"
@@ -1663,12 +1623,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "void"
version = "1.0.2"

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Config {
pub gitlab: Vec<GitlabConfig>,
pub gitea: Vec<GiteaConfig>,
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,

View File

@@ -1,12 +1,24 @@
use crate::config::Config;
use crate::error::GeneralError;
use crate::openproject::client::Client;
use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe;
use crate::gitea::issue::Issue;
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);
Ok(())
/*
let open_project_client = Client::from_config(&config.openproject);
println!("base_url: {}", open_project_client.base_url);
println!("base_url: will get root");
@@ -16,4 +28,6 @@ pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
println!("me: {:?}", u);
Ok(())
*/
}

View File

@@ -1,4 +1,23 @@
use reqwest::header::InvalidHeaderValue;
use reqwest::Error;
#[derive(Debug)]
pub struct GeneralError {
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()),
}
}
}

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

@@ -0,0 +1,70 @@
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?;
let url = format!(
"{}/projects/{}/work_packages/{}",
config.openproject.base_url, args.project_id, work_package.id
);
println!(
"new work package created: {:?}, edit at {}",
work_package.subject, url
);
if let Err(e) = open::that(url) {
println!("failed to open work package in browser: {}", e);
};
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(),
}
}

110
src/gitea/client.rs Normal file
View File

@@ -0,0 +1,110 @@
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
);
}
}

70
src/gitea/issue.rs Normal file
View File

@@ -0,0 +1,70 @@
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"
);
}
}

4
src/gitea/mod.rs Normal file
View File

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

9
src/gitea/repository.rs Normal file
View File

@@ -0,0 +1,9 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Repository {
id: u64,
name: String,
owner: 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 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<AsyncGitlab, GeneralError>;
}
pub struct ClientProvider {}
impl ClientProviderTrait for ClientProvider {
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError> {
impl ClientProvider {}
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 {
if url.domain() == Some(c.domain.as_str()) {
if is_client_for_url(url, c) {
let client = GitlabBuilder::new("gitlab.com", c.token.clone())
.build_async()
.await;
@@ -32,4 +36,13 @@ impl ClientProviderTrait for ClientProvider {
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 issue;

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,47 +8,19 @@ pub struct UserLink {
#[serde(rename = "self")]
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)]
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,
}
impl PartialEq<Self> for User {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for User {
}
pub trait GetMe {
async fn me(&self) -> Result<User, OpenProjectError>;
}

View File

@@ -1,16 +1,5 @@
use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::Link;
use iso8601::Duration;
use crate::openproject::user::User;
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)]
pub struct WorkPackageWriterAssignee {
@@ -31,129 +20,19 @@ pub struct DescriptionWriter {
pub(crate) raw: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct WorkPackageLinks {
pub(crate) assignee: Option<UserPartial>,
pub(crate) project: ProjectPartial,
pub(crate) budget: BudgetPartial,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Deserialize, Debug)]
pub struct WorkPackage {
pub id: u64,
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,
}
#[derive(Deserialize, Debug)]
pub struct WorkPackageCollectionLinks {
#[serde(alias = "nextByOffset")]
pub next_by_offset: Option<Link>,
}
#[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()
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),
},
}
}
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,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<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),
},
},
}
}
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<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 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<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));
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 {
// 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"),
Err(GeneralError {
description: format!("This action is not supported for this url: {}", url),
})
}
}

View File

@@ -1,2 +1,12 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use url::Url;
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

@@ -1,142 +0,0 @@
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(())
}

View File

@@ -1,29 +0,0 @@
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
}
}