10 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
0c3320943e add changies 2024-03-17 21:36:10 +01:00
df71e1073c allow multiple instances of gitlab to be configured 2024-03-17 21:34:03 +01:00
34f6eac006 allow to assign automatically current user on new work package 2024-03-15 23:10:21 +01:00
8da5d6ed87 more usage 2024-01-09 09:50:10 +01:00
b4ffee2dd3 add some docs 2024-01-09 09:41:46 +01:00
708be9bdc4 update changelog
All checks were successful
Release binary for cl-cli / build-and-release (push) Successful in 1m42s
2024-01-08 23:22:29 +01:00
f9fb2b1e10 Merge pull request 'Enable CI for releasing the tool and changie for handling changelog' (#1) from ci-for-release into main
Reviewed-on: #1
2024-01-08 22:21:13 +00:00
27 changed files with 856 additions and 100 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,3 +1,4 @@
## v0.1.0 - 2024-01-08
### Added
* Initiate changie versioning
* Enable CI for compiling and publishing binaries

View File

@@ -30,6 +30,7 @@ jobs:
with:
files: |-
target/release/cl-cli
config.toml.dist
api_key: '${{secrets.RELEASE_TOKEN}}'
body: |
${{ steps.read_release.outputs.content }}

View File

@@ -9,3 +9,4 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
## v0.1.0 - 2024-01-08
### Added
* Initiate changie versioning
* Enable CI for compiling and publishing binaries

124
Cargo.lock generated
View File

@@ -25,7 +25,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -64,6 +64,12 @@ 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"
@@ -110,9 +116,12 @@ version = "0.1.0"
dependencies = [
"clap",
"gitlab",
"iso8601",
"log",
"reqwest",
"serde",
"simple-home-dir",
"tabled",
"tokio",
"toml",
"url",
@@ -142,7 +151,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -228,7 +237,7 @@ dependencies = [
"proc-macro2",
"quote",
"scratch",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -245,7 +254,7 @@ checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -749,6 +758,16 @@ dependencies = [
"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]]
name = "itertools"
version = "0.10.5"
@@ -931,7 +950,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -958,6 +977,17 @@ version = "6.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "percent-encoding"
version = "2.2.0"
@@ -983,19 +1013,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a"
[[package]]
name = "proc-macro2"
version = "1.0.54"
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e472a104799c74b514a57226160104aa483546de37e839ec50e3c2e41dd87534"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.26"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
@@ -1195,29 +1249,29 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.158"
version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "771d4d9c4163ee138805e12c710dd365e4f44be8be0503cb1bb9eb989425d9c9"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.158"
version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e801c1712f48475582b7696ac71e0ca34ebb30e09338425384269d9717c62cad"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
name = "serde_json"
version = "1.0.94"
version = "1.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c533a59c9d8a93a09c6ab31f0fd5e5f4dd1b8fc9434804029839884765d04ea"
checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0"
dependencies = [
"itoa",
"ryu",
@@ -1304,9 +1358,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.10"
version = "2.0.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aad1363ed6d37b84299588d62d3a7d95b5a5c2d9aad5c85609fda12afaa1f40"
checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
dependencies = [
"proc-macro2",
"quote",
@@ -1334,6 +1388,30 @@ 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"
@@ -1373,7 +1451,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -1585,6 +1663,12 @@ 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

@@ -14,3 +14,6 @@ toml = "0.7.3"
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"]}

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# CL-cli utility
Provide a CLI interface for several recurring tasks for Champs-Libres
Currently:
- convert a gitlab issue to a work package
## Install & configure
### Install
Download the most recent binaries at https://gitea.champs-libres.be/julienfastre/cl-cli/releases
Once downloaded, install it:
```
# this will install the command globally with the name "cl-cli"
sudo install cl-cli /usr/local/bin/cl-cli
# this will install the command globally with the name "cl"
sudo install cl-cli /usr/local/bin/cl
```
### Configure
Copy the file [config.toml.dist](./config.toml.dist) as a template, and
save it at the path `$HOME/.config/cl-cli/config.toml`:
```bash
mkdir -p $HOME/.config/cl-cli
cp config.toml $HOME/.config/cl-cli/config.toml
editor $HOME/.config/cl-cli/config.toml
```
Then, fill it with the required configuration options (gitlab and openproject token).
## Usage
### Convert a gitlab issue into a work package
```bash
cl-cli planning i2work https://gitlab.com/Chill-Projet/chill-bundles/-/issues/240 chill
```
Where:
- `https://gitlab.com/Chill-Projet/chill-bundles/-/issues/240` is the URL of the issue
- `chill` is the identifier of the project in openproject (see the identifier
in the URL like the page "Vue globale": https://champs-libres.openproject.com/projects/chill/, or
see it there: https://champs-libres.openproject.com/projects/chill/identifier (Paramètres du projet / changer
l'identifiant (bouton en haut)))

View File

@@ -13,18 +13,21 @@ pub(crate) struct Cli {
#[derive(Subcommand)]
pub(crate) enum Commands {
#[command(subcommand)]
Planning(Planning)
Planning(Planning),
Test,
}
#[derive(Subcommand)]
pub(crate) enum Planning {
I2work(Issue2Work)
I2work(Issue2Work),
Load,
}
#[derive(Args, Debug)]
pub(crate) struct Issue2Work {
pub issue_url: String,
pub project_id: String,
#[arg(short, long)]
pub assign_to_me: bool,
}

View File

@@ -2,17 +2,18 @@ use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Config {
pub gitlab: GitlabConfig,
pub gitlab: Vec<GitlabConfig>,
pub openproject: OpenProjectConfig,
}
#[derive(Deserialize, Debug)]
pub(crate) struct GitlabConfig {
pub token: String,
pub domain: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct OpenProjectConfig {
pub token: String,
pub base_url: String,
}
}

19
src/debug.rs Normal file
View File

@@ -0,0 +1,19 @@
use crate::config::Config;
use crate::error::GeneralError;
use crate::openproject::client::Client;
use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe;
pub(crate) async fn debug(config: Config) -> Result<(), GeneralError> {
println!("test");
let open_project_client = Client::from_config(&config.openproject);
println!("base_url: {}", open_project_client.base_url);
println!("base_url: will get root");
let r = open_project_client.root().await?;
println!("root: {:?}", r);
let u = open_project_client.me().await?;
println!("me: {:?}", u);
Ok(())
}

View File

@@ -1,4 +1,4 @@
#[derive(Debug)]
pub struct GeneralError {
pub(crate) description: String,
}

35
src/gitlab/client.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::config::Config;
use crate::error::GeneralError;
use gitlab::AsyncGitlab;
use gitlab::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> {
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;
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()),
})
}
}

View File

@@ -2,13 +2,17 @@ use gitlab::Issue;
use gitlab::Project;
/// A struct which contains Issue and Project
#[derive(Debug)]
pub struct IssueBundle {
pub issue: Issue,
pub project: Project
pub project: Project,
}
impl IssueBundle {
pub fn new(issue: &Issue, project: &Project) -> Self {
IssueBundle{issue: issue.clone(), project: project.clone()}
IssueBundle {
issue: issue.clone(),
project: project.clone(),
}
}
}
}

View File

@@ -1,13 +1,13 @@
pub mod client;
pub mod issue;
use gitlab::GitlabError;
use crate::error::GeneralError;
use gitlab::GitlabError;
impl From<GitlabError> for GeneralError {
fn from(value: GitlabError) -> Self {
GeneralError{
description: value.to_string()
GeneralError {
description: value.to_string(),
}
}
}

View File

@@ -1,24 +1,26 @@
extern crate serde;
extern crate clap;
extern crate reqwest;
extern crate serde;
extern crate simple_home_dir;
mod cli;
mod config;
mod planning;
mod openproject;
mod debug;
mod error;
mod gitlab;
mod openproject;
mod planning;
mod utils;
use std::fs;
use std::path::PathBuf;
use std::process::exit;
use crate::cli::Commands::{Planning, Test};
use crate::cli::Planning::I2work;
use crate::error::GeneralError;
use clap::Parser;
use cli::Cli;
use config::Config;
use crate::cli::Commands::Planning;
use crate::cli::Planning::I2work;
use crate::error::GeneralError;
use std::fs;
use std::path::PathBuf;
use std::process::exit;
#[tokio::main]
async fn main() {
@@ -29,13 +31,16 @@ async fn main() {
let config_path = match cli.config.as_deref() {
Some(p) => p,
None => &default_config_path
None => &default_config_path,
};
let config_path_content = match fs::read_to_string(config_path) {
Ok(content) => content,
Err(e) => {
println!("Could not read config file at {:?}, error: {}", config_path, e);
println!(
"Could not read config file at {:?}, error: {}",
config_path, e
);
exit(1);
}
};
@@ -44,7 +49,11 @@ async fn main() {
let result = match cli.command {
Some(Planning(I2work(args))) => planning::issue2work::issue2work(config, &args).await,
None => Err(GeneralError{description: "No command launched".to_string()})
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(),
}),
};
match result {

View File

@@ -1,48 +1,98 @@
use crate::config::OpenProjectConfig;
use crate::error::GeneralError;
use crate::openproject::work::{WorkPackageWriter, WorkPackage};
use crate::openproject::work::{WorkPackage, WorkPackageWriter};
use reqwest::Response;
use serde::Deserialize;
use std::error::Error;
pub(crate) struct Error {
description: String,
#[derive(Deserialize, Debug)]
pub struct OpenProjectError {
pub description: String,
}
impl From<reqwest::Error> for Error {
impl From<reqwest::Error> for OpenProjectError {
fn from(value: reqwest::Error) -> Self {
Error {
description: format!("Error while connecting to openproject instance: {}", value)
OpenProjectError {
description: format!("Error while connecting to openproject instance: {}", value),
}
}
}
impl From<Error> for GeneralError {
fn from(value: Error) -> GeneralError {
GeneralError{description: value.description}
impl From<OpenProjectError> for GeneralError {
fn from(value: OpenProjectError) -> GeneralError {
GeneralError {
description: value.description,
}
}
}
pub(crate) struct Client {
base_url: String,
token: String,
pub(crate) base_url: String,
pub(crate) token: String,
}
pub async fn handle_response_status<T: for<'de> serde::Deserialize<'de>>(
response: Response,
error_message: &str,
) -> Result<T, OpenProjectError> {
if !response.status().is_success() {
let status = response.status().to_string().clone();
let content = response
.text()
.await
.unwrap_or_else(|_| "Impossible to decode".into())
.clone();
return Err(OpenProjectError {
description: format!(
"{}, status: {}, content: {}",
error_message, status, content
),
});
}
let t = response.json::<T>().await;
match t {
Ok(t) => Ok(t),
Err(e) => Err(OpenProjectError {
description: format!(
"Error while decoding json: {}, source: {:?}",
e.to_string(),
e.source()
),
}),
}
}
impl Client {
pub fn from_config(config: &OpenProjectConfig) -> Client {
Client{base_url: config.base_url.clone(), token: config.token.clone()}
Client {
base_url: config.base_url.clone(),
token: config.token.clone(),
}
}
pub async fn create_work_package(&self, work_package: &WorkPackageWriter, project_id: &String) -> Result<WorkPackage, Error> {
pub async fn create_work_package(
&self,
work_package: &WorkPackageWriter,
project_id: &String,
) -> Result<WorkPackage, OpenProjectError> {
let client = reqwest::Client::new();
let work_package: WorkPackage = client
.post(format!("{}/api/v3/projects/{}/work_packages", self.base_url, project_id))
let response = client
.post(format!(
"{}/api/v3/projects/{}/work_packages",
self.base_url, project_id
))
.basic_auth("apikey", Some(&self.token))
.json(&work_package)
.json(work_package)
.send()
.await?
.json()
.await?;
let work_package =
handle_response_status(response, "error while retrieving work package").await?;
Ok(work_package)
}
}

13
src/openproject/hal.rs Normal file
View File

@@ -0,0 +1,13 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct HalEntity {
#[serde(rename = "_type")]
pub d_type: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Link {
pub href: String,
pub title: Option<String>,
}

View File

@@ -1,3 +1,6 @@
pub(crate) mod client;
mod work;
mod hal;
pub(crate) mod root;
pub(crate) mod user;
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,
}

42
src/openproject/root.rs Normal file
View File

@@ -0,0 +1,42 @@
use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::HalEntity;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct User {
pub href: String,
pub title: String,
}
#[derive(Deserialize, Debug)]
pub struct Links {
pub user: User,
}
#[derive(Deserialize, Debug)]
pub struct Root {
#[serde(rename = "instanceName")]
pub instance_name: String,
#[serde(rename = "_links")]
pub links: Links,
#[serde(flatten)]
pub hal_entity: HalEntity,
}
pub trait RootClient {
async fn root(&self) -> Result<Root, OpenProjectError>;
}
impl RootClient for Client {
async fn root(&self) -> Result<Root, OpenProjectError> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/api/v3", self.base_url))
.basic_auth("apikey", Some(&self.token))
.send()
.await?;
let r = handle_response_status(response, "Error while retrieving root").await?;
Ok(r)
}
}

71
src/openproject/user.rs Normal file
View File

@@ -0,0 +1,71 @@
use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::Link;
use crate::openproject::root::RootClient;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
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")]
pub d_type: String,
pub id: u64,
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>;
}
impl GetMe for Client {
async fn me(&self) -> Result<User, OpenProjectError> {
let r = self.root().await?;
let client = reqwest::Client::new();
let response = client
.get(format!("{}{}", self.base_url, r.links.user.href))
.basic_auth("apikey", Some(&self.token))
.send()
.await?;
let u = handle_response_status(response, "Error while retrieving user").await?;
Ok(u)
}
}

View File

@@ -1,36 +1,159 @@
use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::Link;
use iso8601::Duration;
use serde::{Deserialize, Serialize};
use crate::gitlab::issue::IssueBundle;
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 {
pub(crate) href: Option<String>,
}
#[derive(Serialize, Debug)]
pub struct WorkPackageWriter {
subject: String,
pub(crate) subject: String,
#[serde(alias = "type")]
work_type: String,
description: DescriptionWriter,
pub(crate) work_type: String,
pub(crate) description: DescriptionWriter,
pub assignee: WorkPackageWriterAssignee,
}
#[derive(Serialize, Debug)]
pub struct DescriptionWriter {
format: String,
raw: String,
pub(crate) format: 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 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,
}
impl From<&IssueBundle> for WorkPackageWriter {
#[derive(Deserialize, Debug)]
pub struct WorkPackageCollectionLinks {
#[serde(alias = "nextByOffset")]
pub next_by_offset: Option<Link>,
}
fn from(value: &IssueBundle) -> Self {
WorkPackageWriter {
subject: format!("{} ({}/{})", value.issue.title, value.project.name_with_namespace, value.issue.iid),
work_type: "TASK".into(),
description: DescriptionWriter {
format: "markdown".into(),
raw: format!("From gitlab: {}", value.issue.web_url)
}
}
#[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,11 +1,14 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::openproject::client::Client;
use gitlab::{ GitlabBuilder, Issue, Project};
use gitlab::api::{issues, AsyncQuery, projects};
use url::Url;
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 url::Url;
#[derive(Debug)]
struct IssueInfo {
@@ -13,19 +16,48 @@ struct IssueInfo {
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> {
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 client = GitlabBuilder::new("gitlab.com", config.gitlab.token).build_async().await?;
let client = ClientProvider::client_for_url(&url, &config).await?;
let endpoint = issues::ProjectIssues::builder()
.iid(data.iid)
.project(String::from(data.project))
.build()
.unwrap()
;
.unwrap();
let issues: Vec<Issue> = endpoint.query_async(&client).await.unwrap();
let issue = issues.first().unwrap();
@@ -41,15 +73,33 @@ pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(),
let open_project_client = Client::from_config(&config.openproject);
let work_package = open_project_client.create_work_package(&(&issue_bundle).into(), &args.project_id).await?;
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,
},
};
println!("new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", work_package.subject, config.openproject.base_url, args.project_id, work_package.id);
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 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;
@@ -61,7 +111,7 @@ fn extract_issue_info(url: &Url) -> Option<IssueInfo> {
continue;
}
if el == "issues" {
continue
continue;
}
if !project_found {
project_url.push(String::from(el));
@@ -74,7 +124,9 @@ fn extract_issue_info(url: &Url) -> Option<IssueInfo> {
Some(IssueInfo {
project: project_url.join("/"),
iid: iid.expect("iid of the issue not found")
.parse().expect("could not transform issue id to u64")
iid: iid
.expect("iid of the issue not found")
.parse()
.expect("could not transform issue id to u64"),
})
}
}

View File

@@ -1 +1,2 @@
pub(crate) mod issue2work;
pub(crate) mod issue2work;
pub(crate) mod planning_load;

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
}
}