16 Commits

Author SHA1 Message Date
d830f33eec Release v0.4.1
All checks were successful
Release binary for cl-cli / build-and-release (push) Successful in 1m23s
Check go code / build-and-release (push) Successful in 1m6s
2025-10-25 00:37:21 +02:00
825623c9f3 Fix release workflow (#4)
All checks were successful
Check go code / build-and-release (push) Successful in 1m6s
Reviewed-on: #4
Co-authored-by: Julien Fastré <julien.fastre@champs-libres.coop>
Co-committed-by: Julien Fastré <julien.fastre@champs-libres.coop>
2025-10-24 22:35:21 +00:00
81350f38e4 Update dependencies in Cargo.lock to the latest versions
All checks were successful
Check go code / build-and-release (push) Successful in 1m4s
2025-10-25 00:13:23 +02:00
1f2d42e1d5 Update release workflow to include pull_request_target event and specify branches
All checks were successful
Check go code / build-and-release (push) Successful in 1m3s
2025-10-24 23:43:11 +02:00
a0f67464b5 Simplify build-and-release workflow by switching to akkuman/gitea-release-action and enabling checksum generation.
Some checks failed
Release binary for cl-cli / build-and-release (push) Failing after 7s
2025-10-24 23:41:29 +02:00
ff5699e627 Update workflow to specify branches for push and pull_request events 2025-10-24 23:35:28 +02:00
dc0040c2d1 Add workflow to check Go code on push and pull requests 2025-10-24 23:34:15 +02:00
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
22 changed files with 1621 additions and 859 deletions

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"

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

@@ -0,0 +1,3 @@
## v0.4.1 - 2025-10-25
### Fixed
* Fix dependencies

View File

@@ -20,17 +20,14 @@ jobs:
id: read_release id: read_release
with: with:
path: .changes/${{ github.ref_name }}.md path: .changes/${{ github.ref_name }}.md
- name: Setup go for using go gitea actions - name: Release
uses: https://github.com/actions/setup-go@v4 uses: https://gitea.com/akkuman/gitea-release-action@v1
with: env:
go-version: '>=1.20.1' NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
- name: Use Go Action to release
id: use-go-action
uses: https://gitea.com/actions/release-action@main
with: with:
files: |- files: |-
target/release/cl-cli target/release/cl-cli
config.toml.dist config.toml.dist
api_key: '${{secrets.RELEASE_TOKEN}}' md5sum: true
body: | sha256sum: true
${{ steps.read_release.outputs.content }} body_path: .changes/${{ github.ref_name }}.md

View File

@@ -0,0 +1,25 @@
name: Check go code
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
pull_request_target:
branches:
- '**'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: https://github.com/actions/checkout@v4
- name: Install rust toolchain
uses: https://github.com/dtolnay/rust-toolchain@stable
- name: Build binaries
run: cargo build
- name: Run tests
run: cargo test

View File

@@ -6,6 +6,18 @@ 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.1 - 2025-10-25
### Fixed
* Fix dependencies
## 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 ## v0.2.0 - 2024-11-14
### Added ### Added
* Add an option `--assign-to-me` to automatically assign the openproject's user to the newly created work package * Add an option `--assign-to-me` to automatically assign the openproject's user to the newly created work package

2056
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,12 @@ edition = "2021"
[dependencies] [dependencies]
clap = { version = "4.1.13", features = ["derive"] } clap = { version = "4.1.13", features = ["derive"] }
gitlab = "0.1607.0" gitlab = "0.1804.0"
reqwest = "0.11.23" reqwest = "0.12.24"
serde = { version = "1.0.158", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
toml = "0.7.3" toml = "0.9.8"
url = "2.3.1" url = "2.5.7"
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.5.2"
log = "0.4.17" log = "0.4.28"
open = "5.3.2"

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

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

View File

@@ -2,9 +2,10 @@ use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::client::has_client_for_url; use crate::gitea::client::has_client_for_url;
use crate::gitea::issue::{issue_html_url_to_api, Issue}; use crate::gitea::issue::{issue_html_url_to_api, Issue, IssueWriteSetBody};
use crate::openproject::user::{GetMe, User}; use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriter; use crate::openproject::work::WorkPackageWriter;
use crate::planning::utils::{append_related_issues, IssueRelated};
use crate::planning::Issue2WorkActionTrait; use crate::planning::Issue2WorkActionTrait;
use url::Url; use url::Url;
@@ -32,11 +33,31 @@ impl Issue2WorkActionTrait for GiteaAction {
.create_work_package(&work_package, &args.project_id) .create_work_package(&work_package, &args.project_id)
.await?; .await?;
println!( let url_wp = format!(
"new work package created: {:?}, edit at {}/projects/{}/work_packages/{}", "{}/projects/{}/work_packages/{}",
work_package.subject, config.openproject.base_url, args.project_id, work_package.id 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(()) Ok(())
} }
@@ -47,7 +68,10 @@ impl Issue2WorkActionTrait for GiteaAction {
fn create_work_package_from_issue(issue: &Issue, assignee: Option<User>) -> WorkPackageWriter { fn create_work_package_from_issue(issue: &Issue, assignee: Option<User>) -> WorkPackageWriter {
WorkPackageWriter { WorkPackageWriter {
subject: format!("{} ({}/{})", issue.title, issue.repository.full_name, issue.number), subject: format!(
"{} ({}/{})",
issue.title, issue.repository.full_name, issue.number
),
work_type: "TASK".into(), work_type: "TASK".into(),
description: crate::openproject::work::DescriptionWriter { description: crate::openproject::work::DescriptionWriter {
format: "markdown".into(), format: "markdown".into(),

View File

@@ -3,12 +3,13 @@ use crate::error::GeneralError;
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION}; 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;
#[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 { fn is_client_for_url(url: &Url, config: &GiteaConfig) -> bool {
@@ -28,6 +29,7 @@ pub(crate) fn has_client_for_url(url: &Url, config: &Config) -> bool {
false false
} }
#[allow(dead_code)]
fn client_for_url(url: &Url, config: &Config) -> Result<Client, GeneralError> { fn client_for_url(url: &Url, config: &Config) -> Result<Client, GeneralError> {
for c in &config.gitea { for c in &config.gitea {
if is_client_for_url(url, c) { if is_client_for_url(url, c) {
@@ -45,10 +47,10 @@ impl Client {
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()),
} }
} }
@@ -79,6 +81,38 @@ 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)] #[cfg(test)]

View File

@@ -1,12 +1,11 @@
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitea::client::Client;
use crate::gitea::repository::Repository; use crate::gitea::repository::Repository;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Issue { pub struct Issue {
pub id: u64, // pub id: u64,
pub number: u64, pub number: u64,
pub title: String, pub title: String,
pub body: String, pub body: String,
@@ -14,14 +13,9 @@ pub struct Issue {
pub html_url: String, 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 {
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> { pub fn issue_html_url_to_api(url: &Url) -> Result<Url, GeneralError> {

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,
pub full_name: String, pub full_name: String,
} }

View File

@@ -2,13 +2,12 @@ use crate::cli::Issue2Work;
use crate::config::Config; use crate::config::Config;
use crate::error::GeneralError; use crate::error::GeneralError;
use crate::gitlab::client::{client_for_url, has_client_for_url}; use crate::gitlab::client::{client_for_url, has_client_for_url};
use crate::gitlab::issue::IssueBundle; use crate::gitlab::issue::{Issue, IssueBundle, Project};
use crate::openproject::client::Client; use crate::openproject::client::Client;
use crate::openproject::user::{GetMe, User}; use crate::openproject::user::{GetMe, User};
use crate::openproject::work::WorkPackageWriterAssignee; use crate::openproject::work::WorkPackageWriterAssignee;
use crate::planning::Issue2WorkActionTrait; use crate::planning::Issue2WorkActionTrait;
use gitlab::api::{issues, projects, AsyncQuery}; use gitlab::api::{issues, projects, AsyncQuery};
use gitlab::{Issue, Project};
use url::Url; use url::Url;
#[derive(Debug)] #[derive(Debug)]
@@ -41,7 +40,7 @@ impl Issue2WorkActionTrait for GitlabAction {
let issue = issues.first().unwrap(); let issue = issues.first().unwrap();
let project_endpoint = projects::Project::builder() let project_endpoint = projects::Project::builder()
.project(issue.project_id.value()) .project(issue.project_id)
.build() .build()
.unwrap(); .unwrap();

View File

@@ -3,10 +3,6 @@ use crate::error::GeneralError;
use gitlab::{AsyncGitlab, GitlabBuilder}; use gitlab::{AsyncGitlab, GitlabBuilder};
use url::Url; use url::Url;
pub struct ClientProvider {}
impl ClientProvider {}
fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool { fn is_client_for_url(url: &Url, config: &GitlabConfig) -> bool {
if url.domain() == Some(config.domain.as_str()) { if url.domain() == Some(config.domain.as_str()) {
return true; return true;

View File

@@ -1,5 +1,17 @@
use gitlab::Issue; use serde::Deserialize;
use gitlab::Project;
#[derive(Clone, Debug, Deserialize)]
pub struct Issue {
pub iid: u64,
pub title: String,
pub web_url: String,
pub project_id: u64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Project {
pub name_with_namespace: String,
}
/// A struct which contains Issue and Project /// A struct which contains Issue and Project
#[derive(Debug)] #[derive(Debug)]

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,13 +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")]
#[allow(unused_variables)] // pub d_type: String,
pub d_type: String, // pub id: u64,
#[allow(unused_variables)] // pub name: String,
pub id: u64,
#[allow(unused_variables)]
pub name: String,
#[serde(rename = "_links")] #[serde(rename = "_links")]
pub d_links: UserLink, pub d_links: UserLink,
} }

View File

@@ -4,6 +4,7 @@ use crate::error::GeneralError;
use url::Url; use url::Url;
pub(crate) mod issue2work; pub(crate) mod issue2work;
pub mod utils;
pub trait Issue2WorkActionTrait { pub trait Issue2WorkActionTrait {
async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>; async fn run(&self, url: &Url, config: &Config, args: &Issue2Work) -> Result<(), GeneralError>;

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