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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/aof-triggers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub use platforms::{
TelegramConfig, TelegramPlatform,
WhatsAppConfig, WhatsAppPlatform,
GitHubConfig, GitHubPlatform,
JiraConfig, JiraPlatform,
PagerDutyConfig, PagerDutyPlatform,
TypedPlatformConfig,
// Platform registry for extensibility
Expand Down
92 changes: 66 additions & 26 deletions crates/aof-triggers/src/platforms/jira.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ pub struct JiraIssueFields {
/// Jira issue type
#[derive(Debug, Clone, Deserialize)]
pub struct JiraIssueType {
pub id: String,
#[serde(default)]
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub description: Option<String>,
Expand All @@ -238,16 +239,19 @@ pub struct JiraIssueType {
/// Jira project information
#[derive(Debug, Clone, Deserialize)]
pub struct JiraProject {
pub id: String,
#[serde(default)]
pub id: Option<String>,
pub key: String,
pub name: String,
#[serde(default)]
pub name: Option<String>,
}

/// Jira status
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JiraStatus {
pub id: String,
#[serde(default)]
pub id: Option<String>,
pub name: String,
#[serde(default)]
pub status_category: Option<JiraStatusCategory>,
Expand All @@ -257,41 +261,47 @@ pub struct JiraStatus {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JiraStatusCategory {
pub id: i64,
pub key: String,
#[serde(default)]
pub id: Option<i64>,
#[serde(default)]
pub key: Option<String>,
pub name: String,
}

/// Jira priority
#[derive(Debug, Clone, Deserialize)]
pub struct JiraPriority {
pub id: String,
#[serde(default)]
pub id: Option<String>,
pub name: String,
}

/// Jira issue information
#[derive(Debug, Clone, Deserialize)]
pub struct JiraIssue {
pub id: String,
#[serde(default)]
pub id: Option<String>,
pub key: String,
#[serde(rename = "self")]
pub self_url: String,
#[serde(rename = "self", default)]
pub self_url: Option<String>,
pub fields: JiraIssueFields,
}

/// Jira comment information
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JiraComment {
pub id: String,
#[serde(rename = "self")]
pub self_url: String,
#[serde(default)]
pub id: Option<String>,
#[serde(rename = "self", default)]
pub self_url: Option<String>,
pub body: String,
#[serde(default)]
pub author: Option<JiraUser>,
#[serde(default)]
pub update_author: Option<JiraUser>,
pub created: String,
#[serde(default)]
pub created: Option<String>,
#[serde(default)]
pub updated: Option<String>,
}
Expand Down Expand Up @@ -339,8 +349,9 @@ pub struct JiraChangelog {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JiraWebhookPayload {
/// Webhook event timestamp
pub timestamp: i64,
/// Webhook event timestamp (optional - may not be provided by Jira Automation)
#[serde(default)]
pub timestamp: Option<i64>,

/// Event type
pub webhook_event: String,
Expand Down Expand Up @@ -431,8 +442,25 @@ impl JiraPlatform {
Ok(Self { config, client })
}

/// Verify HMAC-SHA256 signature from Jira webhook
/// Verify signature from Jira webhook
/// Supports multiple modes:
/// 1. HMAC-SHA256 signature (prefixed with "sha256=" or raw hex)
/// 2. Static shared secret (direct comparison for Jira Automation)
fn verify_jira_signature(&self, payload: &[u8], signature: &str) -> bool {
// Strip common prefixes like "sha256=" or "sha1=" if present
let provided_signature = signature
.strip_prefix("sha256=")
.or_else(|| signature.strip_prefix("sha1="))
.unwrap_or(signature);

// Mode 1: Direct secret comparison (for Jira Automation static secrets)
// Jira Automation sends the secret value directly in the header
if provided_signature == self.config.webhook_secret {
debug!("Jira signature verified via direct secret match");
return true;
}

// Mode 2: HMAC-SHA256 verification (for computed signatures)
let mut mac = match HmacSha256::new_from_slice(self.config.webhook_secret.as_bytes()) {
Ok(m) => m,
Err(e) => {
Expand All @@ -445,14 +473,14 @@ impl JiraPlatform {
let result = mac.finalize();
let computed_signature = hex::encode(result.into_bytes());

if computed_signature == signature {
debug!("Jira signature verified successfully");
if computed_signature == provided_signature {
debug!("Jira signature verified via HMAC-SHA256");
true
} else {
debug!(
"Signature mismatch - computed: {}, provided: {}",
&computed_signature[..8],
&signature[..8.min(signature.len())]
"Signature mismatch - computed HMAC: {}..., provided: {}...",
&computed_signature[..8.min(computed_signature.len())],
&provided_signature[..8.min(provided_signature.len())]
);
false
}
Expand Down Expand Up @@ -810,7 +838,9 @@ impl JiraPlatform {
// Build metadata with full event details
let mut metadata = HashMap::new();
metadata.insert("event_type".to_string(), serde_json::json!(event_type));
metadata.insert("issue_id".to_string(), serde_json::json!(issue.id));
if let Some(ref id) = issue.id {
metadata.insert("issue_id".to_string(), serde_json::json!(id));
}
metadata.insert("issue_key".to_string(), serde_json::json!(issue.key));
metadata.insert("issue_type".to_string(), serde_json::json!(issue.fields.issuetype.name));
metadata.insert("project_id".to_string(), serde_json::json!(issue.fields.project.id));
Expand All @@ -836,8 +866,10 @@ impl JiraPlatform {
metadata.insert("changelog".to_string(), serde_json::to_value(changelog).unwrap_or_default());
}

// Message ID from issue and timestamp
let message_id = format!("jira-{}-{}-{}", issue.id, event_type, payload.timestamp);
// Message ID from issue and timestamp (use current time if not provided)
let ts = payload.timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
let issue_id = issue.id.as_deref().unwrap_or(&issue.key);
let message_id = format!("jira-{}-{}-{}", issue_id, event_type, ts);

// Thread ID from issue key
let thread_id = Some(issue.key.clone());
Expand All @@ -848,7 +880,7 @@ impl JiraPlatform {
channel_id,
user: trigger_user,
text,
timestamp: chrono::DateTime::from_timestamp(payload.timestamp / 1000, 0).unwrap_or_else(chrono::Utc::now),
timestamp: chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_else(chrono::Utc::now),
metadata,
thread_id,
reply_to: None,
Expand All @@ -867,6 +899,14 @@ impl TriggerPlatform for JiraPlatform {
raw: &[u8],
headers: &HashMap<String, String>,
) -> Result<TriggerMessage, PlatformError> {
// Log raw payload for debugging
if let Ok(raw_str) = std::str::from_utf8(raw) {
debug!("Jira webhook raw payload ({} bytes): {}", raw.len(),
if raw_str.len() > 500 { &raw_str[..500] } else { raw_str });
} else {
debug!("Jira webhook raw payload ({} bytes): <binary>", raw.len());
}

// Verify signature if present
if let Some(signature) = headers.get("x-hub-signature") {
if !self.verify_jira_signature(raw, signature) {
Expand Down
100 changes: 100 additions & 0 deletions crates/aofctl/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use aof_triggers::{
TelegramPlatform, TelegramConfig,
WhatsAppPlatform, WhatsAppConfig,
GitHubPlatform, GitHubConfig,
JiraPlatform, JiraConfig,
CommandBinding as HandlerCommandBinding,
flow::{FlowRegistry, FlowRouter},
};
Expand Down Expand Up @@ -135,6 +136,9 @@ pub struct PlatformConfigs {

/// WhatsApp configuration
pub whatsapp: Option<WhatsAppPlatformConfig>,

/// Jira configuration
pub jira: Option<JiraPlatformConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -203,6 +207,43 @@ pub struct WhatsAppPlatformConfig {
pub app_secret: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraPlatformConfig {
#[serde(default = "default_true")]
pub enabled: bool,

/// Jira Cloud ID (for cloud instances)
pub cloud_id: Option<String>,
pub cloud_id_env: Option<String>,

/// Base URL (e.g., https://your-domain.atlassian.net)
pub base_url: Option<String>,

/// User email for API authentication
pub user_email: Option<String>,
pub user_email_env: Option<String>,

/// API token for authentication
pub api_token: Option<String>,
pub api_token_env: Option<String>,

/// Webhook secret for signature verification
pub webhook_secret: Option<String>,
pub webhook_secret_env: Option<String>,

/// Bot name for identification in comments
#[serde(default)]
pub bot_name: Option<String>,

/// Allowed project keys (whitelist)
#[serde(default)]
pub allowed_projects: Option<Vec<String>>,

/// Allowed event types (whitelist)
#[serde(default)]
pub allowed_events: Option<Vec<String>>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentDiscoveryConfig {
/// Directory containing agent YAML files
Expand Down Expand Up @@ -512,6 +553,65 @@ pub async fn execute(
}
}

// Jira
if let Some(jira_config) = &config.spec.platforms.jira {
if jira_config.enabled {
let api_token = resolve_env_value(
jira_config.api_token.as_deref(),
jira_config.api_token_env.as_deref(),
);
let user_email = resolve_env_value(
jira_config.user_email.as_deref(),
jira_config.user_email_env.as_deref(),
);
let webhook_secret = resolve_env_value(
jira_config.webhook_secret.as_deref(),
jira_config.webhook_secret_env.as_deref(),
);

// Build base URL from cloud_id or use provided base_url
let base_url = if let Some(ref url) = jira_config.base_url {
Some(url.clone())
} else {
let cloud_id = resolve_env_value(
jira_config.cloud_id.as_deref(),
jira_config.cloud_id_env.as_deref(),
);
cloud_id.map(|id| format!("https://api.atlassian.com/ex/jira/{}", id))
};

if let (Some(token), Some(email), Some(secret), Some(url)) =
(api_token, user_email, webhook_secret, base_url)
{
let platform_config = JiraConfig {
base_url: url,
email,
api_token: token,
webhook_secret: secret,
bot_name: jira_config.bot_name.clone().unwrap_or_else(|| "aofbot".to_string()),
allowed_projects: jira_config.allowed_projects.clone(),
allowed_events: jira_config.allowed_events.clone(),
allowed_users: None,
enable_comments: true,
enable_updates: true,
enable_transitions: true,
};
match JiraPlatform::new(platform_config) {
Ok(platform) => {
handler.register_platform(Arc::new(platform));
println!(" Registered platform: jira");
platforms_registered += 1;
}
Err(e) => {
eprintln!(" Failed to create Jira platform: {}", e);
}
}
} else {
eprintln!(" Jira enabled but missing required config (api_token, user_email, webhook_secret, and base_url or cloud_id)");
}
}
}

// Load Triggers from directory
let triggers_dir_path = triggers_dir
.map(PathBuf::from)
Expand Down
Loading