Skip to main content

devboy_github/
enricher.rs

1//! GitHub schema enricher.
2//!
3//! Removes parameters not supported by GitHub and adjusts GitHub-specific behavior.
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 GitHub provider.
12///
13/// GitHub doesn't support:
14/// - `priority` (no built-in priority on issues)
15/// - `parentId` (sub-issues are relatively new and limited)
16/// - `customFields` (no custom fields)
17/// - `issueType` (no issue types)
18/// - `components` (no components)
19/// - `projectId` (not applicable)
20/// - `points` (no story points)
21/// - `link_issues` tool (not supported via API — use #123 mentions instead)
22pub struct GitHubSchemaEnricher;
23
24const ISSUE_TOOLS: &[&str] = &["create_issue", "update_issue", "get_issues"];
25
26/// Parameters to remove from issue tools.
27const ISSUE_REMOVE_PARAMS: &[&str] = &[
28    "priority",
29    "parentId",
30    "customFields",
31    "issueType",
32    "components",
33    "projectId",
34    "points",
35];
36
37/// Parameters to remove from get_issues specifically.
38const 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        // Remove unsupported params from issue tools
47        if ISSUE_TOOLS.contains(&tool_name) {
48            schema.remove_params(ISSUE_REMOVE_PARAMS);
49        }
50
51        // Additional removals for get_issues
52        if tool_name == "get_issues" {
53            schema.remove_params(GET_ISSUES_REMOVE_PARAMS);
54        }
55
56        // link_issues is not supported by GitHub API — will be filtered by unsupported_tools()
57        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        // Map line_type to GitHub side parameter for code comments
64        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    /// Paper 3 — value-model annotations for GitHub read-only tools.
77    /// Mirrors the GitLab structure (PRs/issues/comments) — they share
78    /// the same `list → detail → comments` shape.
79    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    /// GitHub SaaS = `api.github.com`. Self-hosted Enterprise users
202    /// can override per-tool via TOML; we don't read args because the
203    /// host is a session-level fixed value.
204    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        // No side added for non-comment tools
271        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        // No line_type → no side
280        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    // ─── Paper 3 — value_model annotations ───────────────────────────
318
319    #[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}