pipedash_plugin_azure_devops/
plugin.rs1use std::collections::{
2 HashMap,
3 HashSet,
4};
5use std::sync::Arc;
6
7use pipedash_plugin_api::config::{
8 required_value,
9 selected_items,
10};
11use pipedash_plugin_api::pagination::paginate;
12use pipedash_plugin_api::support::{
13 organizations,
14 require_configured,
15 try_buffered,
16 uuid_id,
17 CONCURRENCY,
18};
19use pipedash_plugin_api::*;
20
21use crate::log_dialect::AzureDevOpsLogDialect;
22use crate::{
23 client,
24 config,
25 mapper,
26 metadata,
27 types,
28};
29
30plugin_struct!(
31 AzureDevOpsPlugin,
32 client::AzureDevOpsClient,
33 metadata::create_metadata();
34 organization: String = String::new(),
35 status_source: Option<StatusSource> = None
36);
37
38impl AzureDevOpsPlugin {
39 async fn fetch_project_pipelines(&self, project: &str) -> PluginResult<Vec<Pipeline>> {
40 let definitions = self.client()?.list_definitions(project).await?;
41 Ok(definitions
42 .iter()
43 .map(|definition| mapper::map_pipeline(&self.organization, project, definition))
44 .collect())
45 }
46
47 async fn owned_build(
48 &self, project: &str, definition_id: i64, pipeline_id: &str, build_id: i64,
49 ) -> PluginResult<types::Build> {
50 let build = self.client()?.get_build(project, build_id).await?;
51 let belongs_to_pipeline = build
52 .definition
53 .as_ref()
54 .and_then(|definition| definition.id)
55 .is_none_or(|id| id == definition_id);
56 if !belongs_to_pipeline {
57 return Err(PluginError::PipelineNotFound(format!(
58 "Run #{build_id} does not belong to {pipeline_id}"
59 )));
60 }
61 Ok(build)
62 }
63}
64
65plugin_impl!(AzureDevOpsPlugin [
66 metadata,
67 status_source,
68 available_from_filtered,
69] {
70 fn initialize(
71 &mut self, _provider_id: i64, config: HashMap<String, String>,
72 http_client: Option<Arc<reqwest::Client>>,
73 ) -> PluginResult<()> {
74 let server_url = config::get_server_url(&config)?;
75 let token = required_value(
76 &config,
77 "token",
78 "Missing Azure DevOps personal access token",
79 )?
80 .to_string();
81 let status_source = custom_status_source(&config)?;
82
83 let http_client = require_client(http_client)?;
84
85 self.organization = config::organization_of(&server_url);
86 self.status_source = status_source;
87 self.client = Some(client::AzureDevOpsClient::new(
88 http_client,
89 server_url,
90 token,
91 ));
92 self.config = config;
93 Ok(())
94 }
95
96 async fn validate_credentials(&self) -> PluginResult<bool> {
97 self.client()?.ping().await?;
98 Ok(true)
99 }
100
101 async fn fetch_organizations(&self) -> PluginResult<Vec<Organization>> {
102 self.client()?;
103 Ok(organizations([self.organization.as_str()], None))
104 }
105
106 async fn ids_with_ci(&self, ids: Vec<String>) -> PluginResult<Vec<String>> {
107 let client = self.client()?;
108 Ok(retain_with_ci(ids, |project| async move {
109 client.has_definitions(&project).await
110 })
111 .await)
112 }
113
114 async fn fetch_available_pipelines_filtered(
115 &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
116 ) -> PluginResult<PaginatedAvailablePipelines> {
117 let client = self.client()?;
118
119 let mut projects = if org.is_none_or(|org| org == self.organization) {
120 client.list_projects().await?
121 } else {
122 Vec::new()
123 };
124 if let Some(term) = search.as_deref().map(str::to_lowercase) {
125 projects.retain(|project| {
126 project.name.to_lowercase().contains(&term)
127 || project
128 .description
129 .as_deref()
130 .is_some_and(|description| description.to_lowercase().contains(&term))
131 });
132 }
133 projects.sort_by_cached_key(|project| project.name.to_lowercase());
134
135 let items = projects
136 .iter()
137 .map(|project| mapper::map_available_pipeline(project, &self.organization))
138 .collect::<Vec<_>>();
139 paginate(items, params.unwrap_or_default())
140 }
141
142 async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
143 let projects = require_configured(
144 selected_items(&self.config).unwrap_or_default(),
145 "projects",
146 )?;
147 let requests: Vec<_> = projects
148 .iter()
149 .map(|project| self.fetch_project_pipelines(project))
150 .collect();
151 let results = try_buffered(requests, CONCURRENCY).await?;
152
153 Ok(results.into_iter().flatten().collect())
154 }
155
156 async fn fetch_run_history(
157 &self, pipeline_id: &str, limit: usize,
158 ) -> PluginResult<Vec<PipelineRun>> {
159 let id = config::parse_pipeline_id(pipeline_id)?;
160 let client = self.client()?;
161 let builds = client
162 .list_builds(id.project, id.definition_id, limit)
163 .await?;
164 Ok(builds
165 .iter()
166 .map(|build| mapper::map_run(build, pipeline_id, id.project, client.server_url()))
167 .collect())
168 }
169
170 async fn fetch_run_details(
171 &self, pipeline_id: &str, run_number: i64,
172 ) -> PluginResult<PipelineRun> {
173 let id = config::parse_pipeline_id(pipeline_id)?;
174 let build = self
175 .owned_build(id.project, id.definition_id, pipeline_id, run_number)
176 .await?;
177 Ok(mapper::map_run(
178 &build,
179 pipeline_id,
180 id.project,
181 self.client()?.server_url(),
182 ))
183 }
184
185 async fn fetch_log_nodes(&self, run: RunRef<'_>) -> PluginResult<Vec<LogNode>> {
186 let id = config::parse_pipeline_id(run.pipeline_id)?;
187 let timeline = self
188 .client()?
189 .get_timeline(id.project, run.run_number)
190 .await?;
191 Ok(mapper::timeline_to_log_nodes(&timeline))
192 }
193
194 async fn read_log(
195 &self, run: RunRef<'_>, node_id: &str, from: Option<&LogCursor>, sink: &mut LogSink,
196 ) -> PluginResult<LogRead> {
197 let id = config::parse_pipeline_id(run.pipeline_id)?;
198 let record_id = uuid_id(node_id, "Azure DevOps log node ID")?;
199 let client = self.client()?;
200 let timeline = client.get_timeline(id.project, run.run_number).await?;
201 let (log_id, attempt) = mapper::job_log_ref(&timeline, record_id).ok_or_else(|| {
202 PluginError::PipelineNotFound(format!(
203 "Run #{} has no job log {node_id}",
204 run.run_number
205 ))
206 })?;
207 client
208 .stream_build_log(id.project, run.run_number, log_id, attempt, from, sink)
209 .await
210 }
211
212 fn log_dialect(&self) -> &'static dyn LogDialect {
213 &AzureDevOpsLogDialect
214 }
215
216 async fn fetch_workflow_parameters(
217 &self, workflow_id: &str,
218 ) -> PluginResult<Vec<WorkflowParameter>> {
219 let id = config::parse_pipeline_id(workflow_id)?;
220 let definition = self
221 .client()?
222 .get_definition(id.project, id.definition_id)
223 .await?;
224
225 let default_branch = definition
226 .repository
227 .and_then(|repository| repository.default_branch)
228 .filter(|branch| !branch.is_empty())
229 .map(|branch| mapper::short_branch(&branch).to_string());
230 let mut parameters = vec![WorkflowParameter {
231 name: mapper::BRANCH_INPUT.to_string(),
232 label: Some("Branch".to_string()),
233 description: Some(
234 "Branch to build. The definition's default branch is used when empty".to_string(),
235 ),
236 param_type: WorkflowParameterType::String {
237 default: default_branch,
238 },
239 required: false,
240 }];
241 parameters.extend(
242 definition
243 .variables
244 .unwrap_or_default()
245 .into_iter()
246 .filter(|(_, variable)| variable.is_overridable())
247 .map(|(name, variable)| WorkflowParameter {
248 label: Some(name.clone()),
249 description: Some("Pipeline variable".to_string()),
250 param_type: WorkflowParameterType::String {
251 default: variable.value,
252 },
253 required: false,
254 name,
255 }),
256 );
257 Ok(parameters)
258 }
259
260 async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<TriggerResult> {
261 let id = config::parse_pipeline_id(¶ms.workflow_id)?;
262 let client = self.client()?;
263 let overridable: HashSet<String> = client
264 .get_definition(id.project, id.definition_id)
265 .await?
266 .variables
267 .unwrap_or_default()
268 .into_iter()
269 .filter(|(_, variable)| variable.is_overridable())
270 .map(|(name, _)| name.to_lowercase())
271 .collect();
272 let body = mapper::trigger_body(id.definition_id, params.inputs, &overridable);
273 let build = client.queue_build(id.project, &body).await?;
274 Ok(TriggerResult {
275 run_id: None,
276 run_number: Some(build.id),
277 })
278 }
279
280 async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
281 let id = config::parse_pipeline_id(pipeline_id)?;
282 let client = self.client()?;
283 self.owned_build(id.project, id.definition_id, pipeline_id, run_number)
284 .await?;
285 client.cancel_build(id.project, run_number).await
286 }
287});
288
289#[cfg(test)]
290#[cfg(not(target_os = "ios"))]
291mod tests;