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"
190            | "run_pipeline_job" => ToolValueModel {
191                value_class: ValueClass::Supporting,
192                cost_model: CostModel {
193                    typical_kb: 0.8,
194                    latency_ms_p50: Some(350),
195                    ..CostModel::default()
196                },
197                side_effect_class: SideEffectClass::MutatesExternal,
198                ..ToolValueModel::default()
199            },
200            _ => return None,
201        };
202        Some(model)
203    }
204
205    /// Paper 3 — `gitlab.com` for SaaS, picked up from the runtime
206    /// args if the tool carries an explicit instance URL. We don't
207    /// look at `args` here because GitLab tools live behind one
208    /// configured client per session; the host falls back to the
209    /// tool's static `rate_limit_host` annotation.
210    fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
211        // Static host is not embedded — GitLab self-hosted instances
212        // vary per deployment. Operators that need rate-limit grouping
213        // for self-hosted GitLab set `[tools.<name>].rate_limit_host`
214        // in pipeline_config.toml. SaaS users get the default `None`
215        // here and the dispatcher leaves it uncapped.
216        None
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use serde_json::json;
224
225    #[test]
226    fn test_gitlab_enricher_removes_unsupported_params() {
227        let enricher = GitLabSchemaEnricher;
228        let mut schema = ToolSchema::from_json(&json!({
229            "type": "object",
230            "properties": {
231                "title": { "type": "string" },
232                "priority": { "type": "string" },
233                "parentId": { "type": "string" },
234                "customFields": { "type": "object" },
235                "issueType": { "type": "string" },
236                "components": { "type": "array" },
237                "projectId": { "type": "string" },
238                "points": { "type": "number" },
239            },
240        }));
241
242        enricher.enrich_schema("create_issue", &mut schema);
243
244        assert!(schema.properties.contains_key("title"));
245        assert!(!schema.properties.contains_key("priority"));
246        assert!(!schema.properties.contains_key("parentId"));
247        assert!(!schema.properties.contains_key("customFields"));
248        assert!(!schema.properties.contains_key("issueType"));
249        assert!(!schema.properties.contains_key("components"));
250        assert!(!schema.properties.contains_key("projectId"));
251        assert!(!schema.properties.contains_key("points"));
252    }
253
254    #[test]
255    fn test_gitlab_enricher_adds_link_types() {
256        let enricher = GitLabSchemaEnricher;
257        let mut schema = ToolSchema::new();
258
259        enricher.enrich_schema("link_issues", &mut schema);
260
261        let link_type = schema.properties.get("link_type").unwrap();
262        assert_eq!(
263            link_type.enum_values,
264            Some(vec![
265                "relates_to".into(),
266                "blocks".into(),
267                "is_blocked_by".into()
268            ])
269        );
270    }
271
272    #[test]
273    fn test_gitlab_enricher_get_issues_extra_removals() {
274        let enricher = GitLabSchemaEnricher;
275        let mut schema = ToolSchema::from_json(&json!({
276            "type": "object",
277            "properties": {
278                "state": { "type": "string" },
279                "projectKey": { "type": "string" },
280                "nativeQuery": { "type": "string" },
281                "stateCategory": { "type": "string" },
282            },
283        }));
284
285        enricher.enrich_schema("get_issues", &mut schema);
286
287        assert!(schema.properties.contains_key("state"));
288        assert!(!schema.properties.contains_key("projectKey"));
289        assert!(!schema.properties.contains_key("nativeQuery"));
290        assert!(!schema.properties.contains_key("stateCategory"));
291    }
292
293    // ─── Paper 3 — value_model annotations ───────────────────────────
294
295    #[test]
296    fn paper3_get_merge_requests_is_read_only_with_discussion_followup() {
297        let m = GitLabSchemaEnricher
298            .value_model("get_merge_requests")
299            .expect("get_merge_requests must be annotated");
300        assert_eq!(m.side_effect_class, SideEffectClass::ReadOnly);
301        assert!(m.is_speculatable());
302        let link = m
303            .follow_up
304            .iter()
305            .find(|l| l.tool == "get_merge_request_discussions")
306            .expect("discussions follow-up missing");
307        assert_eq!(link.projection.as_deref(), Some("iid"));
308        assert_eq!(link.projection_arg.as_deref(), Some("key"));
309        assert!(link.probability >= 0.5);
310    }
311
312    #[test]
313    fn paper3_get_issues_chains_to_comments_with_iid_to_issue_id() {
314        let m = GitLabSchemaEnricher.value_model("get_issues").unwrap();
315        assert_eq!(m.side_effect_class, SideEffectClass::ReadOnly);
316        let link = m
317            .follow_up
318            .iter()
319            .find(|l| l.tool == "get_issue_comments")
320            .expect("comments follow-up missing");
321        assert_eq!(link.projection_arg.as_deref(), Some("key"));
322    }
323
324    #[test]
325    fn paper3_mutating_endpoints_are_never_speculatable() {
326        for tool in [
327            "create_issue",
328            "update_issue",
329            "create_merge_request",
330            "create_merge_request_comment",
331            "add_issue_comment",
332            "link_issues",
333            "run_pipeline_job",
334        ] {
335            let m = GitLabSchemaEnricher
336                .value_model(tool)
337                .unwrap_or_else(|| panic!("{tool} must be annotated"));
338            assert_eq!(
339                m.side_effect_class,
340                SideEffectClass::MutatesExternal,
341                "{tool} must be MutatesExternal — never speculate writes"
342            );
343            assert!(!m.is_speculatable());
344        }
345    }
346}