Skip to main content

torii_lib/platforms/azure/
issue.rs

1//! Azure DevOps — issue client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::issue::*;
5use reqwest::blocking::Client;
6
7pub struct AzureIssueClient {
8    token: String,
9}
10
11impl AzureIssueClient {
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 `Work Items (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
23    fn auth(&self) -> String {
24        use base64::Engine;
25        let b64 = base64::engine::general_purpose::STANDARD.encode(format!(":{}", self.token));
26        format!("Basic {}", b64)
27    }
28}
29
30impl IssueClient for AzureIssueClient {
31    fn list(&self, owner: &str, _repo: &str, state: &str) -> Result<Vec<Issue>> {
32        // Azure Issues are project-scoped, not repo-scoped — we ignore
33        // `repo` here. The WIQL query filters by State.
34        let (org, project) = crate::platforms::pr::split_azure_owner(owner)?;
35        let state_filter = match state {
36            "open" => {
37                r#"[System.State] <> 'Closed' AND [System.State] <> 'Resolved' AND [System.State] <> 'Done' AND [System.State] <> 'Removed'"#
38            }
39            "closed" => {
40                r#"([System.State] = 'Closed' OR [System.State] = 'Resolved' OR [System.State] = 'Done')"#
41            }
42            _ => "[System.Id] > 0", // dummy always-true
43        };
44        let query = format!(
45            "SELECT [System.Id] FROM workitems WHERE [System.TeamProject] = '{}' AND {} ORDER BY [System.Id] DESC",
46            project, state_filter
47        );
48
49        // Step 1: WIQL query → list of IDs.
50        let wiql_url = format!(
51            "https://dev.azure.com/{}/{}/_apis/wit/wiql?api-version=7.0&$top=50",
52            org, project
53        );
54        let wiql_req = self
55            .client()
56            .post(&wiql_url)
57            .header("Authorization", self.auth())
58            .header("Accept", "application/json")
59            .json(&serde_json::json!({ "query": query }));
60        let wiql_json = crate::http::send_json(wiql_req, "Azure WIQL")?;
61        let ids: Vec<u64> = wiql_json["workItems"]
62            .as_array()
63            .map(|arr| arr.iter().filter_map(|v| v["id"].as_u64()).collect())
64            .unwrap_or_default();
65        if ids.is_empty() {
66            return Ok(vec![]);
67        }
68
69        // Step 2: batch GET work items by id.
70        let ids_csv = ids
71            .iter()
72            .map(|i| i.to_string())
73            .collect::<Vec<_>>()
74            .join(",");
75        let fields = "System.Id,System.Title,System.Description,System.State,\
76                      System.CreatedBy,System.CreatedDate,System.AssignedTo,System.Tags";
77        let wi_url = format!(
78            "https://dev.azure.com/{}/_apis/wit/workitems?ids={}&fields={}&api-version=7.0",
79            org, ids_csv, fields
80        );
81        let wi_req = self
82            .client()
83            .get(&wi_url)
84            .header("Authorization", self.auth())
85            .header("Accept", "application/json");
86        let wi_json = crate::http::send_json(wi_req, "Azure get work items")?;
87        let arr = wi_json["value"]
88            .as_array()
89            .ok_or_else(|| ToriiError::MalformedResponse {
90                provider: "azure".into(),
91                message: format!("Azure returned no `value` array. Body: {}", wi_json),
92            })?;
93        let org_for_url = org.clone();
94        Ok(arr
95            .iter()
96            .filter_map(|v| parse_azure_work_item(v, &org_for_url).ok())
97            .collect())
98    }
99
100    fn create(&self, owner: &str, _repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
101        let (org, project) = crate::platforms::pr::split_azure_owner(owner)?;
102        // POST body is JSON-Patch — the Content-Type matters.
103        let mut ops = vec![
104            serde_json::json!({ "op": "add", "path": "/fields/System.Title", "value": opts.title }),
105        ];
106        if let Some(b) = opts.body {
107            ops.push(serde_json::json!({ "op": "add", "path": "/fields/System.Description", "value": b }));
108        }
109        let url = format!(
110            "https://dev.azure.com/{}/{}/_apis/wit/workitems/$Issue?api-version=7.0",
111            org, project
112        );
113        let req = self
114            .client()
115            .post(&url)
116            .header("Authorization", self.auth())
117            .header("Content-Type", "application/json-patch+json")
118            .header("Accept", "application/json")
119            .json(&serde_json::Value::Array(ops));
120        let json = crate::http::send_json(req, "Azure create work item")?;
121        parse_azure_work_item(&json, &org)
122    }
123
124    fn close(&self, owner: &str, _repo: &str, number: u64) -> Result<()> {
125        let (org, _project) = crate::platforms::pr::split_azure_owner(owner)?;
126        let url = format!(
127            "https://dev.azure.com/{}/_apis/wit/workitems/{}?api-version=7.0",
128            org, number
129        );
130        let body = serde_json::json!([
131            { "op": "add", "path": "/fields/System.State", "value": "Closed" }
132        ]);
133        let req = self
134            .client()
135            .patch(&url)
136            .header("Authorization", self.auth())
137            .header("Content-Type", "application/json-patch+json")
138            .header("Accept", "application/json")
139            .json(&body);
140        crate::http::send_empty(req, "Azure close work item")
141    }
142
143    fn comment(&self, owner: &str, _repo: &str, number: u64, body: &str) -> Result<()> {
144        let (org, project) = crate::platforms::pr::split_azure_owner(owner)?;
145        // Comments endpoint is still preview as of api-version 7.1.
146        let url = format!(
147            "https://dev.azure.com/{}/{}/_apis/wit/workitems/{}/comments?api-version=7.1-preview.3",
148            org, project, number
149        );
150        let payload = serde_json::json!({ "text": body });
151        let req = self
152            .client()
153            .post(&url)
154            .header("Authorization", self.auth())
155            .header("Accept", "application/json")
156            .json(&payload);
157        crate::http::send_empty(req, "Azure comment work item")
158    }
159}
160
161fn parse_azure_work_item(json: &serde_json::Value, org: &str) -> Result<Issue> {
162    let id = json["id"].as_u64().unwrap_or(0);
163    let fields = &json["fields"];
164    let state_raw = fields["System.State"].as_str().unwrap_or("");
165    let project = fields["System.TeamProject"].as_str().unwrap_or("");
166    Ok(Issue {
167        number: id,
168        title: fields["System.Title"].as_str().unwrap_or("").to_string(),
169        body: fields["System.Description"].as_str().map(String::from),
170        state: match state_raw {
171            "New" | "Active" | "Open" | "Approved" | "To Do" | "Committed" | "In Progress" => {
172                "open".to_string()
173            }
174            "Closed" | "Resolved" | "Done" | "Removed" => "closed".to_string(),
175            other => other.to_string(),
176        },
177        author: fields["System.CreatedBy"]["displayName"]
178            .as_str()
179            .or_else(|| fields["System.CreatedBy"].as_str())
180            .unwrap_or("")
181            .to_string(),
182        url: if !project.is_empty() {
183            format!(
184                "https://dev.azure.com/{}/{}/_workitems/edit/{}",
185                org, project, id
186            )
187        } else {
188            json["url"].as_str().unwrap_or("").to_string()
189        },
190        labels: fields["System.Tags"]
191            .as_str()
192            .map(|s| {
193                s.split(';')
194                    .map(|t| t.trim().to_string())
195                    .filter(|t| !t.is_empty())
196                    .collect()
197            })
198            .unwrap_or_default(),
199        assignees: fields["System.AssignedTo"]["displayName"]
200            .as_str()
201            .or_else(|| fields["System.AssignedTo"].as_str())
202            .map(|s| vec![s.to_string()])
203            .unwrap_or_default(),
204        created_at: fields["System.CreatedDate"]
205            .as_str()
206            .unwrap_or("")
207            .to_string(),
208        comments: 0,
209    })
210}
211
212// ── Factory ───────────────────────────────────────────────────────────────────
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn parse_azure_work_item_full() {
220        let json = serde_json::json!({
221            "id": 7u64,
222            "fields": {
223                "System.Title": "Crash on startup",
224                "System.Description": "<div>boom</div>",
225                "System.State": "Active",
226                "System.TeamProject": "proj",
227                "System.CreatedBy": { "displayName": "Jane" },
228                "System.AssignedTo": { "displayName": "Bob" },
229                "System.Tags": "bug; ui ; ",
230                "System.CreatedDate": "2026-01-01T00:00:00Z",
231            },
232        });
233        let issue = parse_azure_work_item(&json, "org").unwrap();
234        assert_eq!(issue.number, 7);
235        assert_eq!(issue.title, "Crash on startup");
236        assert_eq!(issue.body.as_deref(), Some("<div>boom</div>"));
237        assert_eq!(issue.state, "open");
238        assert_eq!(issue.author, "Jane");
239        assert_eq!(
240            issue.url,
241            "https://dev.azure.com/org/proj/_workitems/edit/7"
242        );
243        // Tags split on `;`, trimmed, empties dropped.
244        assert_eq!(issue.labels, vec!["bug".to_string(), "ui".to_string()]);
245        assert_eq!(issue.assignees, vec!["Bob".to_string()]);
246        assert_eq!(issue.created_at, "2026-01-01T00:00:00Z");
247        assert_eq!(issue.comments, 0);
248    }
249
250    #[test]
251    fn parse_azure_work_item_state_mapping() {
252        for (az, ours) in [
253            ("New", "open"),
254            ("Active", "open"),
255            ("To Do", "open"),
256            ("In Progress", "open"),
257            ("Closed", "closed"),
258            ("Resolved", "closed"),
259            ("Done", "closed"),
260            ("Removed", "closed"),
261            ("Blocked", "Blocked"), // unknown states pass through raw
262        ] {
263            let json = serde_json::json!({ "fields": { "System.State": az } });
264            assert_eq!(parse_azure_work_item(&json, "org").unwrap().state, ours);
265        }
266    }
267
268    #[test]
269    fn parse_azure_work_item_url_falls_back_without_project() {
270        let json = serde_json::json!({
271            "id": 3u64,
272            "url": "https://dev.azure.com/org/_apis/wit/workItems/3",
273            "fields": {},
274        });
275        let issue = parse_azure_work_item(&json, "org").unwrap();
276        assert_eq!(issue.url, "https://dev.azure.com/org/_apis/wit/workItems/3");
277    }
278
279    #[test]
280    fn parse_azure_work_item_identity_string_fallbacks() {
281        // Older API shapes return identities as plain strings rather
282        // than `{ displayName }` objects.
283        let json = serde_json::json!({
284            "fields": {
285                "System.CreatedBy": "jane@example.com",
286                "System.AssignedTo": "bob@example.com",
287            },
288        });
289        let issue = parse_azure_work_item(&json, "org").unwrap();
290        assert_eq!(issue.author, "jane@example.com");
291        assert_eq!(issue.assignees, vec!["bob@example.com".to_string()]);
292    }
293
294    #[test]
295    fn parse_azure_work_item_minimal_defaults() {
296        let json = serde_json::json!({});
297        let issue = parse_azure_work_item(&json, "org").unwrap();
298        assert_eq!(issue.number, 0);
299        assert_eq!(issue.title, "");
300        assert_eq!(issue.body, None);
301        assert!(issue.labels.is_empty());
302        assert!(issue.assignees.is_empty());
303        assert_eq!(issue.created_at, "");
304    }
305}