Compare commits

..

16 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
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
31 changed files with 908 additions and 153 deletions

View File

@ -1,3 +1,4 @@
## v0.1.0 - 2024-01-08 ## v0.1.0 - 2024-01-08
### Added ### Added
* Initiate changie versioning * Initiate changie versioning
* Enable CI for compiling and publishing binaries

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

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

View File

@ -6,6 +6,13 @@ 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
* Enable CI for compiling and publishing binaries

76
Cargo.lock generated
View File

@ -25,7 +25,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]
@ -110,6 +110,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"gitlab", "gitlab",
"log",
"open",
"reqwest", "reqwest",
"serde", "serde",
"simple-home-dir", "simple-home-dir",
@ -142,7 +144,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]
@ -228,7 +230,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"scratch", "scratch",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]
@ -245,7 +247,7 @@ checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]
@ -737,6 +739,15 @@ version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e22bd8629359895450b59ea7a776c850561b96a3b1d31321c1949d9e6c9146" 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]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.5" version = "0.4.5"
@ -749,6 +760,16 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.10.5"
@ -908,6 +929,17 @@ version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" 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]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.62" version = "0.10.62"
@ -931,7 +963,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]
@ -958,6 +990,12 @@ 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 = "pathdiff"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.2.0" version = "2.2.0"
@ -984,18 +1022,18 @@ checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.54" version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e472a104799c74b514a57226160104aa483546de37e839ec50e3c2e41dd87534" checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.26" version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -1195,29 +1233,29 @@ dependencies = [
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.158" version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "771d4d9c4163ee138805e12c710dd365e4f44be8be0503cb1bb9eb989425d9c9" checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.158" version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e801c1712f48475582b7696ac71e0ca34ebb30e09338425384269d9717c62cad" checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.94" version = "1.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c533a59c9d8a93a09c6ab31f0fd5e5f4dd1b8fc9434804029839884765d04ea" checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",
@ -1304,9 +1342,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.10" version = "2.0.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aad1363ed6d37b84299588d62d3a7d95b5a5c2d9aad5c85609fda12afaa1f40" checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1373,7 +1411,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.10", "syn 2.0.52",
] ]
[[package]] [[package]]

View File

@ -14,3 +14,5 @@ toml = "0.7.3"
url = "2.3.1" 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"
open = "5.3.1"

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

@ -1,6 +1,11 @@
[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

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

View File

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

33
src/debug.rs Normal file
View File

@ -0,0 +1,33 @@
use crate::config::Config;
use crate::error::GeneralError;
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");
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,23 @@
use reqwest::header::InvalidHeaderValue;
use reqwest::Error;
#[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()),
}
}
}

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

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

@ -0,0 +1,48 @@
use crate::config::{Config, GitlabConfig};
use crate::error::GeneralError;
use gitlab::{AsyncGitlab, GitlabBuilder};
use url::Url;
pub struct ClientProvider {}
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 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()),
})
}
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

