clpack integration
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# New features
|
||||
- Add integration to JetBrains YouTrack (#SW-4712)
|
||||
+40
@@ -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"
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
|
||||
+25
-6
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+40
-2
@@ -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<String>,
|
||||
|
||||
/// 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<ChannelName>,
|
||||
|
||||
/// Name of the State option to switch to when generating changelog (e.g. Released)
|
||||
pub released_state: Option<String>,
|
||||
|
||||
/// Name of the version field (Available in version)
|
||||
pub version_field: Option<String>,
|
||||
}
|
||||
|
||||
+17
-14
@@ -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<Option<String>> {
|
||||
let Some(pat) = ctx.config.branch_version_pattern.as_ref().empty_to_none() else {
|
||||
pub fn parse_version(&self, config: &Config) -> anyhow::Result<Option<String>> {
|
||||
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<Option<String>> {
|
||||
let Some(pat) = ctx.config.branch_issue_pattern.as_ref().empty_to_none() else {
|
||||
pub fn parse_issue(&self, config: &Config) -> anyhow::Result<Option<String>> {
|
||||
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<Option<String>> {
|
||||
for (channel_id, template) in &ctx.config.channels {
|
||||
pub fn parse_channel(&self, config: &Config) -> anyhow::Result<Option<String>> {
|
||||
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
|
||||
);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
mod youtrack;
|
||||
|
||||
pub use youtrack::YouTrackClient;
|
||||
/// Third party service (e.g. issue trackers) integrations
|
||||
pub mod youtrack;
|
||||
|
||||
+226
-99
@@ -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 struct YouTrackClient {
|
||||
client: reqwest::blocking::Client,
|
||||
pub url: 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,
|
||||
/// 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<Self> {
|
||||
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::<YoutrackErrorResponse>(&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<T: Serialize + ?Sized, O: DeserializeOwned>(
|
||||
&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::<YoutrackErrorResponse>(&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<T: Serialize + ?Sized, B: Serialize + ?Sized, O: DeserializeOwned>(
|
||||
&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::<YoutrackErrorResponse>(&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<ProjectId> {
|
||||
/// Find YouTrack project ID from an issue name
|
||||
pub fn find_project_id(&self, issue_name: &str) -> anyhow::Result<ProjectId> {
|
||||
#[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<DateTime<Utc>>,
|
||||
) -> 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<YTVersion> = 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<i64>,
|
||||
// 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<i64>,
|
||||
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<CustomFieldValue>,
|
||||
}
|
||||
|
||||
@@ -284,23 +392,35 @@ impl YouTrackClient {
|
||||
value: EnumValue,
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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: 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(),
|
||||
},
|
||||
},
|
||||
],
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user