This commit is contained in:
atagen 2026-03-16 22:23:10 +11:00
commit fd80fbab7e
48 changed files with 16775 additions and 0 deletions

View file

@ -0,0 +1,19 @@
[package]
name = "jupiter-forge"
version.workspace = true
edition.workspace = true
[dependencies]
jupiter-api-types = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View file

@ -0,0 +1,65 @@
//! Error types for the forge integration layer.
//!
//! [`ForgeError`] is the single error enum shared by all forge providers
//! (GitHub, Gitea, Radicle). It covers the full range of failure modes that
//! can occur during webhook verification, payload parsing, and outbound API
//! calls.
//!
//! The enum uses [`thiserror`] for ergonomic `Display` / `Error` derivation
//! and provides automatic `From` conversions for the two most common
//! underlying error types: [`reqwest::Error`] (HTTP client failures) and
//! [`serde_json::Error`] (JSON deserialization failures).
use thiserror::Error;
/// Unified error type for all forge operations.
///
/// Each variant maps to a distinct failure category so that callers in the
/// Jupiter server can decide how to respond (e.g. return HTTP 401 for
/// `InvalidSignature`, HTTP 400 for `ParseError`, HTTP 502 for `ApiError`).
#[derive(Debug, Error)]
pub enum ForgeError {
/// The webhook signature was structurally valid but did not match the
/// expected HMAC. The server should reject the request with HTTP 401.
///
/// Note: this variant is distinct from `verify_webhook` returning
/// `Ok(false)`. `Ok(false)` means the signature was absent or wrong;
/// `InvalidSignature` signals a structural problem detected during
/// verification (e.g. the HMAC could not be initialized).
#[error("invalid webhook signature")]
InvalidSignature,
/// The webhook carried an event type that this provider does not handle
/// and considers an error (as opposed to returning `Ok(None)` for
/// silently ignored events).
#[error("unsupported event type: {0}")]
UnsupportedEvent(String),
/// The webhook payload could not be deserialized or was missing required
/// fields. Also used for malformed signature headers (e.g. a GitHub
/// signature without the `sha256=` prefix).
#[error("parse error: {0}")]
ParseError(String),
/// An outbound API call to the forge succeeded at the HTTP level but
/// returned a non-success status code (4xx / 5xx). The string contains
/// the status code and response body for diagnostics.
#[error("API error: {0}")]
ApiError(String),
/// The underlying HTTP client (reqwest) encountered a transport-level
/// error (DNS failure, timeout, TLS error, etc.).
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
/// JSON serialization or deserialization failed. Automatically converted
/// from `serde_json::Error`.
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
/// The provider was asked to perform an operation that requires
/// configuration it does not have. For example, calling
/// `poll_changes` on a Radicle provider that is in webhook mode.
#[error("not configured: {0}")]
NotConfigured(String),
}

View file

