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;