diff --git a/crates/aof-triggers/src/lib.rs b/crates/aof-triggers/src/lib.rs index 70a6c0e..454b632 100644 --- a/crates/aof-triggers/src/lib.rs +++ b/crates/aof-triggers/src/lib.rs @@ -54,6 +54,7 @@ pub use platforms::{ TelegramConfig, TelegramPlatform, WhatsAppConfig, WhatsAppPlatform, GitHubConfig, GitHubPlatform, + JiraConfig, JiraPlatform, PagerDutyConfig, PagerDutyPlatform, TypedPlatformConfig, // Platform registry for extensibility diff --git a/crates/aof-triggers/src/platforms/jira.rs b/crates/aof-triggers/src/platforms/jira.rs index 88272b7..c53ef6e 100644 --- a/crates/aof-triggers/src/platforms/jira.rs +++ b/crates/aof-triggers/src/platforms/jira.rs @@ -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, pub name: String, #[serde(default)] pub description: Option, @@ -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, pub key: String, - pub name: String, + #[serde(default)] + pub name: Option, } /// Jira status #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JiraStatus { - pub id: String, + #[serde(default)] + pub id: Option, pub name: String, #[serde(default)] pub status_category: Option, @@ -257,25 +261,29 @@ 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, + #[serde(default)] + pub key: Option, pub name: String, } /// Jira priority #[derive(Debug, Clone, Deserialize)] pub struct JiraPriority { - pub id: String, + #[serde(default)] + pub id: Option, pub name: String, } /// Jira issue information #[derive(Debug, Clone, Deserialize)] pub struct JiraIssue { - pub id: String, + #[serde(default)] + pub id: Option, pub key: String, - #[serde(rename = "self")] - pub self_url: String, + #[serde(rename = "self", default)] + pub self_url: Option, pub fields: JiraIssueFields, } @@ -283,15 +291,17 @@ pub struct JiraIssue { #[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, + #[serde(rename = "self", default)] + pub self_url: Option, pub body: String, #[serde(default)] pub author: Option, #[serde(default)] pub update_author: Option, - pub created: String, + #[serde(default)] + pub created: Option, #[serde(default)] pub updated: Option, } @@ -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, /// Event type pub webhook_event: String, @@ -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) => { @@ -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 } @@ -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)); @@ -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()); @@ -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, @@ -867,6 +899,14 @@ impl TriggerPlatform for JiraPlatform { raw: &[u8], headers: &HashMap, ) -> Result { + // 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): ", raw.len()); + } + // Verify signature if present if let Some(signature) = headers.get("x-hub-signature") { if !self.verify_jira_signature(raw, signature) { diff --git a/crates/aofctl/src/commands/serve.rs b/crates/aofctl/src/commands/serve.rs index 9103f93..923e39d 100644 --- a/crates/aofctl/src/commands/serve.rs +++ b/crates/aofctl/src/commands/serve.rs @@ -18,6 +18,7 @@ use aof_triggers::{ TelegramPlatform, TelegramConfig, WhatsAppPlatform, WhatsAppConfig, GitHubPlatform, GitHubConfig, + JiraPlatform, JiraConfig, CommandBinding as HandlerCommandBinding, flow::{FlowRegistry, FlowRouter}, }; @@ -135,6 +136,9 @@ pub struct PlatformConfigs { /// WhatsApp configuration pub whatsapp: Option, + + /// Jira configuration + pub jira: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -203,6 +207,43 @@ pub struct WhatsAppPlatformConfig { pub app_secret: Option, } +#[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, + pub cloud_id_env: Option, + + /// Base URL (e.g., https://your-domain.atlassian.net) + pub base_url: Option, + + /// User email for API authentication + pub user_email: Option, + pub user_email_env: Option, + + /// API token for authentication + pub api_token: Option, + pub api_token_env: Option, + + /// Webhook secret for signature verification + pub webhook_secret: Option, + pub webhook_secret_env: Option, + + /// Bot name for identification in comments + #[serde(default)] + pub bot_name: Option, + + /// Allowed project keys (whitelist) + #[serde(default)] + pub allowed_projects: Option>, + + /// Allowed event types (whitelist) + #[serde(default)] + pub allowed_events: Option>, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AgentDiscoveryConfig { /// Directory containing agent YAML files @@ -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) diff --git a/docs/concepts/jira-integration.md b/docs/concepts/jira-integration.md index df66e86..d1dc7c5 100644 --- a/docs/concepts/jira-integration.md +++ b/docs/concepts/jira-integration.md @@ -404,39 +404,53 @@ spec: ```yaml # daemon.yaml -apiVersion: aof.dev/v1alpha1 +apiVersion: aof.dev/v1 kind: DaemonConfig metadata: name: aof-daemon spec: server: - host: 0.0.0.0 + host: "0.0.0.0" port: 3000 + cors: true + timeout_secs: 60 platforms: - - type: Jira - config: - webhook_secret: ${JIRA_WEBHOOK_SECRET} - webhook_path: /webhook/jira # Default path + jira: + enabled: true + # Use base_url for direct Atlassian URL (recommended) + base_url: https://your-domain.atlassian.net + # Authentication credentials via environment variables + user_email_env: JIRA_USER_EMAIL + api_token_env: JIRA_API_TOKEN + webhook_secret_env: JIRA_WEBHOOK_SECRET + bot_name: aofbot # Optional: name displayed in comments + + # Optional: Restrict to specific projects + allowed_projects: + - PROJ + - DEV - # Optional: Filter at platform level - allowed_projects: - - PROJ - - DEV + # Resource directories + triggers: + directory: "./triggers" + watch: true - # Jira API credentials for agent actions - api_config: - instance_url: ${JIRA_CLOUD_INSTANCE_URL} - user_email: ${JIRA_USER_EMAIL} - api_token: ${JIRA_API_TOKEN} + agents: + directory: "./agents" flows: - - path: flows/bug-triage.yaml - - path: flows/sprint-planning.yaml - - path: flows/standup-summary.yaml + directory: "./flows" + enabled: true + + runtime: + max_concurrent_tasks: 10 + task_timeout_secs: 300 ``` +**Webhook endpoint**: `https://your-domain.com/webhook/jira` + ### Trigger with Interactive Commands Enable `/analyze` style commands in Jira comments: diff --git a/docs/reference/daemon-config.md b/docs/reference/daemon-config.md index 9a069c8..718f0d3 100644 --- a/docs/reference/daemon-config.md +++ b/docs/reference/daemon-config.md @@ -25,6 +25,7 @@ spec: telegram: object discord: object whatsapp: object + jira: object agents: # Required: Agent discovery directory: string fleets: # Optional: Fleet discovery @@ -167,6 +168,65 @@ spec: verify_token_env: WHATSAPP_VERIFY_TOKEN ``` +### Jira Platform + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `enabled` | bool | Yes | Enable Jira Cloud integration | +| `base_url` | string | Yes* | Jira instance URL (e.g., `https://your-domain.atlassian.net`) | +| `cloud_id_env` | string | Yes* | Env var for Jira Cloud ID (alternative to base_url) | +| `user_email_env` | string | Yes | Env var for user email for API authentication | +| `api_token_env` | string | Yes | Env var for API token | +| `webhook_secret_env` | string | Yes | Env var for webhook secret (for signature verification) | +| `bot_name` | string | No | Bot name for comments (default: "aofbot") | +| `allowed_projects` | array | No | Project keys allowed to trigger (whitelist) | +| `allowed_events` | array | No | Event types to handle (whitelist) | + +*Either `base_url` or `cloud_id_env` must be provided. + +**Supported Events:** +- `jira:issue_created` - Issue created +- `jira:issue_updated` - Issue updated +- `jira:issue_deleted` - Issue deleted +- `comment_created` - Comment added +- `comment_updated` - Comment updated +- `comment_deleted` - Comment deleted +- `sprint_started` - Sprint started +- `sprint_closed` - Sprint closed +- `worklog_created` - Work logged +- `worklog_updated` - Worklog updated + +**Example:** +```yaml +spec: + platforms: + jira: + enabled: true + base_url: https://your-domain.atlassian.net + user_email_env: JIRA_USER_EMAIL + api_token_env: JIRA_API_TOKEN + webhook_secret_env: JIRA_WEBHOOK_SECRET + bot_name: aof-automation + + # Optional: Restrict to specific projects + allowed_projects: + - SCRUM + - OPS + + # Optional: Only handle these events + allowed_events: + - jira:issue_created + - jira:issue_updated + - comment_created +``` + +**Setting up Jira Automation webhook URL:** + +Configure your Jira Automation rules to POST to: +``` +https://your-domain/webhook/jira +``` + --- ## Agent Discovery @@ -379,6 +439,7 @@ DaemonConfig references environment variables for sensitive data. Never hardcode | Telegram | `TELEGRAM_BOT_TOKEN` | | Discord | `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID` | | WhatsApp | `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_VERIFY_TOKEN` | +| Jira | `JIRA_USER_EMAIL`, `JIRA_API_TOKEN`, `JIRA_WEBHOOK_SECRET` (+ `JIRA_CLOUD_ID` or `base_url` in config) | **LLM API keys:** | Provider | Variable | @@ -478,6 +539,7 @@ The server exposes these endpoints for each platform: | GitHub | `https://your-domain/webhook/github` | | GitLab | `https://your-domain/webhook/gitlab` | | Bitbucket | `https://your-domain/webhook/bitbucket` | +| Jira | `https://your-domain/webhook/jira` | --- diff --git a/docs/reference/jira-integration.md b/docs/reference/jira-integration.md index a784eb1..0fcdc01 100644 --- a/docs/reference/jira-integration.md +++ b/docs/reference/jira-integration.md @@ -75,13 +75,25 @@ spec: platforms: jira: enabled: true - base_url: https://yourcompany.atlassian.net # Jira Cloud URL - auth: - type: api_token # api_token, oauth2, or pat - email_env: JIRA_EMAIL # For API token auth - token_env: JIRA_API_TOKEN + # Use base_url for direct Atlassian URL (recommended) + base_url: https://yourcompany.atlassian.net + # Or use cloud_id_env for Cloud ID based URL construction + # cloud_id_env: JIRA_CLOUD_ID + user_email_env: JIRA_USER_EMAIL + api_token_env: JIRA_API_TOKEN webhook_secret_env: JIRA_WEBHOOK_SECRET - bot_name: aofbot # Optional: for @mentions + bot_name: aofbot # Optional: name for comments + + # Optional: Restrict to specific projects + allowed_projects: + - PROJ + - DEV + + # Optional: Filter by event types + allowed_events: + - jira:issue_created + - jira:issue_updated + - comment_created # Resource discovery triggers: @@ -103,70 +115,43 @@ spec: task_timeout_secs: 300 ``` +**Webhook endpoint**: `https://your-domain.com/webhook/jira` + +> **Important**: When configuring Jira automation rules, use the full URL with `/webhook/jira` path, not just the base domain. + ### Platform Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `enabled` | bool | Yes | Enable Jira webhook endpoint (`/webhook/jira`) | -| `base_url` | string | Yes | Jira instance URL (Cloud or self-hosted) | -| `auth.type` | string | Yes | Authentication type: `api_token`, `oauth2`, or `pat` | -| `auth.email_env` | string | Conditional | Required for `api_token` auth | -| `auth.token_env` | string | Yes | Environment variable name for token/PAT | +| `base_url` | string | Yes* | Jira instance URL (e.g., `https://your-domain.atlassian.net`) | +| `cloud_id_env` | string | Yes* | Environment variable for Jira Cloud ID (alternative to base_url) | +| `user_email_env` | string | Yes | Environment variable name for user email | +| `api_token_env` | string | Yes | Environment variable name for API token | | `webhook_secret_env` | string | Yes | Environment variable name for webhook secret | -| `bot_name` | string | No | Bot name for @mentions (default: "aofbot") | +| `bot_name` | string | No | Bot name for comments (default: "aofbot") | +| `allowed_projects` | array | No | Project keys allowed to trigger (whitelist) | +| `allowed_events` | array | No | Event types to handle (whitelist) | -#### Authentication Types - -**API Token (Recommended for Cloud):** -```yaml -auth: - type: api_token - email_env: JIRA_EMAIL - token_env: JIRA_API_TOKEN -``` - -**Personal Access Token (Server/Data Center):** -```yaml -auth: - type: pat - token_env: JIRA_PAT -``` - -**OAuth 2.0 (Advanced):** -```yaml -auth: - type: oauth2 - token_env: JIRA_OAUTH_TOKEN - # Additional OAuth config... -``` +*Either `base_url` or `cloud_id_env` must be provided. ### Self-Hosted Jira Configuration -For Jira Server or Data Center deployments: +For Jira Server or Data Center deployments, use `base_url` pointing to your internal instance: ```yaml platforms: jira: enabled: true base_url: https://jira.yourcompany.com # Self-hosted URL - auth: - type: pat - token_env: JIRA_PAT + user_email_env: JIRA_USER_EMAIL + api_token_env: JIRA_API_TOKEN # Use PAT for Server/DC webhook_secret_env: JIRA_WEBHOOK_SECRET - - # Optional: Proxy configuration - proxy: - http_proxy: http://proxy.company.com:8080 - https_proxy: https://proxy.company.com:8080 - no_proxy: localhost,127.0.0.1 - - # Optional: TLS configuration - tls: - verify: true - ca_cert_path: /etc/ssl/certs/company-ca.pem ``` -> **Note**: Event filtering, project filtering, and command routing are configured in **Trigger** files, not in DaemonConfig. This separation keeps daemon config minimal and allows per-trigger customization. +> **Note**: For Jira Server/Data Center, create a Personal Access Token (PAT) instead of an API token. The configuration is the same - just store the PAT in `JIRA_API_TOKEN`. + +> **Note**: Event filtering, project filtering, and command routing can also be configured in **Trigger** files for per-trigger customization. ### Trigger Configuration @@ -1338,7 +1323,133 @@ spec: ## Webhook Setup -### 1. Create Webhook in Jira +There are two ways to configure Jira webhooks: + +### Option A: Jira Automation Rules (Project-Level) + +Use this method if you don't have Jira admin access or want per-project control. + +#### 1. Create Automation Rule + +1. Go to your Jira project +2. Navigate to **Project Settings** → **Automation** +3. Click **Create rule** +4. Choose a trigger (e.g., **When: Issue created**) +5. Add action → **Send web request** + +#### 2. Configure Web Request + +**URL**: `https://your-domain.com/webhook/jira` + +**HTTP method**: `POST` + +**Headers**: +| Key | Value | +|-----|-------| +| `Content-Type` | `application/json` | +| `X-Hub-Signature` | `` | + +**Web request body**: Select **Custom data** and use a payload template. + +#### 3. Payload Templates + +AOF accepts flexible payloads - most fields are optional. Use minimal templates or add more fields as needed. + +**Issue Created/Updated (Minimal):** +```json +{ + "webhookEvent": "jira:issue_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "issuetype": { "name": "{{issue.issueType.name}}" }, + "status": { "name": "{{issue.status.name}}" }, + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" } + } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +**Comment Created:** +```json +{ + "webhookEvent": "comment_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" } + } + }, + "comment": { + "body": "{{comment.body}}", + "author": { "accountId": "{{comment.author.accountId}}", "displayName": "{{comment.author.displayName}}" } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +**Work Logged:** +```json +{ + "webhookEvent": "worklog_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "issuetype": { "name": "{{issue.issueType.name}}" }, + "status": { "name": "{{issue.status.name}}" }, + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" } + } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +> **Important**: The `{{...}}` placeholders are Jira smart values. They get replaced with actual data when the webhook fires. + +#### Testing with curl + +Test the endpoint before configuring Jira: + +```bash +curl -X POST https://your-domain.com/webhook/jira \ + -H "Content-Type: application/json" \ + -H "X-Hub-Signature: YOUR_SECRET_HERE" \ + -d '{ + "webhookEvent": "jira:issue_created", + "timestamp": 1735897519000, + "issue": { + "id": "10005", + "key": "PROJ-123", + "fields": { + "summary": "Test issue", + "issuetype": { "name": "Bug" }, + "status": { "name": "To Do" }, + "project": { "key": "PROJ", "name": "My Project" } + } + }, + "user": { "accountId": "test", "displayName": "Test User" } + }' +``` + +#### 4. Signature Verification + +Jira Automation sends the `X-Hub-Signature` header value as a **static secret** (not computed HMAC). Your `JIRA_WEBHOOK_SECRET` environment variable must **exactly match** the value you configure in the header. + +--- + +### Option B: System Webhooks (Admin Only) + +Use this method if you have Jira admin access. System webhooks automatically include complete payloads. #### Jira Cloud @@ -1348,8 +1459,8 @@ spec: - **Name**: AOF Automation - **Status**: Enabled - **URL**: `https://your-domain.com/webhook/jira` - - **Secret**: Your `JIRA_WEBHOOK_SECRET` value - - **Events**: Select desired events or check "All issues" + - **Secret**: Your `JIRA_WEBHOOK_SECRET` value (enables HMAC verification) + - **Events**: Select desired events - **Exclude body**: Uncheck (AOF needs full payload) #### Jira Server/Data Center @@ -1358,7 +1469,9 @@ spec: 2. Create webhook with same configuration as Cloud 3. Ensure firewall allows webhook traffic to AOF daemon -### 2. Expose Endpoint +--- + +### Expose Endpoint **For production:** ```bash @@ -1380,19 +1493,19 @@ ngrok http 3000 Use tunnel URL as webhook URL in Jira. -### 3. Verify Webhook +### Verify Webhook -1. Test webhook in Jira webhook settings -2. Check webhook delivery logs in Jira -3. Verify AOF logs show received event +1. Test webhook using Jira's "Validate" button (Automation) or delivery logs (System webhooks) +2. Check AOF daemon logs for received events ```bash # Check logs -tail -f /var/log/aof/daemon.log +RUST_LOG=debug aofctl serve --config daemon.yaml # Look for: -# INFO Jira webhook received: issue_created -# INFO Posted comment to PROJ-123 +# INFO Received webhook for platform: jira +# DEBUG Jira signature verified via direct secret match +# INFO Processing event: jira:issue_created ``` --- diff --git a/docs/tutorials/jira-automation.md b/docs/tutorials/jira-automation.md index 0fd0cea..eeca0d8 100644 --- a/docs/tutorials/jira-automation.md +++ b/docs/tutorials/jira-automation.md @@ -406,10 +406,19 @@ spec: platforms: jira: enabled: true - cloud_id_env: JIRA_CLOUD_ID + # Use base_url for direct Atlassian URL (recommended) + base_url: https://your-domain.atlassian.net + # Or use cloud_id_env for Cloud ID based URL construction + # cloud_id_env: JIRA_CLOUD_ID user_email_env: JIRA_USER_EMAIL api_token_env: JIRA_API_TOKEN webhook_secret_env: JIRA_WEBHOOK_SECRET + bot_name: aof-automation # Optional: name for comments + + # Optional: Restrict to specific projects + # allowed_projects: + # - PROJ + # - DEV # Resource directories triggers: @@ -425,7 +434,9 @@ spec: task_timeout_secs: 300 ``` -**Webhook endpoint**: `http://your-domain:3000/webhook/jira` +**Webhook endpoint**: `https://your-domain.com/webhook/jira` + +> **Important**: Configure your Jira automation rules to POST to `/webhook/jira`, not just the base URL. ## Step 9: Start the AOF Daemon @@ -458,25 +469,196 @@ Deploy to a server with HTTPS: # Webhook URL: https://aof.example.com/webhook/jira ``` -## Step 11: Configure Jira Webhook +## Step 11: Configure Jira Automation Webhook + +Jira Automation requires you to explicitly configure the webhook body. Here's how: + +### Creating the Automation Rule 1. Go to your Jira project 2. Navigate to **Project Settings** → **Automation** -3. Click **Create rule** → **When: Issue created** -4. Add action → **Send web request** -5. Configure: +3. Click **Create rule** +4. Choose a trigger (e.g., **When: Issue created**) +5. Add action → **Send web request** + +### Configuring the Web Request + +**URL**: +``` +https://your-domain.com/webhook/jira +``` + +**HTTP method**: `POST` + +**Headers** (click "Add another header"): + +| Key | Value | +|-----|-------| +| `Content-Type` | `application/json` | +| `X-Hub-Signature` | `` | + +**Web request body**: Select **Custom data** and paste the appropriate template below. + +### Payload Templates by Event Type + +AOF accepts flexible payloads - most fields are optional. Use the minimal templates below, or add more fields as needed. + +#### Issue Created / Issue Updated (Minimal) + +```json +{ + "webhookEvent": "jira:issue_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "issuetype": { "name": "{{issue.issueType.name}}" }, + "status": { "name": "{{issue.status.name}}" }, + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" } + } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +#### Issue Created / Issue Updated (Full) + +```json +{ + "webhookEvent": "jira:issue_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "description": "{{issue.description}}", + "issuetype": { "name": "{{issue.issueType.name}}" }, + "status": { "name": "{{issue.status.name}}" }, + "priority": { "name": "{{issue.priority.name}}" }, + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" }, + "assignee": { "displayName": "{{issue.assignee.displayName}}", "accountId": "{{issue.assignee.accountId}}" }, + "reporter": { "displayName": "{{issue.reporter.displayName}}", "accountId": "{{issue.reporter.accountId}}" } + } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +> **Note**: Change `"webhookEvent": "jira:issue_created"` to `"jira:issue_updated"` for update triggers. + +#### Comment Created + +```json +{ + "webhookEvent": "comment_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" } + } + }, + "comment": { + "body": "{{comment.body}}", + "author": { "accountId": "{{comment.author.accountId}}", "displayName": "{{comment.author.displayName}}" } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +#### Work Logged + +```json +{ + "webhookEvent": "worklog_created", + "timestamp": {{now.epochMillis}}, + "issue": { + "id": "{{issue.id}}", + "key": "{{issue.key}}", + "fields": { + "summary": "{{issue.summary}}", + "issuetype": { "name": "{{issue.issueType.name}}" }, + "status": { "name": "{{issue.status.name}}" }, + "priority": { "name": "{{issue.priority.name}}" }, + "project": { "key": "{{issue.project.key}}", "name": "{{issue.project.name}}" } + } + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +#### Sprint Started / Sprint Closed + +```json +{ + "webhookEvent": "sprint_started", + "timestamp": {{now.epochMillis}}, + "sprint": { + "id": {{sprint.id}}, + "name": "{{sprint.name}}", + "state": "{{sprint.state}}", + "goal": "{{sprint.goal}}" + }, + "user": { "accountId": "{{initiator.accountId}}", "displayName": "{{initiator.displayName}}" } +} +``` + +### Testing with curl + +Before configuring Jira, test the endpoint directly: + +```bash +curl -X POST https://your-ngrok-url.ngrok-free.dev/webhook/jira \ + -H "Content-Type: application/json" \ + -H "X-Hub-Signature: YOUR_SECRET_HERE" \ + -d '{ + "webhookEvent": "worklog_created", + "timestamp": 1735897519000, + "issue": { + "id": "10005", + "key": "SCRUM-5", + "fields": { + "summary": "Test issue", + "issuetype": { "name": "Task" }, + "status": { "name": "To Do" }, + "project": { "key": "SCRUM", "name": "Team Astro" } + } + }, + "user": { "accountId": "test", "displayName": "Test User" } + }' +``` + +Replace `YOUR_SECRET_HERE` with your `JIRA_WEBHOOK_SECRET` value. + +### Important Notes + +1. **The `X-Hub-Signature` header value must exactly match your `JIRA_WEBHOOK_SECRET` environment variable** (case-sensitive) + +2. **Jira Automation sends a static secret**, not a computed HMAC signature. AOF supports both modes. + +3. **Smart values**: The `{{...}}` placeholders are Jira smart values that get replaced with actual data when the webhook fires. + +4. **Test your webhook**: After saving, use Jira's "Validate" button to test the configuration. + +### Alternative: System Webhooks (Admin Only) + +If you have Jira admin access, you can use built-in webhooks which automatically include full payloads: + +1. Go to **Settings** → **System** → **WebHooks** +2. Click **Create a WebHook** +3. Configure: + - **Name**: AOF Integration - **URL**: `https://your-domain.com/webhook/jira` - - **Headers**: Add `X-Hub-Signature` with webhook secret - - **HTTP method**: POST - - **Webhook body**: Issue data - - **Events**: Issue created, Issue updated -6. Click **Turn it on** - -**Alternative (Jira Cloud)**: -- Settings → System → Webhooks → Create Webhook -- URL: `https://your-domain.com/webhook/jira` -- Events: Issue created, updated, commented -- Secret: Your webhook secret + - **Secret**: Your webhook secret (for HMAC verification) + - **Events**: Select desired events +4. Click **Create** + +System webhooks automatically send complete payloads without manual body configuration. ## Step 12: Test Bug Triage