@ -0,0 +1,441 @@
//! Gitea / Forgejo forge provider for Jupiter CI.
//!
//! This module implements the [`ForgeProvider`] trait for Gitea (and its
//! community fork Forgejo), handling:
//!
//! - **Webhook verification** using HMAC-SHA256 with the `X-Gitea-Signature`
//! header. Unlike GitHub, Gitea sends the raw hex digest **without** a
//! `sha256=` prefix. The constant-time comparison logic is otherwise
//! identical to the GitHub provider.
//!
//! - **Webhook parsing** for `push` and `pull_request` events (via the
//! `X-Gitea-Event` header). Unrecognized event types are silently ignored.
//!
//! - **Commit status reporting** via `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}`.
//!
//! - **Repository listing** via `GET /api/v1/repos/search`.
//!
//! ## Authentication Model
//!
//! Gitea uses **personal access tokens** (or OAuth2 tokens) for API
//! authentication. These are passed in the `Authorization: token <value>`
//! header -- note that Gitea uses the literal word `token` rather than
//! `Bearer` as the scheme.
//!
//! ## Differences from GitHub
//!
//! | Aspect | GitHub | Gitea |
//! |-----------------------|---------------------------------|----------------------------------|
//! | Signature header | `X-Hub-Signature-256` | `X-Gitea-Signature` |
//! | Signature format | `sha256=<hex>` | `<hex>` (no prefix) |
//! | Auth header | `Authorization: Bearer <tok>` | `Authorization: token <tok>` |
//! | API path prefix | `/repos/...` | `/api/v1/repos/...` |
//! | PR sync action name | `"synchronize"` | `"synchronized"` (extra "d") |
//! | User field name | Always `login` | `login` or `username` (varies) |
//! | PR ref fields | Always present | Optional (may be `null`) |
//!
//! The `"synchronize"` vs `"synchronized"` difference is a notable pitfall:
//! GitHub uses `"synchronize"` (no trailing "d") while Gitea uses
//! `"synchronized"` (with trailing "d"). Both mean "new commits were pushed
//! to the PR branch."
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tracing::{debug, warn};
use crate::error::ForgeError;
use crate::{ForgeProvider, RawForgeEvent};
use jupiter_api_types::{CommitStatus, CommitStatusUpdate, ForgeType, PullRequestAction};
/// Type alias for HMAC-SHA256, used for webhook signature verification.
type HmacSha256 = Hmac<Sha256>;
// ---------------------------------------------------------------------------
// Internal serde types for Gitea webhook payloads
// ---------------------------------------------------------------------------
//
// Gitea's webhook payloads are similar to GitHub's but differ in several
// structural details (see module-level docs). These structs capture only the
// fields Jupiter needs.
/// Payload for Gitea `push` events.
#[derive(Debug, Deserialize)]
struct GiteaPushPayload {
#[serde(rename = "ref")]
git_ref: String,
before: String,
after: String,
repository: GiteaRepo,
sender: GiteaUser,
}
/// Repository object embedded in Gitea webhook payloads.
#[derive(Debug, Deserialize)]
struct GiteaRepo {
owner: GiteaUser,
name: String,
#[allow(dead_code)]
clone_url: Option<String>,
}
/// User object in Gitea payloads.
///
/// Gitea is inconsistent about which field it populates: some payloads use
/// `login`, others use `username`. The [`name()`](GiteaUser::name) helper
/// tries both, falling back to `"unknown"`.
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: Option<String>,
username: Option<String>,
}
impl GiteaUser {
/// Extract a usable display name, preferring `login` over `username`.
///
/// Gitea populates different fields depending on the API version and
/// context, so we try both.
fn name(&self) -> String {
self.login
.as_deref()
.or(self.username.as_deref())
.unwrap_or("unknown")
.to_string()
}
}
/// Payload for Gitea `pull_request` events.
#[derive(Debug, Deserialize)]
struct GiteaPRPayload {
action: String,
number: u64,
pull_request: GiteaPR,
repository: GiteaRepo,
}
/// The `pull_request` object inside a Gitea PR event.
#[derive(Debug, Deserialize)]
struct GiteaPR {
head: GiteaPRRef,
base: GiteaPRRef,
}
/// A ref endpoint (head or base) of a Gitea pull request.
///
/// Unlike GitHub where `sha` and `ref` are always present, Gitea may return
/// `null` for these fields in some edge cases (e.g. deleted branches), so
/// they are `Option<String>`. The `label` field serves as a fallback for
/// `ref_name` in the base ref.
#[derive(Debug, Deserialize)]
struct GiteaPRRef {
sha: Option<String>,
#[serde(rename = "ref")]
ref_name: Option<String>,
label: Option<String>,
}
/// Request body for `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}`.
///
/// The field names and semantics mirror GitHub's status API, which Gitea
/// intentionally replicates for compatibility.
#[derive(Debug, Serialize)]
struct GiteaStatusRequest {
state: String,
context: String,
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
target_url: Option<String>,
}
/// Minimal repo item from the Gitea search API response.
#[derive(Debug, Deserialize)]
struct GiteaRepoListItem {
owner: GiteaUser,
name: String,
clone_url: String,
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/// Gitea / Forgejo forge provider.
///
/// Implements [`ForgeProvider`] for self-hosted Gitea and Forgejo instances.
///
/// # Fields
///
/// - `base_url` -- The instance URL (e.g. `https://gitea.example.com`), used
/// as the prefix for all API calls (`/api/v1/...`).
/// - `api_token` -- A personal access token or OAuth2 token, sent via
/// `Authorization: token <value>`.
/// - `webhook_secret` -- Shared HMAC-SHA256 secret for verifying incoming
/// webhooks.
/// - `client` -- A reusable `reqwest::Client` for connection pooling.
pub struct GiteaProvider {
/// Instance base URL (no trailing slash).
base_url: String,
/// Personal access token for API authentication.
api_token: String,
/// Shared HMAC secret for webhook verification.
webhook_secret: String,
/// Reusable HTTP client.
client: Client,
}
impl GiteaProvider {
/// Create a new Gitea provider.
///
/// # Parameters
///
/// * `base_url` -- The Gitea instance URL, e.g. `https://gitea.example.com`.
/// A trailing slash is stripped automatically.
/// * `api_token` -- Personal access token or OAuth2 token for API
/// authentication.
/// * `webhook_secret` -- Shared secret string configured in the Gitea
/// webhook settings, used for HMAC-SHA256 verification.
pub fn new(base_url: String, api_token: String, webhook_secret: String) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_token,
webhook_secret,
client: Client::new(),
}
}
/// Map Jupiter's [`CommitStatus`] enum to the string values expected by
/// the Gitea commit status API.
fn gitea_status_string(status: CommitStatus) -> &'static str {
match status {
CommitStatus::Pending => "pending",
CommitStatus::Success => "success",
CommitStatus::Failure => "failure",
CommitStatus::Error => "error",
}
}
/// Convert a Gitea PR action string to the internal [`PullRequestAction`].
///
/// Note the spelling difference: Gitea uses `"synchronized"` (with a
/// trailing "d") while GitHub uses `"synchronize"` (without). Both map
/// to [`PullRequestAction::Synchronize`] internally.
fn parse_pr_action(action: &str) -> Option<PullRequestAction> {
match action {
"opened" => Some(PullRequestAction::Opened),
"synchronized" => Some(PullRequestAction::Synchronize),
"reopened" => Some(PullRequestAction::Reopened),
"closed" => Some(PullRequestAction::Closed),
_ => None,
}
}
}
#[async_trait]
impl ForgeProvider for GiteaProvider {
fn forge_type(&self) -> ForgeType {
ForgeType::Gitea
}
/// Verify a Gitea webhook using HMAC-SHA256.
///
/// Gitea sends the `X-Gitea-Signature` header containing the **raw hex
/// HMAC-SHA256 digest** (no `sha256=` prefix, unlike GitHub). This is
/// the key protocol difference in signature format between the two forges.
///
/// The constant-time comparison logic is identical to the GitHub provider:
/// byte-wise XOR with OR accumulation to prevent timing attacks.
fn verify_webhook(
&self,
signature_header: Option<&str>,
body: &[u8],
) -> Result<bool, ForgeError> {
let hex_sig = match signature_header {
Some(h) => h,
None => return Ok(false),
};
// Gitea sends the raw hex HMAC-SHA256 (no "sha256=" prefix).
let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes())
.map_err(|e| ForgeError::ParseError(format!("HMAC init error: {e}")))?;
mac.update(body);
let result = hex::encode(mac.finalize().into_bytes());
if result.len() != hex_sig.len() {
return Ok(false);
}
let equal = result
.as_bytes()
.iter()
.zip(hex_sig.as_bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b));
Ok(equal == 0)
}
/// Parse a Gitea webhook payload into a [`RawForgeEvent`].
///
/// Recognized `X-Gitea-Event` values:
///
/// - `"push"` -- produces [`RawForgeEvent::Push`].
/// - `"pull_request"` -- produces [`RawForgeEvent::PullRequest`] for
/// `opened`, `synchronized`, `reopened`, and `closed` actions.
///
/// Gitea PR payloads have optional `sha` and `ref` fields (they can be
/// `null` for deleted branches), so this method handles `None` values
/// gracefully by defaulting to empty strings. The `base_ref` falls back
/// to the `label` field if `ref_name` is absent.
fn parse_webhook(
&self,
event_type: &str,
body: &[u8],
) -> Result<Option<RawForgeEvent>, ForgeError> {
match event_type {
"push" => {
let payload: GiteaPushPayload = serde_json::from_slice(body)?;
debug!(
repo = %payload.repository.name,
git_ref = %payload.git_ref,
"parsed Gitea push event"
);
Ok(Some(RawForgeEvent::Push {
repo_owner: payload.repository.owner.name(),
repo_name: payload.repository.name,
git_ref: payload.git_ref,
before: payload.before,
after: payload.after,
sender: payload.sender.name(),
}))
}
"pull_request" => {
let payload: GiteaPRPayload = serde_json::from_slice(body)?;
let action = match Self::parse_pr_action(&payload.action) {
Some(a) => a,
None => {
debug!(action = %payload.action, "ignoring Gitea PR action");
return Ok(None);
}
};
let head_sha = payload
.pull_request
.head
.sha
.unwrap_or_default();
let base_ref = payload
.pull_request
.base
.ref_name
.or(payload.pull_request.base.label)
.unwrap_or_default();
Ok(Some(RawForgeEvent::PullRequest {
repo_owner: payload.repository.owner.name(),
repo_name: payload.repository.name,
action,
pr_number: payload.number,
head_sha,
base_ref,
}))
}
other => {
debug!(event = %other, "ignoring unhandled Gitea event type");
Ok(None)
}
}
}
/// Report a commit status to Gitea via `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}`.
///
/// Gitea's status API is modeled after GitHub's, so the request body
/// is structurally identical. The key difference is the authentication
/// header: Gitea uses `Authorization: token <value>` rather than
/// `Authorization: Bearer <value>`.
async fn set_commit_status(
&self,
repo_owner: &str,
repo_name: &str,
commit_sha: &str,
status: &CommitStatusUpdate,
) -> Result<(), ForgeError> {
let url = format!(
"{}/api/v1/repos/{}/{}/statuses/{}",
self.base_url, repo_owner, repo_name, commit_sha,
);
let body = GiteaStatusRequest {
state: Self::gitea_status_string(status.status).to_string(),
context: status.context.clone(),
description: status.description.clone().unwrap_or_default(),
target_url: status.target_url.clone(),
};
let resp = self
.client
.post(&url)
.header("Authorization", format!("token {}", self.api_token))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!(%status_code, body = %text, "Gitea status API error");
return Err(ForgeError::ApiError(format!(
"Gitea API returned {status_code}: {text}"
)));
}
Ok(())
}
/// Return the clone URL for a Gitea repository.
///
/// Unlike GitHub (which embeds the access token in the URL), Gitea clone
/// URLs are plain HTTPS. Authentication for git operations is expected
/// to be handled out-of-band by the agent (e.g. via `.netrc`,
/// `credential.helper`, or an `http.extraheader` git config entry).
async fn clone_url(
&self,
repo_owner: &str,
repo_name: &str,
) -> Result<String, ForgeError> {
Ok(format!(
"{}/{}/{}.git",
self.base_url, repo_owner, repo_name,
))
}
/// List repositories accessible to the authenticated Gitea user.
///
/// Uses `GET /api/v1/repos/search?limit=50` to fetch repositories.
/// The `limit=50` parameter increases the page size from the default
/// (typically 20).
///
/// Note: This does not yet handle pagination; users with more than 50
/// accessible repositories will only see the first page.
async fn list_repos(&self) -> Result<Vec<(String, String, String)>, ForgeError> {
let url = format!("{}/api/v1/repos/search?limit=50", self.base_url);
let resp = self
.client
.get(&url)
.header("Authorization", format!("token {}", self.api_token))
.send()
.await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ForgeError::ApiError(format!(
"Gitea API returned {status_code}: {text}"
)));
}
let repos: Vec<GiteaRepoListItem> = resp.json().await?;
Ok(repos
.into_iter()
.map(|r| (r.owner.name(), r.name, r.clone_url))
.collect())
}
}

