parent
c7e9df60c9
commit
753cbebd48
@ -1,2 +1,3 @@ |
|||||||
/target |
/target |
||||||
.idea/ |
.idea/ |
||||||
|
.env |
||||||
|
|||||||
@ -0,0 +1,3 @@ |
|||||||
|
mod youtrack; |
||||||
|
|
||||||
|
pub use youtrack::YouTrackClient; |
||||||
@ -0,0 +1,370 @@ |
|||||||
|
//! Youtrack integration (mark issues as Released when packing to changelog)
|
||||||
|
|
||||||
|
use crate::config::VersionName; |
||||||
|
use anyhow::bail; |
||||||
|
use chrono::{DateTime, Utc}; |
||||||
|
use json_dotpath::DotPaths; |
||||||
|
use log::debug; |
||||||
|
use reqwest::header::{HeaderMap, HeaderValue}; |
||||||
|
use serde::de::DeserializeOwned; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
use serde_json::Value; |
||||||
|
use std::borrow::Cow; |
||||||
|
|
||||||
|
type ProjectId = String; |
||||||
|
type BundleID = String; |
||||||
|
type FieldID = String; |
||||||
|
|
||||||
|
pub struct YouTrackClient { |
||||||
|
client: reqwest::blocking::Client, |
||||||
|
pub url: String, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Deserialize)] |
||||||
|
struct YoutrackErrorResponse { |
||||||
|
error: String, |
||||||
|
error_description: String, |
||||||
|
} |
||||||
|
|
||||||
|
impl YouTrackClient { |
||||||
|
pub fn new(url: impl ToString, token: &str) -> anyhow::Result<Self> { |
||||||
|
let token_bearer = format!("Bearer {token}"); |
||||||
|
|
||||||
|
let mut hm = HeaderMap::new(); |
||||||
|
hm.insert("Authorization", HeaderValue::from_str(&token_bearer)?); |
||||||
|
hm.insert("Content-Type", HeaderValue::from_str("application/json")?); |
||||||
|
hm.insert("Accept", HeaderValue::from_str("application/json")?); |
||||||
|
|
||||||
|
Ok(YouTrackClient { |
||||||
|
url: url.to_string(), |
||||||
|
client: reqwest::blocking::Client::builder() |
||||||
|
.default_headers(hm) |
||||||
|
.build()?, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
fn get_json<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 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); |
||||||
|
} |
||||||
|
|
||||||
|
Ok(serde_json::from_str(&response_text)?) |
||||||
|
} |
||||||
|
|
||||||
|
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 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); |
||||||
|
} |
||||||
|
|
||||||
|
Ok(serde_json::from_str(&response_text)?) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn find_project_id(&self, issue: &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}"), &[("fields", "project(id)")])?; |
||||||
|
|
||||||
|
// example:
|
||||||
|
// {"project":{"id":"0-172","$type":"Project"},"$type":"Issue"}
|
||||||
|
|
||||||
|
Ok(issue.project.id) |
||||||
|
} |
||||||
|
|
||||||
|
/// Try to find a version by name in a YouTrack project.
|
||||||
|
/// If it is not found but we find the field where to add it, the version will be created.
|
||||||
|
///
|
||||||
|
/// project_id - obtained by `find_project_id()`
|
||||||
|
/// field_name - name of the version field, e.g. "Available in version"
|
||||||
|
/// version_to_create - name of the version to find or create
|
||||||
|
/// release_date - if creating, a date YYYY-MM-DD can be passed here. It will be stored into the
|
||||||
|
/// newly created version & this marked as released.
|
||||||
|
pub fn ensure_version_exists_in_project( |
||||||
|
&self, |
||||||
|
project_id: &str, |
||||||
|
field_name: &str, |
||||||
|
version_to_create: &str, |
||||||
|
release_date: Option<DateTime<Utc>>, |
||||||
|
) -> anyhow::Result<()> { |
||||||
|
#[derive(Deserialize)] |
||||||
|
struct BudleDescription { |
||||||
|
id: BundleID, |
||||||
|
} |
||||||
|
#[derive(Deserialize)] |
||||||
|
struct FieldDescription { |
||||||
|
name: String, |
||||||
|
id: FieldID, |
||||||
|
} |
||||||
|
#[derive(Deserialize)] |
||||||
|
struct YTCustomField { |
||||||
|
// Bundle is sometimes missing - we skip these entries
|
||||||
|
bundle: Option<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")], |
||||||
|
)?; |
||||||
|
|
||||||
|
let mut field_bundle = None; |
||||||
|
for entry in fields { |
||||||
|
if &entry.field.name == field_name |
||||||
|
&& let Some(bundle) = entry.bundle |
||||||
|
{ |
||||||
|
field_bundle = Some((entry.field.id, bundle.id)); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let Some((field_id, bundle_id)) = field_bundle else { |
||||||
|
bail!("YouTrack version field {field_name} not found in the project {project_id}"); |
||||||
|
}; |
||||||
|
|
||||||
|
println!("Found YouTrack version field, checking defined versions"); |
||||||
|
|
||||||
|
#[derive(Deserialize)] |
||||||
|
struct YTVersion { |
||||||
|
name: VersionName, |
||||||
|
id: String, |
||||||
|
} |
||||||
|
|
||||||
|
let versions: Vec<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(()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
println!("Creating version in YouTrack: {version_to_create}"); |
||||||
|
|
||||||
|
/* |
||||||
|
$body = ['name' => $name]; |
||||||
|
if ($released !== null) { |
||||||
|
$body['released'] = $released; |
||||||
|
} |
||||||
|
if ($releaseDate !== null) { |
||||||
|
$body['releaseDate'] = $releaseDate; |
||||||
|
} |
||||||
|
if ($archived !== null) { |
||||||
|
$body['archived'] = $archived; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->postJson( |
||||||
|
"admin/customFieldSettings/bundles/version/$bundleId/values", |
||||||
|
$body, |
||||||
|
['fields' => 'id,name,released,releaseDate,archived'], |
||||||
|
); |
||||||
|
*/ |
||||||
|
|
||||||
|
#[derive(Serialize)] |
||||||
|
struct CreateVersionBody { |
||||||
|
name: String, |
||||||
|
archived: bool, |
||||||
|
released: bool, |
||||||
|
#[allow(non_snake_case)] |
||||||
|
releaseDate: Option<i64>, |
||||||
|
// archived
|
||||||
|
} |
||||||
|
|
||||||
|
let body = CreateVersionBody { |
||||||
|
name: version_to_create.to_string(), |
||||||
|
archived: false, |
||||||
|
released: release_date.is_some(), |
||||||
|
releaseDate: release_date.map(|d| d.timestamp()), |
||||||
|
}; |
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)] |
||||||
|
struct CreateVersionResponse { |
||||||
|
#[allow(non_snake_case)] |
||||||
|
releaseDate: Option<i64>, |
||||||
|
released: bool, |
||||||
|
archived: bool, |
||||||
|
name: String, |
||||||
|
id: String, |
||||||
|
} |
||||||
|
|
||||||
|
let resp: CreateVersionResponse = self.post_json( |
||||||
|
format!("admin/customFieldSettings/bundles/version/{bundle_id}/values"), |
||||||
|
&body, |
||||||
|
&[("fields", "id,name,released,releaseDate,archived")], |
||||||
|
)?; |
||||||
|
|
||||||
|
// Example response:
|
||||||
|
// {"releaseDate":null,"released":false,"archived":false,"name":"TEST1","id":"232-356","$type":"VersionBundleElement"}
|
||||||
|
// {"releaseDate":1758619201,"released":true,"archived":false,"name":"TEST2","id":"232-358","$type":"VersionBundleElement"}
|
||||||
|
|
||||||
|
debug!("Created version entry = {:#?}", resp); |
||||||
|
println!("Version {version_to_create} created in YouTrack."); |
||||||
|
|
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn set_issue_version_and_state( |
||||||
|
&self, |
||||||
|
issue_id: &str, |
||||||
|
version_field_name: &str, |
||||||
|
version_name: &str, |
||||||
|
target_state_name: &str, |
||||||
|
) -> anyhow::Result<()> { |
||||||
|
#[derive(Serialize)] |
||||||
|
struct PatchIssueBody { |
||||||
|
#[allow(non_snake_case)] |
||||||
|
customFields: Vec<CustomFieldValue>, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Serialize)] |
||||||
|
struct EnumValue { |
||||||
|
name: String, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Serialize)] |
||||||
|
struct CustomFieldValue { |
||||||
|
name: String, |
||||||
|
#[serde(rename = "$type")] |
||||||
|
field_type: String, |
||||||
|
value: EnumValue, |
||||||
|
} |
||||||
|
|
||||||
|
let body = PatchIssueBody { |
||||||
|
customFields: vec![ |
||||||
|
CustomFieldValue { |
||||||
|
name: version_field_name.to_string(), |
||||||
|
field_type: "SingleVersionIssueCustomField".to_string(), |
||||||
|
value: EnumValue { |
||||||
|
name: version_name.to_string(), |
||||||
|
}, |
||||||
|
}, |
||||||
|
CustomFieldValue { |
||||||
|
name: "State".to_string(), |
||||||
|
field_type: "StateIssueCustomField".to_string(), |
||||||
|
value: EnumValue { |
||||||
|
name: target_state_name.to_string(), |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}; |
||||||
|
|
||||||
|
let resp: Value = self.post_json( |
||||||
|
format!("issues/{issue_id}"), |
||||||
|
&body, |
||||||
|
&[("fields", "id,customFields(name,value(name))")], |
||||||
|
)?; |
||||||
|
|
||||||
|
// TODO? Do something with the fields
|
||||||
|
|
||||||
|
// Example success:
|
||||||
|
// {"customFields":[{"value":null,"name":"Type","$type":"SingleEnumIssueCustomField"},{"value":{"name":"Released","$type":"StateBundleElement"},"name":"State","$type":"StateIssueCustomField"},{"value":null,"name":"Assignee","$type":"SingleUserIssueCustomField"},{"value":null,"name":"Priority","$type":"SingleEnumIssueCustomField"},{"value":{"name":"Internal tooling","$type":"EnumBundleElement"},"name":"Category","$type":"SingleEnumIssueCustomField"},{"value":[],"name":"Customer","$type":"MultiEnumIssueCustomField"},{"value":null,"name":"Customer Funding","$type":"SingleEnumIssueCustomField"},{"value":null,"name":"Product Stream","$type":"SingleEnumIssueCustomField"},{"value":null,"name":"Estimation","$type":"PeriodIssueCustomField"},{"value":{"$type":"PeriodValue"},"name":"Spent time","$type":"PeriodIssueCustomField"},{"value":null,"name":"Due Date","$type":"DateIssueCustomField"},{"value":[],"name":"Affected version","$type":"MultiVersionIssueCustomField"},{"value":{"name":"TEST2","$type":"VersionBundleElement"},"name":"Available in version","$type":"SingleVersionIssueCustomField"},{"value":null,"name":"SlackAlertSent","$type":"SimpleIssueCustomField"},{"value":13.0,"name":"Dev costs","$type":"SimpleIssueCustomField"}],"id":"2-25820","$type":"Issue"}
|
||||||
|
|
||||||
|
println!("YouTrack issue {issue_id} updated."); |
||||||
|
|
||||||
|
debug!("Response to request to edit issue: {resp:?}"); |
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[cfg(test)] |
||||||
|
mod tests { |
||||||
|
use super::YouTrackClient; |
||||||
|
use crate::config::{ENV_YOUTRACK_TOKEN, ENV_YOUTRACK_URL}; |
||||||
|
use log::{LevelFilter, debug}; |
||||||
|
use serde_json::Value; |
||||||
|
|
||||||
|
// #[test] // Disabled
|
||||||
|
fn test_youtrack_communication() { |
||||||
|
simple_logging::log_to_stderr(LevelFilter::Debug); |
||||||
|
|
||||||
|
let url = dotenv::var(ENV_YOUTRACK_URL).expect("Missing youtrack URL from env"); |
||||||
|
let token = dotenv::var(ENV_YOUTRACK_TOKEN).expect("Missing youtrack token from env"); |
||||||
|
|
||||||
|
// this must match the config in the connected youtrack
|
||||||
|
let issue_id = "SW-4739"; |
||||||
|
let version_field_name = "Available in version"; |
||||||
|
let target_state_name = "Released"; |
||||||
|
let version_name = "TEST2"; |
||||||
|
|
||||||
|
let mut client = YouTrackClient::new(url, &token).unwrap(); |
||||||
|
|
||||||
|
let project_id = client.find_project_id(issue_id).unwrap(); |
||||||
|
|
||||||
|
debug!("Found YouTrack project ID = {project_id}"); |
||||||
|
|
||||||
|
let date = chrono::Utc::now(); |
||||||
|
|
||||||
|
client |
||||||
|
.ensure_version_exists_in_project( |
||||||
|
&project_id, |
||||||
|
version_field_name, |
||||||
|
version_name, |
||||||
|
Some(date), |
||||||
|
) |
||||||
|
.unwrap(); |
||||||
|
|
||||||
|
client |
||||||
|
.set_issue_version_and_state( |
||||||
|
issue_id, |
||||||
|
version_field_name, |
||||||
|
version_name, |
||||||
|
target_state_name, |
||||||
|
) |
||||||
|
.unwrap(); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,11 +0,0 @@ |
|||||||
//! Youtrack integration (mark issues as Released when packing to changelog)
|
|
||||||
|
|
||||||
#[cfg(test)] |
|
||||||
mod tests { |
|
||||||
#[test] |
|
||||||
fn test_youtrack_communication() { |
|
||||||
let token = ""; |
|
||||||
|
|
||||||
|
|
||||||
} |
|
||||||
} |
|
||||||
Loading…
Reference in new issue