torii_lib/platforms/azure/
issue.rs1use 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 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", };
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 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 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 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 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#[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 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"), ] {
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 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}