|
|
|
@ -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 crate::config::{ChannelName, ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL, VersionName}; |
|
|
|
use anyhow::bail; |
|
|
|
use crate::git::BranchName; |
|
|
|
|
|
|
|
use crate::store::Release; |
|
|
|
|
|
|
|
use anyhow::{Context, bail}; |
|
|
|
use chrono::{DateTime, Utc}; |
|
|
|
use chrono::{DateTime, Utc}; |
|
|
|
use json_dotpath::DotPaths; |
|
|
|
|
|
|
|
use log::debug; |
|
|
|
use log::debug; |
|
|
|
use reqwest::header::{HeaderMap, HeaderValue}; |
|
|
|
use reqwest::header::{HeaderMap, HeaderValue}; |
|
|
|
use serde::de::DeserializeOwned; |
|
|
|
use serde::de::DeserializeOwned; |
|
|
|
use serde::{Deserialize, Serialize}; |
|
|
|
use serde::{Deserialize, Serialize}; |
|
|
|
use serde_json::Value; |
|
|
|
use serde_json::Value; |
|
|
|
use std::borrow::Cow; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// ID of a youtrack project
|
|
|
|
type ProjectId = String; |
|
|
|
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 { |
|
|
|
pub struct YouTrackClient { |
|
|
|
|
|
|
|
/// HTTPS client with default presets to access the API
|
|
|
|
client: reqwest::blocking::Client, |
|
|
|
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)] |
|
|
|
#[derive(Deserialize)] |
|
|
|
struct YoutrackErrorResponse { |
|
|
|
struct YoutrackErrorResponse { |
|
|
|
|
|
|
|
/// Error ID
|
|
|
|
error: String, |
|
|
|
error: String, |
|
|
|
|
|
|
|
/// Error message
|
|
|
|
error_description: String, |
|
|
|
error_description: String, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
impl YouTrackClient { |
|
|
|
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> { |
|
|
|
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(); |
|
|
|
let mut headers = HeaderMap::new(); |
|
|
|
hm.insert("Authorization", HeaderValue::from_str(&token_bearer)?); |
|
|
|
headers.insert("Authorization", HeaderValue::from_str(&token_bearer)?); |
|
|
|
hm.insert("Content-Type", HeaderValue::from_str("application/json")?); |
|
|
|
headers.insert("Content-Type", HeaderValue::from_str("application/json")?); |
|
|
|
hm.insert("Accept", HeaderValue::from_str("application/json")?); |
|
|
|
headers.insert("Accept", HeaderValue::from_str("application/json")?); |
|
|
|
|
|
|
|
|
|
|
|
Ok(YouTrackClient { |
|
|
|
Ok(YouTrackClient { |
|
|
|
url: url.to_string(), |
|
|
|
url: url.to_string(), |
|
|
|
client: reqwest::blocking::Client::builder() |
|
|
|
client: reqwest::blocking::Client::builder() |
|
|
|
.default_headers(hm) |
|
|
|
.default_headers(headers) |
|
|
|
.build()?, |
|
|
|
.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>( |
|
|
|
fn get_json<T: Serialize + ?Sized, O: DeserializeOwned>( |
|
|
|
&self, |
|
|
|
&self, |
|
|
|
api_path: String, |
|
|
|
api_path: String, |
|
|
|
@ -57,18 +161,19 @@ impl YouTrackClient { |
|
|
|
debug!("GET {}", url); |
|
|
|
debug!("GET {}", url); |
|
|
|
|
|
|
|
|
|
|
|
let response = self.client.get(&url).query(query).send()?; |
|
|
|
let response = self.client.get(&url).query(query).send()?; |
|
|
|
|
|
|
|
let is_ok = response.status().is_success(); |
|
|
|
let response_text = response.text()?; |
|
|
|
let response_text = response.text()?; |
|
|
|
|
|
|
|
|
|
|
|
debug!("Resp = {}", response_text); |
|
|
|
debug!("Resp = {}", response_text); |
|
|
|
|
|
|
|
|
|
|
|
if let Ok(e) = serde_json::from_str::<YoutrackErrorResponse>(&response_text) { |
|
|
|
if !is_ok { |
|
|
|
bail!("Error from YouTrack: {} - {}", e.error, e.error_description); |
|
|
|
return Err(Self::parse_youtrack_error_response(&response_text)); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::from_str(&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>( |
|
|
|
fn post_json<T: Serialize + ?Sized, B: Serialize + ?Sized, O: DeserializeOwned>( |
|
|
|
&self, |
|
|
|
&self, |
|
|
|
api_path: String, |
|
|
|
api_path: String, |
|
|
|
@ -91,18 +196,20 @@ impl YouTrackClient { |
|
|
|
.body(body_serialized.into_bytes()) |
|
|
|
.body(body_serialized.into_bytes()) |
|
|
|
.send()?; |
|
|
|
.send()?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let is_ok = response.status().is_success(); |
|
|
|
let response_text = response.text()?; |
|
|
|
let response_text = response.text()?; |
|
|
|
|
|
|
|
|
|
|
|
debug!("Resp = {}", response_text); |
|
|
|
debug!("Resp = {}", response_text); |
|
|
|
|
|
|
|
|
|
|
|
if let Ok(e) = serde_json::from_str::<YoutrackErrorResponse>(&response_text) { |
|
|
|
if !is_ok { |
|
|
|
bail!("Error from YouTrack: {} - {}", e.error, e.error_description); |
|
|
|
return Err(Self::parse_youtrack_error_response(&response_text)); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::from_str(&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)] |
|
|
|
#[derive(Deserialize)] |
|
|
|
struct Issue { |
|
|
|
struct Issue { |
|
|
|
project: Project, |
|
|
|
project: Project, |
|
|
|
@ -114,7 +221,7 @@ impl YouTrackClient { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let issue: Issue = |
|
|
|
let issue: Issue = |
|
|
|
self.get_json(format!("issues/{issue}"), &[("fields", "project(id)")])?; |
|
|
|
self.get_json(format!("issues/{issue_name}"), &[("fields", "project(id)")])?; |
|
|
|
|
|
|
|
|
|
|
|
// example:
|
|
|
|
// example:
|
|
|
|
// {"project":{"id":"0-172","$type":"Project"},"$type":"Issue"}
|
|
|
|
// {"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.
|
|
|
|
/// 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.
|
|
|
|
/// 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()`
|
|
|
|
/// - project_id - obtained by `find_project_id()`
|
|
|
|
/// field_name - name of the version field, e.g. "Available in version"
|
|
|
|
/// - version_info - version name and field name
|
|
|
|
/// 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
|
|
|
|
/// 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.
|
|
|
|
/// newly created version & this marked as released.
|
|
|
|
|
|
|
|
pub fn ensure_version_exists_in_project( |
|
|
|
pub fn ensure_version_exists_in_project( |
|
|
|
&self, |
|
|
|
&self, |
|
|
|
project_id: &str, |
|
|
|
project_id: &str, |
|
|
|
field_name: &str, |
|
|
|
version_info: &SetVersion, |
|
|
|
version_to_create: &str, |
|
|
|
|
|
|
|
release_date: Option<DateTime<Utc>>, |
|
|
|
release_date: Option<DateTime<Utc>>, |
|
|
|
) -> anyhow::Result<()> { |
|
|
|
) -> anyhow::Result<()> { |
|
|
|
|
|
|
|
type BundleID = String; |
|
|
|
|
|
|
|
type FieldID = String; |
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)] |
|
|
|
#[derive(Deserialize)] |
|
|
|
struct BudleDescription { |
|
|
|
struct BudleDescription { |
|
|
|
id: BundleID, |
|
|
|
id: BundleID, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)] |
|
|
|
#[derive(Deserialize)] |
|
|
|
struct FieldDescription { |
|
|
|
struct FieldDescription { |
|
|
|
name: String, |
|
|
|
name: String, |
|
|
|
id: FieldID, |
|
|
|
id: FieldID, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)] |
|
|
|
#[derive(Deserialize)] |
|
|
|
struct YTCustomField { |
|
|
|
struct YTCustomField { |
|
|
|
// Bundle is sometimes missing - we skip these entries
|
|
|
|
// Bundle is sometimes missing - we skip these entries
|
|
|
|
@ -159,9 +269,10 @@ impl YouTrackClient { |
|
|
|
&[("fields", "field(name,id),bundle(id)"), ("top", "200")], |
|
|
|
&[("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; |
|
|
|
let mut field_bundle = None; |
|
|
|
for entry in fields { |
|
|
|
for entry in fields { |
|
|
|
if &entry.field.name == field_name |
|
|
|
if &entry.field.name == version_info.field_name |
|
|
|
&& let Some(bundle) = entry.bundle |
|
|
|
&& let Some(bundle) = entry.bundle |
|
|
|
{ |
|
|
|
{ |
|
|
|
field_bundle = Some((entry.field.id, bundle.id)); |
|
|
|
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 { |
|
|
|
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"); |
|
|
|
println!("Found YouTrack version field, checking defined versions"); |
|
|
|
@ -181,60 +296,46 @@ impl YouTrackClient { |
|
|
|
id: String, |
|
|
|
id: String, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Look at options already defined on the field
|
|
|
|
let versions: Vec<YTVersion> = self.get_json( |
|
|
|
let versions: Vec<YTVersion> = self.get_json( |
|
|
|
format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), |
|
|
|
format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), |
|
|
|
&[("fields", "id,name"), ("top", "500")], |
|
|
|
&[("fields", "id,name"), ("top", "500")], |
|
|
|
)?; |
|
|
|
)?; |
|
|
|
|
|
|
|
|
|
|
|
// Find the version we want
|
|
|
|
// Is our version defined?
|
|
|
|
for version in versions { |
|
|
|
if versions.iter().any(|v| v.name == version_info.version) { |
|
|
|
if &version.name == version_to_create { |
|
|
|
eprintln!( |
|
|
|
eprintln!("Version {version_to_create} already exists in YouTrack"); |
|
|
|
"Version {v} already exists in YouTrack", |
|
|
|
return Ok(()); |
|
|
|
v = version_info.version |
|
|
|
} |
|
|
|
); |
|
|
|
|
|
|
|
return Ok(()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
println!("Creating version in YouTrack: {version_to_create}"); |
|
|
|
println!( |
|
|
|
|
|
|
|
"Creating version in YouTrack: {v}", |
|
|
|
/* |
|
|
|
v = version_info.version |
|
|
|
$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)] |
|
|
|
#[derive(Serialize)] |
|
|
|
|
|
|
|
#[allow(non_snake_case)] |
|
|
|
struct CreateVersionBody { |
|
|
|
struct CreateVersionBody { |
|
|
|
name: String, |
|
|
|
name: String, |
|
|
|
archived: bool, |
|
|
|
archived: bool, |
|
|
|
released: bool, |
|
|
|
released: bool, |
|
|
|
#[allow(non_snake_case)] |
|
|
|
|
|
|
|
releaseDate: Option<i64>, |
|
|
|
releaseDate: Option<i64>, |
|
|
|
// archived
|
|
|
|
// archived
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let body = CreateVersionBody { |
|
|
|
let request_body = CreateVersionBody { |
|
|
|
name: version_to_create.to_string(), |
|
|
|
name: version_info.version.to_string(), |
|
|
|
archived: false, |
|
|
|
archived: false, |
|
|
|
released: release_date.is_some(), |
|
|
|
released: release_date.is_some(), |
|
|
|
releaseDate: release_date.map(|d| d.timestamp()), |
|
|
|
releaseDate: release_date.map(|d| d.timestamp()), |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Debug)] |
|
|
|
#[derive(Deserialize, Debug)] |
|
|
|
|
|
|
|
#[allow(non_snake_case)] |
|
|
|
struct CreateVersionResponse { |
|
|
|
struct CreateVersionResponse { |
|
|
|
#[allow(non_snake_case)] |
|
|
|
|
|
|
|
releaseDate: Option<i64>, |
|
|
|
releaseDate: Option<i64>, |
|
|
|
released: bool, |
|
|
|
released: bool, |
|
|
|
archived: bool, |
|
|
|
archived: bool, |
|
|
|
@ -244,7 +345,7 @@ impl YouTrackClient { |
|
|
|
|
|
|
|
|
|
|
|
let resp: CreateVersionResponse = self.post_json( |
|
|
|
let resp: CreateVersionResponse = self.post_json( |
|
|
|
format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), |
|
|
|
format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), |
|
|
|
&body, |
|
|
|
&request_body, |
|
|
|
&[("fields", "id,name,released,releaseDate,archived")], |
|
|
|
&[("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"}
|
|
|
|
// {"releaseDate":1758619201,"released":true,"archived":false,"name":"TEST2","id":"232-358","$type":"VersionBundleElement"}
|
|
|
|
|
|
|
|
|
|
|
|
debug!("Created version entry = {:#?}", resp); |
|
|
|
debug!("Created version entry = {:#?}", resp); |
|
|
|
println!("Version {version_to_create} created in YouTrack."); |
|
|
|
println!("Version {v} created in YouTrack.", v = version_info.version); |
|
|
|
|
|
|
|
|
|
|
|
Ok(()) |
|
|
|
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, |
|
|
|
&self, |
|
|
|
issue_id: &str, |
|
|
|
issue_id: &str, |
|
|
|
version_field_name: &str, |
|
|
|
version: Option<&SetVersion>, |
|
|
|
version_name: &str, |
|
|
|
state: Option<&str>, |
|
|
|
target_state_name: &str, |
|
|
|
|
|
|
|
) -> anyhow::Result<()> { |
|
|
|
) -> anyhow::Result<()> { |
|
|
|
#[derive(Serialize)] |
|
|
|
#[derive(Serialize)] |
|
|
|
|
|
|
|
#[allow(non_snake_case)] |
|
|
|
struct PatchIssueBody { |
|
|
|
struct PatchIssueBody { |
|
|
|
#[allow(non_snake_case)] |
|
|
|
|
|
|
|
customFields: Vec<CustomFieldValue>, |
|
|
|
customFields: Vec<CustomFieldValue>, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -284,23 +392,35 @@ impl YouTrackClient { |
|
|
|
value: EnumValue, |
|
|
|
value: EnumValue, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let body = PatchIssueBody { |
|
|
|
let mut custom_fields = Vec::new(); |
|
|
|
customFields: vec![ |
|
|
|
|
|
|
|
CustomFieldValue { |
|
|
|
if let Some(version) = version { |
|
|
|
name: version_field_name.to_string(), |
|
|
|
custom_fields.push(CustomFieldValue { |
|
|
|
field_type: "SingleVersionIssueCustomField".to_string(), |
|
|
|
name: version.field_name.to_string(), |
|
|
|
value: EnumValue { |
|
|
|
field_type: "SingleVersionIssueCustomField".to_string(), |
|
|
|
name: version_name.to_string(), |
|
|
|
value: EnumValue { |
|
|
|
}, |
|
|
|
name: version.version.to_string(), |
|
|
|
}, |
|
|
|
}, |
|
|
|
CustomFieldValue { |
|
|
|
}); |
|
|
|
name: "State".to_string(), |
|
|
|
} |
|
|
|
field_type: "StateIssueCustomField".to_string(), |
|
|
|
|
|
|
|
value: EnumValue { |
|
|
|
if let Some(target_state_name) = state { |
|
|
|
name: target_state_name.to_string(), |
|
|
|
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( |
|
|
|
let resp: Value = self.post_json( |
|
|
|
@ -309,7 +429,7 @@ impl YouTrackClient { |
|
|
|
&[("fields", "id,customFields(name,value(name))")], |
|
|
|
&[("fields", "id,customFields(name,value(name))")], |
|
|
|
)?; |
|
|
|
)?; |
|
|
|
|
|
|
|
|
|
|
|
// TODO? Do something with the fields
|
|
|
|
// Do something with the requested fields?
|
|
|
|
|
|
|
|
|
|
|
|
// Example success:
|
|
|
|
// 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"}
|
|
|
|
// {"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)] |
|
|
|
#[cfg(test)] |
|
|
|
mod tests { |
|
|
|
mod tests { |
|
|
|
use super::YouTrackClient; |
|
|
|
use super::{SetVersion, YouTrackClient}; |
|
|
|
use crate::config::{ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL}; |
|
|
|
use crate::config::{ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL}; |
|
|
|
use log::{LevelFilter, debug}; |
|
|
|
use log::{LevelFilter, debug}; |
|
|
|
use serde_json::Value; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// #[test] // Disabled
|
|
|
|
// #[test] // Disabled
|
|
|
|
fn test_youtrack_communication() { |
|
|
|
fn test_youtrack_communication() { |
|
|
|
@ -341,7 +469,12 @@ mod tests { |
|
|
|
let target_state_name = "Released"; |
|
|
|
let target_state_name = "Released"; |
|
|
|
let version_name = "TEST2"; |
|
|
|
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(); |
|
|
|
let project_id = client.find_project_id(issue_id).unwrap(); |
|
|
|
|
|
|
|
|
|
|
|
@ -350,20 +483,14 @@ mod tests { |
|
|
|
let date = chrono::Utc::now(); |
|
|
|
let date = chrono::Utc::now(); |
|
|
|
|
|
|
|
|
|
|
|
client |
|
|
|
client |
|
|
|
.ensure_version_exists_in_project( |
|
|
|
.ensure_version_exists_in_project(&project_id, &set_version, Some(date)) |
|
|
|
&project_id, |
|
|
|
|
|
|
|
version_field_name, |
|
|
|
|
|
|
|
version_name, |
|
|
|
|
|
|
|
Some(date), |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
.unwrap(); |
|
|
|
.unwrap(); |
|
|
|
|
|
|
|
|
|
|
|
client |
|
|
|
client |
|
|
|
.set_issue_version_and_state( |
|
|
|
.set_issue_version_and_state_by_name( |
|
|
|
issue_id, |
|
|
|
issue_id, |
|
|
|
version_field_name, |
|
|
|
Some(&set_version), |
|
|
|
version_name, |
|
|
|
Some(target_state_name), |
|
|
|
target_state_name, |
|
|
|
|
|
|
|
) |
|
|
|
) |
|
|
|
.unwrap(); |
|
|
|
.unwrap(); |
|
|
|
} |
|
|
|
} |
|
|
|
|