Skip to main content

pipedash_plugin_azure_devops/
plugin.rs

1use 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(&params.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;