1use devboy_core::{
6 CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
7 ToolValueModel, ValueClass,
8};
9use serde_json::Value;
10
11pub struct GitLabSchemaEnricher;
22
23const ISSUE_TOOLS: &[&str] = &["create_issue", "update_issue", "get_issues", "link_issues"];
24
25const ISSUE_REMOVE_PARAMS: &[&str] = &[
27 "priority",
28 "parentId",
29 "customFields",
30 "issueType",
31 "components",
32 "projectId",
33 "points",
34];
35
36const 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 if ISSUE_TOOLS.contains(&tool_name) {
47 schema.remove_params(ISSUE_REMOVE_PARAMS);
48 }
49
50 if tool_name == "get_issues" {
52 schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
53 }
54
55 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 }
68
69 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 "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 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
210 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 #[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}