View file

@ -0,0 +1,496 @@
//! GitHub / GitHub Enterprise forge provider for Jupiter CI.
//!
//! This module implements the [`ForgeProvider`] trait for GitHub, handling:
//!
//! - **Webhook verification** using HMAC-SHA256 with the `X-Hub-Signature-256`
//! header. GitHub sends signatures in `sha256=<hex>` format; the provider
//! strips the prefix, computes the expected HMAC, and performs constant-time
//! comparison to prevent timing attacks.
//!
//! - **Webhook parsing** for `push` and `pull_request` events (via the
//! `X-GitHub-Event` header). Other event types (e.g. `star`, `fork`,
//! `issue_comment`) are silently ignored by returning `Ok(None)`.
//!
//! - **Commit status reporting** via `POST /repos/{owner}/{repo}/statuses/{sha}`.
//! This causes GitHub to show Jupiter CI results as status checks on PRs and
//! commits.
//!
//! - **Authenticated clone URLs** using the `x-access-token` scheme that GitHub
//! App installation tokens require.
//!
//! - **Repository listing** via the GitHub App installation API
//! (`GET /installation/repositories`).
//!
//! ## Authentication Model
//!
//! GitHub Apps authenticate in two stages:
//!
//! 1. The App signs a JWT with its RSA private key to identify itself.
//! 2. The JWT is exchanged for a short-lived **installation token** scoped to
//! the repositories the App has been installed on.
//!
//! Currently, the provider accepts a pre-minted installation token directly
//! (the `api_token` field). The `app_id` and `private_key_pem` fields are
//! stored for future automatic token rotation.
//!
//! ## GitHub Enterprise Support
//!
//! The [`GitHubProvider::with_api_base`] builder method allows pointing the
//! provider at a GitHub Enterprise instance by overriding the default
//! `https://api.github.com` base URL.
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tracing::{debug, warn};
use crate::error::ForgeError;
use crate::{ForgeProvider, RawForgeEvent};
use jupiter_api_types::{CommitStatus, CommitStatusUpdate, ForgeType, PullRequestAction};
/// Type alias for HMAC-SHA256, used for webhook signature verification.
type HmacSha256 = Hmac<Sha256>;
// ---------------------------------------------------------------------------
// Internal serde types for GitHub webhook payloads
// ---------------------------------------------------------------------------
//
// These structs mirror the relevant subset of GitHub's webhook JSON schemas.
// Only the fields that Jupiter needs are deserialized; everything else is
// silently ignored by serde.
/// Payload for `push` events.
#[derive(Debug, Deserialize)]
struct GitHubPushPayload {
/// Full git ref, e.g. `refs/heads/main` or `refs/tags/v1.0`.
#[serde(rename = "ref")]
git_ref: String,
/// SHA before the push (all-zeros for newly created refs).
before: String,
/// SHA after the push.
after: String,
repository: GitHubRepo,
sender: GitHubUser,
}
/// Repository object embedded in webhook payloads.
#[derive(Debug, Deserialize)]
struct GitHubRepo {
owner: GitHubRepoOwner,
name: String,
#[allow(dead_code)]
clone_url: Option<String>,
}
/// The `owner` sub-object inside a repository payload.
#[derive(Debug, Deserialize)]
struct GitHubRepoOwner {
login: String,
}
/// User object (sender) embedded in webhook payloads.
#[derive(Debug, Deserialize)]
struct GitHubUser {
login: String,
}
/// Payload for `pull_request` events.
#[derive(Debug, Deserialize)]
struct GitHubPRPayload {
/// The action that triggered this event (e.g. "opened", "synchronize").
action: String,
/// Pull request number.
number: u64,
pull_request: GitHubPR,
repository: GitHubRepo,
}
/// The `pull_request` object inside the PR event payload.
#[derive(Debug, Deserialize)]
struct GitHubPR {
head: GitHubPRRef,
base: GitHubPRRef,
}
/// A ref endpoint (head or base) of a pull request.
#[derive(Debug, Deserialize)]
struct GitHubPRRef {
/// The commit SHA at this ref.
sha: String,
/// Branch name.
#[serde(rename = "ref")]
ref_name: String,
}
/// Request body for `POST /repos/{owner}/{repo}/statuses/{sha}`.
///
/// Maps to the GitHub REST API "Create a commit status" endpoint.
/// The `target_url` field is optional and links back to the Jupiter
/// build page for the evaluation.
#[derive(Debug, Serialize)]
struct GitHubStatusRequest {
/// One of: `"pending"`, `"success"`, `"failure"`, `"error"`.
state: String,
/// A label that identifies this status (e.g. `"jupiter-ci/eval"`).
context: String,
/// Human-readable description of the status.
description: String,
/// Optional URL linking to the Jupiter build details page.
#[serde(skip_serializing_if = "Option::is_none")]
target_url: Option<String>,
}
/// Minimal repo item returned by `GET /installation/repositories`.
#[derive(Debug, Deserialize)]
struct GitHubRepoListItem {
owner: GitHubRepoOwner,
name: String,
clone_url: String,
}
/// Wrapper for the paginated response from `GET /installation/repositories`.
#[derive(Debug, Deserialize)]
struct GitHubInstallationReposResponse {
repositories: Vec<GitHubRepoListItem>,
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/// GitHub App forge provider.
///
/// Implements [`ForgeProvider`] for GitHub.com and GitHub Enterprise.
///
/// # Fields
///
/// - `api_base` -- Base URL for API requests (default: `https://api.github.com`).
/// Overridden via [`with_api_base`](GitHubProvider::with_api_base) for
/// GitHub Enterprise.
/// - `app_id` / `private_key_pem` -- GitHub App credentials, reserved for
/// future automatic JWT-based token rotation.
/// - `webhook_secret` -- The shared secret configured in the GitHub webhook
/// settings, used to compute the expected HMAC-SHA256 digest.
/// - `api_token` -- An installation access token (or personal access token)
/// used as a Bearer token for all outbound API requests.
/// - `client` -- A reusable `reqwest::Client` for connection pooling.
pub struct GitHubProvider {
/// Base URL for GitHub API requests (no trailing slash).
api_base: String,
/// GitHub App ID (reserved for future JWT-based token minting).
#[allow(dead_code)]
app_id: u64,
/// PEM-encoded RSA private key for the GitHub App (reserved for future
/// JWT-based token minting).
#[allow(dead_code)]
private_key_pem: String,
/// Shared HMAC secret for webhook signature verification.
webhook_secret: String,
/// Bearer token used for all outbound GitHub API calls.
api_token: String,
/// Reusable HTTP client with connection pooling.
client: Client,
}
impl GitHubProvider {
/// Create a new GitHub provider targeting `https://api.github.com`.
///
/// # Parameters
///
/// * `app_id` -- GitHub App ID (numeric). Currently stored for future
/// JWT-based token rotation; not used in API calls yet.
/// * `private_key_pem` -- PEM-encoded RSA private key for the GitHub App.
/// Stored for future JWT minting; not used directly yet.
/// * `webhook_secret` -- The shared secret string configured in the
/// GitHub webhook settings. Used to compute and verify HMAC-SHA256
/// signatures on incoming webhook payloads.
/// * `api_token` -- A valid GitHub installation access token (or personal
/// access token) used as a `Bearer` token for outbound API calls
/// (status updates, repo listing, etc.).
pub fn new(
app_id: u64,
private_key_pem: String,
webhook_secret: String,
api_token: String,
) -> Self {
Self {
api_base: "https://api.github.com".to_string(),
app_id,
private_key_pem,
webhook_secret,
api_token,
client: Client::new(),
}
}
/// Builder method: override the API base URL.
///
/// Use this for GitHub Enterprise Server instances or integration tests
/// with a mock server. The trailing slash is stripped automatically.
///
/// # Example
///
/// ```ignore
/// let provider = GitHubProvider::new(app_id, key, secret, token)
/// .with_api_base("https://github.corp.example.com/api/v3".into());
/// ```
pub fn with_api_base(mut self, base: String) -> Self {
self.api_base = base.trim_end_matches('/').to_string();
self
}
// -- helpers --
/// Map Jupiter's [`CommitStatus`] enum to the string values that the
/// GitHub REST API expects in the `state` field of a status request.
fn github_status_string(status: CommitStatus) -> &'static str {
match status {
CommitStatus::Pending => "pending",
CommitStatus::Success => "success",
CommitStatus::Failure => "failure",
CommitStatus::Error => "error",
}
}
/// Convert a GitHub PR action string to the internal [`PullRequestAction`]
/// enum.
///
/// Returns `None` for actions Jupiter does not act on (e.g. `"labeled"`,
/// `"assigned"`), which causes `parse_webhook` to return `Ok(None)` and
/// silently skip the event.
fn parse_pr_action(action: &str) -> Option<PullRequestAction> {
match action {
"opened" => Some(PullRequestAction::Opened),
"synchronize" => Some(PullRequestAction::Synchronize),
"reopened" => Some(PullRequestAction::Reopened),
"closed" => Some(PullRequestAction::Closed),
_ => None,
}
}
}
#[async_trait]
impl ForgeProvider for GitHubProvider {
fn forge_type(&self) -> ForgeType {
ForgeType::GitHub
}
/// Verify a GitHub webhook using HMAC-SHA256.
///
/// GitHub sends the `X-Hub-Signature-256` header with format
/// `sha256=<hex-digest>`. This method:
///
/// 1. Returns `Ok(false)` if the header is absent (webhook has no secret
/// configured, or the request is forged).
/// 2. Strips the `sha256=` prefix -- returns `Err(ParseError)` if missing.
/// 3. Computes HMAC-SHA256 over the raw body using the shared
/// `webhook_secret`.
/// 4. Compares the computed and received hex digests using **constant-time
/// XOR accumulation** to prevent timing side-channel attacks.
///
/// The constant-time comparison works by XOR-ing each pair of bytes and
/// OR-ing the results into an accumulator. If any byte differs, the
/// accumulator becomes non-zero. This avoids early-exit behavior that
/// would leak information about how many leading bytes match.
fn verify_webhook(
&self,
signature_header: Option<&str>,
body: &[u8],
) -> Result<bool, ForgeError> {
let sig_header = match signature_header {
Some(h) => h,
None => return Ok(false),
};
// GitHub sends "sha256=<hex>" -- strip the prefix.
let hex_sig = sig_header
.strip_prefix("sha256=")
.ok_or_else(|| ForgeError::ParseError("missing sha256= prefix".into()))?;
let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes())
.map_err(|e| ForgeError::ParseError(format!("HMAC init error: {e}")))?;
mac.update(body);
let result = hex::encode(mac.finalize().into_bytes());
// Constant-time comparison: XOR each byte pair and OR into accumulator.
// If lengths differ the signatures cannot match (and the length itself
// is not secret -- it is always 64 hex chars for SHA-256).
if result.len() != hex_sig.len() {
return Ok(false);
}
let equal = result
.as_bytes()
.iter()
.zip(hex_sig.as_bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b));
Ok(equal == 0)
}
/// Parse a GitHub webhook payload into a [`RawForgeEvent`].
///
/// Recognized `X-GitHub-Event` values:
///
/// - `"push"` -- branch/tag push; produces [`RawForgeEvent::Push`].
/// - `"pull_request"` -- PR lifecycle; produces [`RawForgeEvent::PullRequest`]
/// for `opened`, `synchronize`, `reopened`, and `closed` actions.
/// Other PR actions (e.g. `labeled`, `assigned`) return `Ok(None)`.
///
/// All other event types are silently ignored (`Ok(None)`).
fn parse_webhook(
&self,
event_type: &str,
body: &[u8],
) -> Result<Option<RawForgeEvent>, ForgeError> {
match event_type {
"push" => {
let payload: GitHubPushPayload = serde_json::from_slice(body)?;
debug!(
repo = %payload.repository.name,
git_ref = %payload.git_ref,
"parsed GitHub push event"
);
Ok(Some(RawForgeEvent::Push {
repo_owner: payload.repository.owner.login,
repo_name: payload.repository.name,
git_ref: payload.git_ref,
before: payload.before,
after: payload.after,
sender: payload.sender.login,
}))
}
"pull_request" => {
let payload: GitHubPRPayload = serde_json::from_slice(body)?;
let action = match Self::parse_pr_action(&payload.action) {
Some(a) => a,
None => {
debug!(action = %payload.action, "ignoring PR action");
return Ok(None);
}
};
Ok(Some(RawForgeEvent::PullRequest {
repo_owner: payload.repository.owner.login,
repo_name: payload.repository.name,
action,
pr_number: payload.number,
head_sha: payload.pull_request.head.sha,
base_ref: payload.pull_request.base.ref_name,
}))
}
other => {
debug!(event = %other, "ignoring unhandled GitHub event type");
Ok(None)
}
}
}
/// Report a commit status to GitHub via `POST /repos/{owner}/{repo}/statuses/{sha}`.
///
/// This makes Jupiter CI results appear as status checks on pull requests
/// and commit pages in the GitHub UI. The request uses Bearer
/// authentication with the installation token and includes the
/// `application/vnd.github+json` Accept header as recommended by the
/// GitHub REST API documentation.
async fn set_commit_status(
&self,
repo_owner: &str,
repo_name: &str,
commit_sha: &str,
status: &CommitStatusUpdate,
) -> Result<(), ForgeError> {
let url = format!(
"{}/repos/{}/{}/statuses/{}",
self.api_base, repo_owner, repo_name, commit_sha,
);
let body = GitHubStatusRequest {
state: Self::github_status_string(status.status).to_string(),
context: status.context.clone(),
description: status.description.clone().unwrap_or_default(),
target_url: status.target_url.clone(),
};
let resp = self
.client
.post(&url)
.bearer_auth(&self.api_token)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "jupiter-ci")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!(%status_code, body = %text, "GitHub status API error");
return Err(ForgeError::ApiError(format!(
"GitHub API returned {status_code}: {text}"
)));
}
Ok(())
}
/// Return an authenticated HTTPS clone URL for a GitHub repository.
///
/// GitHub App installation tokens authenticate via a special username:
/// `x-access-token`. The resulting URL has the form:
///
/// ```text
/// https://x-access-token:<token>@github.com/<owner>/<repo>.git
/// ```
///
/// Hercules agents use this URL directly with `git clone` -- no additional
/// credential helper configuration is needed.
///
/// Note: For GitHub Enterprise the URL host would need to be derived from
/// `api_base`; the current implementation hardcodes `github.com`.
async fn clone_url(
&self,
repo_owner: &str,
repo_name: &str,
) -> Result<String, ForgeError> {
Ok(format!(
"https://x-access-token:{}@github.com/{}/{}.git",
self.api_token, repo_owner, repo_name,
))
}
/// List repositories accessible to the GitHub App installation.
///
/// Calls `GET /installation/repositories` which returns all repos the
/// App has been granted access to. The response is mapped to
/// `(owner, name, clone_url)` tuples.
///
/// Note: This does not yet handle pagination; installations with more
/// than 30 repositories (GitHub's default page size) will only return
/// the first page.
async fn list_repos(&self) -> Result<Vec<(String, String, String)>, ForgeError> {
let url = format!("{}/installation/repositories", self.api_base);
let resp = self
.client
.get(&url)
.bearer_auth(&self.api_token)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "jupiter-ci")
.send()
.await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ForgeError::ApiError(format!(
"GitHub API returned {status_code}: {text}"
)));
}
let body: GitHubInstallationReposResponse = resp.json().await?;
Ok(body
.repositories
.into_iter()
.map(|r| (r.owner.login, r.name, r.clone_url))
.collect())
}
}

