Skip to main content

torii_lib/platforms/azure/
pr.rs

1//! Azure DevOps — pr client.
2
3use 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    /// Azure PATs go in the password slot with an empty username.
25    /// Equivalent to `Authorization: Basic <base64(":PAT")>`.
26    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
33/// Split the packed `org/project` owner back into its parts. Returns
34/// a clear error if the owner doesn't contain a `/` — that means the
35/// URL parser saw something unexpected.
36pub(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        // Azure expects fully-qualified ref names.
64        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        // Azure merges by transitioning status → "completed" with a
131        // completionOptions block. mergeStrategy: noFastForward (≈ merge
132        // commit) / squash / rebase / rebaseMerge.
133        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        // Azure deletes a ref by POSTing the refUpdates list with the
200        // old object id and an all-zeros new object id. This needs the
201        // current SHA of the ref, which means an extra round-trip.
202        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    // Azure surfaces ref names as `refs/heads/foo` — strip the prefix
247    // so the value matches how every other client reports it.
248    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
275// ============================================================================
276// Factory
277// ============================================================================
278
279/// Extract `(org, project, repo)` from any of the three Azure DevOps
280/// URL shapes. Returns `None` if the URL doesn't match a known shape.
281pub(crate) fn parse_azure_url(url: &str) -> Option<(String, String, String)> {
282    // SSH: git@ssh.dev.azure.com:v3/<org>/<project>/<repo>
283    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    // HTTPS legacy: https://<org>.visualstudio.com/<project>/_git/<repo>
292    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                // path = "<project>/_git/<repo>"
298                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            // HTTPS modern: dev.azure.com/<org>/<project>/_git/<repo>
305            // (host might also include "<org>@dev.azure.com" for legacy
306            // basic-auth-in-URL — strip the userinfo.)
307            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    // ── split_azure_owner ─────────────────────────────────────────────
326
327    #[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        // splitn(2, '/') — anything after the first slash belongs to
352        // the project part verbatim.
353        let (org, project) = split_azure_owner("org/team/project").unwrap();
354        assert_eq!(org, "org");
355        assert_eq!(project, "team/project");
356    }
357
358    // ── parse_azure_pr ────────────────────────────────────────────────
359
360    #[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"), // unknown statuses pass through raw
395        ] {
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    // ── parse_azure_url ───────────────────────────────────────────────
429
430    #[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        // Missing the repo segment after `_git`.
480        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}