Skip to main content

github_bot_sdk/client/
workflow.rs

1// Workflow and workflow run operations for GitHub API
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::client::InstallationClient;
7use crate::error::ApiError;
8
9/// GitHub Actions workflow.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Workflow {
12    /// Unique workflow identifier
13    pub id: u64,
14
15    /// Node ID for GraphQL API
16    pub node_id: String,
17
18    /// Workflow name
19    pub name: String,
20
21    /// Workflow file path
22    pub path: String,
23
24    /// Workflow state
25    pub state: String, // "active", "disabled_manually", "disabled_inactivity"
26
27    /// Creation timestamp
28    pub created_at: DateTime<Utc>,
29
30    /// Last update timestamp
31    pub updated_at: DateTime<Utc>,
32
33    /// Workflow URL
34    pub url: String,
35
36    /// Workflow HTML URL
37    pub html_url: String,
38
39    /// Workflow badge URL
40    pub badge_url: String,
41}
42
43/// GitHub Actions workflow run.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct WorkflowRun {
46    /// Unique workflow run identifier
47    pub id: u64,
48
49    /// Node ID for GraphQL API
50    pub node_id: String,
51
52    /// Workflow run name
53    pub name: String,
54
55    /// Workflow run number
56    pub run_number: u64,
57
58    /// Event that triggered the workflow
59    pub event: String,
60
61    /// Workflow run status
62    pub status: String, // "queued", "in_progress", "completed"
63
64    /// Workflow run conclusion (if completed)
65    pub conclusion: Option<String>, // "success", "failure", "cancelled", "skipped", etc.
66
67    /// Workflow ID
68    pub workflow_id: u64,
69
70    /// Head branch
71    pub head_branch: String,
72
73    /// Head commit SHA
74    pub head_sha: String,
75
76    /// Creation timestamp
77    pub created_at: DateTime<Utc>,
78
79    /// Last update timestamp
80    pub updated_at: DateTime<Utc>,
81
82    /// Workflow run URL
83    pub url: String,
84
85    /// Workflow run HTML URL
86    pub html_url: String,
87}
88
89/// Request to trigger a workflow.
90#[derive(Debug, Clone, Serialize)]
91pub struct TriggerWorkflowRequest {
92    /// Git reference (branch or tag)
93    #[serde(rename = "ref")]
94    pub git_ref: String,
95
96    /// Workflow inputs (key-value pairs)
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub inputs: Option<std::collections::HashMap<String, String>>,
99}
100
101impl InstallationClient {
102    // ========================================================================
103    // Workflow Operations
104    // ========================================================================
105
106    /// List workflows in a repository.
107    ///
108    /// Retrieves all GitHub Actions workflows for a repository.
109    ///
110    /// # Arguments
111    ///
112    /// * `owner` - Repository owner
113    /// * `repo` - Repository name
114    ///
115    /// # Returns
116    ///
117    /// Returns vector of workflows.
118    ///
119    /// # Errors
120    ///
121    /// * `ApiError::NotFound` - Repository does not exist
122    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
123    ///
124    /// # Example
125    ///
126    /// ```no_run
127    /// # use github_bot_sdk::client::InstallationClient;
128    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
129    /// let workflows = client.list_workflows("owner", "repo").await?;
130    /// for workflow in workflows {
131    ///     println!("Workflow: {} ({})", workflow.name, workflow.state);
132    /// }
133    /// # Ok(())
134    /// # }
135    /// ```
136    pub async fn list_workflows(&self, owner: &str, repo: &str) -> Result<Vec<Workflow>, ApiError> {
137        let path = format!("/repos/{}/{}/actions/workflows", owner, repo);
138        let response = self.get(&path).await?;
139
140        let status = response.status();
141        if !status.is_success() {
142            return Err(match status.as_u16() {
143                404 => ApiError::NotFound,
144                403 => ApiError::AuthorizationFailed,
145                401 => ApiError::AuthenticationFailed,
146                _ => {
147                    let message = response
148                        .text()
149                        .await
150                        .unwrap_or_else(|_| "Unknown error".to_string());
151                    ApiError::HttpError {
152                        status: status.as_u16(),
153                        message,
154                    }
155                }
156            });
157        }
158
159        #[derive(Deserialize)]
160        struct WorkflowsResponse {
161            workflows: Vec<Workflow>,
162        }
163
164        let workflows_response: WorkflowsResponse =
165            response.json().await.map_err(ApiError::from)?;
166        Ok(workflows_response.workflows)
167    }
168
169    /// Get a specific workflow by ID.
170    ///
171    /// Retrieves details about a single workflow.
172    ///
173    /// # Arguments
174    ///
175    /// * `owner` - Repository owner
176    /// * `repo` - Repository name
177    /// * `workflow_id` - Workflow ID
178    ///
179    /// # Returns
180    ///
181    /// Returns the `Workflow` with the specified ID.
182    ///
183    /// # Errors
184    ///
185    /// * `ApiError::NotFound` - Workflow does not exist
186    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
187    ///
188    /// # Example
189    ///
190    /// ```no_run
191    /// # use github_bot_sdk::client::InstallationClient;
192    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
193    /// let workflow = client.get_workflow("owner", "repo", 123456).await?;
194    /// println!("Workflow: {} at {}", workflow.name, workflow.path);
195    /// # Ok(())
196    /// # }
197    /// ```
198    pub async fn get_workflow(
199        &self,
200        owner: &str,
201        repo: &str,
202        workflow_id: u64,
203    ) -> Result<Workflow, ApiError> {
204        let path = format!(
205            "/repos/{}/{}/actions/workflows/{}",
206            owner, repo, workflow_id
207        );
208        let response = self.get(&path).await?;
209
210        let status = response.status();
211        if !status.is_success() {
212            return Err(match status.as_u16() {
213                404 => ApiError::NotFound,
214                403 => ApiError::AuthorizationFailed,
215                401 => ApiError::AuthenticationFailed,
216                _ => {
217                    let message = response
218                        .text()
219                        .await
220                        .unwrap_or_else(|_| "Unknown error".to_string());
221                    ApiError::HttpError {
222                        status: status.as_u16(),
223                        message,
224                    }
225                }
226            });
227        }
228
229        response.json().await.map_err(ApiError::from)
230    }
231
232    /// Trigger a workflow run.
233    ///
234    /// Manually triggers a workflow run using workflow_dispatch event.
235    ///
236    /// # Arguments
237    ///
238    /// * `owner` - Repository owner
239    /// * `repo` - Repository name
240    /// * `workflow_id` - Workflow ID
241    /// * `request` - Trigger parameters (ref and optional inputs)
242    ///
243    /// # Returns
244    ///
245    /// Returns `Ok(())` on successful trigger.
246    ///
247    /// # Errors
248    ///
249    /// * `ApiError::NotFound` - Workflow does not exist
250    /// * `ApiError::InvalidRequest` - Workflow not configured for manual dispatch
251    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
252    ///
253    /// # Example
254    ///
255    /// ```no_run
256    /// # use github_bot_sdk::client::{InstallationClient, TriggerWorkflowRequest};
257    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
258    /// let request = TriggerWorkflowRequest {
259    ///     git_ref: "main".to_string(),
260    ///     inputs: None,
261    /// };
262    /// client.trigger_workflow("owner", "repo", 123456, request).await?;
263    /// println!("Workflow triggered");
264    /// # Ok(())
265    /// # }
266    /// ```
267    pub async fn trigger_workflow(
268        &self,
269        owner: &str,
270        repo: &str,
271        workflow_id: u64,
272        request: TriggerWorkflowRequest,
273    ) -> Result<(), ApiError> {
274        let path = format!(
275            "/repos/{}/{}/actions/workflows/{}/dispatches",
276            owner, repo, workflow_id
277        );
278        let response = self.post(&path, &request).await?;
279
280        let status = response.status();
281        if !status.is_success() {
282            return Err(match status.as_u16() {
283                404 => ApiError::NotFound,
284                403 => ApiError::AuthorizationFailed,
285                401 => ApiError::AuthenticationFailed,
286                422 => {
287                    let message = response
288                        .text()
289                        .await
290                        .unwrap_or_else(|_| "Validation error".to_string());
291                    ApiError::InvalidRequest { message }
292                }
293                _ => {
294                    let message = response
295                        .text()
296                        .await
297                        .unwrap_or_else(|_| "Unknown error".to_string());
298                    ApiError::HttpError {
299                        status: status.as_u16(),
300                        message,
301                    }
302                }
303            });
304        }
305
306        Ok(())
307    }
308
309    // ========================================================================
310    // Workflow Run Operations
311    // ========================================================================
312
313    /// List workflow runs for a workflow.
314    ///
315    /// Retrieves workflow runs for a specific workflow.
316    ///
317    /// # Arguments
318    ///
319    /// * `owner` - Repository owner
320    /// * `repo` - Repository name
321    /// * `workflow_id` - Workflow ID
322    ///
323    /// # Returns
324    ///
325    /// Returns vector of workflow runs.
326    ///
327    /// # Errors
328    ///
329    /// * `ApiError::NotFound` - Workflow does not exist
330    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
331    ///
332    /// # Example
333    ///
334    /// ```no_run
335    /// # use github_bot_sdk::client::InstallationClient;
336    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
337    /// let runs = client.list_workflow_runs("owner", "repo", 123456).await?;
338    /// for run in runs {
339    ///     println!("Run #{}: {} ({})", run.run_number, run.status, run.conclusion.unwrap_or_default());
340    /// }
341    /// # Ok(())
342    /// # }
343    /// ```
344    pub async fn list_workflow_runs(
345        &self,
346        owner: &str,
347        repo: &str,
348        workflow_id: u64,
349    ) -> Result<Vec<WorkflowRun>, ApiError> {
350        let path = format!(
351            "/repos/{}/{}/actions/workflows/{}/runs",
352            owner, repo, workflow_id
353        );
354        let response = self.get(&path).await?;
355
356        let status = response.status();
357        if !status.is_success() {
358            return Err(match status.as_u16() {
359                404 => ApiError::NotFound,
360                403 => ApiError::AuthorizationFailed,
361                401 => ApiError::AuthenticationFailed,
362                _ => {
363                    let message = response
364                        .text()
365                        .await
366                        .unwrap_or_else(|_| "Unknown error".to_string());
367                    ApiError::HttpError {
368                        status: status.as_u16(),
369                        message,
370                    }
371                }
372            });
373        }
374
375        #[derive(Deserialize)]
376        struct WorkflowRunsResponse {
377            workflow_runs: Vec<WorkflowRun>,
378        }
379
380        let runs_response: WorkflowRunsResponse = response.json().await.map_err(ApiError::from)?;
381        Ok(runs_response.workflow_runs)
382    }
383
384    /// Get a specific workflow run by ID.
385    ///
386    /// Retrieves details about a single workflow run.
387    ///
388    /// # Arguments
389    ///
390    /// * `owner` - Repository owner
391    /// * `repo` - Repository name
392    /// * `run_id` - Workflow run ID
393    ///
394    /// # Returns
395    ///
396    /// Returns the `WorkflowRun` with the specified ID.
397    ///
398    /// # Errors
399    ///
400    /// * `ApiError::NotFound` - Workflow run does not exist
401    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
402    ///
403    /// # Example
404    ///
405    /// ```no_run
406    /// # use github_bot_sdk::client::InstallationClient;
407    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
408    /// let run = client.get_workflow_run("owner", "repo", 987654).await?;
409    /// println!("Run #{}: {}", run.run_number, run.status);
410    /// # Ok(())
411    /// # }
412    /// ```
413    pub async fn get_workflow_run(
414        &self,
415        owner: &str,
416        repo: &str,
417        run_id: u64,
418    ) -> Result<WorkflowRun, ApiError> {
419        let path = format!("/repos/{}/{}/actions/runs/{}", owner, repo, run_id);
420        let response = self.get(&path).await?;
421
422        let status = response.status();
423        if !status.is_success() {
424            return Err(match status.as_u16() {
425                404 => ApiError::NotFound,
426                403 => ApiError::AuthorizationFailed,
427                401 => ApiError::AuthenticationFailed,
428                _ => {
429                    let message = response
430                        .text()
431                        .await
432                        .unwrap_or_else(|_| "Unknown error".to_string());
433                    ApiError::HttpError {
434                        status: status.as_u16(),
435                        message,
436                    }
437                }
438            });
439        }
440
441        response.json().await.map_err(ApiError::from)
442    }
443
444    /// Cancel a workflow run.
445    ///
446    /// Cancels a workflow run that is in progress.
447    ///
448    /// # Arguments
449    ///
450    /// * `owner` - Repository owner
451    /// * `repo` - Repository name
452    /// * `run_id` - Workflow run ID
453    ///
454    /// # Returns
455    ///
456    /// Returns `Ok(())` on successful cancellation.
457    ///
458    /// # Errors
459    ///
460    /// * `ApiError::NotFound` - Workflow run does not exist
461    /// * `ApiError::InvalidRequest` - Workflow run cannot be cancelled
462    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
463    ///
464    /// # Example
465    ///
466    /// ```no_run
467    /// # use github_bot_sdk::client::InstallationClient;
468    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
469    /// client.cancel_workflow_run("owner", "repo", 987654).await?;
470    /// println!("Workflow run cancelled");
471    /// # Ok(())
472    /// # }
473    /// ```
474    pub async fn cancel_workflow_run(
475        &self,
476        owner: &str,
477        repo: &str,
478        run_id: u64,
479    ) -> Result<(), ApiError> {
480        let path = format!("/repos/{}/{}/actions/runs/{}/cancel", owner, repo, run_id);
481        let response = self.post(&path, &serde_json::json!({})).await?;
482
483        let status = response.status();
484        if !status.is_success() {
485            return Err(match status.as_u16() {
486                404 => ApiError::NotFound,
487                403 => ApiError::AuthorizationFailed,
488                401 => ApiError::AuthenticationFailed,
489                422 => {
490                    let message = response
491                        .text()
492                        .await
493                        .unwrap_or_else(|_| "Validation error".to_string());
494                    ApiError::InvalidRequest { message }
495                }
496                _ => {
497                    let message = response
498                        .text()
499                        .await
500                        .unwrap_or_else(|_| "Unknown error".to_string());
501                    ApiError::HttpError {
502                        status: status.as_u16(),
503                        message,
504                    }
505                }
506            });
507        }
508
509        Ok(())
510    }
511
512    /// Re-run a workflow run.
513    ///
514    /// Re-runs a completed workflow run.
515    ///
516    /// # Arguments
517    ///
518    /// * `owner` - Repository owner
519    /// * `repo` - Repository name
520    /// * `run_id` - Workflow run ID
521    ///
522    /// # Returns
523    ///
524    /// Returns `Ok(())` on successful re-run trigger.
525    ///
526    /// # Errors
527    ///
528    /// * `ApiError::NotFound` - Workflow run does not exist
529    /// * `ApiError::InvalidRequest` - Workflow run cannot be re-run
530    /// * `ApiError::AuthorizationFailed` - Insufficient permissions
531    ///
532    /// # Example
533    ///
534    /// ```no_run
535    /// # use github_bot_sdk::client::InstallationClient;
536    /// # async fn example(client: &InstallationClient) -> Result<(), Box<dyn std::error::Error>> {
537    /// client.rerun_workflow_run("owner", "repo", 987654).await?;
538    /// println!("Workflow run re-triggered");
539    /// # Ok(())
540    /// # }
541    /// ```
542    pub async fn rerun_workflow_run(
543        &self,
544        owner: &str,
545        repo: &str,
546        run_id: u64,
547    ) -> Result<(), ApiError> {
548        let path = format!("/repos/{}/{}/actions/runs/{}/rerun", owner, repo, run_id);
549        let response = self.post(&path, &serde_json::json!({})).await?;
550
551        let status = response.status();
552        if !status.is_success() {
553            return Err(match status.as_u16() {
554                404 => ApiError::NotFound,
555                403 => ApiError::AuthorizationFailed,
556                401 => ApiError::AuthenticationFailed,
557                422 => {
558                    let message = response
559                        .text()
560                        .await
561                        .unwrap_or_else(|_| "Validation error".to_string());
562                    ApiError::InvalidRequest { message }
563                }
564                _ => {
565                    let message = response
566                        .text()
567                        .await
568                        .unwrap_or_else(|_| "Unknown error".to_string());
569                    ApiError::HttpError {
570                        status: status.as_u16(),
571                        message,
572                    }
573                }
574            });
575        }
576
577        Ok(())
578    }
579}
580
581#[cfg(test)]
582#[path = "workflow_tests.rs"]
583mod tests;