Skip to main content

commit_bridge/trigger/
mod.rs

1//! Asynchronous task to trigger remote repository workflows.
2
3use async_trait::async_trait;
4use reqwest::Client;
5use tracing::{info, warn};
6
7use crate::{
8    context::SharedContext,
9    engine::AsyncEngine,
10    model::{SubscriptionWithBranch, TriggerQueueItem},
11    repository::{
12        subscription::SubscriptionRepository,
13        trigger::{TriggerRepository, UpdateRetryStatus},
14    },
15    trigger::error::{RequestError, WorkflowTriggerError},
16};
17
18mod auth;
19pub use auth::{Authenticator, GitHubAuthenticator};
20pub mod error;
21
22/// Runs an asynchronous task
23/// that triggers a workflow in a remote repository.
24pub struct TriggerEngine {
25    /// Shared data for all async engines.
26    pub ctx: SharedContext,
27
28    /// HTTP client to make requests to the GitHub API.
29    pub http_client: Client,
30
31    /// Authenticates requests to the GitHub API.
32    pub authenticator: Box<dyn Authenticator + Send + Sync>,
33}
34
35#[async_trait]
36impl AsyncEngine for TriggerEngine {
37    async fn run(&self) {
38        trigger_loop(self).await;
39    }
40}
41
42/// Controls whether to shut down the trigger engine or process a queued event.
43async fn trigger_loop(engine: &TriggerEngine) {
44    loop {
45        tokio::select! {
46            _ = engine.ctx.token.cancelled() => break,
47            _ = tokio::time::sleep(engine.ctx.config.engine.trigger_queue_polling_interval) => {
48                if let Err(e) = process_queue(engine).await {
49                    warn!("Error processing queue: {e}");
50                }
51            }
52        }
53    }
54    info!("Gracefully shutting down trigger engine");
55}
56
57/// Processes a single queued event.
58async fn process_queue(engine: &TriggerEngine) -> Result<(), WorkflowTriggerError> {
59    let Some(trigger) = engine
60        .ctx
61        .repository
62        .find_oldest_pending_and_mark_processing()
63        .await?
64    else {
65        return Ok(());
66    };
67
68    let dispatch_result = dispatch_events(engine, &trigger).await;
69    match dispatch_result {
70        Ok(_) => {
71            TriggerRepository::delete_by_id(&*engine.ctx.repository, trigger.id).await?;
72        }
73        Err(WorkflowTriggerError::Repository(crate::repository::RepositoryError::NotFound)) => {
74            warn!(
75                "Subscription for branch ID {} and target repo {} was not found (likely deleted). Deleting trigger task {} from queue.",
76                trigger.branch_id, trigger.target_repo, trigger.id
77            );
78            TriggerRepository::delete_by_id(&*engine.ctx.repository, trigger.id).await?;
79        }
80        Err(e) => {
81            warn!("Dispatch failed: {e}");
82            schedule_retry(engine, trigger, e).await?;
83        }
84    }
85
86    Ok(())
87}
88
89/// Schedules the next retry for a trigger in the `trigger_queue`.
90async fn schedule_retry(
91    engine: &TriggerEngine,
92    trigger: TriggerQueueItem,
93    e: WorkflowTriggerError,
94) -> Result<(), WorkflowTriggerError> {
95    let next_retry_count = trigger.retry_count + 1;
96    let max_attempts = engine.ctx.config.engine.trigger_retry_max_attempts;
97    let backoff_base_secs = engine
98        .ctx
99        .config
100        .engine
101        .trigger_retry_backoff_base
102        .as_secs();
103
104    if next_retry_count as u32 >= max_attempts {
105        tracing::warn!(
106            "Task {} failed after {} attempts: {e}",
107            trigger.id,
108            max_attempts
109        );
110    }
111
112    engine
113        .ctx
114        .repository
115        .update_retry_status(UpdateRetryStatus {
116            id: trigger.id,
117            retry_count: trigger.retry_count,
118            max_attempts,
119            backoff_base_secs,
120        })
121        .await?;
122
123    Ok(())
124}
125
126/// Recovers tasks that have been stuck in `PROCESSING` for too long.
127pub async fn recover_stuck_tasks(
128    repo: &crate::repository::SqliteRepository,
129    config: &crate::config::Config,
130) -> Result<(), crate::repository::RepositoryError> {
131    let threshold_seconds = config.engine.stuck_task_threshold.as_secs();
132
133    repo.recover_stuck_tasks(threshold_seconds).await?;
134    Ok(())
135}
136
137/// Sends a `repository_dispatch` event for each relevant [`Subscription`].
138///
139/// <!-- LINKS -->
140/// [`Subscription`]: crate::model::Subscription
141pub async fn dispatch_events(
142    engine: &TriggerEngine,
143    trigger: &TriggerQueueItem,
144) -> Result<(), WorkflowTriggerError> {
145    let sub_with_branch = engine
146        .ctx
147        .repository
148        .get_by_keys_with_branch(trigger.branch_id, &trigger.target_repo, &trigger.event_type)
149        .await?
150        .ok_or_else(|| {
151            WorkflowTriggerError::Repository(crate::repository::RepositoryError::NotFound)
152        })?;
153
154    info!(
155        "Received update event for branch {} (repo: {}, branch: {}): {}",
156        trigger.branch_id,
157        sub_with_branch.source_branch.repo_url,
158        sub_with_branch.source_branch.name,
159        trigger.new_hash
160    );
161
162    let iat = engine
163        .authenticator
164        .request_installation_token(&sub_with_branch.subscription)
165        .await?;
166    notify_subscription(engine, iat, trigger, sub_with_branch).await?;
167
168    Ok(())
169}
170
171/// Manages IAT authentication,
172/// and sends a `repository_dispatch` event to the specified [`Subscription`].
173///
174/// <!-- LINKS -->
175/// [`Subscription`]: crate::model::Subscription
176async fn notify_subscription(
177    engine: &TriggerEngine,
178    iat: String,
179    trigger: &TriggerQueueItem,
180    sub_with_branch: SubscriptionWithBranch,
181) -> Result<(), WorkflowTriggerError> {
182    send_repository_dispatch(engine, &iat, trigger, &sub_with_branch).await?;
183    Ok(())
184}
185
186/// Sends a `repository_dispatch` event to the specified [`Subscription`].
187///
188/// <!-- LINKS -->
189/// [`Subscription`]: crate::model::Subscription
190async fn send_repository_dispatch(
191    engine: &TriggerEngine,
192    iat: &str,
193    trigger: &TriggerQueueItem,
194    sub_with_branch: &SubscriptionWithBranch,
195) -> Result<(), WorkflowTriggerError> {
196    let api_url = format!(
197        "{}/repos/{}/dispatches",
198        engine
199            .ctx
200            .config
201            .github_api
202            .base_url
203            .as_str()
204            .trim_end_matches('/'),
205        sub_with_branch.subscription.target_repo
206    );
207
208    let payload = serde_json::json!({
209        "event_type": sub_with_branch.subscription.event_type,
210        "client_payload": {
211            "branch_id": trigger.branch_id.to_string(),
212            "new_commit_hash": trigger.new_hash,
213            "source_repo": sub_with_branch.source_branch.repo_url.to_string(),
214            "source_branch": sub_with_branch.source_branch.name.to_string(),
215        }
216    });
217
218    info!(
219        "Sending payload to {} (Source repo: {}, Tracked branch: {}): {}",
220        sub_with_branch.subscription.target_repo,
221        sub_with_branch.source_branch.repo_url,
222        sub_with_branch.source_branch.name,
223        payload
224    );
225
226    let response = engine
227        .http_client
228        .post(&api_url)
229        .bearer_auth(iat)
230        .header(
231            "Accept",
232            engine.ctx.config.github_api.accept_header.to_string(),
233        )
234        .header(
235            "X-GitHub-Api-Version",
236            engine.ctx.config.github_api.version.to_string(),
237        )
238        .json(&payload)
239        .send()
240        .await?;
241
242    if response.status().is_success() {
243        info!(
244            "`repository_dispatch` sent to {} (Source repo: {}, Tracked branch: {}): Event: {}",
245            sub_with_branch.subscription.target_repo,
246            sub_with_branch.source_branch.repo_url,
247            sub_with_branch.source_branch.name,
248            sub_with_branch.subscription.event_type
249        );
250        Ok(())
251    } else {
252        Err(WorkflowTriggerError::Api(RequestError::Response {
253            status: response.status(),
254            text: response.text().await?,
255        }))
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    #![allow(
262        clippy::panic,
263        clippy::expect_used,
264        clippy::todo,
265        clippy::unimplemented,
266        clippy::indexing_slicing
267    )]
268
269    use super::*;
270    use crate::domain::{CommitHash, EventType, TargetRepo};
271    use crate::test_utils::{MockAuthenticator, MockGitFetcher};
272    use std::sync::Arc;
273
274    use tokio_util::sync::CancellationToken;
275    use wiremock::matchers::{method, path};
276    use wiremock::{Mock, MockServer, ResponseTemplate};
277
278    #[tokio::test]
279    async fn test_recover_stuck_tasks() {
280        let pool = crate::test_utils::create_test_db().await;
281
282        // Insert tasks
283        let hash = "a".repeat(40);
284        // 1. Processing (stuck)
285        sqlx::query!(
286            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, status_updated_at) VALUES (?, ?, ?, ?, DATETIME('now', '-10 minutes'))",
287            1,
288            hash,
289            "PROCESSING",
290            0
291        )
292        .execute(&pool)
293        .await
294        .unwrap();
295        // 2. Processing (recent)
296        sqlx::query!(
297            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, next_retry_at, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?)",
298            1,
299            hash,
300            "PROCESSING",
301            0,
302            "org/repo",
303            "event",
304            1
305        )
306        .execute(&pool)
307        .await
308        .unwrap();
309        // 3. Pending
310        sqlx::query!(
311            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, status_updated_at) VALUES (?, ?, ?, ?, DATETIME('now'))",
312            1,
313            hash,
314            "PENDING",
315            0
316        )
317        .execute(&pool)
318        .await
319        .unwrap();
320
321        recover_stuck_tasks(
322            &crate::repository::SqliteRepository::new(pool.clone()),
323            &crate::test_utils::create_test_config(),
324        )
325        .await
326        .unwrap();
327
328        // Check status
329        let tasks = sqlx::query!("SELECT status FROM trigger_queue ORDER BY rowid")
330            .fetch_all(&pool)
331            .await
332            .unwrap();
333
334        assert_eq!(tasks[0].status, "PENDING"); // was stuck
335        assert_eq!(tasks[1].status, "PROCESSING"); // was recent
336        assert_eq!(tasks[2].status, "PENDING"); // was pending
337    }
338
339    #[tokio::test]
340    async fn test_get_oldest_queued_trigger() {
341        let pool = crate::test_utils::create_test_db().await;
342
343        // Insert some dummy items
344        let hash = "a".repeat(40);
345        sqlx::query!(
346            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, next_retry_at, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?, datetime('now', '-1 minute'), ?, ?, ?)",
347            1,
348            hash,
349            "PENDING",
350            0,
351            "org/repo1",
352            "event",
353            1
354        )
355        .execute(&pool)
356        .await
357        .unwrap();
358        let hash = "a".repeat(40);
359        sqlx::query!(
360            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, next_retry_at, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?, datetime('now', '-5 minutes'), ?, ?, ?)",
361            1,
362            hash,
363            "PENDING",
364            0,
365            "org/repo2",
366            "event",
367            1
368        )
369        .execute(&pool)
370        .await
371        .unwrap();
372        let hash = "a".repeat(40);
373        sqlx::query!(
374            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, next_retry_at, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?, datetime('now', '+1 minute'), ?, ?, ?)",
375            1,
376            hash,
377            "PENDING",
378            0,
379            "org/repo3",
380            "event",
381            1
382        )
383        .execute(&pool)
384        .await
385        .unwrap();
386
387        let repo = crate::repository::SqliteRepository::new(pool.clone());
388        let trigger = repo
389            .find_oldest_pending_and_mark_processing()
390            .await
391            .unwrap()
392            .unwrap();
393
394        // Assert: The one with -5 minutes should be returned
395        assert_eq!(trigger.retry_count, 0);
396
397        // Verify it was updated to PROCESSING
398        let db_trigger = sqlx::query!("SELECT status FROM trigger_queue WHERE id = ?", trigger.id)
399            .fetch_one(&pool)
400            .await
401            .unwrap();
402        assert_eq!(db_trigger.status, "PROCESSING");
403    }
404
405    #[tokio::test]
406    async fn test_schedule_retry() {
407        let pool = crate::test_utils::create_test_db().await;
408        let hash = "a".repeat(40);
409        let id = sqlx::query!(
410            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, next_retry_at) VALUES (?, ?, ?, ?, datetime('now'))",
411            1,
412            hash,
413            "PROCESSING",
414            0
415        )
416        .execute(&pool)
417        .await
418        .unwrap()
419        .last_insert_rowid();
420
421        let trigger = TriggerQueueItem {
422            id,
423            branch_id: 1,
424            new_hash: CommitHash::new("a".repeat(40)).expect("valid commit hash"),
425            retry_count: 0,
426            target_repo: TargetRepo::new("org/repo".to_string()).unwrap(),
427            event_type: EventType::new("event".to_string()).unwrap(),
428            gh_app_installation_id: 1,
429        };
430
431        let engine = TriggerEngine {
432            ctx: SharedContext {
433                config: crate::test_utils::create_test_config(),
434                repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(
435                    pool.clone(),
436                )),
437                db_pool: pool.clone(),
438                token: CancellationToken::new(),
439                git_fetcher: Arc::new(MockGitFetcher {
440                    hash: CommitHash::new("a".repeat(40)).unwrap(),
441                }),
442            },
443            http_client: reqwest::Client::new(),
444            authenticator: Box::new(MockAuthenticator {
445                iat: "token".to_string(),
446            }),
447        };
448
449        schedule_retry(
450            &engine,
451            trigger,
452            WorkflowTriggerError::Api(RequestError::Response {
453                status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
454                text: "error".to_string(),
455            }),
456        )
457        .await
458        .unwrap();
459
460        let updated = sqlx::query!(
461            "SELECT status, retry_count FROM trigger_queue WHERE id = ?",
462            id
463        )
464        .fetch_one(&pool)
465        .await
466        .unwrap();
467        assert_eq!(updated.status, "PENDING");
468        assert_eq!(updated.retry_count, 1);
469    }
470
471    #[tokio::test]
472    async fn test_process_queue_failure_and_retry() {
473        let pool = crate::test_utils::create_test_db().await;
474        let mock_server = MockServer::start().await;
475
476        // Setup subscription
477        sqlx::query!(
478            "INSERT INTO branches (repo_url, name) VALUES (?, ?)",
479            "repo",
480            "main"
481        )
482        .execute(&pool)
483        .await
484        .unwrap();
485        sqlx::query!("INSERT INTO subscriptions (branch_id, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?)",
486                     1, "org/target", "dispatch", 1).execute(&pool).await.unwrap();
487
488        // Mock token success, but dispatch failure
489        Mock::given(method("POST"))
490            .and(path("/app/installations/1/access_tokens"))
491            .respond_with(
492                ResponseTemplate::new(200).set_body_json(serde_json::json!({"token": "token"})),
493            )
494            .mount(&mock_server)
495            .await;
496
497        Mock::given(method("POST"))
498            .and(path("/repos/org/target/dispatches"))
499            .respond_with(ResponseTemplate::new(500))
500            .mount(&mock_server)
501            .await;
502
503        let hash = "a".repeat(40);
504        sqlx::query!(
505            "INSERT INTO trigger_queue (branch_id, new_hash, status, retry_count, next_retry_at, target_repo, event_type, gh_app_installation_id) VALUES (?, ?, ?, ?, '2000-01-01 00:00:00', ?, ?, ?)",
506            1,
507            hash,
508            "PENDING",
509            0,
510            "org/target",
511            "dispatch",
512            1
513        )
514        .execute(&pool)
515        .await
516        .unwrap();
517
518        let engine = TriggerEngine {
519            ctx: SharedContext {
520                config: crate::test_utils::create_test_config(),
521                repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(
522                    pool.clone(),
523                )),
524                db_pool: pool.clone(),
525                token: CancellationToken::new(),
526                git_fetcher: Arc::new(MockGitFetcher {
527                    hash: CommitHash::new("a".repeat(40)).unwrap(),
528                }),
529            },
530            http_client: reqwest::Client::new(),
531            authenticator: Box::new(MockAuthenticator {
532                iat: "token".to_string(),
533            }),
534        };
535
536        process_queue(&engine).await.unwrap();
537
538        // Should still exist and retry_count increased
539        let trigger = sqlx::query!("SELECT retry_count, status FROM trigger_queue")
540            .fetch_one(&pool)
541            .await
542            .unwrap();
543        assert_eq!(trigger.retry_count, 1);
544        assert_eq!(trigger.status, "PENDING");
545    }
546}