init
This commit is contained in:
commit
fd80fbab7e
48 changed files with 16775 additions and 0 deletions
19
crates/jupiter-forge/Cargo.toml
Normal file
19
crates/jupiter-forge/Cargo.toml
Normal 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 }
|
||||
65
crates/jupiter-forge/src/error.rs
Normal file
65
crates/jupiter-forge/src/error.rs
Normal 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),
|
||||
}
|
||||
441
crates/jupiter-forge/src/gitea.rs
Normal file
441
crates/jupiter-forge/src/gitea.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
496
crates/jupiter-forge/src/github.rs
Normal file
496
crates/jupiter-forge/src/github.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
281
crates/jupiter-forge/src/lib.rs
Normal file
281
crates/jupiter-forge/src/lib.rs
Normal 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>;
|
||||
}
|
||||
506
crates/jupiter-forge/src/radicle.rs
Normal file
506
crates/jupiter-forge/src/radicle.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue