1use 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
22pub struct TriggerEngine {
25 pub ctx: SharedContext,
27
28 pub http_client: Client,
30
31 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
42async 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
57async 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
89async 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
126pub 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
137pub 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
171async 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
186async 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 let hash = "a".repeat(40);
284 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 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 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 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"); assert_eq!(tasks[1].status, "PROCESSING"); assert_eq!(tasks[2].status, "PENDING"); }
338
339 #[tokio::test]
340 async fn test_get_oldest_queued_trigger() {
341 let pool = crate::test_utils::create_test_db().await;
342
343 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_eq!(trigger.retry_count, 0);
396
397 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 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::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 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}