implement youtrack client

master
Ondřej Hruška 3 months ago
parent c7e9df60c9
commit 753cbebd48
  1. 1
      .gitignore
  2. 168
      Cargo.lock
  3. 12
      Cargo.toml
  4. 4
      src/config.rs
  5. 3
      src/integrations/mod.rs
  6. 370
      src/integrations/youtrack.rs
  7. 3
      src/main.rs
  8. 11
      src/youtrack.rs

1
.gitignore vendored

@ -1,2 +1,3 @@
/target
.idea/
.env

168
Cargo.lock generated

@ -218,13 +218,17 @@ dependencies = [
"chrono",
"clap",
"colored",
"dotenv",
"faccess",
"indexmap",
"inquire",
"json_dotpath",
"log",
"regex",
"reqwest",
"serde",
"serde_json",
"simple-logging",
"smart-default",
"toml",
]
@ -244,6 +248,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "convert_case"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -262,15 +275,17 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossterm"
version = "0.28.1"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.9.4",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix 0.38.44",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
@ -285,6 +300,27 @@ dependencies = [
"winapi",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -296,6 +332,21 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
dependencies = [
"litrs",
]
[[package]]
name = "dotenv"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@ -762,9 +813,9 @@ dependencies = [
[[package]]
name = "inquire"
version = "0.8.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced"
checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a"
dependencies = [
"bitflags 2.9.4",
"crossterm",
@ -825,16 +876,28 @@ dependencies = [
]
[[package]]
name = "libc"
version = "0.2.175"
name = "json_dotpath"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
checksum = "dbdcfef3cf5591f0cef62da413ae795e3d1f5a00936ccec0b2071499a32efd1a"
dependencies = [
"serde",
"serde_derive",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "linux-raw-sys"
@ -848,6 +911,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "litrs"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed"
[[package]]
name = "lock_api"
version = "0.4.13"
@ -1012,7 +1081,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.17",
"smallvec",
"windows-targets 0.52.6",
]
@ -1082,7 +1151,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.16",
"tokio",
"tracing",
"web-time",
@ -1103,7 +1172,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"thiserror 2.0.16",
"tinyvec",
"tracing",
"web-time",
@ -1167,6 +1236,12 @@ dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "redox_syscall"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
[[package]]
name = "redox_syscall"
version = "0.5.17"
@ -1277,19 +1352,6 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.9.4",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.0.8"
@ -1299,7 +1361,7 @@ dependencies = [
"bitflags 2.9.4",
"errno",
"libc",
"linux-raw-sys 0.9.4",
"linux-raw-sys",
"windows-sys 0.60.2",
]
@ -1477,6 +1539,17 @@ dependencies = [
"libc",
]
[[package]]
name = "simple-logging"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00d48e85675326bb182a2286ea7c1a0b264333ae10f27a937a72be08628b542"
dependencies = [
"lazy_static",
"log",
"thread-id",
]
[[package]]
name = "slab"
version = "0.4.11"
@ -1589,17 +1662,37 @@ dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.8",
"rustix",
"windows-sys 0.60.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.16",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@ -1613,6 +1706,17 @@ dependencies = [
"syn",
]
[[package]]
name = "thread-id"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fbf4c9d56b320106cd64fd024dadfa0be7cb4706725fc44a7d7ce952d820c1"
dependencies = [
"libc",
"redox_syscall 0.1.57",
"winapi",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@ -1820,9 +1924,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "untrusted"

@ -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,9 +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"] }
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], optional = true }
# For integrations
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"] } #, optional = true
json_dotpath = "1.1.0"
dotenv = "0.15.0"
[features]
youtrack = ["reqwest"]
[dev-dependencies]
simple-logging = "2"

@ -13,6 +13,10 @@ pub type EntryName = String;
pub const CONFIG_FILE_TEMPLATE: &str = include_str!("assets/config_file_template.toml");
pub const ENV_YOUTRACK_URL : &str = "CLPACK_YOUTRACK_URL";
pub const ENV_YOUTRACK_TOKEN : &str = "CLPACK_YOUTRACK_TOKEN";
#[cfg(test)]
#[test]
fn test_template_file() {

@ -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();
}
}

@ -24,8 +24,7 @@ mod store;
mod utils;
#[cfg(feature = "youtrack")]
mod youtrack;
mod integrations;
#[derive(Debug)]
pub struct AppContext {

@ -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…
Cancel
Save