1use crate::error::{Result, ToriiError};
4use crate::platforms::pr::*;
5use reqwest::blocking::Client;
6
7pub struct AzurePrClient {
8 token: String,
9}
10
11impl AzurePrClient {
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 one at https://dev.azure.com/{org}/_usersSettings/tokens \
15 with scopes `Code (read/write)`, `Build (read/execute)`, `Work Items (read/write)`, \
16 `Release (read/write)` and run: torii auth set azure YOUR_PAT".to_string() })?;
17 Ok(Self { token })
18 }
19
20 fn client(&self) -> Client {
21 crate::http::make_client()
22 }
23
24 fn auth(&self) -> String {
27 use base64::Engine;
28 let b64 = base64::engine::general_purpose::STANDARD.encode(format!(":{}", self.token));
29 format!("Basic {}", b64)
30 }
31}
32
33pub(crate) fn split_azure_owner(owner: &str) -> Result<(String, String)> {
37 let mut parts = owner.splitn(2, '/');
38 let org =
39 parts
40 .next()
41 .filter(|s| !s.is_empty())
42 .ok_or_else(|| ToriiError::MalformedResponse {
43 provider: "azure".into(),
44 message: format!("Azure: cannot parse organisation from owner '{}'", owner),
45 })?;
46 let project = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
47 ToriiError::InvalidConfig(format!(
48 "Azure: cannot parse project from owner '{}' — \
49 expected 'org/project' (URL parser should populate both)",
50 owner
51 ))
52 })?;
53 Ok((org.to_string(), project.to_string()))
54}
55
56impl PrClient for AzurePrClient {
57 fn create(&self, owner: &str, repo: &str, opts: CreatePrOptions) -> Result<PullRequest> {
58 let (org, project) = split_azure_owner(owner)?;
59 let url = format!(
60 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullrequests?api-version=7.0",
61 org, project, repo
62 );
63 let body = serde_json::json!({
65 "title": opts.title,
66 "description": opts.body.unwrap_or_default(),
67 "sourceRefName": format!("refs/heads/{}", opts.head),
68 "targetRefName": format!("refs/heads/{}", opts.base),
69 "isDraft": opts.draft,
70 });
71 let req = self
72 .client()
73 .post(&url)
74 .header("Authorization", self.auth())
75 .header("Accept", "application/json")
76 .json(&body);
77 let json = crate::http::send_json(req, "Azure create PR")?;
78 parse_azure_pr(&json)
79 }
80
81 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<PullRequest>> {
82 let (org, project) = split_azure_owner(owner)?;
83 let az_state = match state {
84 "open" => "active",
85 "closed" => "abandoned",
86 "merged" => "completed",
87 _ => "active",
88 };
89 let url = format!(
90 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullrequests\
91 ?searchCriteria.status={}&$top=50&api-version=7.0",
92 org, project, repo, az_state
93 );
94 let req = self
95 .client()
96 .get(&url)
97 .header("Authorization", self.auth())
98 .header("Accept", "application/json");
99 let json = crate::http::send_json(req, &format!("Azure (url: {})", url))?;
100 let arr = json["value"]
101 .as_array()
102 .ok_or_else(|| ToriiError::MalformedResponse {
103 provider: "azure".into(),
104 message: format!("Azure returned no `value` array. Body: {}", json),
105 })?;
106 arr.iter().map(parse_azure_pr).collect()
107 }
108
109 fn get(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest> {
110 let (org, project) = split_azure_owner(owner)?;
111 let url = format!(
112 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullrequests/{}?api-version=7.0",
113 org, project, repo, number
114 );
115 let req = self
116 .client()
117 .get(&url)
118 .header("Authorization", self.auth())
119 .header("Accept", "application/json");
120 let json = crate::http::send_json(req, &format!("Azure PR #{}", number))?;
121 parse_azure_pr(&json)
122 }
123
124 fn merge(&self, owner: &str, repo: &str, number: u64, method: MergeMethod) -> Result<()> {
125 let (org, project) = split_azure_owner(owner)?;
126 let url = format!(
127 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullrequests/{}?api-version=7.0",
128 org, project, repo, number
129 );
130 let strategy = match method {
134 MergeMethod::Merge => "noFastForward",
135 MergeMethod::Squash => "squash",
136 MergeMethod::Rebase => "rebase",
137 };
138 let body = serde_json::json!({
139 "status": "completed",
140 "completionOptions": { "mergeStrategy": strategy }
141 });
142 let req = self
143 .client()
144 .patch(&url)
145 .header("Authorization", self.auth())
146 .header("Accept", "application/json")
147 .json(&body);
148 crate::http::send_empty(req, "Azure merge PR")
149 }
150
151 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
152 let (org, project) = split_azure_owner(owner)?;
153 let url = format!(
154 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullrequests/{}?api-version=7.0",
155 org, project, repo, number
156 );
157 let body = serde_json::json!({ "status": "abandoned" });
158 let req = self
159 .client()
160 .patch(&url)
161 .header("Authorization", self.auth())
162 .header("Accept", "application/json")
163 .json(&body);
164 crate::http::send_empty(req, "Azure abandon PR")
165 }
166
167 fn update(&self, owner: &str, repo: &str, number: u64, opts: UpdatePrOptions) -> Result<()> {
168 let (org, project) = split_azure_owner(owner)?;
169 let url = format!(
170 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullrequests/{}?api-version=7.0",
171 org, project, repo, number
172 );
173 let mut body = serde_json::Map::new();
174 if let Some(t) = opts.title {
175 body.insert("title".into(), serde_json::Value::String(t));
176 }
177 if let Some(b) = opts.body {
178 body.insert("description".into(), serde_json::Value::String(b));
179 }
180 if let Some(base) = opts.base {
181 body.insert(
182 "targetRefName".into(),
183 serde_json::Value::String(format!("refs/heads/{}", base)),
184 );
185 }
186 if body.is_empty() {
187 return Ok(());
188 }
189 let req = self
190 .client()
191 .patch(&url)
192 .header("Authorization", self.auth())
193 .header("Accept", "application/json")
194 .json(&serde_json::Value::Object(body));
195 crate::http::send_empty(req, "Azure update PR")
196 }
197
198 fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<()> {
199 let (org, project) = split_azure_owner(owner)?;
203 let lookup_url = format!(
204 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/refs?filter=heads/{}&api-version=7.0",
205 org, project, repo, branch
206 );
207 let lookup_req = self
208 .client()
209 .get(&lookup_url)
210 .header("Authorization", self.auth())
211 .header("Accept", "application/json");
212 let lookup_json = crate::http::send_json(lookup_req, "Azure lookup ref")?;
213 let old_oid = lookup_json["value"][0]["objectId"]
214 .as_str()
215 .ok_or_else(|| {
216 ToriiError::BranchNotFound(format!(
217 "Azure: branch '{}' not found on remote",
218 branch
219 ))
220 })?;
221
222 let update_url = format!(
223 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/refs?api-version=7.0",
224 org, project, repo
225 );
226 let body = serde_json::json!([{
227 "name": format!("refs/heads/{}", branch),
228 "oldObjectId": old_oid,
229 "newObjectId": "0000000000000000000000000000000000000000",
230 }]);
231 let req = self
232 .client()
233 .post(&update_url)
234 .header("Authorization", self.auth())
235 .header("Accept", "application/json")
236 .json(&body);
237 crate::http::send_empty(req, "Azure delete branch")
238 }
239
240 fn checkout_branch(&self, pr: &PullRequest) -> String {
241 pr.head.clone()
242 }
243}
244
245fn parse_azure_pr(json: &serde_json::Value) -> Result<PullRequest> {
246 fn strip_ref(s: &str) -> String {
249 s.trim_start_matches("refs/heads/").to_string()
250 }
251 Ok(PullRequest {
252 number: json["pullRequestId"].as_u64().unwrap_or(0),
253 title: json["title"].as_str().unwrap_or("").to_string(),
254 body: json["description"].as_str().map(String::from),
255 state: match json["status"].as_str().unwrap_or("") {
256 "active" => "open".to_string(),
257 "abandoned" => "closed".to_string(),
258 "completed" => "merged".to_string(),
259 other => other.to_string(),
260 },
261 head: strip_ref(json["sourceRefName"].as_str().unwrap_or("")),
262 base: strip_ref(json["targetRefName"].as_str().unwrap_or("")),
263 author: json["createdBy"]["displayName"]
264 .as_str()
265 .or_else(|| json["createdBy"]["uniqueName"].as_str())
266 .unwrap_or("")
267 .to_string(),
268 url: json["url"].as_str().unwrap_or("").to_string(),
269 draft: json["isDraft"].as_bool().unwrap_or(false),
270 mergeable: json["mergeStatus"].as_str().map(|s| s == "succeeded"),
271 created_at: json["creationDate"].as_str().unwrap_or("").to_string(),
272 })
273}
274
275pub(crate) fn parse_azure_url(url: &str) -> Option<(String, String, String)> {
282 if let Some(rest) = url.strip_prefix("git@ssh.dev.azure.com:") {
284 let rest = rest.trim_start_matches("v3/").trim_end_matches(".git");
285 let mut parts = rest.splitn(3, '/');
286 let org = parts.next()?.to_string();
287 let project = parts.next()?.to_string();
288 let repo = parts.next()?.to_string();
289 return Some((org, project, repo));
290 }
291 if let Some(after_scheme) = url.split("://").nth(1) {
293 if let Some(host_end) = after_scheme.find('/') {
294 let host = &after_scheme[..host_end];
295 let path = &after_scheme[host_end + 1..].trim_end_matches(".git");
296 if let Some(org) = host.strip_suffix(".visualstudio.com") {
297 let mut parts = path.splitn(3, '/');
299 let project = parts.next()?.to_string();
300 let _git_marker = parts.next()?;
301 let repo = parts.next()?.to_string();
302 return Some((org.to_string(), project, repo));
303 }
304 let host = host.split('@').last().unwrap_or(host);
308 if host == "dev.azure.com" {
309 let mut parts = path.splitn(4, '/');
310 let org = parts.next()?.to_string();
311 let project = parts.next()?.to_string();
312 let _git_marker = parts.next()?;
313 let repo = parts.next()?.to_string();
314 return Some((org, project, repo));
315 }
316 }
317 }
318 None
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
328 fn split_azure_owner_org_project_ok() {
329 let (org, project) = split_azure_owner("myorg/myproject").unwrap();
330 assert_eq!(org, "myorg");
331 assert_eq!(project, "myproject");
332 }
333
334 #[test]
335 fn split_azure_owner_missing_project_is_err() {
336 assert!(split_azure_owner("soloorg").is_err());
337 }
338
339 #[test]
340 fn split_azure_owner_empty_project_is_err() {
341 assert!(split_azure_owner("org/").is_err());
342 }
343
344 #[test]
345 fn split_azure_owner_empty_org_is_err() {
346 assert!(split_azure_owner("/project").is_err());
347 }
348
349 #[test]
350 fn split_azure_owner_splits_only_on_first_slash() {
351 let (org, project) = split_azure_owner("org/team/project").unwrap();
354 assert_eq!(org, "org");
355 assert_eq!(project, "team/project");
356 }
357
358 #[test]
361 fn parse_azure_pr_full() {
362 let json = serde_json::json!({
363 "pullRequestId": 42u64,
364 "title": "Add feature",
365 "description": "Long description",
366 "status": "active",
367 "sourceRefName": "refs/heads/feature/x",
368 "targetRefName": "refs/heads/main",
369 "createdBy": { "displayName": "Jane Doe", "uniqueName": "jane@example.com" },
370 "url": "https://dev.azure.com/org/proj/_apis/git/repositories/repo/pullRequests/42",
371 "isDraft": true,
372 "mergeStatus": "succeeded",
373 "creationDate": "2026-01-02T03:04:05Z",
374 });
375 let pr = parse_azure_pr(&json).unwrap();
376 assert_eq!(pr.number, 42);
377 assert_eq!(pr.title, "Add feature");
378 assert_eq!(pr.body.as_deref(), Some("Long description"));
379 assert_eq!(pr.state, "open");
380 assert_eq!(pr.head, "feature/x");
381 assert_eq!(pr.base, "main");
382 assert_eq!(pr.author, "Jane Doe");
383 assert!(pr.draft);
384 assert_eq!(pr.mergeable, Some(true));
385 assert_eq!(pr.created_at, "2026-01-02T03:04:05Z");
386 }
387
388 #[test]
389 fn parse_azure_pr_state_mapping() {
390 for (az, ours) in [
391 ("active", "open"),
392 ("abandoned", "closed"),
393 ("completed", "merged"),
394 ("notSet", "notSet"), ] {
396 let json = serde_json::json!({ "status": az });
397 assert_eq!(parse_azure_pr(&json).unwrap().state, ours);
398 }
399 }
400
401 #[test]
402 fn parse_azure_pr_minimal_defaults() {
403 let json = serde_json::json!({});
404 let pr = parse_azure_pr(&json).unwrap();
405 assert_eq!(pr.number, 0);
406 assert_eq!(pr.title, "");
407 assert_eq!(pr.body, None);
408 assert_eq!(pr.head, "");
409 assert_eq!(pr.author, "");
410 assert!(!pr.draft);
411 assert_eq!(pr.mergeable, None);
412 }
413
414 #[test]
415 fn parse_azure_pr_author_falls_back_to_unique_name() {
416 let json = serde_json::json!({
417 "createdBy": { "uniqueName": "jane@example.com" }
418 });
419 assert_eq!(parse_azure_pr(&json).unwrap().author, "jane@example.com");
420 }
421
422 #[test]
423 fn parse_azure_pr_merge_status_conflicts_is_not_mergeable() {
424 let json = serde_json::json!({ "mergeStatus": "conflicts" });
425 assert_eq!(parse_azure_pr(&json).unwrap().mergeable, Some(false));
426 }
427
428 #[test]
431 fn parse_azure_url_ssh() {
432 assert_eq!(
433 parse_azure_url("git@ssh.dev.azure.com:v3/org/project/repo"),
434 Some(("org".into(), "project".into(), "repo".into()))
435 );
436 }
437
438 #[test]
439 fn parse_azure_url_ssh_strips_git_suffix() {
440 assert_eq!(
441 parse_azure_url("git@ssh.dev.azure.com:v3/org/project/repo.git"),
442 Some(("org".into(), "project".into(), "repo".into()))
443 );
444 }
445
446 #[test]
447 fn parse_azure_url_https_modern() {
448 assert_eq!(
449 parse_azure_url("https://dev.azure.com/org/project/_git/repo"),
450 Some(("org".into(), "project".into(), "repo".into()))
451 );
452 }
453
454 #[test]
455 fn parse_azure_url_https_modern_with_userinfo() {
456 assert_eq!(
457 parse_azure_url("https://org@dev.azure.com/org/project/_git/repo"),
458 Some(("org".into(), "project".into(), "repo".into()))
459 );
460 }
461
462 #[test]
463 fn parse_azure_url_https_legacy_visualstudio() {
464 assert_eq!(
465 parse_azure_url("https://org.visualstudio.com/project/_git/repo"),
466 Some(("org".into(), "project".into(), "repo".into()))
467 );
468 }
469
470 #[test]
471 fn parse_azure_url_non_azure_returns_none() {
472 assert_eq!(parse_azure_url("https://github.com/owner/repo.git"), None);
473 assert_eq!(parse_azure_url("git@github.com:owner/repo.git"), None);
474 assert_eq!(parse_azure_url("not a url"), None);
475 }
476
477 #[test]
478 fn parse_azure_url_incomplete_path_returns_none() {
479 assert_eq!(
481 parse_azure_url("https://dev.azure.com/org/project/_git"),
482 None
483 );
484 assert_eq!(
485 parse_azure_url("git@ssh.dev.azure.com:v3/org/project"),
486 None
487 );
488 }
489}