@ -2,13 +2,17 @@ use gitlab::Issue;
use gitlab::Project; use gitlab::Project;
/// A struct which contains Issue and Project /// A struct which contains Issue and Project
#[derive(Debug)]
pub struct IssueBundle { pub struct IssueBundle {
pub issue: Issue, pub issue: Issue,
pub project: Project pub project: Project,
} }
impl IssueBundle { impl IssueBundle {
pub fn new(issue: &Issue, project: &Project) -> Self { 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,14 @@
pub mod action;
pub mod client;
pub mod issue; pub mod issue;
use gitlab::GitlabError;
use crate::error::GeneralError; use crate::error::GeneralError;
use gitlab::GitlabError;
impl From<GitlabError> for GeneralError { impl From<GitlabError> for GeneralError {
fn from(value: GitlabError) -> Self { fn from(value: GitlabError) -> Self {
GeneralError{ GeneralError {
description: value.to_string() description: value.to_string(),
} }
} }
} }

View File

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

View File

@ -1,48 +1,98 @@
use crate::config::OpenProjectConfig; use crate::config::OpenProjectConfig;
use crate::error::GeneralError; 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 { #[derive(Deserialize, Debug)]
description: String, pub(crate) struct OpenProjectError {
pub(crate) description: String,
} }
impl From<reqwest::Error> for Error { impl From<reqwest::Error> for OpenProjectError {
fn from(value: reqwest::Error) -> Self { fn from(value: reqwest::Error) -> Self {
Error { OpenProjectError {
description: format!("Error while connecting to openproject instance: {}", value) description: format!("Error while connecting to openproject instance: {}", value),
} }
} }
} }
impl From<Error> for GeneralError { impl From<OpenProjectError> for GeneralError {
fn from(value: Error) -> GeneralError { fn from(value: OpenProjectError) -> GeneralError {
GeneralError{description: value.description} GeneralError {
description: value.description,
}
} }
} }
pub(crate) struct Client { pub(crate) struct Client {
base_url: String, pub(crate) base_url: String,
token: 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 { impl Client {
pub fn from_config(config: &OpenProjectConfig) -> 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 client = reqwest::Client::new();
let work_package: WorkPackage = client let response = client
.post(format!("{}/api/v3/projects/{}/work_packages", self.base_url, project_id)) .post(format!(
"{}/api/v3/projects/{}/work_packages",
self.base_url, project_id
))
.basic_auth("apikey", Some(&self.token)) .basic_auth("apikey", Some(&self.token))
.json(&work_package) .json(work_package)
.send() .send()
.await?
.json()
.await?; .await?;
let work_package =
handle_response_status(response, "error while retrieving work package").await?;
Ok(work_package) 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,5 @@
pub(crate) mod client; pub(crate) mod client;
mod work; mod hal;
pub(crate) mod root;
pub(crate) mod user;
pub(crate) mod work;

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

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

@ -0,0 +1,43 @@
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 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,
}
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,18 +1,23 @@
use crate::openproject::user::User;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::gitlab::issue::IssueBundle;
#[derive(Serialize, Debug)]
pub struct WorkPackageWriterAssignee {
pub(crate) href: Option<String>,
}
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct WorkPackageWriter { pub struct WorkPackageWriter {
subject: String, pub(crate) subject: String,
#[serde(alias = "type")] #[serde(alias = "type")]
work_type: String, pub(crate) work_type: String,
description: DescriptionWriter, pub(crate) description: DescriptionWriter,
pub assignee: WorkPackageWriterAssignee,
} }
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct DescriptionWriter { pub struct DescriptionWriter {
format: String, pub(crate) format: String,
raw: String, pub(crate) raw: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@ -21,16 +26,13 @@ pub struct WorkPackage {
pub subject: String, pub subject: String,
} }
impl From<&IssueBundle> for WorkPackageWriter { impl From<Option<User>> for WorkPackageWriterAssignee {
fn from(value: Option<User>) -> Self {
fn from(value: &IssueBundle) -> Self { WorkPackageWriterAssignee {
WorkPackageWriter { href: match value {
subject: format!("{} ({}/{})", value.issue.title, value.project.name_with_namespace, value.issue.iid), None => None,
work_type: "TASK".into(), Some(w) => Some(w.clone().d_links.d_self.href),
description: DescriptionWriter { },
format: "markdown".into(),
raw: format!("From gitlab: {}", value.issue.web_url)
}
} }
} }
} }

View File

@ -1,80 +1,30 @@
use crate::cli::Issue2Work; use crate::cli::Issue2Work;
use crate::config::Config; 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::error::GeneralError;
use crate::gitlab::issue::IssueBundle; use crate::gitea::action::GiteaAction;
use crate::gitlab::action::GitlabAction;
use crate::planning::Issue2WorkActionTrait;
use url::Url;
#[derive(Debug)] struct App {
struct IssueInfo { gitlab_issue2work_action: GitlabAction,
project: String, gitea_issue2work_action: GiteaAction,
iid: u64,
} }
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) let url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
.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 = GitlabBuilder::new("gitlab.com", config.gitlab.token).build_async().await?; if app.gitlab_issue2work_action.supports(&url, &config, args) {
app.gitlab_issue2work_action.run(&url, &config, args).await
let endpoint = issues::ProjectIssues::builder() } else if app.gitea_issue2work_action.supports(&url, &config, args) {
.iid(data.iid) app.gitea_issue2work_action.run(&url, &config, args).await
.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 work_package = open_project_client.create_work_package(&(&issue_bundle).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));
} else { } else {
// must be the id Err(GeneralError {
iid = Some(String::from(el)); description: format!("This action is not supported for this url: {}", url),
break;
}
}
Some(IssueInfo {
project: project_url.join("/"),
iid: iid.expect("iid of the issue not found")
.parse().expect("could not transform issue id to u64")
}) })
}
} }

View File

@ -1 +1,12 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use url::Url;
pub(crate) mod issue2work; pub(crate) mod issue2work;
pub trait Issue2WorkActionTrait {
async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>;
fn supports(&self, url: &Url, config: &Config, args: &Issue2Work) -> bool;
}