1use devboy_core::{
6 CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
7 ToolValueModel, ValueClass,
8};
9use serde_json::Value;
10
11pub struct GitHubSchemaEnricher;
23
24const ISSUE_TOOLS: &[&str] = &["create_issue", "update_issue", "get_issues"];
25
26const ISSUE_REMOVE_PARAMS: &[&str] = &[
28 "priority",
29 "parentId",
30 "customFields",
31 "issueType",
32 "components",
33 "projectId",
34 "points",
35];
36
37const GET_ISSUES_REMOVE_PARAMS: &[&str] = &["projectKey", "nativeQuery", "stateCategory"];
39
40impl ToolEnricher for GitHubSchemaEnricher {
41 fn supported_categories(&self) -> &[ToolCategory] {
42 &[ToolCategory::IssueTracker, ToolCategory::GitRepository]
43 }
44
45 fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema) {
46 if ISSUE_TOOLS.contains(&tool_name) {
48 schema.remove_params(ISSUE_REMOVE_PARAMS);
49 }
50
51 if tool_name == "get_issues" {
53 schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
54 }
55
56 if tool_name == "link_issues" {
58 schema.remove_params(&["source_key", "target_key", "link_type"]);
59 }
60 }
61
62 fn transform_args(&self, tool_name: &str, args: &mut Value) {
63 if tool_name == "create_merge_request_comment"
65 && let Some(obj) = args.as_object_mut()
66 && let Some(line_type) = obj.get("line_type").and_then(|v| v.as_str())
67 {
68 let side = match line_type {
69 "old" => "LEFT",
70 _ => "RIGHT",
71 };
72 obj.insert("side".into(), Value::String(side.into()));
73 }
74 }
75
76 fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
80 let model = match tool_name {
81 "get_merge_requests" => ToolValueModel {
82 value_class: ValueClass::Supporting,
83 cost_model: CostModel {
84 typical_kb: 4.5,
85 max_kb: Some(45.0),
86 latency_ms_p50: Some(380),
87 freshness_ttl_s: Some(60),
88 ..CostModel::default()
89 },
90 follow_up: vec![
91 FollowUpLink {
92 tool: "get_merge_request_discussions".into(),
93 probability: 0.60,
94 projection: Some("number".into()),
95 projection_arg: Some("key".into()),
96 },
97 FollowUpLink {
98 tool: "get_merge_request_diffs".into(),
99 probability: 0.40,
100 projection: Some("number".into()),
101 projection_arg: Some("key".into()),
102 },
103 ],
104 side_effect_class: SideEffectClass::ReadOnly,
105 ..ToolValueModel::default()
106 },
107 "get_merge_request" => ToolValueModel {
108 value_class: ValueClass::Critical,
109 cost_model: CostModel {
110 typical_kb: 1.6,
111 latency_ms_p50: Some(220),
112 freshness_ttl_s: Some(60),
113 ..CostModel::default()
114 },
115 follow_up: vec![FollowUpLink {
116 tool: "get_merge_request_discussions".into(),
117 probability: 0.55,
118 projection: Some("number".into()),
119 projection_arg: Some("key".into()),
120 }],
121 side_effect_class: SideEffectClass::ReadOnly,
122 ..ToolValueModel::default()
123 },
124 "get_merge_request_discussions" | "get_merge_request_diffs" => ToolValueModel {
125 value_class: ValueClass::Critical,
126 cost_model: CostModel {
127 typical_kb: 6.0,
128 max_kb: Some(60.0),
129 latency_ms_p50: Some(360),
130 freshness_ttl_s: Some(60),
131 ..CostModel::default()
132 },
133 side_effect_class: SideEffectClass::ReadOnly,
134 ..ToolValueModel::default()
135 },
136 "get_issues" => ToolValueModel {
137 value_class: ValueClass::Supporting,
138 cost_model: CostModel {
139 typical_kb: 3.5,
140 latency_ms_p50: Some(380),
141 freshness_ttl_s: Some(60),
142 ..CostModel::default()
143 },
144 follow_up: vec![FollowUpLink {
145 tool: "get_issue_comments".into(),
146 probability: 0.45,
147 projection: Some("number".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("number".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"
183 | "update_issue"
184 | "create_merge_request"
185 | "create_merge_request_comment"
186 | "add_issue_comment" => ToolValueModel {
187 value_class: ValueClass::Supporting,
188 cost_model: CostModel {
189 typical_kb: 0.8,
190 latency_ms_p50: Some(350),
191 ..CostModel::default()
192 },
193 side_effect_class: SideEffectClass::MutatesExternal,
194 ..ToolValueModel::default()
195 },
196 _ => return None,
197 };
198 Some(model)
199 }
200
201 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
205 Some("api.github.com".into())
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use serde_json::json;
213
214 #[test]
215 fn test_github_enricher_removes_unsupported_params() {
216 let enricher = GitHubSchemaEnricher;
217 let mut schema = ToolSchema::from_json(&json!({
218 "type": "object",
219 "properties": {
220 "title": { "type": "string" },
221 "priority": { "type": "string" },
222 "parentId": { "type": "string" },
223 "customFields": { "type": "object" },
224 },
225 }));
226
227 enricher.enrich_schema("create_issue", &mut schema);
228
229 assert!(schema.properties.contains_key("title"));
230 assert!(!schema.properties.contains_key("priority"));
231 assert!(!schema.properties.contains_key("parentId"));
232 assert!(!schema.properties.contains_key("customFields"));
233 }
234
235 #[test]
236 fn test_github_enricher_transforms_line_type_to_side() {
237 let enricher = GitHubSchemaEnricher;
238 let mut args = json!({
239 "key": "pr#1",
240 "body": "test",
241 "file_path": "src/main.rs",
242 "line": 10,
243 "line_type": "old",
244 });
245
246 enricher.transform_args("create_merge_request_comment", &mut args);
247
248 assert_eq!(args["side"], "LEFT");
249 }
250
251 #[test]
252 fn test_github_enricher_transforms_new_line_to_right() {
253 let enricher = GitHubSchemaEnricher;
254 let mut args = json!({
255 "key": "pr#1",
256 "body": "test",
257 "line_type": "new",
258 });
259
260 enricher.transform_args("create_merge_request_comment", &mut args);
261
262 assert_eq!(args["side"], "RIGHT");
263 }
264
265 #[test]
266 fn test_github_enricher_no_transform_for_other_tools() {
267 let enricher = GitHubSchemaEnricher;
268 let mut args = json!({"line_type": "old"});
269 enricher.transform_args("get_issues", &mut args);
270 assert!(args.get("side").is_none());
272 }
273
274 #[test]
275 fn test_github_enricher_no_transform_without_line_type() {
276 let enricher = GitHubSchemaEnricher;
277 let mut args = json!({"key": "pr#1", "body": "test"});
278 enricher.transform_args("create_merge_request_comment", &mut args);
279 assert!(args.get("side").is_none());
281 }
282
283 #[test]
284 fn test_github_enricher_get_issues_removals() {
285 let enricher = GitHubSchemaEnricher;
286 let mut schema = ToolSchema::from_json(&json!({
287 "type": "object",
288 "properties": {
289 "state": { "type": "string" },
290 "projectKey": { "type": "string" },
291 "nativeQuery": { "type": "string" },
292 "stateCategory": { "type": "string" },
293 }
294 }));
295 enricher.enrich_schema("get_issues", &mut schema);
296 assert!(schema.properties.contains_key("state"));
297 assert!(!schema.properties.contains_key("projectKey"));
298 assert!(!schema.properties.contains_key("nativeQuery"));
299 assert!(!schema.properties.contains_key("stateCategory"));
300 }
301
302 #[test]
303 fn test_github_enricher_link_issues_unsupported() {
304 let enricher = GitHubSchemaEnricher;
305 let mut schema = ToolSchema::from_json(&json!({
306 "type": "object",
307 "properties": {
308 "target_key": { "type": "string" },
309 "link_type": { "type": "string" }
310 }
311 }));
312 enricher.enrich_schema("link_issues", &mut schema);
313 assert!(!schema.properties.contains_key("target_key"));
314 assert!(!schema.properties.contains_key("link_type"));
315 }
316
317 #[test]
320 fn paper3_get_merge_requests_chains_to_discussions_with_pr_number() {
321 let m = GitHubSchemaEnricher
322 .value_model("get_merge_requests")
323 .unwrap();
324 assert_eq!(m.side_effect_class, SideEffectClass::ReadOnly);
325 let link = m
326 .follow_up
327 .iter()
328 .find(|l| l.tool == "get_merge_request_discussions")
329 .unwrap();
330 assert_eq!(link.projection_arg.as_deref(), Some("key"));
331 }
332
333 #[test]
334 fn paper3_get_issues_chains_to_comments_with_issue_number() {
335 let m = GitHubSchemaEnricher.value_model("get_issues").unwrap();
336 let link = m
337 .follow_up
338 .iter()
339 .find(|l| l.tool == "get_issue_comments")
340 .unwrap();
341 assert_eq!(link.projection_arg.as_deref(), Some("key"));
342 }
343
344 #[test]
345 fn paper3_mutating_endpoints_are_never_speculatable() {
346 for tool in [
347 "create_issue",
348 "update_issue",
349 "create_merge_request",
350 "create_merge_request_comment",
351 "add_issue_comment",
352 ] {
353 let m = GitHubSchemaEnricher.value_model(tool).unwrap();
354 assert_eq!(m.side_effect_class, SideEffectClass::MutatesExternal);
355 assert!(!m.is_speculatable());
356 }
357 }
358
359 #[test]
360 fn paper3_rate_limit_host_is_api_github_com() {
361 let host = GitHubSchemaEnricher.rate_limit_host("get_issues", &json!({}));
362 assert_eq!(host.as_deref(), Some("api.github.com"));
363 }
364}