14 Commits

Author SHA1 Message Date
9950282d6e Update build-and-release workflow to use Gitea-hosted setup-go action
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 7s
2025-10-24 23:33:31 +02:00
7c8e8eb236 Clean code by commenting out some variable, and allowing some dead code. 2025-10-24 23:24:47 +02:00
442b5d25b4 Release v0.4.0
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 1m40s
2025-10-24 18:08:01 +02:00
4763d48290 Add PATCH support in Gitea client and update issue handling
Extend Gitea client with a `patch` method for executing PATCH requests. Introduce `IssueWriteSetBody` struct for serializing issue update payloads. Update Gitea action to append related issues and send PATCH requests to update issue descriptions.
2025-10-24 18:04:28 +02:00
1af6596b51 Enhance issue handling by adding utils module
Introduce `append_related_issues` function and `IssueRelated` enum to manage related issue linking. Update Gitea action to utilize the new functionality for appending related issues in work package content. Add corresponding tests.
2025-10-24 17:32:10 +02:00
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
27 changed files with 737 additions and 206 deletions

View File

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

View File

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

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

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

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

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

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

@@ -0,0 +1,3 @@
## v0.4.0 - 2025-10-24
### Added
* [planning - i2work] Met à jour le body du ticket gitea avec un lien vers le work package, dans une section "related issues"

View File

@@ -21,7 +21,7 @@ jobs:
with: with:
path: .changes/${{ github.ref_name }}.md path: .changes/${{ github.ref_name }}.md
- name: Setup go for using go gitea actions - name: Setup go for using go gitea actions
uses: https://github.com/actions/setup-go@v4 uses: https://gitea.com/actions/setup-go@v4
with: with:
go-version: '>=1.20.1' go-version: '>=1.20.1'
- name: Use Go Action to release - name: Use Go Action to release

View File

@@ -6,6 +6,20 @@ 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.4.0 - 2025-10-24
### Added
* [planning - i2work] Met à jour le body du ticket gitea avec un lien vers le work package, dans une section "related issues"
## v0.3.0 - 2024-11-17
### Added
* Open the newly create work package after the wp has been created
## 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

37
Cargo.lock generated
View File

