Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ This file contains crucial context for AI agents working in this repository.
5. Run `cargo sqlx prepare` again.

## Environment & Setup
- **Nix First**: The project uses Nix flakes (`flake.nix`) and `direnv`. Agents should rely on `nix develop` to get the proper Rust toolchain, `sqlx-cli`, and wrapped `cargo` commands.
- **Nix First**: The project uses Nix flakes (`flake.nix`) and `direnv`, and the Rust toolchain is only available inside the dev shell. In interactive shells, direnv loads it automatically; in non-interactive/automated shells the direnv hook does not run, so load the environment explicitly before any `cargo` invocation:

- Run `direnv allow` once if the `.envrc` is blocked.
- Run `eval "$(direnv export bash 2>/dev/null)"`, then verify with `command -v cargo` (should print a `/nix/store/...` path).
- If direnv is unavailable or cannot evaluate the envrc (e.g. restricted home access), fall back to `nix develop -c <command>`.
- **Runtime Variables**: To run the server locally, you must ensure `CBRIDGE__AUTH__CLIENT_ID` and `CBRIDGE__AUTH__PEM_PATH` are set (pointing to a valid GitHub App private key).
- **External Dependencies**: The application shells out to `git ls-remote` at runtime, so `git` must be available in the environment.
- **External Dependencies**: Remote git operations are performed in-process via the `gix` crate; the application no longer shells out to `git ls-remote`, so an external `git` binary is not required at runtime.

## Architecture
- **Frameworks**: `axum` for HTTP, `sqlx` (SQLite) for state, `tokio` for async execution.
Expand Down
151 changes: 151 additions & 0 deletions src/trigger/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! Dispatch of `repository_dispatch` events to target repositories.

use tracing::info;

use crate::{
model::{SubscriptionWithBranch, TriggerQueueItem},
repository::subscription::SubscriptionRepository,
trigger::{
TriggerEngine,
error::{RequestError, WorkflowTriggerError},
},
};

/// Sends a `repository_dispatch` event for each relevant [`Subscription`].
///
/// <!-- LINKS -->
/// [`Subscription`]: crate::model::Subscription
#[tracing::instrument(skip_all, fields(otel.kind = "internal"))]
pub async fn dispatch_events(
engine: &TriggerEngine,
trigger: &TriggerQueueItem,
) -> Result<(), WorkflowTriggerError> {
let sub_with_branch = engine
.ctx
.repository
.subscriptions_get_by_keys_with_branch(
trigger.branch_id,
&trigger.target_repo,
&trigger.event_type,
)
.await?
.ok_or_else(|| {
WorkflowTriggerError::Repository(crate::repository::RepositoryError::NotFound)
})?;

info!(
"Received update event for branch {} (repo: {}, branch: {}): {}",
trigger.branch_id,
sub_with_branch.source_branch.repo_url,
sub_with_branch.source_branch.name,
trigger.new_hash
);

let iat = engine
.authenticator
.request_installation_token(&sub_with_branch.subscription)
.await?;
notify_subscription(engine, iat, trigger, sub_with_branch).await?;

Ok(())
}

/// Manages IAT authentication,
/// and sends a `repository_dispatch` event to the specified [`Subscription`].
///
/// <!-- LINKS -->
/// [`Subscription`]: crate::model::Subscription
#[tracing::instrument(skip_all, fields(otel.kind = "internal"))]
async fn notify_subscription(
engine: &TriggerEngine,
iat: String,
trigger: &TriggerQueueItem,
sub_with_branch: SubscriptionWithBranch,
) -> Result<(), WorkflowTriggerError> {
send_repository_dispatch(engine, &iat, trigger, &sub_with_branch).await?;
Ok(())
}

/// Sends a `repository_dispatch` event to the specified [`Subscription`].
///
/// <!-- LINKS -->
/// [`Subscription`]: crate::model::Subscription
#[tracing::instrument(
skip_all,
fields(
otel.kind = "client",
otel.status_code = tracing::field::Empty,
error.type = tracing::field::Empty,
)
)]
async fn send_repository_dispatch(
engine: &TriggerEngine,
iat: &str,
trigger: &TriggerQueueItem,
sub_with_branch: &SubscriptionWithBranch,
) -> Result<(), WorkflowTriggerError> {
let api_url = format!(
"{}/repos/{}/dispatches",
engine
.ctx
.config
.github_api
.base_url
.as_str()
.trim_end_matches('/'),
sub_with_branch.subscription.target_repo
);

let payload = serde_json::json!({
"event_type": sub_with_branch.subscription.event_type,
"client_payload": {
"branch_id": trigger.branch_id.to_string(),
"new_commit_hash": trigger.new_hash,
"source_repo": sub_with_branch.source_branch.repo_url.to_string(),
"source_branch": sub_with_branch.source_branch.name.to_string(),
}
});

info!(
"Sending payload to {} (Source repo: {}, Tracked branch: {}): {}",
sub_with_branch.subscription.target_repo,
sub_with_branch.source_branch.repo_url,
sub_with_branch.source_branch.name,
payload
);

let response = engine
.http_client
.post(&api_url)
.bearer_auth(iat)
.header(
"Accept",
engine.ctx.config.github_api.accept_header.to_string(),
)
.header(
"X-GitHub-Api-Version",
engine.ctx.config.github_api.version.to_string(),
)
.json(&payload)
.send()
.await?;

if response.status().is_success() {
info!(
"`repository_dispatch` sent to {} (Source repo: {}, Tracked branch: {}): Event: {}",
sub_with_branch.subscription.target_repo,
sub_with_branch.source_branch.repo_url,
sub_with_branch.source_branch.name,
sub_with_branch.subscription.event_type
);
Ok(())
} else {
let span = tracing::Span::current();
span.record("otel.status_code", "ERROR");
span.record("error.type", response.status().as_u16().to_string());
Err(WorkflowTriggerError::Api(RequestError::Response {
status: response.status(),
text: response.text().await?,
}))
}
}
Loading
Loading