From 753cbebd4842013f26ff91b6dd1463ba8b98ee15 Mon Sep 17 00:00:00 2001 From: ondra Date: Tue, 23 Sep 2025 18:02:52 +0200 Subject: [PATCH] implement youtrack client --- .gitignore | 1 + Cargo.lock | 168 +++++++++++++--- Cargo.toml | 12 +- src/config.rs | 4 + src/integrations/mod.rs | 3 + src/integrations/youtrack.rs | 370 +++++++++++++++++++++++++++++++++++ src/main.rs | 3 +- src/youtrack.rs | 11 -- 8 files changed, 523 insertions(+), 49 deletions(-) create mode 100644 src/integrations/mod.rs create mode 100644 src/integrations/youtrack.rs delete mode 100644 src/youtrack.rs diff --git a/.gitignore b/.gitignore index c403c34..ddbac2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .idea/ +.env diff --git a/Cargo.lock b/Cargo.lock index ffa24da..5c22a24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,13 +218,17 @@ dependencies = [ "chrono", "clap", "colored", + "dotenv", "faccess", "indexmap", "inquire", + "json_dotpath", + "log", "regex", "reqwest", "serde", "serde_json", + "simple-logging", "smart-default", "toml", ] @@ -244,6 +248,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -262,15 +275,17 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crossterm" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.9.4", "crossterm_winapi", + "derive_more", + "document-features", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -285,6 +300,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -296,6 +332,21 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -762,9 +813,9 @@ dependencies = [ [[package]] name = "inquire" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced" +checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" dependencies = [ "bitflags 2.9.4", "crossterm", @@ -825,16 +876,28 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.175" +name = "json_dotpath" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "dbdcfef3cf5591f0cef62da413ae795e3d1f5a00936ccec0b2071499a32efd1a" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", +] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" @@ -848,6 +911,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" + [[package]] name = "lock_api" version = "0.4.13" @@ -1012,7 +1081,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -1082,7 +1151,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -1103,7 +1172,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.16", "tinyvec", "tracing", "web-time", @@ -1167,6 +1236,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + [[package]] name = "redox_syscall" version = "0.5.17" @@ -1277,19 +1352,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.9.4", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.0.8" @@ -1299,7 +1361,7 @@ dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", + "linux-raw-sys", "windows-sys 0.60.2", ] @@ -1477,6 +1539,17 @@ dependencies = [ "libc", ] +[[package]] +name = "simple-logging" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00d48e85675326bb182a2286ea7c1a0b264333ae10f27a937a72be08628b542" +dependencies = [ + "lazy_static", + "log", + "thread-id", +] + [[package]] name = "slab" version = "0.4.11" @@ -1589,17 +1662,37 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", + "rustix", "windows-sys 0.60.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1613,6 +1706,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thread-id" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fbf4c9d56b320106cd64fd024dadfa0be7cb4706725fc44a7d7ce952d820c1" +dependencies = [ + "libc", + "redox_syscall 0.1.57", + "winapi", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1820,9 +1924,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "untrusted" diff --git a/Cargo.toml b/Cargo.toml index af55b4f..219abe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Ondřej Hruška "] description = "Manage changelog across multiple release channels" [dependencies] +log = "0.4" clap = { version = "4.5", features = ["string"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -17,9 +18,12 @@ colored = "3" faccess = "0.2" chrono = "0.4" indexmap = { version = "2.11", features = ["serde"] } -inquire = { version = "0.8.0", features = ["editor"] } +inquire = { version = "0.9", features = ["editor"] } -reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], optional = true } +# For integrations +reqwest = { version = "0.12", features = ["rustls-tls", "blocking"] } #, optional = true +json_dotpath = "1.1.0" +dotenv = "0.15.0" -[features] -youtrack = ["reqwest"] +[dev-dependencies] +simple-logging = "2" diff --git a/src/config.rs b/src/config.rs index 5ff023b..dc77f27 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,10 @@ pub type EntryName = String; pub const CONFIG_FILE_TEMPLATE: &str = include_str!("assets/config_file_template.toml"); +pub const ENV_YOUTRACK_URL : &str = "CLPACK_YOUTRACK_URL"; + +pub const ENV_YOUTRACK_TOKEN : &str = "CLPACK_YOUTRACK_TOKEN"; + #[cfg(test)] #[test] fn test_template_file() { diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 0000000..6cafcde --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,3 @@ +mod youtrack; + +pub use youtrack::YouTrackClient; diff --git a/src/integrations/youtrack.rs b/src/integrations/youtrack.rs new file mode 100644 index 0000000..518af5c --- /dev/null +++ b/src/integrations/youtrack.rs @@ -0,0 +1,370 @@ +//! Youtrack integration (mark issues as Released when packing to changelog) + +use crate::config::VersionName; +use anyhow::bail; +use chrono::{DateTime, Utc}; +use json_dotpath::DotPaths; +use log::debug; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::borrow::Cow; + +type ProjectId = String; +type BundleID = String; +type FieldID = String; + +pub struct YouTrackClient { + client: reqwest::blocking::Client, + pub url: String, +} + +#[derive(Deserialize)] +struct YoutrackErrorResponse { + error: String, + error_description: String, +} + +impl YouTrackClient { + pub fn new(url: impl ToString, token: &str) -> anyhow::Result { + let token_bearer = format!("Bearer {token}"); + + let mut hm = HeaderMap::new(); + hm.insert("Authorization", HeaderValue::from_str(&token_bearer)?); + hm.insert("Content-Type", HeaderValue::from_str("application/json")?); + hm.insert("Accept", HeaderValue::from_str("application/json")?); + + Ok(YouTrackClient { + url: url.to_string(), + client: reqwest::blocking::Client::builder() + .default_headers(hm) + .build()?, + }) + } + + fn get_json( + &self, + api_path: String, + query: &T, + ) -> anyhow::Result { + let url = format!( + "{base}/api/{path}", + base = self.url.trim_end_matches('/'), + path = api_path.trim_start_matches('/') + ); + + debug!("GET {}", url); + + let response = self.client.get(&url).query(query).send()?; + + let response_text = response.text()?; + + debug!("Resp = {}", response_text); + + if let Ok(e) = serde_json::from_str::(&response_text) { + bail!("Error from YouTrack: {} - {}", e.error, e.error_description); + } + + Ok(serde_json::from_str(&response_text)?) + } + + fn post_json( + &self, + api_path: String, + body: &B, + query: &T, + ) -> anyhow::Result { + let url = format!( + "{base}/api/{path}", + base = self.url.trim_end_matches('/'), + path = api_path.trim_start_matches('/') + ); + + debug!("POST {}", url); + + let body_serialized = serde_json::to_string(body)?; + let response = self + .client + .post(&url) + .query(query) + .body(body_serialized.into_bytes()) + .send()?; + + let response_text = response.text()?; + + debug!("Resp = {}", response_text); + + if let Ok(e) = serde_json::from_str::(&response_text) { + bail!("Error from YouTrack: {} - {}", e.error, e.error_description); + } + + Ok(serde_json::from_str(&response_text)?) + } + + pub fn find_project_id(&self, issue: &str) -> anyhow::Result { + #[derive(Deserialize)] + struct Issue { + project: Project, + } + + #[derive(Deserialize)] + struct Project { + id: ProjectId, + } + + let issue: Issue = + self.get_json(format!("issues/{issue}"), &[("fields", "project(id)")])?; + + // example: + // {"project":{"id":"0-172","$type":"Project"},"$type":"Issue"} + + Ok(issue.project.id) + } + + /// Try to find a version by name in a YouTrack project. + /// If it is not found but we find the field where to add it, the version will be created. + /// + /// project_id - obtained by `find_project_id()` + /// field_name - name of the version field, e.g. "Available in version" + /// version_to_create - name of the version to find or create + /// release_date - if creating, a date YYYY-MM-DD can be passed here. It will be stored into the + /// newly created version & this marked as released. + pub fn ensure_version_exists_in_project( + &self, + project_id: &str, + field_name: &str, + version_to_create: &str, + release_date: Option>, + ) -> anyhow::Result<()> { + #[derive(Deserialize)] + struct BudleDescription { + id: BundleID, + } + #[derive(Deserialize)] + struct FieldDescription { + name: String, + id: FieldID, + } + #[derive(Deserialize)] + struct YTCustomField { + // Bundle is sometimes missing - we skip these entries + bundle: Option, + field: FieldDescription, + } + + // Find field description + let fields: Vec = self.get_json( + format!("admin/projects/{project_id}/customFields"), + &[("fields", "field(name,id),bundle(id)"), ("top", "200")], + )?; + + let mut field_bundle = None; + for entry in fields { + if &entry.field.name == field_name + && let Some(bundle) = entry.bundle + { + field_bundle = Some((entry.field.id, bundle.id)); + break; + } + } + + let Some((field_id, bundle_id)) = field_bundle else { + bail!("YouTrack version field {field_name} not found in the project {project_id}"); + }; + + println!("Found YouTrack version field, checking defined versions"); + + #[derive(Deserialize)] + struct YTVersion { + name: VersionName, + id: String, + } + + let versions: Vec = self.get_json( + format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), + &[("fields", "id,name"), ("top", "500")], + )?; + + // Find the version we want + for version in versions { + if &version.name == version_to_create { + eprintln!("Version {version_to_create} already exists in YouTrack"); + return Ok(()); + } + } + + println!("Creating version in YouTrack: {version_to_create}"); + + /* + $body = ['name' => $name]; + if ($released !== null) { + $body['released'] = $released; + } + if ($releaseDate !== null) { + $body['releaseDate'] = $releaseDate; + } + if ($archived !== null) { + $body['archived'] = $archived; + } + + return $this->postJson( + "admin/customFieldSettings/bundles/version/$bundleId/values", + $body, + ['fields' => 'id,name,released,releaseDate,archived'], + ); + */ + + #[derive(Serialize)] + struct CreateVersionBody { + name: String, + archived: bool, + released: bool, + #[allow(non_snake_case)] + releaseDate: Option, + // archived + } + + let body = CreateVersionBody { + name: version_to_create.to_string(), + archived: false, + released: release_date.is_some(), + releaseDate: release_date.map(|d| d.timestamp()), + }; + + #[derive(Deserialize, Debug)] + struct CreateVersionResponse { + #[allow(non_snake_case)] + releaseDate: Option, + released: bool, + archived: bool, + name: String, + id: String, + } + + let resp: CreateVersionResponse = self.post_json( + format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), + &body, + &[("fields", "id,name,released,releaseDate,archived")], + )?; + + // Example response: + // {"releaseDate":null,"released":false,"archived":false,"name":"TEST1","id":"232-356","$type":"VersionBundleElement"} + // {"releaseDate":1758619201,"released":true,"archived":false,"name":"TEST2","id":"232-358","$type":"VersionBundleElement"} + + debug!("Created version entry = {:#?}", resp); + println!("Version {version_to_create} created in YouTrack."); + + Ok(()) + } + + pub fn set_issue_version_and_state( + &self, + issue_id: &str, + version_field_name: &str, + version_name: &str, + target_state_name: &str, + ) -> anyhow::Result<()> { + #[derive(Serialize)] + struct PatchIssueBody { + #[allow(non_snake_case)] + customFields: Vec, + } + + #[derive(Serialize)] + struct EnumValue { + name: String, + } + + #[derive(Serialize)] + struct CustomFieldValue { + name: String, + #[serde(rename = "$type")] + field_type: String, + value: EnumValue, + } + + let body = PatchIssueBody { + customFields: vec![ + CustomFieldValue { + name: version_field_name.to_string(), + field_type: "SingleVersionIssueCustomField".to_string(), + value: EnumValue { + name: version_name.to_string(), + }, + }, + CustomFieldValue { + name: "State".to_string(), + field_type: "StateIssueCustomField".to_string(), + value: EnumValue { + name: target_state_name.to_string(), + }, + }, + ], + }; + + let resp: Value = self.post_json( + format!("issues/{issue_id}"), + &body, + &[("fields", "id,customFields(name,value(name))")], + )?; + + // TODO? Do something with the fields + + // Example success: + // {"customFields":[{"value":null,"name":"Type","$type":"SingleEnumIssueCustomField"},{"value":{"name":"Released","$type":"StateBundleElement"},"name":"State","$type":"StateIssueCustomField"},{"value":null,"name":"Assignee","$type":"SingleUserIssueCustomField"},{"value":null,"name":"Priority","$type":"SingleEnumIssueCustomField"},{"value":{"name":"Internal tooling","$type":"EnumBundleElement"},"name":"Category","$type":"SingleEnumIssueCustomField"},{"value":[],"name":"Customer","$type":"MultiEnumIssueCustomField"},{"value":null,"name":"Customer Funding","$type":"SingleEnumIssueCustomField"},{"value":null,"name":"Product Stream","$type":"SingleEnumIssueCustomField"},{"value":null,"name":"Estimation","$type":"PeriodIssueCustomField"},{"value":{"$type":"PeriodValue"},"name":"Spent time","$type":"PeriodIssueCustomField"},{"value":null,"name":"Due Date","$type":"DateIssueCustomField"},{"value":[],"name":"Affected version","$type":"MultiVersionIssueCustomField"},{"value":{"name":"TEST2","$type":"VersionBundleElement"},"name":"Available in version","$type":"SingleVersionIssueCustomField"},{"value":null,"name":"SlackAlertSent","$type":"SimpleIssueCustomField"},{"value":13.0,"name":"Dev costs","$type":"SimpleIssueCustomField"}],"id":"2-25820","$type":"Issue"} + + println!("YouTrack issue {issue_id} updated."); + + debug!("Response to request to edit issue: {resp:?}"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::YouTrackClient; + use crate::config::{ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL}; + use log::{LevelFilter, debug}; + use serde_json::Value; + + // #[test] // Disabled + fn test_youtrack_communication() { + simple_logging::log_to_stderr(LevelFilter::Debug); + + let url = dotenv::var(ENV_YOUTRACK_URL).expect("Missing youtrack URL from env"); + let token = dotenv::var(ENV_YOUTRACK_TOKEN).expect("Missing youtrack token from env"); + + // this must match the config in the connected youtrack + let issue_id = "SW-4739"; + let version_field_name = "Available in version"; + let target_state_name = "Released"; + let version_name = "TEST2"; + + let mut client = YouTrackClient::new(url, &token).unwrap(); + + let project_id = client.find_project_id(issue_id).unwrap(); + + debug!("Found YouTrack project ID = {project_id}"); + + let date = chrono::Utc::now(); + + client + .ensure_version_exists_in_project( + &project_id, + version_field_name, + version_name, + Some(date), + ) + .unwrap(); + + client + .set_issue_version_and_state( + issue_id, + version_field_name, + version_name, + target_state_name, + ) + .unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 43df5c4..0678dff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,8 +24,7 @@ mod store; mod utils; -#[cfg(feature = "youtrack")] -mod youtrack; +mod integrations; #[derive(Debug)] pub struct AppContext { diff --git a/src/youtrack.rs b/src/youtrack.rs deleted file mode 100644 index 6c6b553..0000000 --- a/src/youtrack.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Youtrack integration (mark issues as Released when packing to changelog) - -#[cfg(test)] -mod tests { - #[test] - fn test_youtrack_communication() { - let token = ""; - - - } -}