View file

@ -0,0 +1,281 @@
//! # jupiter-forge -- Forge Integration Layer for Jupiter CI
//!
//! This crate is the bridge between Jupiter (a self-hosted, wire-compatible
//! replacement for [hercules-ci.com](https://hercules-ci.com)) and the external
//! code forges (GitHub, Gitea, Radicle) where source code lives.
//!
//! ## Architectural Role
//!
//! In the Hercules CI model, the server must:
//!
//! 1. **Receive webhooks** when code changes (pushes, pull requests, patches).
//! 2. **Verify** the webhook is authentic (HMAC signatures or trusted transport).
//! 3. **Parse** the forge-specific JSON payload into a common internal event.
//! 4. **Report CI status** back to the forge so that PR/patch checks reflect the
//! build outcome (pending / success / failure).
//! 5. **Provide authenticated clone URLs** so that Hercules agents can fetch
//! the source code.
//!
//! This crate encapsulates steps 1-5 behind the [`ForgeProvider`] trait. The
//! Jupiter server holds a registry of providers keyed by [`ForgeType`]; when an
//! HTTP request arrives at the webhook endpoint, the server inspects headers to
//! determine the forge, looks up the matching provider, calls
//! [`ForgeProvider::verify_webhook`] and then [`ForgeProvider::parse_webhook`].
//!
//! ## Why `RawForgeEvent` Instead of Database Types?
//!
//! Webhook payloads identify repositories with forge-native names (e.g.
//! `owner/repo` on GitHub, an RID on Radicle) rather than Jupiter database
//! UUIDs. [`RawForgeEvent`] preserves these raw identifiers so the crate
//! stays independent of any database layer. The server resolves raw events
//! into fully-typed `jupiter_api_types::ForgeEvent` objects with DB-backed
//! entity references before scheduling evaluations.
//!
//! ## Protocol Differences Between Forges
//!
//! | Aspect | GitHub | Gitea | Radicle |
//! |----------------------|---------------------------------|-------------------------------|--------------------------------|
//! | **Signature header** | `X-Hub-Signature-256` | `X-Gitea-Signature` | None (trusted local transport) |
//! | **Signature format** | `sha256=<hex>` (prefixed) | Raw hex HMAC-SHA256 | N/A |
//! | **Event header** | `X-GitHub-Event` | `X-Gitea-Event` | CI broker message type |
//! | **Auth model** | GitHub App (installation token) | Personal access token | Local node identity |
//! | **Clone URL** | `https://x-access-token:<tok>@github.com/...` | `https://<host>/<owner>/<repo>.git` | `rad://<RID>` |
//! | **Repo identifier** | `(owner, name)` | `(owner, name)` | RID string |
//! | **PR concept** | Pull Request | Pull Request | Patch (with revisions) |
//!
//! ## Submodules
//!
//! - [`error`] -- shared error enum for all forge operations.
//! - [`github`] -- GitHub / GitHub Enterprise provider.
//! - [`gitea`] -- Gitea / Forgejo provider.
//! - [`radicle`] -- Radicle provider (webhook and polling modes).
pub mod error;
pub mod gitea;
pub mod github;
pub mod radicle;
use async_trait::async_trait;
use jupiter_api_types::{CommitStatusUpdate, ForgeType, PullRequestAction};
/// A raw forge event carrying repository-identifying information (owner/name or RID)
/// rather than a resolved database UUID.
///
/// The server layer resolves these into `jupiter_api_types::ForgeEvent` by
/// looking up the repository in the Jupiter database.
///
/// # Design Rationale
///
/// This enum intentionally uses `String` fields (not database IDs) so that the
/// forge crate has zero coupling to the database schema. Each forge backend
/// populates the variant that matches the incoming webhook:
///
/// - **GitHub / Gitea**: emit [`Push`](RawForgeEvent::Push) or
/// [`PullRequest`](RawForgeEvent::PullRequest) with `repo_owner` and
/// `repo_name` strings extracted from the JSON payload.
/// - **Radicle**: emits [`PatchUpdated`](RawForgeEvent::PatchUpdated) with the
/// Radicle RID (e.g. `rad:z2...`) and patch/revision identifiers, or
/// [`Push`](RawForgeEvent::Push) with the RID in the `repo_name` field and
/// an empty `repo_owner` (Radicle has no owner concept).
#[derive(Debug, Clone)]
pub enum RawForgeEvent {
/// A branch or tag was pushed.
///
/// Emitted by all three forges. For Radicle push events the `repo_owner`
/// is empty and `repo_name` holds the RID.
Push {
/// Repository owner login (empty for Radicle).
repo_owner: String,
/// Repository name, or the Radicle RID for Radicle push events.
repo_name: String,
/// Full git ref, e.g. `refs/heads/main`.
git_ref: String,
/// Commit SHA before the push (all-zeros for new branches).
before: String,
/// Commit SHA after the push.
after: String,
/// Login / node-ID of the user who pushed.
sender: String,
},
/// A pull request was opened, synchronized, reopened, or closed.
///
/// Used by GitHub and Gitea. Radicle uses [`PatchUpdated`](RawForgeEvent::PatchUpdated)
/// instead, since its collaboration model is patch-based rather than
/// branch-based.
PullRequest {
/// Repository owner login.
repo_owner: String,
/// Repository name.
repo_name: String,
/// The action that triggered this event (opened, synchronize, etc.).
action: PullRequestAction,
/// Pull request number.
pr_number: u64,
/// SHA of the head commit on the PR branch.
head_sha: String,
/// Name of the base branch the PR targets.
base_ref: String,
},
/// A Radicle patch was created or updated with a new revision.
///
/// Radicle's collaboration model uses "patches" instead of pull requests.
/// Each patch can have multiple revisions (analogous to force-pushing a
/// PR branch). This event fires for both new patches and new revisions
/// on existing patches.
PatchUpdated {
/// The Radicle Repository ID (e.g. `rad:z2...`).
repo_rid: String,
/// The unique patch identifier.
patch_id: String,
/// The specific revision within the patch.
revision_id: String,
/// The commit SHA at the tip of this revision.
head_sha: String,
},
}
/// Trait implemented by each forge backend (GitHub, Gitea, Radicle).
///
/// The Jupiter server maintains a `HashMap<ForgeType, Box<dyn ForgeProvider>>`
/// registry. When a webhook request arrives, the server:
///
/// 1. Determines the [`ForgeType`] from request headers.
/// 2. Looks up the corresponding `ForgeProvider`.
/// 3. Calls [`verify_webhook`](ForgeProvider::verify_webhook) to authenticate
/// the request.
/// 4. Calls [`parse_webhook`](ForgeProvider::parse_webhook) to extract a
/// [`RawForgeEvent`].
/// 5. Later calls [`set_commit_status`](ForgeProvider::set_commit_status) to
/// report evaluation results back to the forge.
///
/// # Sync vs Async Methods
///
/// Webhook verification and parsing are **synchronous** because they operate
/// purely on in-memory data (HMAC computation + JSON deserialization). Methods
/// that perform HTTP requests to the forge API (`set_commit_status`,
/// `clone_url`, `list_repos`) are **async**.
///
/// # Thread Safety
///
/// Providers must be `Send + Sync` so they can be shared across the async
/// task pool in the server. All mutable state (e.g. HTTP clients) is
/// internally synchronized by `reqwest::Client`.
#[async_trait]
pub trait ForgeProvider: Send + Sync {
/// Returns the [`ForgeType`] discriminant for this provider.
///
/// Used by the server to route incoming webhooks to the correct provider
/// implementation.
fn forge_type(&self) -> ForgeType;
/// Verify the authenticity of an incoming webhook request.
///
/// Each forge uses a different mechanism:
///
/// - **GitHub**: HMAC-SHA256 with a `sha256=` hex prefix in the
/// `X-Hub-Signature-256` header.
/// - **Gitea**: HMAC-SHA256 with raw hex in the `X-Gitea-Signature` header.
/// - **Radicle**: No signature -- Radicle CI broker connections are trusted
/// local transport, so this always returns `Ok(true)`.
///
/// Both GitHub and Gitea implementations use **constant-time comparison**
/// (byte-wise XOR accumulation) to prevent timing side-channel attacks
/// that could allow an attacker to iteratively guess a valid signature.
///
/// # Parameters
///
/// * `signature_header` -- the raw value of the forge's signature header,
/// or `None` if the header was absent. A missing header causes GitHub
/// and Gitea to return `Ok(false)`.
/// * `body` -- the raw HTTP request body bytes used as HMAC input.
///
/// # Returns
///
/// * `Ok(true)` -- signature is valid.
/// * `Ok(false)` -- signature is invalid or missing.
/// * `Err(ForgeError)` -- the signature header was malformed or HMAC
/// initialization failed.
fn verify_webhook(
&self,
signature_header: Option<&str>,
body: &[u8],
) -> Result<bool, error::ForgeError>;
/// Parse a verified webhook payload into a [`RawForgeEvent`].
///
/// This should only be called **after** [`verify_webhook`](ForgeProvider::verify_webhook)
/// returns `Ok(true)`.
///
/// # Parameters
///
/// * `event_type` -- the value of the forge's event-type header:
/// - GitHub: `X-GitHub-Event` (e.g. `"push"`, `"pull_request"`).
/// - Gitea: `X-Gitea-Event` (e.g. `"push"`, `"pull_request"`).
/// - Radicle: CI broker message type (e.g. `"patch"`, `"push"`).
/// * `body` -- the raw JSON request body.
///
/// # Returns
///
/// * `Ok(Some(event))` -- a recognized, actionable event.
/// * `Ok(None)` -- a valid but uninteresting event that Jupiter does not
/// act on (e.g. GitHub "star" or "fork" events, or unhandled PR actions
/// like "labeled").
/// * `Err(ForgeError)` -- the payload could not be parsed.
fn parse_webhook(
&self,
event_type: &str,
body: &[u8],
) -> Result<Option<RawForgeEvent>, error::ForgeError>;
/// Report a commit status back to the forge so that PR checks / patch
/// status reflect the Jupiter CI evaluation result.
///
/// This is the primary feedback mechanism: when an agent finishes
/// evaluating a jobset, the server calls this method to set the commit
/// to `pending`, `success`, or `failure`. On GitHub and Gitea this
/// creates a "status check" visible in the PR UI. On Radicle it posts
/// to the `radicle-httpd` status API.
///
/// # Parameters
///
/// * `repo_owner` -- repository owner (ignored for Radicle).
/// * `repo_name` -- repository name, or RID for Radicle.
/// * `commit_sha` -- the full commit SHA to attach the status to.
/// * `status` -- the status payload (state, context, description, URL).
async fn set_commit_status(
&self,
repo_owner: &str,
repo_name: &str,
commit_sha: &str,
status: &CommitStatusUpdate,
) -> Result<(), error::ForgeError>;
/// Return an authenticated clone URL that a Hercules agent can use to
/// fetch the repository.
///
/// The URL format varies by forge:
///
/// - **GitHub**: `https://x-access-token:<installation-token>@github.com/<owner>/<repo>.git`
/// -- uses the special `x-access-token` username that GitHub App
/// installation tokens require.
/// - **Gitea**: `https://<host>/<owner>/<repo>.git` -- authentication is
/// handled out-of-band (e.g. via git credential helpers or `.netrc`).
/// - **Radicle**: `rad://<RID>` -- the native Radicle protocol URL; the
/// local Radicle node handles authentication via its cryptographic
/// identity.
async fn clone_url(
&self,
repo_owner: &str,
repo_name: &str,
) -> Result<String, error::ForgeError>;
/// List all repositories accessible through this forge connection.
///
/// Returns a vec of `(owner, name, clone_url)` tuples. For Radicle the
/// `owner` is always an empty string since Radicle repositories are
/// identified solely by their RID.
///
/// Used during initial setup and periodic sync to discover which
/// repositories should be tracked by Jupiter.
async fn list_repos(&self) -> Result<Vec<(String, String, String)>, error::ForgeError>;
}

