From 9810ecc886124adff3243d16fd5049abd8a92dc2 Mon Sep 17 00:00:00 2001 From: ondra Date: Tue, 23 Sep 2025 22:42:45 +0200 Subject: [PATCH] clpack integration --- .../SW-4712-clpack-youtrack-integration.md | 2 + .../entries/SW-4716-add-cl-status.md | 0 clpack.toml | 40 +++ src/action_log.rs | 2 +- src/action_pack.rs | 31 +- src/assets/config_file_template.toml | 39 +++ src/config.rs | 42 ++- src/git.rs | 31 +- src/integrations/mod.rs | 5 +- src/integrations/youtrack.rs | 319 ++++++++++++------ 10 files changed, 389 insertions(+), 122 deletions(-) create mode 100644 changelog/entries/SW-4712-clpack-youtrack-integration.md rename "changelog/entries/Add \"cl status\".md" => changelog/entries/SW-4716-add-cl-status.md (100%) diff --git a/changelog/entries/SW-4712-clpack-youtrack-integration.md b/changelog/entries/SW-4712-clpack-youtrack-integration.md new file mode 100644 index 0000000..d8413cb --- /dev/null +++ b/changelog/entries/SW-4712-clpack-youtrack-integration.md @@ -0,0 +1,2 @@ +# New features +- Add integration to JetBrains YouTrack (#SW-4712) diff --git "a/changelog/entries/Add \"cl status\".md" b/changelog/entries/SW-4716-add-cl-status.md similarity index 100% rename from "changelog/entries/Add \"cl status\".md" rename to changelog/entries/SW-4716-add-cl-status.md diff --git a/clpack.toml b/clpack.toml index 78faa89..320c99b 100644 --- a/clpack.toml +++ b/clpack.toml @@ -76,3 +76,43 @@ branch_version_pattern = '/^rel\/([\d.]+)$/' # To specify a regex pattern (wildcard name), enclose it in slashes, e.g. '/^release\//' [channels] default = '/^(?:main|master)$/' + +[integrations.youtrack] + +# When creating a release, clpack can mark the included issues +# as "Released" and record the versions into YouTrack. +# +# clpack will ask for confirmation before doing this. +# +# Requirements: +# +# This integration only works if your changelog entry file names (by default taken from branch names) +# contain the issue numbers - e.g. SW-1234-added-stuff.md. If the issue number can't be recognized, +# the entry will be skipped. +# +# You must not combine multiple tickets into one file if you want the numbers to be found +# - the file content is not parsed by this integration. +# +# Each developer who wants to use this integration when packing changelog must set their YouTrack +# API token in an env variable CLPACK_YOUTRACK_TOKEN (in their environment or in an .env file). +# It is also possible to change the server URL by setting CLPACK_YOUTRACK_URL, if needed. + +# Enable the YouTrack integration +enabled = true + +# YouTrack server URL. Can be changed locally by setting env var CLPACK_YOUTRACK_URL +url = "SET IN YOUR ENV" + +# Channels filter - release on those channels will trigger the YouTrack integration +# (i.e. don't mark as Released if it's only in beta) +channels = [ + "default" +] + +# Change the "State" field of the released issues. +# Uncomment to enable, change to fit your project +released_state = "Released" + +# Change a custom version field of the released issues. +# Uncomment to enable, change to fit your project +version_field = "Available in version" diff --git a/src/action_log.rs b/src/action_log.rs index 85bd8d6..4936d0e 100644 --- a/src/action_log.rs +++ b/src/action_log.rs @@ -12,7 +12,7 @@ pub(crate) fn cl_log(ctx: AppContext) -> anyhow::Result<()> { let branch = get_branch_name(&ctx); let issue = branch .as_ref() - .map(|b| b.parse_issue(&ctx)) + .map(|b| b.parse_issue(&ctx.config)) .transpose()? .flatten(); diff --git a/src/action_pack.rs b/src/action_pack.rs index 53af397..3ed5a59 100644 --- a/src/action_pack.rs +++ b/src/action_pack.rs @@ -1,8 +1,12 @@ use crate::AppContext; -use crate::config::ChannelName; +use crate::config::{ChannelName, ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL}; use crate::git::{BranchName, get_branch_name}; +use crate::integrations::youtrack::{ + YouTrackClient, youtrack_integration_enabled, youtrack_integration_on_release, +}; use crate::store::{Release, Store}; -use anyhow::bail; +use crate::utils::empty_to_none::EmptyToNone; +use anyhow::{Context, bail}; use colored::Colorize; pub fn pack_resolve_and_show_preview( @@ -50,7 +54,7 @@ fn resolve_channel( None => ( branch .as_ref() - .map(|b| b.parse_channel(ctx)) + .map(|b| b.parse_channel(&ctx.config)) .transpose()? .flatten(), false, @@ -107,7 +111,7 @@ pub(crate) fn cl_pack( // TODO try to get something better from git! let version_base = branch .as_ref() - .map(|b| b.parse_version(&ctx)) + .map(|b| b.parse_version(&ctx.config)) .transpose()? .flatten(); @@ -130,7 +134,7 @@ pub(crate) fn cl_pack( } } - release.version = version; + release.version = version.clone(); if !inquire::Confirm::new("Continue - write to changelog file?") .with_default(true) @@ -140,8 +144,23 @@ pub(crate) fn cl_pack( return Ok(()); } - store.create_release(channel, release)?; + store.create_release(channel.clone(), release.clone())?; println!("{}", "Changelog written.".green()); + + // YouTrack + if youtrack_integration_enabled(&ctx.config, &channel) { + if inquire::Confirm::new("Update released issues in YouTrack?") + .with_default(true) + .prompt()? + { + youtrack_integration_on_release(&ctx.config, release)?; + println!("{}", "YouTrack updated.".green()); + } else { + eprintln!("{}", "YouTrack changes skipped.".yellow()); + return Ok(()); + } + } + Ok(()) } diff --git a/src/assets/config_file_template.toml b/src/assets/config_file_template.toml index 78faa89..339f549 100644 --- a/src/assets/config_file_template.toml +++ b/src/assets/config_file_template.toml @@ -76,3 +76,42 @@ branch_version_pattern = '/^rel\/([\d.]+)$/' # To specify a regex pattern (wildcard name), enclose it in slashes, e.g. '/^release\//' [channels] default = '/^(?:main|master)$/' + +[integrations.youtrack] +# When creating a release, clpack can mark the included issues +# as "Released" and record the versions into YouTrack. +# +# clpack will ask for confirmation before doing this. +# +# Requirements: +# +# This integration only works if your changelog entry file names (by default taken from branch names) +# contain the issue numbers - e.g. SW-1234-added-stuff.md. If the issue number can't be recognized, +# the entry will be skipped. +# +# You must not combine multiple tickets into one file if you want the numbers to be found +# - the file content is not parsed by this integration. +# +# Each developer who wants to use this integration when packing changelog must set their YouTrack +# API token in an env variable CLPACK_YOUTRACK_TOKEN (in their environment or in an .env file). +# It is also possible to change the server URL by setting CLPACK_YOUTRACK_URL, if needed. + +# Enable the YouTrack integration +enabled = false + +# YouTrack server URL. Can be changed locally by setting env var CLPACK_YOUTRACK_URL +url = "https://example.youtrack.cloud" + +# Channels filter - release on those channels will trigger the YouTrack integration +# (i.e. don't mark as Released if it's only in beta) +channels = [ + "default" +] + +# Change the "State" field of the released issues. +# Uncomment to enable, change to fit your project +#released_state = "Released" + +# Change a custom version field of the released issues. +# Uncomment to enable, change to fit your project +#version_field = "Available in version" diff --git a/src/config.rs b/src/config.rs index dc77f27..77a2226 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,11 +11,15 @@ pub type VersionName = String; /// e.g. SW-1234-stuff-is-broken (without .md) pub type EntryName = String; +/// Config file with nice comments pub const CONFIG_FILE_TEMPLATE: &str = include_str!("assets/config_file_template.toml"); -pub const ENV_YOUTRACK_URL : &str = "CLPACK_YOUTRACK_URL"; +/// ENV / dotenv key for the youtrack integration server URL +/// This is only for unit tests +pub const ENV_YOUTRACK_URL: &str = "CLPACK_YOUTRACK_URL"; -pub const ENV_YOUTRACK_TOKEN : &str = "CLPACK_YOUTRACK_TOKEN"; +/// ENV / dotenv key for the youtrack integration API token +pub const ENV_YOUTRACK_TOKEN: &str = "CLPACK_YOUTRACK_TOKEN"; #[cfg(test)] #[test] @@ -107,4 +111,38 @@ pub struct Config { /// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others #[default(Some(r"/^rel\/([\d.]+)$/".to_string()))] pub branch_version_pattern: Option, + + /// Integrations config + pub integrations: IntegrationsConfig, +} + +/// Integrations config +#[derive(Debug, Serialize, Deserialize, SmartDefault, PartialEq, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct IntegrationsConfig { + /// YouTrack integration + pub youtrack: YouTrackIntegrationConfig, +} + +#[derive(Debug, Serialize, Deserialize, SmartDefault, PartialEq, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct YouTrackIntegrationConfig { + /// Enable the integration + pub enabled: bool, + + /// URL of the youtrack server (just https://domain) + #[default = "https://example.youtrack.cloud"] + pub url: String, + + /// Channels filter + #[default(vec![ + "default".to_string(), + ])] + pub channels: Vec, + + /// Name of the State option to switch to when generating changelog (e.g. Released) + pub released_state: Option, + + /// Name of the version field (Available in version) + pub version_field: Option, } diff --git a/src/git.rs b/src/git.rs index b6ba08c..d6c6ba6 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,4 +1,5 @@ use crate::AppContext; +use crate::config::Config; use crate::utils::empty_to_none::EmptyToNone; use anyhow::bail; use std::fmt::Display; @@ -79,8 +80,8 @@ impl BranchName { /// Parse version from this branch name. /// /// Aborts if the configured regex pattern is invalid. - pub fn parse_version(&self, ctx: &AppContext) -> anyhow::Result> { - let Some(pat) = ctx.config.branch_version_pattern.as_ref().empty_to_none() else { + pub fn parse_version(&self, config: &Config) -> anyhow::Result> { + let Some(pat) = config.branch_version_pattern.as_ref().empty_to_none() else { return Ok(None); }; self.parse_using_regex(pat, "branch_version_pattern") @@ -89,16 +90,16 @@ impl BranchName { /// Parse issue number from this branch name. /// /// Aborts if the configured regex pattern is invalid. - pub fn parse_issue(&self, ctx: &AppContext) -> anyhow::Result> { - let Some(pat) = ctx.config.branch_issue_pattern.as_ref().empty_to_none() else { + pub fn parse_issue(&self, config: &Config) -> anyhow::Result> { + let Some(pat) = config.branch_issue_pattern.as_ref().empty_to_none() else { return Ok(None); }; self.parse_using_regex(pat, "branch_issue_pattern") } /// Try to detect a release channel from this branch name (e.g. stable, EAP) - pub fn parse_channel(&self, ctx: &AppContext) -> anyhow::Result> { - for (channel_id, template) in &ctx.config.channels { + pub fn parse_channel(&self, config: &Config) -> anyhow::Result> { + for (channel_id, template) in &config.channels { if template.is_empty() { // Channel only for manual choosing continue; @@ -165,14 +166,14 @@ mod test { assert_eq!( BranchName("rel/3.14".to_string()) - .parse_version(&ctx) + .parse_version(&ctx.config) .unwrap(), Some("3.14".to_string()) ); assert_eq!( BranchName("rel/foo".to_string()) - .parse_version(&ctx) + .parse_version(&ctx.config) .unwrap(), None ); @@ -188,21 +189,21 @@ mod test { assert_eq!( BranchName("1234-bober-kurwa".to_string()) - .parse_issue(&ctx) + .parse_issue(&ctx.config) .unwrap(), Some("1234".to_string()) ); assert_eq!( BranchName("SW-778-jakie-bydłe-jebane".to_string()) - .parse_issue(&ctx) + .parse_issue(&ctx.config) .unwrap(), Some("SW-778".to_string()) ); assert_eq!( BranchName("nie-spierdalaj-mordo".to_string()) - .parse_issue(&ctx) + .parse_issue(&ctx.config) .unwrap(), None ); @@ -217,20 +218,22 @@ mod test { }; assert_eq!( - BranchName("main".to_string()).parse_channel(&ctx).unwrap(), + BranchName("main".to_string()) + .parse_channel(&ctx.config) + .unwrap(), Some("default".to_string()) ); assert_eq!( BranchName("master".to_string()) - .parse_channel(&ctx) + .parse_channel(&ctx.config) .unwrap(), Some("default".to_string()) ); assert_eq!( BranchName("my-cool-feature".to_string()) - .parse_version(&ctx) + .parse_version(&ctx.config) .unwrap(), None ); diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 6cafcde..ea7c22d 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -1,3 +1,2 @@ -mod youtrack; - -pub use youtrack::YouTrackClient; +/// Third party service (e.g. issue trackers) integrations +pub mod youtrack; diff --git a/src/integrations/youtrack.rs b/src/integrations/youtrack.rs index 518af5c..75332f1 100644 --- a/src/integrations/youtrack.rs +++ b/src/integrations/youtrack.rs @@ -1,48 +1,152 @@ -//! Youtrack integration (mark issues as Released when packing to changelog) +//! Youtrack integration (mark issues as Released when packing to changelog, change Available in version) -use crate::config::VersionName; -use anyhow::bail; +use crate::config::{ChannelName, ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL, VersionName}; +use crate::git::BranchName; +use crate::store::Release; +use anyhow::{Context, 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; +/// ID of a youtrack project type ProjectId = String; -type BundleID = String; -type FieldID = String; +pub fn youtrack_integration_enabled(config: &crate::Config, channel: &ChannelName) -> bool { + let ytconf = &config.integrations.youtrack; + ytconf.enabled + // Channel filter + && ytconf.channels.contains(&channel) + // URL is required + && (!ytconf.url.is_empty() || dotenv::var(ENV_YOUTRACK_URL).is_ok_and(|v| !v.is_empty())) + // Token is required + && dotenv::var(ENV_YOUTRACK_TOKEN).is_ok_and(|v| !v.is_empty()) + // Check if we have something to do + && (ytconf.version_field.as_ref().is_some_and(|v| !v.is_empty()) + || ytconf + .released_state + .as_ref() + .is_some_and(|v| !v.is_empty())) +} + +pub fn youtrack_integration_on_release( + config: &crate::Config, + release: Release, +) -> anyhow::Result<()> { + let ytconf = &config.integrations.youtrack; + let url = dotenv::var(ENV_YOUTRACK_URL) + .ok() + .unwrap_or_else(|| ytconf.url.clone()); + + if url.is_empty() { + bail!("YouTrack URL is empty!"); + } + let token = dotenv::var(ENV_YOUTRACK_TOKEN).context("Error getting YouTrack token")?; + + if token.is_empty() { + bail!("YouTrack token is empty!"); + } + + let client = YouTrackClient::new(url, &token)?; + + let mut project_id_opt = None; + + let date = chrono::Utc::now(); + for entry in release.entries { + let branch_name = BranchName(entry); + let Ok(Some(issue_num)) = branch_name.parse_issue(config) else { + eprintln!("No issue number recognized in {}", branch_name.0); + continue; + }; + + // Assume all tickets belong to the same project + + if project_id_opt.is_none() { + match client.find_project_id(&issue_num) { + Ok(project_id) => { + project_id_opt = Some(project_id); + } + Err(e) => { + eprintln!("Failed to find project number from {issue_num}: {e}"); + continue; + } + } + } + + let project_id = project_id_opt.as_ref().unwrap(); // We know it is set now + + let mut set_version_opt = None; + if let Some(field) = &ytconf.version_field { + let set_version = SetVersion { + field_name: field, + version: &release.version, + }; + + client.ensure_version_exists_in_project(&project_id, &set_version, Some(date))?; + + set_version_opt = Some(set_version); + } + + println!("Update issue {issue_num} ({}) in YouTrack", branch_name.0); + client.set_issue_version_and_state_by_name( + &issue_num, + set_version_opt.as_ref(), + ytconf.released_state.as_deref(), + )?; + } + + Ok(()) +} + +/// YouTrack API client (with only the bare minimum of the API implemented to satisfy clpack's needs) pub struct YouTrackClient { + /// HTTPS client with default presets to access the API client: reqwest::blocking::Client, - pub url: String, + /// Base URL of the API server + url: String, } +/// Error received from the API instead of the normal response #[derive(Deserialize)] struct YoutrackErrorResponse { + /// Error ID error: String, + /// Error message error_description: String, } impl YouTrackClient { + /// Create a YouTrack client + /// + /// url - API server base URL (e.g. https://mycompany.youtrack.cloud) + /// token - JWT-like token, starts with "perm-". Obtained from YouTrack profile settings pub fn new(url: impl ToString, token: &str) -> anyhow::Result { - let token_bearer = format!("Bearer {token}"); + 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")?); + let mut headers = HeaderMap::new(); + headers.insert("Authorization", HeaderValue::from_str(&token_bearer)?); + headers.insert("Content-Type", HeaderValue::from_str("application/json")?); + headers.insert("Accept", HeaderValue::from_str("application/json")?); Ok(YouTrackClient { url: url.to_string(), client: reqwest::blocking::Client::builder() - .default_headers(hm) + .default_headers(headers) .build()?, }) } + fn parse_youtrack_error_response(payload: &str) -> anyhow::Error { + if let Ok(e) = serde_json::from_str::(&payload) { + anyhow::format_err!("Error from YouTrack: {} - {}", e.error, e.error_description) + } else { + anyhow::format_err!("Error from YouTrack (unknown response format): {payload}") + } + } + + /// Send a GET request with query parameters. Deserialize response. fn get_json( &self, api_path: String, @@ -57,18 +161,19 @@ impl YouTrackClient { debug!("GET {}", url); let response = self.client.get(&url).query(query).send()?; - + let is_ok = response.status().is_success(); 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); + if !is_ok { + return Err(Self::parse_youtrack_error_response(&response_text)); } Ok(serde_json::from_str(&response_text)?) } + /// Send a POST request with query parameters and serializable (JSON) body. Deserialize response. fn post_json( &self, api_path: String, @@ -91,18 +196,20 @@ impl YouTrackClient { .body(body_serialized.into_bytes()) .send()?; + let is_ok = response.status().is_success(); 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); + if !is_ok { + return Err(Self::parse_youtrack_error_response(&response_text)); } Ok(serde_json::from_str(&response_text)?) } - pub fn find_project_id(&self, issue: &str) -> anyhow::Result { + /// Find YouTrack project ID from an issue name + pub fn find_project_id(&self, issue_name: &str) -> anyhow::Result { #[derive(Deserialize)] struct Issue { project: Project, @@ -114,7 +221,7 @@ impl YouTrackClient { } let issue: Issue = - self.get_json(format!("issues/{issue}"), &[("fields", "project(id)")])?; + self.get_json(format!("issues/{issue_name}"), &[("fields", "project(id)")])?; // example: // {"project":{"id":"0-172","$type":"Project"},"$type":"Issue"} @@ -125,27 +232,30 @@ impl YouTrackClient { /// 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. + /// - project_id - obtained by `find_project_id()` + /// - version_info - version name and field name + /// - release_date - if creating, a date YYYY-MM-DD can be passed here. It will be stored into the + /// newly created version & it will be marked as released. pub fn ensure_version_exists_in_project( &self, project_id: &str, - field_name: &str, - version_to_create: &str, + version_info: &SetVersion, release_date: Option>, ) -> anyhow::Result<()> { + type BundleID = String; + type FieldID = String; + #[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 @@ -159,9 +269,10 @@ impl YouTrackClient { &[("fields", "field(name,id),bundle(id)"), ("top", "200")], )?; + // Find the field we want in the list (XXX this can probably be done with some API query?) let mut field_bundle = None; for entry in fields { - if &entry.field.name == field_name + if &entry.field.name == version_info.field_name && let Some(bundle) = entry.bundle { field_bundle = Some((entry.field.id, bundle.id)); @@ -169,8 +280,12 @@ impl YouTrackClient { } } + // Got something? let Some((field_id, bundle_id)) = field_bundle else { - bail!("YouTrack version field {field_name} not found in the project {project_id}"); + bail!( + "YouTrack version field {field_name} not found in the project {project_id}", + field_name = version_info.field_name + ); }; println!("Found YouTrack version field, checking defined versions"); @@ -181,60 +296,46 @@ impl YouTrackClient { id: String, } + // Look at options already defined on the field 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(()); - } + // Is our version defined? + if versions.iter().any(|v| v.name == version_info.version) { + eprintln!( + "Version {v} already exists in YouTrack", + v = version_info.version + ); + 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'], - ); - */ + println!( + "Creating version in YouTrack: {v}", + v = version_info.version + ); #[derive(Serialize)] + #[allow(non_snake_case)] struct CreateVersionBody { name: String, archived: bool, released: bool, - #[allow(non_snake_case)] releaseDate: Option, // archived } - let body = CreateVersionBody { - name: version_to_create.to_string(), + let request_body = CreateVersionBody { + name: version_info.version.to_string(), archived: false, released: release_date.is_some(), releaseDate: release_date.map(|d| d.timestamp()), }; #[derive(Deserialize, Debug)] + #[allow(non_snake_case)] struct CreateVersionResponse { - #[allow(non_snake_case)] releaseDate: Option, released: bool, archived: bool, @@ -244,7 +345,7 @@ impl YouTrackClient { let resp: CreateVersionResponse = self.post_json( format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), - &body, + &request_body, &[("fields", "id,name,released,releaseDate,archived")], )?; @@ -253,21 +354,28 @@ impl YouTrackClient { // {"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."); + println!("Version {v} created in YouTrack.", v = version_info.version); Ok(()) } - pub fn set_issue_version_and_state( + /// Modify a YouTrack issue by changing its State and setting "Available in version". + /// + /// Before calling this, make sure the version exists, e.g. using `ensure_version_exists_in_project` + /// + /// - issue_id - e.g. SW-1234 + /// - version_field_name - name of the YT custom field to modify + /// - version_name - name of the version, e.g. 1.0.0 + /// - target_state_name name of the State to switch the issue to (None for no-op) + pub fn set_issue_version_and_state_by_name( &self, issue_id: &str, - version_field_name: &str, - version_name: &str, - target_state_name: &str, + version: Option<&SetVersion>, + state: Option<&str>, ) -> anyhow::Result<()> { #[derive(Serialize)] + #[allow(non_snake_case)] struct PatchIssueBody { - #[allow(non_snake_case)] customFields: Vec, } @@ -284,23 +392,35 @@ impl YouTrackClient { 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(), - }, + let mut custom_fields = Vec::new(); + + if let Some(version) = version { + custom_fields.push(CustomFieldValue { + name: version.field_name.to_string(), + field_type: "SingleVersionIssueCustomField".to_string(), + value: EnumValue { + name: version.version.to_string(), }, - CustomFieldValue { - name: "State".to_string(), - field_type: "StateIssueCustomField".to_string(), - value: EnumValue { - name: target_state_name.to_string(), - }, + }); + } + + if let Some(target_state_name) = state { + custom_fields.push(CustomFieldValue { + name: "State".to_string(), + field_type: "StateIssueCustomField".to_string(), + value: EnumValue { + name: target_state_name.to_string(), }, - ], + }); + } + + if custom_fields.is_empty() { + eprintln!("Nothing to do in YouTrack - no version field, no target state."); + return Ok(()); + } + + let body = PatchIssueBody { + customFields: custom_fields, }; let resp: Value = self.post_json( @@ -309,7 +429,7 @@ impl YouTrackClient { &[("fields", "id,customFields(name,value(name))")], )?; - // TODO? Do something with the fields + // Do something with the requested 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"} @@ -321,12 +441,20 @@ impl YouTrackClient { } } +/// Params for YT to change version field +#[derive(Clone)] +pub struct SetVersion<'a> { + /// Field name, e.g. Available in version + pub field_name: &'a str, + /// Version name, e.g. 1.0.0 + pub version: &'a str, +} + #[cfg(test)] mod tests { - use super::YouTrackClient; + use super::{SetVersion, YouTrackClient}; use crate::config::{ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL}; use log::{LevelFilter, debug}; - use serde_json::Value; // #[test] // Disabled fn test_youtrack_communication() { @@ -341,7 +469,12 @@ mod tests { let target_state_name = "Released"; let version_name = "TEST2"; - let mut client = YouTrackClient::new(url, &token).unwrap(); + let set_version = SetVersion { + field_name: version_field_name, + version: version_name, + }; + + let client = YouTrackClient::new(url, &token).unwrap(); let project_id = client.find_project_id(issue_id).unwrap(); @@ -350,20 +483,14 @@ mod tests { let date = chrono::Utc::now(); client - .ensure_version_exists_in_project( - &project_id, - version_field_name, - version_name, - Some(date), - ) + .ensure_version_exists_in_project(&project_id, &set_version, Some(date)) .unwrap(); client - .set_issue_version_and_state( + .set_issue_version_and_state_by_name( issue_id, - version_field_name, - version_name, - target_state_name, + Some(&set_version), + Some(target_state_name), ) .unwrap(); }