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 Linear API calls.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub enum LinearScope {
66    /// Single Linear team
67    Team {
68        /// Team UUID.
69        id: String,
70        /// Optional human-readable team key.
71        key: Option<String>,
72    },
73}
74
75/// Scope for YouGile API calls.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub enum YouGileScope {
78    /// Single YouGile board.
79    Board {
80        /// Provider-native board identifier.
81        id: String,
82    },
83}
84
85/// Scope for Confluence API calls.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub enum ConfluenceScope {
88    /// Single Confluence instance, optionally scoped by a default space key.
89    Space {
90        /// Default space key (optional).
91        key: Option<String>,
92    },
93}
94
95/// Scope for Slack API calls.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub enum SlackScope {
98    /// Single Slack workspace/team.
99    Workspace { team_id: Option<String> },
100}
101
102/// Scope for Telegram API calls.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub enum TelegramScope {
105    /// Single Telegram bot identity.
106    Bot { bot_username: Option<String> },
107}
108
109/// Authentication configuration for Confluence self-hosted.
110///
111/// **Note:** intentionally not `Serialize`/`Deserialize` — Confluence credentials
112/// are constructed in-process from the keychain. Cross-process transport would
113/// expose `password` / `token` via the wire format.
114#[derive(Debug, Clone)]
115pub enum ConfluenceAuthConfig {
116    BearerToken {
117        token: SecretString,
118    },
119    Basic {
120        username: String,
121        password: SecretString,
122    },
123}
124
125/// Provider connection configuration with typed scope.
126///
127/// Each variant carries only the fields relevant to that provider.
128/// Scope is provider-specific — compiler prevents invalid combinations
129/// (e.g., GitLab Group scope on a GitHub provider).
130///
131/// **Note:** intentionally not `Serialize`/`Deserialize`. Provider configs
132/// carry plaintext access tokens; serializing them to JSON would defeat the
133/// `SecretString` discipline. Construct provider configs in-process from
134/// `Config` + `CredentialStore` instead of round-tripping through transport.
135#[derive(Debug, Clone)]
136pub enum ProviderConfig {
137    GitLab {
138        base_url: String,
139        access_token: SecretString,
140        scope: GitLabScope,
141        extra: HashMap<String, serde_json::Value>,
142    },
143    GitHub {
144        base_url: String,
145        access_token: SecretString,
146        scope: GitHubScope,
147        extra: HashMap<String, serde_json::Value>,
148    },
149    ClickUp {
150        access_token: SecretString,
151        scope: ClickUpScope,
152        extra: HashMap<String, serde_json::Value>,
153    },
154    Jira {
155        base_url: String,
156        access_token: SecretString,
157        email: String,
158        scope: JiraScope,
159        /// Explicit flavor override. When set, skips auto-detection from URL.
160        /// Important for proxy scenarios where URL doesn't reflect actual Jira deployment.
161        flavor: Option<devboy_jira::JiraFlavor>,
162        extra: HashMap<String, serde_json::Value>,
163    },
164    Linear {
165        base_url: String,
166        access_token: SecretString,
167        scope: LinearScope,
168        extra: HashMap<String, serde_json::Value>,
169    },
170    YouGile {
171        base_url: String,
172        access_token: SecretString,
173        scope: YouGileScope,
174        extra: HashMap<String, serde_json::Value>,
175    },
176    Confluence {
177        base_url: String,
178        auth: ConfluenceAuthConfig,
179        scope: ConfluenceScope,
180        flavor: Option<devboy_confluence::ConfluenceFlavor>,
181        cloud_id: Option<String>,
182        api_version: Option<String>,
183        extra: HashMap<String, serde_json::Value>,
184    },
185    /// Fireflies.ai meeting notes provider.
186    Fireflies {
187        api_key: SecretString,
188        extra: HashMap<String, serde_json::Value>,
189    },
190    /// Slack messenger provider.
191    Slack {
192        base_url: String,
193        access_token: SecretString,
194        scope: SlackScope,
195        required_scopes: Vec<String>,
196        extra: HashMap<String, serde_json::Value>,
197    },
198    /// Telegram messenger provider.
199    Telegram {
200        base_url: String,
201        access_token: SecretString,
202        scope: TelegramScope,
203        extra: HashMap<String, serde_json::Value>,
204    },
205    /// Fully dynamic variant for community/custom provider plugins.
206    Custom {
207        name: String,
208        config: HashMap<String, serde_json::Value>,
209    },
210}
211
212impl ProviderConfig {
213    /// Returns the provider name as a static string.
214    pub fn provider_name(&self) -> &str {
215        match self {
216            Self::GitLab { .. } => "gitlab",
217            Self::GitHub { .. } => "github",
218            Self::ClickUp { .. } => "clickup",
219            Self::Jira { .. } => "jira",
220            Self::Linear { .. } => "linear",
221            Self::YouGile { .. } => "yougile",
222            Self::Confluence { .. } => "confluence",
223            Self::Fireflies { .. } => "fireflies",
224            Self::Slack { .. } => "slack",
225            Self::Telegram { .. } => "telegram",
226            Self::Custom { name, .. } => name,
227        }
228    }
229}
230
231/// Proxy configuration for providers behind firewalls.
232///
233/// When proxy is configured, `url` replaces the provider's base URL
234/// and `headers` are added to every request (e.g. auth tokens, routing headers).
235/// The provider's own auth headers are suppressed — proxy handles authentication.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ProxyConfig {
238    pub url: String,
239    #[serde(default)]
240    pub headers: HashMap<String, String>,
241}
242
243/// Provider-specific metadata for dynamic schema enrichment.
244///
245/// Static providers (GitLab, GitHub) don't need metadata.
246/// Dynamic providers (ClickUp, Jira) receive metadata from external sources
247/// (e.g., DB in cloud mode, API in CLI mode) to populate enum values and custom fields.
248///
249/// Metadata is passed as `serde_json::Value` to avoid coupling devboy-executor
250/// to provider crate types. Each provider enricher deserializes its own metadata.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ProviderMetadata {
253    /// Raw metadata value — provider enricher will deserialize this.
254    pub data: serde_json::Value,
255}
256
257impl ProviderMetadata {
258    pub fn new(data: serde_json::Value) -> Self {
259        Self { data }
260    }
261}
262
263/// Runtime context passed to the executor for each tool call.
264///
265/// Contains everything needed to create a provider and execute a tool:
266/// - `provider` — typed connection config with scope
267/// - `proxy` — optional proxy for self-hosted instances
268/// - `metadata` — optional provider metadata for dynamic enrichment
269/// - `extra` — cross-cutting concerns (tracing, feature flags, caller metadata)
270///
271/// **Note:** intentionally not `Serialize`/`Deserialize` — `provider` carries
272/// `SecretString` access tokens that must not leak through wire formats.
273#[derive(Debug, Clone)]
274pub struct AdditionalContext {
275    pub provider: ProviderConfig,
276    pub proxy: Option<ProxyConfig>,
277    pub metadata: Option<ProviderMetadata>,
278    pub extra: HashMap<String, serde_json::Value>,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use secrecy::ExposeSecret;
285
286    fn token(s: &str) -> SecretString {
287        SecretString::from(s.to_string())
288    }
289
290    #[test]
291    fn test_provider_config_gitlab_project_scope() {
292        let config = ProviderConfig::GitLab {
293            base_url: "https://gitlab.com".into(),
294            access_token: token("glpat-xxx"),
295            scope: GitLabScope::Project { id: "12345".into() },
296            extra: HashMap::new(),
297        };
298        assert_eq!(config.provider_name(), "gitlab");
299    }
300
301    #[test]
302    fn test_provider_config_github_repo_scope() {
303        let config = ProviderConfig::GitHub {
304            base_url: "https://api.github.com".into(),
305            access_token: token("ghp_xxx"),
306            scope: GitHubScope::Repository {
307                owner: "meteora-pro".into(),
308                repo: "devboy-tools".into(),
309            },
310            extra: HashMap::new(),
311        };
312        assert_eq!(config.provider_name(), "github");
313    }
314
315    #[test]
316    fn test_provider_config_custom() {
317        let config = ProviderConfig::Custom {
318            name: "my-provider".into(),
319            config: HashMap::new(),
320        };
321        assert_eq!(config.provider_name(), "my-provider");
322    }
323
324    #[test]
325    fn test_provider_config_confluence_scope() {
326        let config = ProviderConfig::Confluence {
327            base_url: "https://wiki.example.com".into(),
328            auth: ConfluenceAuthConfig::BearerToken {
329                token: token("pat-token"),
330            },
331            scope: ConfluenceScope::Space {
332                key: Some("ENG".into()),
333            },
334            flavor: None,
335            cloud_id: None,
336            api_version: Some("v1".into()),
337            extra: HashMap::new(),
338        };
339        assert_eq!(config.provider_name(), "confluence");
340    }
341
342    #[test]
343    fn test_provider_name_clickup() {
344        let config = ProviderConfig::ClickUp {
345            access_token: token("pk_test"),
346            scope: ClickUpScope::List {
347                id: "list1".into(),
348                team_id: None,
349            },
350            extra: HashMap::new(),
351        };
352        assert_eq!(config.provider_name(), "clickup");
353    }
354
355    #[test]
356    fn test_provider_name_jira() {
357        let config = ProviderConfig::Jira {
358            base_url: "https://jira.example.com".into(),
359            access_token: token("tok"),
360            email: "a@b.com".into(),
361            scope: JiraScope::Project { key: "X".into() },
362            flavor: None,
363            extra: HashMap::new(),
364        };
365        assert_eq!(config.provider_name(), "jira");
366    }
367
368    #[test]
369    fn test_provider_name_linear() {
370        let config = ProviderConfig::Linear {
371            base_url: "https://api.linear.app/graphql".into(),
372            access_token: token("lin_api_x"),
373            scope: LinearScope::Team {
374                id: "team-1".into(),
375                key: Some("ENG".into()),
376            },
377            extra: HashMap::new(),
378        };
379        assert_eq!(config.provider_name(), "linear");
380    }
381
382    #[test]
383    fn test_provider_name_yougile() {
384        let config = ProviderConfig::YouGile {
385            base_url: "https://yougile.com/api-v2".into(),
386            access_token: token("tok"),
387            scope: YouGileScope::Board {
388                id: "board-1".into(),
389            },
390            extra: HashMap::new(),
391        };
392        assert_eq!(config.provider_name(), "yougile");
393    }
394
395    #[test]
396    fn test_provider_name_telegram() {
397        let config = ProviderConfig::Telegram {
398            base_url: "https://api.telegram.org".into(),
399            access_token: token("bot-token"),
400            scope: TelegramScope::Bot { bot_username: None },
401            extra: HashMap::new(),
402        };
403        assert_eq!(config.provider_name(), "telegram");
404    }
405
406    #[test]
407    fn test_provider_metadata_new() {
408        let data = serde_json::json!({"statuses": [{"name": "Done"}]});
409        let meta = ProviderMetadata::new(data.clone());
410        assert_eq!(meta.data, data);
411    }
412
413    #[test]
414    fn test_proxy_config_serialize_deserialize() {
415        // ProxyConfig still keeps Serialize/Deserialize because it carries no
416        // secrets; provider auth lives on `ProviderConfig::access_token` instead.
417        let mut headers = HashMap::new();
418        headers.insert("X-Routing".into(), "internal".into());
419        let proxy = ProxyConfig {
420            url: "https://proxy.internal/jira".into(),
421            headers,
422        };
423        let json = serde_json::to_string(&proxy).unwrap();
424        let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
425        assert_eq!(deserialized.url, "https://proxy.internal/jira");
426        assert_eq!(deserialized.headers["X-Routing"], "internal");
427    }
428
429    #[test]
430    fn test_provider_config_debug_redacts_access_token() {
431        let config = ProviderConfig::GitLab {
432            base_url: "https://gitlab.com".into(),
433            access_token: token("super-secret-glpat"),
434            scope: GitLabScope::Project { id: "12345".into() },
435            extra: HashMap::new(),
436        };
437        let dbg = format!("{:?}", config);
438        assert!(
439            !dbg.contains("super-secret-glpat"),
440            "Debug must redact access_token, got: {dbg}"
441        );
442    }
443
444    #[test]
445    fn test_confluence_auth_basic_password_redacted() {
446        let auth = ConfluenceAuthConfig::Basic {
447            username: "dev@example.com".into(),
448            password: token("super-secret-password"),
449        };
450        let dbg = format!("{:?}", auth);
451        assert!(
452            !dbg.contains("super-secret-password"),
453            "Basic password must not appear in Debug: {dbg}"
454        );
455
456        // Sanity check: SecretString itself round-trips via expose_secret().
457        if let ConfluenceAuthConfig::Basic { password, .. } = &auth {
458            assert_eq!(password.expose_secret(), "super-secret-password");
459        }
460    }
461}