View file

@ -0,0 +1,506 @@
//! Radicle forge provider for Jupiter CI.
//!
//! [Radicle](https://radicle.xyz) is a peer-to-peer code collaboration
//! network. Unlike GitHub and Gitea, Radicle has no central server -- code
//! is replicated across nodes using a gossip protocol. This creates
//! fundamental differences in how Jupiter integrates with Radicle compared
//! to the other forges:
//!
//! - **No webhook signatures**: Radicle CI broker events arrive over trusted
//! local transport (typically a Unix socket or localhost HTTP), so there is
//! no HMAC verification. [`verify_webhook`](ForgeProvider::verify_webhook)
//! always returns `Ok(true)`.
//!
//! - **Repository identity**: Repos are identified by a Radicle ID (RID, e.g.
//! `rad:z2...`) rather than an `(owner, name)` pair. The `repo_owner`
//! field is always empty in events from this provider.
//!
//! - **Patches instead of PRs**: Radicle uses "patches" as its collaboration
//! primitive. Each patch can have multiple revisions (analogous to
//! force-pushing a PR branch). This provider emits
//! [`RawForgeEvent::PatchUpdated`] rather than `PullRequest`.
//!
//! - **Clone URLs**: Radicle repos use the `rad://` protocol, not HTTPS.
//! Agents must have the `rad` CLI and a local Radicle node configured.
//!
//! ## Two Operating Modes
//!
//! The provider supports two modes, selected via [`RadicleMode`]:
//!
//! ### `RadicleMode::CiBroker` (Webhook Mode)
//!
//! In this mode, the `radicle-ci-broker` component pushes events to Jupiter
//! as HTTP POST requests. The provider parses these using
//! [`parse_webhook`](ForgeProvider::parse_webhook) with event types like
//! `"patch"`, `"patch_updated"`, `"patch_created"`, or `"push"`.
//!
//! ### `RadicleMode::HttpdPolling` (Polling Mode)
//!
//! In this mode, Jupiter periodically calls [`poll_changes`](RadicleProvider::poll_changes)
//! to query the `radicle-httpd` REST API for new or updated patches. The
//! caller maintains a `HashMap<String, String>` of `"rid/patch/id" -> last-seen-oid`
//! entries. The provider compares the current state against this map and
//! returns events for any changes detected.
//!
//! Polling is useful when `radicle-ci-broker` is not available, at the cost
//! of higher latency (changes are detected at the poll interval rather than
//! immediately).
use std::collections::HashMap;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use tracing::{debug, warn};
use crate::error::ForgeError;
use crate::{ForgeProvider, RawForgeEvent};
use jupiter_api_types::{CommitStatus, CommitStatusUpdate, ForgeType, RadicleMode};
// ---------------------------------------------------------------------------
// Internal serde types for Radicle httpd API responses
// ---------------------------------------------------------------------------
//
// These structs model the subset of the `radicle-httpd` REST API that Jupiter
// needs for polling mode and status reporting.
/// A repository as returned by `GET /api/v1/repos` on `radicle-httpd`.
#[derive(Debug, Deserialize)]
struct RadicleRepo {
/// The Radicle Repository ID (e.g. `rad:z2...`).
rid: String,
/// Human-readable repository name.
name: String,
#[allow(dead_code)]
description: Option<String>,
}
/// A patch (Radicle's equivalent of a pull request) from the httpd API.
#[derive(Debug, Deserialize)]
struct RadiclePatch {
/// Unique patch identifier.
id: String,
#[allow(dead_code)]
title: String,
#[allow(dead_code)]
state: RadiclePatchState,
/// Ordered list of revisions; the last entry is the most recent.
revisions: Vec<RadicleRevision>,
}
/// A single revision within a Radicle patch.
///
/// Each revision represents a complete rewrite of the patch (analogous to
/// force-pushing a PR branch). The `oid` is the git commit SHA at the
/// tip of that revision.
#[derive(Debug, Deserialize)]
struct RadicleRevision {
/// Revision identifier.
id: String,
/// Git object ID (commit SHA) at the tip of this revision.
oid: String,
}
/// The state sub-object of a Radicle patch (e.g. "open", "merged", "closed").
#[derive(Debug, Deserialize)]
struct RadiclePatchState {
#[allow(dead_code)]
status: String,
}
/// Webhook payload from the Radicle CI broker or a custom webhook sender.
///
/// All fields are optional because Radicle uses a single payload structure
/// for multiple event types. Patch events populate `rid`, `patch_id`,
/// `revision_id`, and `commit`. Push events populate `rid`, `git_ref`,
/// `before`, `commit`, and `sender`.
#[derive(Debug, Deserialize)]
struct RadicleBrokerPayload {
/// The repository RID, e.g. `rad:z2...`.
rid: Option<String>,
/// Patch ID (present for patch events).
patch_id: Option<String>,
/// Revision ID within the patch (present for patch events).
revision_id: Option<String>,
/// The commit SHA at HEAD.
commit: Option<String>,
/// Full git ref (present for push events), e.g. `refs/heads/main`.
#[serde(rename = "ref")]
git_ref: Option<String>,
/// Previous commit SHA (present for push events).
before: Option<String>,
/// Sender node ID or identity (present for push events).
sender: Option<String>,
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/// Radicle forge provider for Jupiter CI.
///
/// Supports two operating modes:
///
/// - **`CiBroker`** (webhook mode): receives JSON events pushed by
/// `radicle-ci-broker` over trusted local HTTP.
/// - **`HttpdPolling`** (poll mode): periodically queries `radicle-httpd`
/// for new patches and compares against last-seen state.
///
/// # Fields
///
/// - `httpd_url` -- Base URL of the `radicle-httpd` instance (e.g.
/// `http://localhost:8080`). Used for API calls in both modes.
/// - `node_id` -- The local Radicle node identity. Reserved for future use
/// (e.g. authenticating status updates).
/// - `mode` -- The active operating mode ([`RadicleMode`]).
/// - `poll_interval_secs` -- Seconds between poll cycles (only meaningful in
/// `HttpdPolling` mode; the actual scheduling is done by the caller).
/// - `client` -- Reusable `reqwest::Client` for connection pooling.
pub struct RadicleProvider {
/// Base URL of the `radicle-httpd` REST API (no trailing slash).
httpd_url: String,
/// Local Radicle node ID (reserved for future authenticated operations).
#[allow(dead_code)]
node_id: String,
/// Operating mode: webhook (`CiBroker`) or poll (`HttpdPolling`).
mode: RadicleMode,
/// Poll interval in seconds (only used in `HttpdPolling` mode).
#[allow(dead_code)]
poll_interval_secs: u64,
/// Reusable HTTP client.
client: Client,
}
impl RadicleProvider {
/// Create a new Radicle provider.
///
/// # Parameters
///
/// * `httpd_url` -- Base URL of the local `radicle-httpd` instance, e.g.
/// `http://localhost:8080`. A trailing slash is stripped automatically.
/// This URL is used for:
/// - Fetching repos and patches during polling (`/api/v1/repos`).
/// - Posting commit statuses (`/api/v1/repos/{rid}/statuses/{sha}`).
/// * `node_id` -- The local Radicle node identity string. Currently
/// stored but not used; will be needed for future authenticated
/// operations.
/// * `mode` -- [`RadicleMode::CiBroker`] for webhook-driven operation or
/// [`RadicleMode::HttpdPolling`] for periodic polling.
/// * `poll_interval_secs` -- How often (in seconds) the server should
/// call [`poll_changes`](RadicleProvider::poll_changes). Only
/// meaningful in `HttpdPolling` mode; the actual timer is managed by
/// the server, not this provider.
pub fn new(
httpd_url: String,
node_id: String,
mode: RadicleMode,
poll_interval_secs: u64,
) -> Self {
Self {
httpd_url: httpd_url.trim_end_matches('/').to_string(),
node_id,
mode,
poll_interval_secs,
client: Client::new(),
}
}
/// Poll `radicle-httpd` for new or updated patches.
///
/// This is the core of `HttpdPolling` mode. The caller maintains a
/// persistent map of `"rid/patch/id" -> last-seen-oid` entries and passes
/// it in as `known_refs`. The method:
///
/// 1. Fetches all repos from `radicle-httpd`.
/// 2. For each repo, fetches all patches.
/// 3. For each patch, looks at the **latest revision** (the last element
/// in the `revisions` array).
/// 4. Compares the revision's `oid` against the `known_refs` map.
/// 5. If the oid is different (or absent from the map), emits a
/// [`RawForgeEvent::PatchUpdated`].
///
/// The caller is responsible for updating `known_refs` with the returned
/// events after processing them.
///
/// # Errors
///
/// Returns `Err(ForgeError::NotConfigured)` if called on a provider in
/// `CiBroker` mode, since polling is not applicable when events are
/// pushed by the broker.
///
/// # Parameters
///
/// * `known_refs` -- Map of `"rid/patch/id"` keys to last-known commit
/// SHA values. An empty map means all patches will be reported as new.
pub async fn poll_changes(
&self,
known_refs: &HashMap<String, String>,
) -> Result<Vec<RawForgeEvent>, ForgeError> {
if self.mode != RadicleMode::HttpdPolling {
return Err(ForgeError::NotConfigured(
"poll_changes called but provider is in webhook mode".into(),
));
}
let repos = self.fetch_repos().await?;
let mut events = Vec::new();
for repo in &repos {
// Check patches for each repo.
let patches = self.fetch_patches(&repo.rid).await?;
for patch in patches {
if let Some(rev) = patch.revisions.last() {
let key = format!("{}/patch/{}", repo.rid, patch.id);
let is_new = known_refs
.get(&key)
.map(|prev| prev != &rev.oid)
.unwrap_or(true);
if is_new {
events.push(RawForgeEvent::PatchUpdated {
repo_rid: repo.rid.clone(),
patch_id: patch.id.clone(),
revision_id: rev.id.clone(),
head_sha: rev.oid.clone(),
});
}
}
}
}
Ok(events)
}
// -- internal helpers ---------------------------------------------------
/// Fetch the list of all repositories known to `radicle-httpd`.
///
/// Calls `GET /api/v1/repos`. No authentication is required because
/// `radicle-httpd` runs locally and trusts all connections.
async fn fetch_repos(&self) -> Result<Vec<RadicleRepo>, ForgeError> {
let url = format!("{}/api/v1/repos", self.httpd_url);
let resp = self.client.get(&url).send().await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ForgeError::ApiError(format!(
"radicle-httpd returned {status_code}: {text}"
)));
}
let repos: Vec<RadicleRepo> = resp.json().await?;
Ok(repos)
}
/// Fetch all patches for a given repository.
///
/// Calls `GET /api/v1/repos/{rid}/patches`. Returns patches in all
/// states (open, merged, closed) so that the polling logic can detect
/// new revisions even on previously-seen patches.
async fn fetch_patches(&self, rid: &str) -> Result<Vec<RadiclePatch>, ForgeError> {
let url = format!("{}/api/v1/repos/{}/patches", self.httpd_url, rid);
let resp = self.client.get(&url).send().await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ForgeError::ApiError(format!(
"radicle-httpd returned {status_code}: {text}"
)));
}
let patches: Vec<RadiclePatch> = resp.json().await?;
Ok(patches)
}
/// Map Jupiter's [`CommitStatus`] enum to the string values expected by
/// the `radicle-httpd` status API.
fn radicle_status_string(status: CommitStatus) -> &'static str {
match status {
CommitStatus::Pending => "pending",
CommitStatus::Success => "success",
CommitStatus::Failure => "failure",
CommitStatus::Error => "error",
}
}
}
#[async_trait]
impl ForgeProvider for RadicleProvider {
fn forge_type(&self) -> ForgeType {
ForgeType::Radicle
}
/// Radicle webhook verification is a **no-op** that always returns
/// `Ok(true)`.
///
/// Unlike GitHub and Gitea, Radicle CI broker events arrive over trusted
/// local transport (e.g. a localhost HTTP connection or a Unix socket).
/// There is no shared secret or HMAC signature because the threat model
/// assumes the broker and Jupiter run on the same machine or within a
/// trusted network boundary.
///
/// If Jupiter is exposed to untrusted networks, access to the Radicle
/// webhook endpoint should be restricted at the network layer (firewall,
/// reverse proxy allowlist, etc.).
fn verify_webhook(
&self,
_signature_header: Option<&str>,
_body: &[u8],
) -> Result<bool, ForgeError> {
Ok(true)
}
/// Parse a Radicle CI broker webhook payload.
///
/// Recognized event types:
///
/// - `"patch"`, `"patch_updated"`, `"patch_created"` -- a Radicle patch
/// was created or received a new revision. Produces
/// [`RawForgeEvent::PatchUpdated`]. The `rid`, `patch_id`, and
/// `commit` fields are required; `revision_id` defaults to empty.
///
/// - `"push"` -- a ref was updated on a Radicle repository. Produces
/// [`RawForgeEvent::Push`] with `repo_owner` set to empty (Radicle
/// has no owner concept) and `repo_name` set to the RID. The `git_ref`
/// defaults to `"refs/heads/main"` and `before` defaults to 40 zeros
/// (indicating a new ref) if not present in the payload.
///
/// All other event types are silently ignored.
fn parse_webhook(
&self,
event_type: &str,
body: &[u8],
) -> Result<Option<RawForgeEvent>, ForgeError> {
match event_type {
"patch" | "patch_updated" | "patch_created" => {
let payload: RadicleBrokerPayload = serde_json::from_slice(body)?;
let rid = payload.rid.ok_or_else(|| {
ForgeError::ParseError("missing rid in Radicle patch event".into())
})?;
let patch_id = payload.patch_id.ok_or_else(|| {
ForgeError::ParseError("missing patch_id in Radicle patch event".into())
})?;
let revision_id = payload.revision_id.unwrap_or_default();
let head_sha = payload.commit.ok_or_else(|| {
ForgeError::ParseError("missing commit in Radicle patch event".into())
})?;
debug!(%rid, %patch_id, "parsed Radicle patch event");
Ok(Some(RawForgeEvent::PatchUpdated {
repo_rid: rid,
patch_id,
revision_id,
head_sha,
}))
}
"push" => {
let payload: RadicleBrokerPayload = serde_json::from_slice(body)?;
let rid = payload.rid.ok_or_else(|| {
ForgeError::ParseError("missing rid in Radicle push event".into())
})?;
let git_ref = payload.git_ref.unwrap_or_else(|| "refs/heads/main".into());
let before = payload.before.unwrap_or_else(|| "0".repeat(40));
let after = payload.commit.unwrap_or_default();
let sender = payload.sender.unwrap_or_default();
debug!(%rid, %git_ref, "parsed Radicle push event");
Ok(Some(RawForgeEvent::Push {
repo_owner: String::new(),
repo_name: rid,
git_ref,
before,
after,
sender,
}))
}
other => {
debug!(event = %other, "ignoring unhandled Radicle event type");
Ok(None)
}
}
}
/// Report a commit status to `radicle-httpd` via
/// `POST /api/v1/repos/{rid}/statuses/{sha}`.
///
/// For Radicle, the `repo_owner` parameter is ignored (Radicle repos have
/// no owner) and `repo_name` is expected to contain the RID.
///
/// No authentication headers are sent because `radicle-httpd` runs
/// locally and trusts all connections.
async fn set_commit_status(
&self,
_repo_owner: &str,
repo_name: &str,
commit_sha: &str,
status: &CommitStatusUpdate,
) -> Result<(), ForgeError> {
// repo_name is the RID for Radicle repos.
let url = format!(
"{}/api/v1/repos/{}/statuses/{}",
self.httpd_url, repo_name, commit_sha,
);
let body = serde_json::json!({
"state": Self::radicle_status_string(status.status),
"context": status.context,
"description": status.description,
"target_url": status.target_url,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let status_code = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!(%status_code, body = %text, "radicle-httpd status API error");
return Err(ForgeError::ApiError(format!(
"radicle-httpd returned {status_code}: {text}"
)));
}
Ok(())
}
/// Return a `rad://` clone URL for a Radicle repository.
///
/// Radicle uses its own protocol (`rad://`) for cloning. The agent
/// machine must have the `rad` CLI installed and a local Radicle node
/// running to resolve and fetch from this URL.
///
/// If the RID already starts with `rad:`, it is returned as-is.
/// Otherwise, the `rad://` prefix is prepended. The `repo_owner`
/// parameter is ignored (Radicle has no owner concept).
async fn clone_url(
&self,
_repo_owner: &str,
repo_name: &str,
) -> Result<String, ForgeError> {
// For Radicle the "repo_name" is the RID.
// Ensure it has the rad:// prefix.
if repo_name.starts_with("rad:") {
Ok(repo_name.to_string())
} else {
Ok(format!("rad://{repo_name}"))
}
}
/// List all repositories known to the local `radicle-httpd` instance.
///
/// Returns `(owner, name, clone_url)` tuples where:
/// - `owner` is always an empty string (Radicle has no owner concept).
/// - `name` is the human-readable repository name.
/// - `clone_url` is the `rad://` URL derived from the RID.
async fn list_repos(&self) -> Result<Vec<(String, String, String)>, ForgeError> {
let repos = self.fetch_repos().await?;
Ok(repos
.into_iter()
.map(|r| {
let clone_url = if r.rid.starts_with("rad:") {
r.rid.clone()
} else {
format!("rad://{}", r.rid)
};
// Radicle has no "owner"; use empty string.
(String::new(), r.name, clone_url)
})
.collect())
}
}