10 Commits

Author SHA1 Message Date
2b37c5b18c Add Gitea Action support for issue-to-work conversion
Implemented Gitea actions to support converting Gitea issues into work packages in OpenProject. Modified various modules to include necessary structs, functions, and tests to handle Gitea issues, ensure URL validation, and adapt work package creation based on Gitea issues.
2024-10-25 00:29:34 +02:00
9ef98e5044 Refactor the issue2work action
- separation the logic to handle issues from gitlab into the gitlab mod
- create a kind of visitor pattern to check between the different providers
2024-10-24 23:14:14 +02:00
9aec267e0a WIP on integrate gitea 2024-05-20 19:16:21 +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
30 changed files with 826 additions and 152 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

39
Cargo.lock generated
View File

@@ -25,7 +25,7 @@ checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -110,6 +110,7 @@ version = "0.1.0"
dependencies = [
"clap",
"gitlab",
"log",
"reqwest",
"serde",
"simple-home-dir",
@@ -142,7 +143,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -228,7 +229,7 @@ dependencies = [
"proc-macro2",
"quote",
"scratch",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -245,7 +246,7 @@ checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -931,7 +932,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]
@@ -984,18 +985,18 @@ checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a"
[[package]]
name = "proc-macro2"
version = "1.0.54"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e472a104799c74b514a57226160104aa483546de37e839ec50e3c2e41dd87534"
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 +1196,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 +1305,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",
@@ -1373,7 +1374,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.10",
"syn 2.0.52",
]
[[package]]

View File

@@ -14,3 +14,4 @@ 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"

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,20 @@ 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),
}
#[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,25 @@ use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub(crate) struct Config {
pub gitlab: GitlabConfig,
pub gitlab: Vec<GitlabConfig>,
pub gitea: Vec<GiteaConfig>,
pub openproject: OpenProjectConfig,
}
#[derive(Deserialize, Debug)]
pub(crate) struct GitlabConfig {
pub token: String,
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,
pub base_url: String,
}
}

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 crate::openproject::client::Client;
use crate::openproject::root::RootClient;
use crate::openproject::user::GetMe;
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);
return 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(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()),
}
}
}

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

@@ -0,0 +1,63 @@
use url::Url;
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use crate::gitea::issue::{issue_html_url_to_api, Issue};
use crate::gitea::client::has_client_for_url;
use crate::openproject::user::{GetMe, User};
use crate::openproject::work::{WorkPackage, WorkPackageWriter, WorkPackageWriterAssignee};
use crate::planning::Issue2WorkActionTrait;
pub(crate) struct GiteaAction {}
impl Issue2WorkActionTrait for GiteaAction {
async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> {
let gitea_client = crate::gitea::client::Client::from_config(config.gitea.first().unwrap());
let issue: Issue = gitea_client
.get(issue_html_url_to_api(url)?)
.await?;
let open_project_client = crate::openproject::client::Client::from_config(&config.openproject);
let work_package = create_work_package_from_issue(
&issue,
match args.assign_to_me {
true => {
let u = open_project_client.me().await?;
Some(u)
}
false => None,
}
);
let work_package = open_project_client
.create_work_package(&work_package, &args.project_id)
.await?;
println!(
"new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
work_package.subject, config.openproject.base_url, args.project_id, work_package.id
);
Ok(())
}
fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {
has_client_for_url(&url, &config)
}
}
fn create_work_package_from_issue(issue: &Issue, assignee: Option<User>) -> WorkPackageWriter {
WorkPackageWriter {
subject: format!(
"{} ({})",
issue.title,
issue.repository.full_name
),
work_type: "TASK".into(),
description: crate::openproject::work::DescriptionWriter {
format: "markdown".into(),
raw: format!("From Gitea issue: {} \n\n{}", issue.html_url, issue.body),
},
assignee: assignee.into()
}
}

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

@@ -0,0 +1,98 @@
use crate::config::{Config, GiteaConfig, GitlabConfig};
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, 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);
}
}

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

@@ -0,0 +1,56 @@
use crate::gitea::client::Client;
use crate::gitea::repository::Repository;
use serde::Deserialize;
use url::Url;
use crate::error::GeneralError;
#[derive(Debug, Deserialize)]
pub struct Issue {
pub id: u64,
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 mod client;
pub mod issue;
pub mod repository;
pub(crate) mod action;

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;
/// 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,14 @@
pub mod action;
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 gitea;
mod gitlab;
mod openproject;
mod planning;
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,10 @@ 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(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(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 {
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,5 @@
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 serde::{Deserialize, Serialize};
use crate::gitlab::issue::IssueBundle;
use crate::openproject::user::User;
#[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)]
@@ -21,16 +26,13 @@ pub struct WorkPackage {
pub subject: String,
}
impl From<&IssueBundle> for WorkPackageWriter {
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)
}
impl From<Option<User>> for WorkPackageWriterAssignee {
fn from(value: Option<User>) -> Self {
WorkPackageWriterAssignee {
href: match value {
None => None,
Some(w) => Some(w.clone().d_links.d_self.href),
},
}
}
}

View File

@@ -1,80 +1,30 @@
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::issue::IssueBundle;
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,
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 url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
let app = App {
gitlab_issue2work_action: GitlabAction {},
gitea_issue2work_action: GiteaAction {},
};
let client = GitlabBuilder::new("gitlab.com", config.gitlab.token).build_async().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 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 {
// must be the id
iid = Some(String::from(el));
break;
}
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 {
Err(GeneralError {
description: format!("This action is not supported for this url: {}", url),
})
}
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,14 @@
pub(crate) mod issue2work;
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use crate::gitlab::client::ClientProvider;
use gitlab::{AsyncGitlab, GitlabBuilder};
use url::Url;
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;
}