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