Skip to main content

devboy_gitlab/
enricher.rs

1//! GitLab schema enricher.
2//!
3//! Removes parameters not supported by GitLab and adds GitLab-specific enums.
4
5use devboy_core::{
6    CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
7    ToolValueModel, ValueClass,
8};
9use serde_json::Value;
10
11/// Static schema enricher for GitLab provider.
12///
13/// GitLab doesn't support:
14/// - `priority` (no built-in priority on issues)
15/// - `parentId` (no subtask hierarchy via API)
16/// - `customFields` (no custom fields)
17/// - `issueType` (no issue types)
18/// - `components` (no components)
19/// - `projectId` (single project scope, not needed)
20/// - `points` (no story points)
21pub struct GitLabSchemaEnricher;
22
23const ISSUE_TOOLS: &[&str] = &["create_issue", "update_issue", "get_issues", "link_issues"];
24
25/// Parameters to remove from issue tools.
26const ISSUE_REMOVE_PARAMS: &[&str] = &[
27    "priority",
28    "parentId",
29    "customFields",
30    "issueType",
31    "components",
32    "projectId",
33    "points",
34];
35
36/// Parameters to remove from get_issues specifically.
37const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["projectKey", "nativeQuery", "stateCategory"];
38
39impl ToolEnricher for GitLabSchemaEnricher {
40    fn supported_categories(&self) -> &[ToolCategory] {
41        &[ToolCategory::IssueTracker, ToolCategory::GitRepository]
42    }
43
44    fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
45        // Remove unsupported params from issue tools
46        if ISSUE_TOOLS.contains(&tool_name) {
47            schema.remove_params(ISSUE_REMOVE_PARAMS);
48        }
49
50        // Additional removals for get_issues
51        if tool_name == "get_issues" {
52            schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
53        }
54
55        // Add link types enum for link_issues
56        if tool_name == "link_issues" {
57            schema.add_enum_param(
58                "link_type",
59                &["relates_to", "blocks", "is_blocked_by"],
60                "Link type between issues",
61            );
62        }
63    }
64
65    fn transform_args(&self, _tool_name: &str, _args: &mut Value) {
66        // GitLab doesn't need arg transformation — no custom fields
67    }
68
69    /// Paper 3 — value-model annotations for GitLab read-only tools.
70    ///
71    /// Speculative pre-fetch wins for the canonical `list → detail`
72    /// chains: after `get_merge_requests` the agent almost always
73    /// reads discussions / diffs of the top hit; after `get_issues`
74    /// it reads comments. We annotate the read-only endpoints (Pure
75    /// for inside-of-TTL, ReadOnly otherwise) and leave mutating
76    /// endpoints (`create_issue`, `update_issue`, …) as the default
77    /// `Indeterminate` so they are never speculated.
78    fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
79        let model = match tool_name {
80            "get_merge_requests" => ToolValueModel {
81                value_class: ValueClass::Supporting,
82                cost_model: CostModel {
83                    typical_kb: 4.0,
84                    max_kb: Some(40.0),
85                    latency_ms_p50: Some(450),
86                    freshness_ttl_s: Some(60),
87                    ..CostModel::default()
88                },
89                follow_up: vec![
90                    FollowUpLink {
91                        tool: "get_merge_request_discussions".into(),
92                        probability: 0.62,
93                        projection: Some("iid".into()),
94                        projection_arg: Some("key".into()),
95                    },
96                    FollowUpLink {
97                        tool: "get_merge_request_diffs".into(),
98                        probability: 0.41,
99                        projection: Some("iid".into()),
100                        projection_arg: Some("key".into()),
101                    },
102                ],
103                side_effect_class: SideEffectClass::ReadOnly,
104                ..ToolValueModel::default()
105            },
106            "get_merge_request" => ToolValueModel {
107                value_class: ValueClass::Critical,
108                cost_model: CostModel {
109                    typical_kb: 1.5,
110                    latency_ms_p50: Some(220),
111                    freshness_ttl_s: Some(60),
112                    ..CostModel::default()
113                },
114                follow_up: vec![FollowUpLink {
115                    tool: "get_merge_request_discussions".into(),
116                    probability: 0.55,
117                    projection: Some("iid".into()),
118                    projection_arg: Some("key".into()),
119                }],
120                side_effect_class: SideEffectClass::ReadOnly,
121                ..ToolValueModel::default()
122            },
123            "get_merge_request_discussions" | "get_merge_request_diffs" => ToolValueModel {
124                value_class: ValueClass::Critical,
125                cost_model: CostModel {
126                    typical_kb: 6.0,
127                    max_kb: Some(60.0),
128                    latency_ms_p50: Some(380),
129                    freshness_ttl_s: Some(60),
130                    ..CostModel::default()
131                },
132                side_effect_class: SideEffectClass::ReadOnly,
133                ..ToolValueModel::default()
134            },
135            "get_issues" => ToolValueModel {
136                value_class: ValueClass::Supporting,
137                cost_model: CostModel {
138                    typical_kb: 3.5,
139                    max_kb: Some(35.0),
140                    latency_ms_p50: Some(420),
141                    freshness_ttl_s: Some(60),
142                    ..CostModel::default()
143                },
144                follow_up: vec![FollowUpLink {
145                    tool: "get_issue_comments".into(),
146                    probability: 0.48,
147                    projection: Some("iid".into()),
148                    projection_arg: Some("key".into()),
149                }],
150                side_effect_class: SideEffectClass::ReadOnly,
151                ..ToolValueModel::default()
152            },
153            "get_issue" => ToolValueModel {
154                value_class: ValueClass::Critical,
155                cost_model: CostModel {
156                    typical_kb: 1.0,
157                    latency_ms_p50: Some(180),
158                    freshness_ttl_s: Some(60),
159                    ..CostModel::default()
160                },
161                follow_up: vec![FollowUpLink {
162                    tool: "get_issue_comments".into(),
163                    probability: 0.50,
164                    projection: Some("iid".into()),
165                    projection_arg: Some("key".into()),
166                }],
167                side_effect_class: SideEffectClass::ReadOnly,
168                ..ToolValueModel::default()
169            },
170            "get_issue_comments" => ToolValueModel {
171                value_class: ValueClass::Critical,
172                cost_model: CostModel {
173                    typical_kb: 2.5,
174                    max_kb: Some(20.0),
175                    latency_ms_p50: Some(280),
176                    freshness_ttl_s: Some(60),
177                    ..CostModel::default()
178                },
179                side_effect_class: SideEffectClass::ReadOnly,
180                ..ToolValueModel::default()
181            },
182            // Mutating endpoints — explicit MutatesExternal so they
183            // are never speculated even by accident.
184            "create_issue"
185            | "update_issue"
186            | "create_merge_request"
187            | "create_merge_request_comment"
188            | "add_issue_comment"
189            | "link_issues" => ToolValueModel {
190                value_class: ValueClass::Supporting,
191                cost_model: CostModel {
192                    typical_kb: 0.8,
193                    latency_ms_p50: Some(350),
194                    ..CostModel::default()
195                },
196                side_effect_class: SideEffectClass::MutatesExternal,
197                ..ToolValueModel::default()
198            },
199            _ => return None,
200        };
201        Some(model)
202    }
203
204    /// Paper 3 — `gitlab.com` for SaaS, picked up from the runtime
205    /// args if the tool carries an explicit instance URL. We don't
206    /// look at `args` here because GitLab tools live behind one
207    /// configured client per session; the host falls back to the
208    /// tool's static `rate_limit_host` annotation.
209    fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
210        // Static host is not embedded — GitLab self-hosted instances
211        // vary per deployment. Operators that need rate-limit grouping
212        // for self-hosted GitLab set `[tools.<name>].rate_limit_host`
213        // in pipeline_config.toml. SaaS users get the default `None`
214        // here and the dispatcher leaves it uncapped.
215        None
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde_json::json;
223
224    #[test]
225    fn test_gitlab_enricher_removes_unsupported_params() {
226        let enricher = GitLabSchemaEnricher;
227        let mut schema = ToolSchema::from_json(&json!({
228            "type": "object",
229            "properties": {
230                "title": { "type": "string" },
231                "priority": { "type": "string" },
232                "parentId": { "type": "string" },
233                "customFields": { "type": "object" },
234                "issueType": { "type": "string" },
235                "components": { "type": "array" },
236                "projectId": { "type": "string" },
237                "points": { "type": "number" },
238            },
239        }));
240
241        enricher.enrich_schema("create_issue", &mut schema);
242
243        assert!(schema.properties.contains_key("title"));
244        assert!(!schema.properties.contains_key("priority"));
245        assert!(!schema.properties.contains_key("parentId"));
246        assert!(!schema.properties.contains_key("customFields"));
247        assert!(!schema.properties.contains_key("issueType"));
248        assert!(!schema.properties.contains_key("components"));
249        assert!(!schema.properties.contains_key("projectId"));
250        assert!(!schema.properties.contains_key("points"));
251    }
252
253    #[test]
254    fn test_gitlab_enricher_adds_link_types() {
255        let enricher = GitLabSchemaEnricher;
256        let mut schema = ToolSchema::new();
257
258        enricher.enrich_schema("link_issues", &mut schema);
259
260        let link_type = schema.properties.get("link_type").unwrap();
261        assert_eq!(
262            link_type.enum_values,
263            Some(vec![
264                "relates_to".into(),
265                "blocks".into(),
266                "is_blocked_by".into()
267            ])
268        );
269    }
270
271    #[test]
272    fn test_gitlab_enricher_get_issues_extra_removals() {
273        let enricher = GitLabSchemaEnricher;
274        let mut schema = ToolSchema::from_json(&json!({
275            "type": "object",
276            "properties": {
277                "state": { "type": "string" },
278                "projectKey": { "type": "string" },
279                "nativeQuery": { "type": "string" },
280                "stateCategory": { "type": "string" },
281            },
282        }));
283
284        enricher.enrich_schema("get_issues", &mut schema);
285
286        assert!(schema.properties.contains_key("state"));
287        assert!(!schema.properties.contains_key("projectKey"));
288        assert!(!schema.properties.contains_key("nativeQuery"));
289        assert!(!schema.properties.contains_key("stateCategory"));
290    }
291
292    // ─── Paper 3 — value_model annotations ───────────────────────────
293
294    #[test]
295    fn paper3_get_merge_requests_is_read_only_with_discussion_followup() {
296        let m = GitLabSchemaEnricher
297            .value_model("get_merge_requests")
298            .expect("get_merge_requests must be annotated");
299        assert_eq!(m.side_effect_class, SideEffectClass::ReadOnly);
300        assert!(m.is_speculatable());
301        let link = m
302            .follow_up
303            .iter()
304            .find(|l| l.tool == "get_merge_request_discussions")
305            .expect("discussions follow-up missing");
306        assert_eq!(link.projection.as_deref(), Some("iid"));
307        assert_eq!(link.projection_arg.as_deref(), Some("key"));
308        assert!(link.probability >= 0.5);
309    }
310
311    #[test]
312    fn paper3_get_issues_chains_to_comments_with_iid_to_issue_id() {
313        let m = GitLabSchemaEnricher.value_model("get_issues").unwrap();
314        assert_eq!(m.side_effect_class, SideEffectClass::ReadOnly);
315        let link = m
316            .follow_up
317            .iter()
318            .find(|l| l.tool == "get_issue_comments")
319            .expect("comments follow-up missing");
320        assert_eq!(link.projection_arg.as_deref(), Some("key"));
321    }
322
323    #[test]
324    fn paper3_mutating_endpoints_are_never_speculatable() {
325        for tool in [
326            "create_issue",
327            "update_issue",
328            "create_merge_request",
329            "create_merge_request_comment",
330            "add_issue_comment",
331            "link_issues",
332        ] {
333            let m = GitLabSchemaEnricher
334                .value_model(tool)
335                .unwrap_or_else(|| panic!("{tool} must be annotated"));
336            assert_eq!(
337                m.side_effect_class,
338                SideEffectClass::MutatesExternal,
339                "{tool} must be MutatesExternal — never speculate writes"
340            );
341            assert!(!m.is_speculatable());
342        }
343    }
344}