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"
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 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
211 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 #[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}