Skip to main content

devboy_executor/
context.rs

1use secrecy::SecretString;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Scope for GitLab API calls — determines the endpoint prefix.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum GitLabScope {
8    /// Single project: `/api/v4/projects/{id}/...`
9    Project {
10        /// Project id (numeric or `group/project` slug).
11        id: String,
12    },
13    /// Group-level: `/api/v4/groups/{id}/...`
14    Group {
15        /// Group id (numeric or path).
16        id: String,
17    },
18    /// Global: `/api/v4/...`
19    Global,
20}
21
22/// Scope for GitHub API calls — determines the endpoint prefix.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub enum GitHubScope {
25    /// Single repository: `/repos/{owner}/{repo}/...`
26    Repository {
27        /// Owner (user or org).
28        owner: String,
29        repo: String,
30    },
31    /// Organization-level: search with `org:` qualifier
32    Organization {
33        /// Organization login.
34        name: String,
35    },
36    /// Global: search across all accessible resources
37    Global,
38}
39
40/// Scope for ClickUp API calls.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub enum ClickUpScope {
43    /// Single list (with optional team_id for custom task ID resolution)
44    List {
45        id: String,
46        /// Optional team id (workspace) for custom task ID resolution.
47        team_id: Option<String>,
48    },
49}
50
51/// Scope for Jira API calls.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub enum JiraScope {
54    /// Single Jira project
55    Project {
56        /// Project key (e.g. `DEV`).
57        key: String,
58    },
59    /// Multiple Jira projects (union of results)
60    MultiProject { keys: Vec<String> },
61}
62
63/// Scope for Confluence API calls.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub enum ConfluenceScope {
66    /// Single Confluence instance, optionally scoped by a default space key.
67    Space {
68        /// Default space key (optional).
69        key: Option<String>,
70    },
71}
72
73/// Scope for Slack API calls.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub enum SlackScope {
76    /// Single Slack workspace/team.
77    Workspace { team_id: Option<String> },
78}
79
80/// Scope for Telegram API calls.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub enum TelegramScope {
83    /// Single Telegram bot identity.
84    Bot { bot_username: Option<String> },
85}
86
87/// Authentication configuration for Confluence self-hosted.
88///
89/// **Note:** intentionally not `Serialize`/`Deserialize` — Confluence credentials
90/// are constructed in-process from the keychain. Cross-process transport would
91/// expose `password` / `token` via the wire format.
92#[derive(Debug, Clone)]
93pub enum ConfluenceAuthConfig {
94    BearerToken {
95        token: SecretString,
96    },
97    Basic {
98        username: String,
99        password: SecretString,
100    },
101}
102
103/// Provider connection configuration with typed scope.
104///
105/// Each variant carries only the fields relevant to that provider.
106/// Scope is provider-specific — compiler prevents invalid combinations
107/// (e.g., GitLab Group scope on a GitHub provider).
108///
109/// **Note:** intentionally not `Serialize`/`Deserialize`. Provider configs
110/// carry plaintext access tokens; serializing them to JSON would defeat the
111/// `SecretString` discipline. Construct provider configs in-process from
112/// `Config` + `CredentialStore` instead of round-tripping through transport.
113#[derive(Debug, Clone)]
114pub enum ProviderConfig {
115    GitLab {
116        base_url: String,
117        access_token: SecretString,
118        scope: GitLabScope,
119        extra: HashMap<String, serde_json::Value>,
120    },
121    GitHub {
122        base_url: String,
123        access_token: SecretString,
124        scope: GitHubScope,
125        extra: HashMap<String, serde_json::Value>,
126    },
127    ClickUp {
128        access_token: SecretString,
129        scope: ClickUpScope,
130        extra: HashMap<String, serde_json::Value>,
131    },
132    Jira {
133        base_url: String,
134        access_token: SecretString,
135        email: String,
136        scope: JiraScope,
137        /// Explicit flavor override. When set, skips auto-detection from URL.
138        /// Important for proxy scenarios where URL doesn't reflect actual Jira deployment.
139        flavor: Option<devboy_jira::JiraFlavor>,
140        extra: HashMap<String, serde_json::Value>,
141    },
142    Confluence {
143        base_url: String,
144        auth: ConfluenceAuthConfig,
145        scope: ConfluenceScope,
146        api_version: Option<String>,
147        extra: HashMap<String, serde_json::Value>,
148    },
149    /// Fireflies.ai meeting notes provider.
150    Fireflies {
151        api_key: SecretString,
152        extra: HashMap<String, serde_json::Value>,
153    },
154    /// Slack messenger provider.
155    Slack {
156        base_url: String,
157        access_token: SecretString,
158        scope: SlackScope,
159        required_scopes: Vec<String>,
160        extra: HashMap<String, serde_json::Value>,
161    },
162    /// Telegram messenger provider.
163    Telegram {
164        base_url: String,
165        access_token: SecretString,
166        scope: TelegramScope,
167        extra: HashMap<String, serde_json::Value>,
168    },
169    /// Fully dynamic variant for community/custom provider plugins.
170    Custom {
171        name: String,
172        config: HashMap<String, serde_json::Value>,
173    },
174}
175
176impl ProviderConfig {
177    /// Returns the provider name as a static string.
178    pub fn provider_name(&self) -> &str {
179        match self {
180            Self::GitLab { .. } => "gitlab",
181            Self::GitHub { .. } => "github",
182            Self::ClickUp { .. } => "clickup",
183            Self::Jira { .. } => "jira",
184            Self::Confluence { .. } => "confluence",
185            Self::Fireflies { .. } => "fireflies",
186            Self::Slack { .. } => "slack",
187            Self::Telegram { .. } => "telegram",
188            Self::Custom { name, .. } => name,
189        }
190    }
191}
192
193/// Proxy configuration for providers behind firewalls.
194///
195/// When proxy is configured, `url` replaces the provider's base URL
196/// and `headers` are added to every request (e.g. auth tokens, routing headers).
197/// The provider's own auth headers are suppressed — proxy handles authentication.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ProxyConfig {
200    pub url: String,
201    #[serde(default)]
202    pub headers: HashMap<String, String>,
203}
204
205/// Provider-specific metadata for dynamic schema enrichment.
206///
207/// Static providers (GitLab, GitHub) don't need metadata.
208/// Dynamic providers (ClickUp, Jira) receive metadata from external sources
209/// (e.g., DB in cloud mode, API in CLI mode) to populate enum values and custom fields.
210///
211/// Metadata is passed as `serde_json::Value` to avoid coupling devboy-executor
212/// to provider crate types. Each provider enricher deserializes its own metadata.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ProviderMetadata {
215    /// Raw metadata value — provider enricher will deserialize this.
216    pub data: serde_json::Value,
217}
218
219impl ProviderMetadata {
220    pub fn new(data: serde_json::Value) -> Self {
221        Self { data }
222    }
223}
224
225/// Runtime context passed to the executor for each tool call.
226///
227/// Contains everything needed to create a provider and execute a tool:
228/// - `provider` — typed connection config with scope
229/// - `proxy` — optional proxy for self-hosted instances
230/// - `metadata` — optional provider metadata for dynamic enrichment
231/// - `extra` — cross-cutting concerns (tracing, feature flags, caller metadata)
232///
233/// **Note:** intentionally not `Serialize`/`Deserialize` — `provider` carries
234/// `SecretString` access tokens that must not leak through wire formats.
235#[derive(Debug, Clone)]
236pub struct AdditionalContext {
237    pub provider: ProviderConfig,
238    pub proxy: Option<ProxyConfig>,
239    pub metadata: Option<ProviderMetadata>,
240    pub extra: HashMap<String, serde_json::Value>,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use secrecy::ExposeSecret;
247
248    fn token(s: &str) -> SecretString {
249        SecretString::from(s.to_string())
250    }
251
252    #[test]
253    fn test_provider_config_gitlab_project_scope() {
254        let config = ProviderConfig::GitLab {
255            base_url: "https://gitlab.com".into(),
256            access_token: token("glpat-xxx"),
257            scope: GitLabScope::Project { id: "12345".into() },
258            extra: HashMap::new(),
259        };
260        assert_eq!(config.provider_name(), "gitlab");
261    }
262
263    #[test]
264    fn test_provider_config_github_repo_scope() {
265        let config = ProviderConfig::GitHub {
266            base_url: "https://api.github.com".into(),
267            access_token: token("ghp_xxx"),
268            scope: GitHubScope::Repository {
269                owner: "meteora-pro".into(),
270                repo: "devboy-tools".into(),
271            },
272            extra: HashMap::new(),
273        };
274        assert_eq!(config.provider_name(), "github");
275    }
276
277    #[test]
278    fn test_provider_config_custom() {
279        let config = ProviderConfig::Custom {
280            name: "my-provider".into(),
281            config: HashMap::new(),
282        };
283        assert_eq!(config.provider_name(), "my-provider");
284    }
285
286    #[test]
287    fn test_provider_config_confluence_scope() {
288        let config = ProviderConfig::Confluence {
289            base_url: "https://wiki.example.com".into(),
290            auth: ConfluenceAuthConfig::BearerToken {
291                token: token("pat-token"),
292            },
293            scope: ConfluenceScope::Space {
294                key: Some("ENG".into()),
295            },
296            api_version: Some("v1".into()),
297            extra: HashMap::new(),
298        };
299        assert_eq!(config.provider_name(), "confluence");
300    }
301
302    #[test]
303    fn test_provider_name_clickup() {
304        let config = ProviderConfig::ClickUp {
305            access_token: token("pk_test"),
306            scope: ClickUpScope::List {
307                id: "list1".into(),
308                team_id: None,
309            },
310            extra: HashMap::new(),
311        };
312        assert_eq!(config.provider_name(), "clickup");
313    }
314
315    #[test]
316    fn test_provider_name_jira() {
317        let config = ProviderConfig::Jira {
318            base_url: "https://jira.example.com".into(),
319            access_token: token("tok"),
320            email: "a@b.com".into(),
321            scope: JiraScope::Project { key: "X".into() },
322            flavor: None,
323            extra: HashMap::new(),
324        };
325        assert_eq!(config.provider_name(), "jira");
326    }
327
328    #[test]
329    fn test_provider_name_telegram() {
330        let config = ProviderConfig::Telegram {
331            base_url: "https://api.telegram.org".into(),
332            access_token: token("bot-token"),
333            scope: TelegramScope::Bot { bot_username: None },
334            extra: HashMap::new(),
335        };
336        assert_eq!(config.provider_name(), "telegram");
337    }
338
339    #[test]
340    fn test_provider_metadata_new() {
341        let data = serde_json::json!({"statuses": [{"name": "Done"}]});
342        let meta = ProviderMetadata::new(data.clone());
343        assert_eq!(meta.data, data);
344    }
345
346    #[test]
347    fn test_proxy_config_serialize_deserialize() {
348        // ProxyConfig still keeps Serialize/Deserialize because it carries no
349        // secrets; provider auth lives on `ProviderConfig::access_token` instead.
350        let mut headers = HashMap::new();
351        headers.insert("X-Routing".into(), "internal".into());
352        let proxy = ProxyConfig {
353            url: "https://proxy.internal/jira".into(),
354            headers,
355        };
356        let json = serde_json::to_string(&proxy).unwrap();
357        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
358        assert_eq!(deserialized.url, "https://proxy.internal/jira");
359        assert_eq!(deserialized.headers["X-Routing"], "internal");
360    }
361
362    #[test]
363    fn test_provider_config_debug_redacts_access_token() {
364        let config = ProviderConfig::GitLab {
365            base_url: "https://gitlab.com".into(),
366            access_token: token("super-secret-glpat"),
367            scope: GitLabScope::Project { id: "12345".into() },
368            extra: HashMap::new(),
369        };
370        let dbg = format!("{:?}", config);
371        assert!(
372            !dbg.contains("super-secret-glpat"),
373            "Debug must redact access_token, got: {dbg}"
374        );
375    }
376
377    #[test]
378    fn test_confluence_auth_basic_password_redacted() {
379        let auth = ConfluenceAuthConfig::Basic {
380            username: "dev@example.com".into(),
381            password: token("super-secret-password"),
382        };
383        let dbg = format!("{:?}", auth);
384        assert!(
385            !dbg.contains("super-secret-password"),
386            "Basic password must not appear in Debug: {dbg}"
387        );
388
389        // Sanity check: SecretString itself round-trips via expose_secret().
390        if let ConfluenceAuthConfig::Basic { password, .. } = &auth {
391            assert_eq!(password.expose_secret(), "super-secret-password");
392        }
393    }
394}