@@ -111,6 +111,7 @@ dependencies = [
"clap", "clap",
"gitlab", "gitlab",
"log", "log",
"open",
"reqwest", "reqwest",
"serde", "serde",
"simple-home-dir", "simple-home-dir",
@@ -738,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"
@@ -750,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"
@@ -909,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"
@@ -959,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"

View File

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

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

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

View File

@@ -7,7 +7,7 @@ pub struct GeneralError {
} }
impl From<InvalidHeaderValue> for GeneralError { impl From<InvalidHeaderValue> for GeneralError {
fn from(value: InvalidHeaderValue) -> Self { fn from(_value: InvalidHeaderValue) -> Self {
GeneralError { GeneralError {
description: "Unable to convert the token into header value".to_string(), description: "Unable to convert the token into header value".to_string(),
} }

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

@@ -0,0 +1,82 @@
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, IssueWriteSetBody};
use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriter;
use crate::planning::utils::{append_related_issues, IssueRelated};
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_wp = format!(
"{}/projects/{}/work_packages/{}",
config.openproject.base_url, args.project_id, work_package.id
);
let content = append_related_issues(
&IssueRelated::OpenProjectIssue(url_wp.to_string()),
&issue.body,
);
let _u: Issue = gitea_client
.patch(
issue_html_url_to_api(url)?,
&IssueWriteSetBody { body: content },
)
.await?;
println!(
"new work package created: {:?}, edit at {}",
work_package.subject, url_wp
);
if let Err(e) = open::that(url_wp) {
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(),
}
}

View File

@@ -1,32 +1,62 @@
use crate::config::{Config, GiteaConfig};
use crate::error::GeneralError; use crate::error::GeneralError;
use reqwest::header::{HeaderMap, AUTHORIZATION, ACCEPT}; use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::{ClientBuilder, StatusCode}; use reqwest::{ClientBuilder, StatusCode};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url; use url::Url;
use crate::config::GiteaConfig;
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
token: String, token: String,
base_uri: 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
}
#[allow(dead_code)]
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 { impl Client {
pub fn from_config(config: &GiteaConfig) -> Self { pub fn from_config(config: &GiteaConfig) -> Self {
Self::new(&config.token, &config.domain) Self::new(&config.token, &config.domain)
} }
pub fn new(token: &String, domain: &String) -> Self { pub fn new(token: &String, _domain: &String) -> Self {
Client { Client {
token: token.clone(), token: token.clone(),
base_uri: format!("https://{}", domain.clone()), // base_uri: format!("https://{}", domain.clone()),
} }
} }
pub async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<T, GeneralError> { pub async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<T, GeneralError> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.append(AUTHORIZATION, self.token.parse()?); headers.append(AUTHORIZATION, format!("token {}", self.token).parse()?);
headers.append(ACCEPT, "application/json".parse()?); headers.append(ACCEPT, "application/json".parse()?);
let client = ClientBuilder::new() let client = ClientBuilder::new()
@@ -40,7 +70,7 @@ impl Client {
StatusCode::OK => { StatusCode::OK => {
let result: T = response.json().await?; let result: T = response.json().await?;
return Ok(result); Ok(result)
} }
_ => Err(GeneralError { _ => Err(GeneralError {
description: format!( description: format!(
@@ -51,4 +81,64 @@ impl Client {
}), }),
} }
} }
pub async fn patch<T: DeserializeOwned, B: Serialize>(
&self,
url: Url,
body: &B,
) -> 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.patch(url.clone()).json(body).send().await?;
match response.status() {
StatusCode::OK | StatusCode::CREATED => {
let result: T = response.json().await?;
Ok(result)
}
_ => Err(GeneralError {
description: format!(
"Could not call PATCH on {:?}, error code {}",
url,
response.status()
),
}),
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_is_client_for_url() {
let config = GiteaConfig {
domain: "gitea.champs-libres.be".into(),
token: "<PASSWORD>".into(),
};
assert_eq!(
is_client_for_url(
&Url::parse("https://gitea.champs-libres.be/something/somewhere").unwrap(),
&config
),
true
);
assert_eq!(
is_client_for_url(
&Url::parse("https://somewhere.else/something/somewhere").unwrap(),
&config
),
false
);
}
} }

View File

@@ -1,22 +1,64 @@
use crate::gitea::client::Client; use crate::error::GeneralError;
use serde::Deserialize;
use crate::gitea::repository::Repository; use crate::gitea::repository::Repository;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Issue { pub struct Issue {
id: u64, // pub id: u64,
number: u64, pub number: u64,
title: String, pub title: String,
body: String, pub body: String,
repository: Repository pub repository: Repository,
pub html_url: String,
} }
pub trait IssueClient { #[derive(Debug, Serialize)]
fn get_issue(owner_or_organisation: &String, repo: &String, number: &u64) -> Option<Issue>; pub struct IssueWriteSetBody {
pub body: String,
} }
impl IssueClient for Client { pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {
fn get_issue(owner_or_organisation: &String, repo: &String, number: &u64) -> Option<Issue> { let mut parts = url.path_segments().unwrap();
todo!() let domain = parts.next().unwrap();
let repo = parts.next().unwrap();
let issue = parts.next().unwrap();
let iid = parts.next().unwrap();
if !issue.eq("issues") {
return Err(GeneralError {
description: format!("Issue url is not valid: {}", url),
});
}
let url = Url::parse(
format!(
"{}://{}/api/v1/repos/{}/{}/issues/{}",
url.scheme(),
url.host().unwrap(),
domain,
repo,
iid
)
.as_str(),
)
.unwrap();
Ok(url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_html_url_to_api() {
let url = Url::parse("https://gitea.champs-libres.be/champs-libres/test/issues/1").unwrap();
let result = issue_html_url_to_api(&url).unwrap();
assert_eq!(
result.as_str(),
"https://gitea.champs-libres.be/api/v1/repos/champs-libres/test/issues/1"
);
} }
} }

View File

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

View File

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

139
src/gitlab/action.rs Normal file
View File

@@ -0,0 +1,139 @@
use crate::cli::Issue2Work;
use crate::config::Config;
use crate::error::GeneralError;
use crate::gitlab::client::{client_for_url, has_client_for_url};
use crate::gitlab::issue::IssueBundle;
use crate::openproject::client::Client;
use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriterAssignee;
use crate::planning::Issue2WorkActionTrait;
use gitlab::api::{issues, projects, AsyncQuery};
use gitlab::{Issue, Project};
use url::Url;
#[derive(Debug)]
struct IssueInfo {
project: String,
iid: u64,
}
/// details on how to create a work package from various informations
#[derive(Debug)]
struct Issue2WorkPackageDTO {
pub issue: IssueBundle,
pub assign_to: Option<User>,
}
pub(crate) struct GitlabAction {}
impl Issue2WorkActionTrait for GitlabAction {
async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError> {
let client = client_for_url(&url, &config).await?;
let data = extract_issue_info(&url).unwrap();
let endpoint = issues::ProjectIssues::builder()
.iid(data.iid)
.project(String::from(data.project))
.build()
.unwrap();
let issues: Vec<Issue> = endpoint.query_async(&client).await.unwrap();
let issue = issues.first().unwrap();
let project_endpoint = projects::Project::builder()
.project(issue.project_id.value())
.build()
.unwrap();
let project: Project = project_endpoint.query_async(&client).await.unwrap();
let issue_bundle = IssueBundle::new(&issue, &project);
let open_project_client = Client::from_config(&config.openproject);
let dto = Issue2WorkPackageDTO {
issue: issue_bundle,
assign_to: match args.assign_to_me {
true => {
let u = open_project_client.me().await?;
Some(u)
}
false => None,
},
};
let work_package = open_project_client
.create_work_package(&(&dto).into(), &args.project_id)
.await?;
println!(
"new work package created: {:?}, edit at {}/projects/{}/work_packages/{}",
work_package.subject, config.openproject.base_url, args.project_id, work_package.id
);
Ok(())
}
fn supports(&self, url: &Url, config: &Config, _args: &Issue2Work) -> bool {
has_client_for_url(&url, &config)
}
}
fn extract_issue_info(url: &Url) -> Option<IssueInfo> {
let parts = url
.path_segments()
.expect("Could not parse path segment of given url");
let mut project_url: Vec<String> = Vec::with_capacity(3);
let mut iid: Option<String> = None;
let mut project_found = false;
for el in parts {
if el == "-" {
project_found = true;
continue;
}
if el == "issues" {
continue;
}
if !project_found {
project_url.push(String::from(el));
} else {
// must be the id
iid = Some(String::from(el));
break;
}
}
Some(IssueInfo {
project: project_url.join("/"),
iid: iid
.expect("iid of the issue not found")
.parse()
.expect("could not transform issue id to u64"),
})
}
impl From<&Issue2WorkPackageDTO> for crate::openproject::work::WorkPackageWriter {
fn from(value: &Issue2WorkPackageDTO) -> Self {
crate::openproject::work::WorkPackageWriter {
subject: format!(
"{} ({}/{})",
value.issue.issue.title,
value.issue.project.name_with_namespace,
value.issue.issue.iid
),
work_type: "TASK".into(),
description: crate::openproject::work::DescriptionWriter {
format: "markdown".into(),
raw: format!("From gitlab: {}", value.issue.issue.web_url),
},
assignee: WorkPackageWriterAssignee {
href: match &value.assign_to {
None => None,
Some(w) => Some(w.clone().d_links.d_self.href),
},
},
}
}
}

View File

@@ -1,35 +1,44 @@
use crate::config::Config; use crate::config::{Config, GitlabConfig};
use crate::error::GeneralError; use crate::error::GeneralError;
use gitlab::AsyncGitlab; use gitlab::{AsyncGitlab, GitlabBuilder};
use gitlab::GitlabBuilder;
use url::Url; use url::Url;
pub trait ClientProviderTrait { fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool {
async fn client_for_url(url: &Url, config: &Config) -> Result<AsyncGitlab, GeneralError>; if url.domain() == Some(config.domain.as_str()) {
} return true;
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()),
})
} }
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

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

View File

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

View File

@@ -1,11 +1,11 @@
use crate::openproject::client::{handle_response_status, Client, OpenProjectError}; use crate::openproject::client::{handle_response_status, Client, OpenProjectError};
use crate::openproject::hal::HalEntity; // use crate::openproject::hal::HalEntity;
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct User { pub struct User {
pub href: String, pub href: String,
pub title: String, // pub title: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Links { pub struct Links {
@@ -13,12 +13,12 @@ pub struct Links {
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct Root { pub struct Root {
#[serde(rename = "instanceName")] // #[serde(rename = "instanceName")]
pub instance_name: String, // pub instance_name: String,
#[serde(rename = "_links")] #[serde(rename = "_links")]
pub links: Links, pub links: Links,
#[serde(flatten)] // #[serde(flatten)]
pub hal_entity: HalEntity, // pub hal_entity: HalEntity,
} }
pub trait RootClient { pub trait RootClient {

View File

@@ -10,10 +10,10 @@ pub struct UserLink {
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct User { pub struct User {
#[serde(rename = "_type")] // #[serde(rename = "_type")]
pub d_type: String, // pub d_type: String,
pub id: u64, // pub id: u64,
pub name: String, // pub name: String,
#[serde(rename = "_links")] #[serde(rename = "_links")]
pub d_links: UserLink, pub d_links: UserLink,
} }

View File

@@ -1,3 +1,4 @@
use crate::openproject::user::User;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
@@ -24,3 +25,14 @@ pub struct WorkPackage {
pub id: u64, pub id: u64,
pub subject: String, pub subject: String,
} }
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,132 +1,30 @@
use crate::cli::Issue2Work; use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitlab::client::{ClientProvider, ClientProviderTrait}; use crate::gitea::action::GiteaAction;
use crate::gitlab::issue::IssueBundle; use crate::gitlab::action::GitlabAction;
use crate::openproject::client::Client; use crate::planning::Issue2WorkActionTrait;
use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriterAssignee;
use gitlab::api::{issues, projects, AsyncQuery};
use gitlab::{Issue, Project};
use url::Url; use url::Url;
#[derive(Debug)] struct App {
struct IssueInfo { gitlab_issue2work_action: GitlabAction,
project: String, gitea_issue2work_action: GiteaAction,
iid: u64,
}
/// details on how to create a work package from various informations
#[derive(Debug)]
struct Issue2WorkPackageDTO {
pub issue: IssueBundle,
pub assign_to: Option<User>,
}
impl From<&Issue2WorkPackageDTO> for crate::openproject::work::WorkPackageWriter {
fn from(value: &Issue2WorkPackageDTO) -> Self {
crate::openproject::work::WorkPackageWriter {
subject: format!(
"{} ({}/{})",
value.issue.issue.title,
value.issue.project.name_with_namespace,
value.issue.issue.iid
),
work_type: "TASK".into(),
description: crate::openproject::work::DescriptionWriter {
format: "markdown".into(),
raw: format!("From gitlab: {}", value.issue.issue.web_url),
},
assignee: WorkPackageWriterAssignee {
href: match &value.assign_to {
None => None,
Some(w) => Some(w.clone().d_links.d_self.href),
},
},
}
}
} }
pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> { pub(crate) async fn issue2work(config: Config, args: &Issue2Work) -> Result<(), GeneralError> {
let url = Url::parse(&*args.issue_url).expect("issue_url is not valid"); let url = Url::parse(&*args.issue_url).expect("issue_url is not valid");
let data = extract_issue_info(&url).unwrap(); let app = App {
gitlab_issue2work_action: GitlabAction {},
let client = ClientProvider::client_for_url(&url, &config).await?; gitea_issue2work_action: GiteaAction {},
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 if app.gitlab_issue2work_action.supports(&url, &config, args) {
.create_work_package(&(&dto).into(), &args.project_id) app.gitlab_issue2work_action.run(&url, &config, args).await
.await?; } else if app.gitea_issue2work_action.supports(&url, &config, args) {
app.gitea_issue2work_action.run(&url, &config, args).await
println!( } else {
"new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", Err(GeneralError {
work_package.subject, config.openproject.base_url, args.project_id, work_package.id description: format!("This action is not supported for this url: {}", url),
); })
Ok(())
}
fn extract_issue_info(url: &Url) -> Option<IssueInfo> {
let parts = url
.path_segments()
.expect("Could not parse path segment of given url");
let mut project_url: Vec<String> = Vec::with_capacity(3);
let mut iid: Option<String> = None;
let mut project_found = false;
for el in parts {
if el == "-" {
project_found = true;
continue;
}
if el == "issues" {
continue;
}
if !project_found {
project_url.push(String::from(el));
} else {
// must be the id
iid = Some(String::from(el));
break;
}
} }
Some(IssueInfo {
project: project_url.join("/"),
iid: iid
.expect("iid of the issue not found")
.parse()
.expect("could not transform issue id to u64"),
})
} }

View File

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

180
src/planning/utils.rs Normal file
View File

@@ -0,0 +1,180 @@
pub enum IssueRelated {
OpenProjectIssue(String),
}
pub fn append_related_issues(issue: &IssueRelated, content: &String) -> String {
let mut splitted = content.lines();
let mut new_content: Vec<String> = Vec::new();
let mut found = false;
let mut iteration_started = false;
while let Some(line) = splitted.next() {
if line.contains("## Related issues") {
found = true;
new_content.push(line.parse().unwrap());
// we go to the end of the section
while let Some(line) = splitted.next() {
if line.starts_with("-") || line.starts_with("*") {
iteration_started = true;
new_content.push(line.parse().unwrap());
} else if (line.trim().is_empty()) && iteration_started {
new_content.append(&mut add_related_issues_section(issue));
iteration_started = false;
found = true;
new_content.push(line.parse().unwrap());
break;
} else if line.starts_with("#") {
new_content.push("new title found".to_string());
found = true;
new_content.append(&mut add_related_issues_section(issue));
new_content.push("".to_string());
new_content.push(line.parse().unwrap());
break;
} else {
new_content.push(line.parse().unwrap());
}
}
} else {
new_content.push(line.parse().unwrap());
}
}
if !found || iteration_started {
if !found {
new_content.append(&mut add_related_issues_title());
}
new_content.append(&mut add_related_issues_section(issue));
}
new_content.join(&"\n")
}
fn add_related_issues_title() -> Vec<String> {
let mut previous: Vec<String> = Vec::new();
previous.push("".to_string());
previous.push("## Related issues".to_string());
previous.push("".to_string());
previous
}
fn add_related_issues_section(issue: &IssueRelated) -> Vec<String> {
let mut previous: Vec<String> = Vec::new();
previous.push(convert_issue_link_items(issue));
previous
}
fn convert_issue_link_items(issue: &IssueRelated) -> String {
match issue {
IssueRelated::OpenProjectIssue(issue_url) => format!("- [{}]({})", issue_url, issue_url),
}
}
#[cfg(test)]
mod tests {
use crate::planning::utils::{append_related_issues, IssueRelated};
#[test]
pub fn test_append_related_issues_content_empty() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"
## Related issues
- [https://example/wp/1](https://example/wp/1)"#,
append_related_issues(&issue, &("".to_string()))
);
}
#[test]
pub fn test_append_related_issues_content_not_empty() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/1](https://example/wp/1)"#,
append_related_issues(
&issue,
&"Something happens.\n\
\n\
## Some title\n\
\n\
Some content"
.to_string()
),
);
}
#[test]
pub fn test_append_related_issues_existing_section_related_issues() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)
- [https://example/wp/1](https://example/wp/1)"#,
append_related_issues(
&issue,
&r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)"#
.to_string()
),
);
}
#[test]
pub fn test_append_related_issues_existing_section_related_issues_not_last() {
let issue = IssueRelated::OpenProjectIssue("https://example/wp/1".to_string());
assert_eq!(
r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)
- [https://example/wp/1](https://example/wp/1)
## Other content
Some other content"#,
append_related_issues(
&issue,
&r#"Something happens.
## Some title
Some content
## Related issues
- [https://example/wp/2](https://example/wp/2)
## Other content
Some other content"#
.to_string()
),
);
}
}