torii_lib/platforms/azure/
release.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::release::*;
5use reqwest::blocking::Client;
6
7pub struct AzureReleaseClient {
8 token: String,
9}
10
11impl AzureReleaseClient {
12 pub fn new() -> Result<Self> {
13 let token = crate::auth::resolve_token("azure", ".").value
14 .ok_or_else(|| ToriiError::Auth { provider: "azure".into(), message: "Azure DevOps PAT not found. Create at https://dev.azure.com/{org}/_usersSettings/tokens \
15 with `Release (read/write)` scope, then: torii auth set azure YOUR_PAT".to_string() })?;
16 Ok(Self { token })
17 }
18
19 fn client(&self) -> Client {
20 crate::http::make_client()
21 }
22 fn auth(&self) -> String {
23 use base64::Engine;
24 let b64 = base64::engine::general_purpose::STANDARD.encode(format!(":{}", self.token));
25 format!("Basic {}", b64)
26 }
27}
28
29impl ReleaseClient for AzureReleaseClient {
30 fn list(&self, owner: &str, _repo: &str, limit: usize) -> Result<Vec<Release>> {
31 let (org, project) = crate::platforms::pr::split_azure_owner(owner)?;
33 let url = format!(
34 "https://vsrm.dev.azure.com/{}/{}/_apis/release/releases?api-version=7.0&$top={}",
35 org,
36 project,
37 limit.clamp(1, 100)
38 );
39 let req = self.client().get(&url).header("Authorization", self.auth());
40 let json = crate::http::send_json(req, &format!("Azure (url: {})", url))?;
41 let arr = json["value"]
42 .as_array()
43 .ok_or_else(|| ToriiError::MalformedResponse {
44 provider: "azure".into(),
45 message: format!("Azure returned no `value` array. Body: {}", json),
46 })?;
47 let org_clone = org.clone();
48 let project_clone = project.clone();
49 arr.iter()
50 .map(|v| parse_azure_release(v, &org_clone, &project_clone))
51 .collect()
52 }
53
54 fn get(&self, owner: &str, _repo: &str, tag_or_id: &str) -> Result<Release> {
55 let (org, project) = crate::platforms::pr::split_azure_owner(owner)?;
58 let id = if tag_or_id.parse::<u64>().is_ok() {
59 tag_or_id.to_string()
60 } else {
61 let list_url = format!(
63 "https://vsrm.dev.azure.com/{}/{}/_apis/release/releases?api-version=7.0&$top=200",
64 org, project
65 );
66 let lookup_req = self
67 .client()
68 .get(&list_url)
69 .header("Authorization", self.auth());
70 let lookup_json = crate::http::send_json(lookup_req, "Azure lookup release by name")?;
71 lookup_json["value"]
72 .as_array()
73 .and_then(|arr| arr.iter().find(|v| v["name"].as_str() == Some(tag_or_id)))
74 .and_then(|v| v["id"].as_u64().map(|n| n.to_string()))
75 .ok_or_else(|| {
76 ToriiError::InvalidConfig(format!(
77 "Azure: no release named '{}' in project {}",
78 tag_or_id, project
79 ))
80 })?
81 };
82 let url = format!(
83 "https://vsrm.dev.azure.com/{}/{}/_apis/release/releases/{}?api-version=7.0",
84 org, project, id
85 );
86 let req = self.client().get(&url).header("Authorization", self.auth());
87 let json = crate::http::send_json(req, &format!("Azure release #{}", id))?;
88 parse_azure_release(&json, &org, &project)
89 }
90
91 fn edit(
92 &self,
93 _o: &str,
94 _r: &str,
95 _tag: &str,
96 _n: Option<&str>,
97 _d: Option<&str>,
98 ) -> Result<()> {
99 Err(ToriiError::Unsupported(
100 "Azure Releases doesn't expose a mutation API for already-created releases — \
101 metadata is derived from the release definition (template). Edit the definition \
102 in the web UI; the next release will pick up the new metadata."
103 .to_string(),
104 ))
105 }
106
107 fn delete(&self, owner: &str, _repo: &str, tag_or_id: &str) -> Result<()> {
108 let release = self.get(owner, "", tag_or_id)?;
109 let id = release.id.ok_or_else(|| ToriiError::MalformedResponse {
110 provider: "azure".into(),
111 message: "Azure release missing id; cannot delete".to_string(),
112 })?;
113 let (org, project) = crate::platforms::pr::split_azure_owner(owner)?;
114 let url = format!(
115 "https://vsrm.dev.azure.com/{}/{}/_apis/release/releases/{}?api-version=7.0",
116 org, project, id
117 );
118 let req = self
119 .client()
120 .delete(&url)
121 .header("Authorization", self.auth());
122 crate::http::send_empty(req, "Azure delete release")
123 }
124}
125
126fn parse_azure_release(v: &serde_json::Value, org: &str, project: &str) -> Result<Release> {
127 let id = v["id"].as_u64().map(|n| n.to_string());
128 let name = v["name"].as_str().unwrap_or("").to_string();
129 Ok(Release {
130 tag: name.clone(),
133 name,
134 description: v["description"].as_str().unwrap_or("").to_string(),
135 created_at: v["createdOn"].as_str().unwrap_or("").to_string(),
136 web_url: id
137 .as_ref()
138 .map(|i| {
139 format!(
140 "https://dev.azure.com/{}/{}/_releaseProgress?releaseId={}",
141 org, project, i
142 )
143 })
144 .unwrap_or_default(),
145 id,
146 })
147}
148
149#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn parse_azure_release_full() {
159 let v = serde_json::json!({
160 "id": 17u64,
161 "name": "Release-3",
162 "description": "Deploy to prod",
163 "createdOn": "2026-02-03T04:05:06Z",
164 });
165 let r = parse_azure_release(&v, "org", "proj").unwrap();
166 assert_eq!(r.id.as_deref(), Some("17"));
167 assert_eq!(r.tag, "Release-3");
169 assert_eq!(r.name, "Release-3");
170 assert_eq!(r.description, "Deploy to prod");
171 assert_eq!(r.created_at, "2026-02-03T04:05:06Z");
172 assert_eq!(
173 r.web_url,
174 "https://dev.azure.com/org/proj/_releaseProgress?releaseId=17"
175 );
176 }
177
178 #[test]
179 fn parse_azure_release_missing_id_has_empty_web_url() {
180 let v = serde_json::json!({ "name": "Release-4" });
181 let r = parse_azure_release(&v, "org", "proj").unwrap();
182 assert_eq!(r.id, None);
183 assert_eq!(r.web_url, "");
184 assert_eq!(r.description, "");
185 assert_eq!(r.created_at, "");
186 }
187
188 #[test]
189 fn parse_azure_release_minimal_defaults() {
190 let v = serde_json::json!({});
191 let r = parse_azure_release(&v, "org", "proj").unwrap();
192 assert_eq!(r.tag, "");
193 assert_eq!(r.name, "");
194 assert_eq!(r.id, None);
195 }
196}