Skip to main content

github_bot_sdk/client/
workflow.rs

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