Merge branch 'youtrack'
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
.idea/
|
||||
.env
|
||||
|
||||
Generated
+1427
-83
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -6,6 +6,7 @@ authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
|
||||
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,4 +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"] }
|
||||
|
||||
# For integrations
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"] } #, optional = true
|
||||
json_dotpath = "1.1.0"
|
||||
dotenv = "0.15.0"
|
||||
|
||||
[dev-dependencies]
|
||||
simple-logging = "2"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,8 +11,16 @@ 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");
|
||||
|
||||
/// ENV / dotenv key for the youtrack integration server URL
|
||||
/// This is only for unit tests
|
||||
pub const ENV_YOUTRACK_URL: &str = "CLPACK_YOUTRACK_URL";
|
||||
|
||||
/// ENV / dotenv key for the youtrack integration API token
|
||||
pub const ENV_YOUTRACK_TOKEN: &str = "CLPACK_YOUTRACK_TOKEN";
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn test_template_file() {
|
||||
@@ -103,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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Third party service (e.g. issue trackers) integrations
|
||||
pub mod youtrack;
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Youtrack integration (mark issues as Released when packing to changelog, change Available in version)
|
||||
|
||||
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 log::debug;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// ID of a youtrack project
|
||||
type ProjectId = 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 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(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,
|
||||
query: &T,
|
||||
) -> anyhow::Result<O> {
|
||||
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 is_ok = response.status().is_success();
|
||||
let response_text = response.text()?;
|
||||
|
||||
debug!("Resp = {}", response_text);
|
||||
|
||||
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,
|
||||
body: &B,
|
||||
query: &T,
|
||||
) -> anyhow::Result<O> {
|
||||
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 is_ok = response.status().is_success();
|
||||
let response_text = response.text()?;
|
||||
|
||||
debug!("Resp = {}", response_text);
|
||||
|
||||
if !is_ok {
|
||||
return Err(Self::parse_youtrack_error_response(&response_text));
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str(&response_text)?)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Project {
|
||||
id: ProjectId,
|
||||
}
|
||||
|
||||
let issue: Issue =
|
||||
self.get_json(format!("issues/{issue_name}"), &[("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()`
|
||||
/// - 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,
|
||||
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
|
||||
bundle: Option<BudleDescription>,
|
||||
field: FieldDescription,
|
||||
}
|
||||
|
||||
// Find field description
|
||||
let fields: Vec<YTCustomField> = self.get_json(
|
||||
format!("admin/projects/{project_id}/customFields"),
|
||||
&[("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 == version_info.field_name
|
||||
&& let Some(bundle) = entry.bundle
|
||||
{
|
||||
field_bundle = Some((entry.field.id, bundle.id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Got something?
|
||||
let Some((field_id, bundle_id)) = field_bundle else {
|
||||
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");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct YTVersion {
|
||||
name: VersionName,
|
||||
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")],
|
||||
)?;
|
||||
|
||||
// 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: {v}",
|
||||
v = version_info.version
|
||||
);
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct CreateVersionBody {
|
||||
name: String,
|
||||
archived: bool,
|
||||
released: bool,
|
||||
releaseDate: Option<i64>,
|
||||
// archived
|
||||
}
|
||||
|
||||
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 {
|
||||
releaseDate: Option<i64>,
|
||||
released: bool,
|
||||
archived: bool,
|
||||
name: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
let resp: CreateVersionResponse = self.post_json(
|
||||
format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"),
|
||||
&request_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 {v} created in YouTrack.", v = version_info.version);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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: Option<&SetVersion>,
|
||||
state: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
#[derive(Serialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct PatchIssueBody {
|
||||
customFields: Vec<CustomFieldValue>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EnumValue {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CustomFieldValue {
|
||||
name: String,
|
||||
#[serde(rename = "$type")]
|
||||
field_type: String,
|
||||
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: custom_fields,
|
||||
};
|
||||
|
||||
let resp: Value = self.post_json(
|
||||
format!("issues/{issue_id}"),
|
||||
&body,
|
||||
&[("fields", "id,customFields(name,value(name))")],
|
||||
)?;
|
||||
|
||||
// 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"}
|
||||
|
||||
println!("YouTrack issue {issue_id} updated.");
|
||||
|
||||
debug!("Response to request to edit issue: {resp:?}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::{SetVersion, YouTrackClient};
|
||||
use crate::config::{ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL};
|
||||
use log::{LevelFilter, debug};
|
||||
|
||||
// #[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 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();
|
||||
|
||||
debug!("Found YouTrack project ID = {project_id}");
|
||||
|
||||
let date = chrono::Utc::now();
|
||||
|
||||
client
|
||||
.ensure_version_exists_in_project(&project_id, &set_version, Some(date))
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.set_issue_version_and_state_by_name(
|
||||
issue_id,
|
||||
Some(&set_version),
|
||||
Some(target_state_name),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ mod store;
|
||||
|
||||
mod utils;
|
||||
|
||||
mod integrations;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppContext {
|
||||
/// Name of the cl binary
|
||||
|
||||
Reference in New Issue
Block a user