Skip to main content

torii_lib/platforms/azure/
release.rs

1//! Azure DevOps — release client.
2
3use 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        // Azure Releases are project-scoped; the `_repo` arg is ignored.
32        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        // Azure releases are identified by numeric id, not tag. Callers
56        // can pass either — if it's not numeric we try a name lookup.
57        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            // Best-effort name lookup via $filter.
62            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        // Azure Releases don't tie to a git tag — we surface the
131        // release name as `tag` so the CLI display stays consistent.
132        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// ============================================================================
150// Factory
151// ============================================================================
152
153#[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        // Azure releases have no git tag — the name doubles as both.
168        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}