Skip to main content

openhelm_github/
lib.rs

1use std::sync::Arc;
2
3use anyhow::{Context, Result, bail};
4use async_trait::async_trait;
5use octocrab::Octocrab;
6use serde_json::{Value, json};
7
8use openhelm_sdk::{Skill, Tool, ToolDefinition, ToolOutput};
9
10fn repo_arg(args: &Value) -> Result<(String, String)> {
11    let repo = args["repo"]
12        .as_str()
13        .context("Missing 'repo' argument (format: owner/repo)")?;
14    let (owner, name) = repo.split_once('/').context(format!(
15        "'repo' must be in 'owner/repo' format, got: {}",
16        repo
17    ))?;
18    Ok((owner.to_string(), name.to_string()))
19}
20
21struct GithubClient(Octocrab);
22
23impl GithubClient {
24    fn new(token: impl Into<String>) -> Result<Self> {
25        let octocrab = Octocrab::builder()
26            .personal_token(token.into())
27            .build()
28            .context("Failed to build GitHub client")?;
29        Ok(Self(octocrab))
30    }
31
32    async fn get(&self, path: &str) -> Result<Value> {
33        let response = self.0.get(path, None::<&()>).await?;
34        Ok(response)
35    }
36}
37
38struct GithubGetRepoTool(Arc<GithubClient>);
39
40#[async_trait]
41impl Tool for GithubGetRepoTool {
42    fn name(&self) -> &'static str {
43        "github_get_repo"
44    }
45
46    fn definition(&self) -> ToolDefinition {
47        ToolDefinition::function(
48            self.name(),
49            "Get metadata for a GitHub repository",
50            json!({
51                "type": "object",
52                "properties": {
53                    "repo": { "type": "string", "description": "Repository in 'owner/repo' format" }
54                },
55                "required": ["repo"]
56            }),
57        )
58    }
59
60    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
61        let (owner, repo) = repo_arg(args)?;
62        let data = self.0.get(&format!("/repos/{}/{}", owner, repo)).await?;
63
64        let output = format!(
65            "Repo:          {}\nDescription:   {}\nVisibility:    {}\nDefault branch:{}\nStars:         {}\nForks:         {}\nOpen issues:   {}\nLicense:       {}\nURL:           {}",
66            data["full_name"].as_str().unwrap_or("-"),
67            data["description"].as_str().unwrap_or("(none)"),
68            data["visibility"].as_str().unwrap_or("-"),
69            data["default_branch"].as_str().unwrap_or("-"),
70            data["stargazers_count"].as_u64().unwrap_or(0),
71            data["forks_count"].as_u64().unwrap_or(0),
72            data["open_issues_count"].as_u64().unwrap_or(0),
73            data["license"]["name"].as_str().unwrap_or("(none)"),
74            data["html_url"].as_str().unwrap_or("-"),
75        );
76
77        Ok(ToolOutput {
78            success: true,
79            output,
80        })
81    }
82}
83
84struct GithubListIssuesTool(Arc<GithubClient>);
85
86#[async_trait]
87impl Tool for GithubListIssuesTool {
88    fn name(&self) -> &'static str {
89        "github_list_issues"
90    }
91
92    fn definition(&self) -> ToolDefinition {
93        ToolDefinition::function(
94            self.name(),
95            "List issues for a GitHub repository",
96            json!({
97                "type": "object",
98                "properties": {
99                    "repo": { "type": "string", "description": "Repository in 'owner/repo' format" },
100                    "state": { "type": "string", "enum": ["open", "closed", "all"], "description": "Issue state filter" },
101                    "limit": { "type": "integer", "description": "Max issues (default: 20, max: 100)" }
102                },
103                "required": ["repo"]
104            }),
105        )
106    }
107
108    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
109        let (owner, repo) = repo_arg(args)?;
110        let state = args["state"].as_str().unwrap_or("open");
111        let limit = args["limit"].as_u64().unwrap_or(20).min(100);
112
113        let data = self
114            .0
115            .get(&format!(
116                "/repos/{}/{}/issues?state={}&per_page={}&pulls=false",
117                owner, repo, state, limit
118            ))
119            .await?;
120
121        let issues = data.as_array().context("Expected array of issues")?;
122        let issues: Vec<_> = issues
123            .iter()
124            .filter(|i| i["pull_request"].is_null())
125            .collect();
126
127        if issues.is_empty() {
128            return Ok(ToolOutput {
129                success: true,
130                output: "No issues found.".to_string(),
131            });
132        }
133
134        let lines: Vec<String> = issues
135            .iter()
136            .map(|i| {
137                format!(
138                    "#{} [{}] {} ({})",
139                    i["number"].as_u64().unwrap_or(0),
140                    i["state"].as_str().unwrap_or("open"),
141                    i["title"].as_str().unwrap_or("(no title)"),
142                    i["user"]["login"].as_str().unwrap_or("?"),
143                )
144            })
145            .collect();
146
147        Ok(ToolOutput {
148            success: true,
149            output: lines.join("\n"),
150        })
151    }
152}
153
154struct GithubGetIssueTool(Arc<GithubClient>);
155
156#[async_trait]
157impl Tool for GithubGetIssueTool {
158    fn name(&self) -> &'static str {
159        "github_get_issue"
160    }
161
162    fn definition(&self) -> ToolDefinition {
163        ToolDefinition::function(
164            self.name(),
165            "Get the full details of a GitHub issue including body and comments",
166            json!({
167                "type": "object",
168                "properties": {
169                    "repo": { "type": "string", "description": "Repository in 'owner/repo' format" },
170                    "number": { "type": "integer", "description": "Issue number" }
171                },
172                "required": ["repo", "number"]
173            }),
174        )
175    }
176
177    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
178        let (owner, repo) = repo_arg(args)?;
179        let number = args["number"]
180            .as_u64()
181            .context("Missing 'number' argument")?;
182
183        let issue = self
184            .0
185            .get(&format!("/repos/{}/{}/issues/{}", owner, repo, number))
186            .await?;
187        let comments = self
188            .0
189            .get(&format!(
190                "/repos/{}/{}/issues/{}/comments",
191                owner, repo, number
192            ))
193            .await?;
194
195        let mut out = format!(
196            "#{} [{}] {}\nAuthor: {}\nCreated: {}\nURL: {}\n\n{}\n",
197            issue["number"].as_u64().unwrap_or(0),
198            issue["state"].as_str().unwrap_or("open"),
199            issue["title"].as_str().unwrap_or("(no title)"),
200            issue["user"]["login"].as_str().unwrap_or("?"),
201            issue["created_at"].as_str().unwrap_or("?"),
202            issue["html_url"].as_str().unwrap_or("?"),
203            issue["body"].as_str().unwrap_or("(no body)"),
204        );
205
206        if let Some(comment_list) = comments.as_array().filter(|list| !list.is_empty()) {
207            out.push_str(&format!("\n--- {} comment(s) ---\n", comment_list.len()));
208            for comment in comment_list {
209                out.push_str(&format!(
210                    "\n[{}] {}:\n{}\n",
211                    comment["created_at"].as_str().unwrap_or("?"),
212                    comment["user"]["login"].as_str().unwrap_or("?"),
213                    comment["body"].as_str().unwrap_or("(empty)"),
214                ));
215            }
216        }
217
218        Ok(ToolOutput {
219            success: true,
220            output: out,
221        })
222    }
223}
224
225struct GithubListPrsTool(Arc<GithubClient>);
226
227#[async_trait]
228impl Tool for GithubListPrsTool {
229    fn name(&self) -> &'static str {
230        "github_list_prs"
231    }
232
233    fn definition(&self) -> ToolDefinition {
234        ToolDefinition::function(
235            self.name(),
236            "List pull requests for a GitHub repository",
237            json!({
238                "type": "object",
239                "properties": {
240                    "repo": { "type": "string", "description": "Repository in 'owner/repo' format" },
241                    "state": { "type": "string", "enum": ["open", "closed", "all"], "description": "PR state filter" },
242                    "limit": { "type": "integer", "description": "Max PRs (default: 20, max: 100)" }
243                },
244                "required": ["repo"]
245            }),
246        )
247    }
248
249    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
250        let (owner, repo) = repo_arg(args)?;
251        let state = args["state"].as_str().unwrap_or("open");
252        let limit = args["limit"].as_u64().unwrap_or(20).min(100);
253
254        let data = self
255            .0
256            .get(&format!(
257                "/repos/{}/{}/pulls?state={}&per_page={}",
258                owner, repo, state, limit
259            ))
260            .await?;
261
262        let prs = data.as_array().context("Expected array of PRs")?;
263
264        if prs.is_empty() {
265            return Ok(ToolOutput {
266                success: true,
267                output: "No pull requests found.".to_string(),
268            });
269        }
270
271        let lines: Vec<String> = prs
272            .iter()
273            .map(|pr| {
274                format!(
275                    "#{} [{}] {} ({} → {}) by {}",
276                    pr["number"].as_u64().unwrap_or(0),
277                    pr["state"].as_str().unwrap_or("open"),
278                    pr["title"].as_str().unwrap_or("(no title)"),
279                    pr["head"]["ref"].as_str().unwrap_or("?"),
280                    pr["base"]["ref"].as_str().unwrap_or("?"),
281                    pr["user"]["login"].as_str().unwrap_or("?"),
282                )
283            })
284            .collect();
285
286        Ok(ToolOutput {
287            success: true,
288            output: lines.join("\n"),
289        })
290    }
291}
292
293struct GithubGetPrTool(Arc<GithubClient>);
294
295#[async_trait]
296impl Tool for GithubGetPrTool {
297    fn name(&self) -> &'static str {
298        "github_get_pr"
299    }
300
301    fn definition(&self) -> ToolDefinition {
302        ToolDefinition::function(
303            self.name(),
304            "Get details of a GitHub pull request including description, diff stats, and review comments",
305            json!({
306                "type": "object",
307                "properties": {
308                    "repo": { "type": "string", "description": "Repository in 'owner/repo' format" },
309                    "number": { "type": "integer", "description": "Pull request number" }
310                },
311                "required": ["repo", "number"]
312            }),
313        )
314    }
315
316    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
317        let (owner, repo) = repo_arg(args)?;
318        let number = args["number"]
319            .as_u64()
320            .context("Missing 'number' argument")?;
321
322        let pr = self
323            .0
324            .get(&format!("/repos/{}/{}/pulls/{}", owner, repo, number))
325            .await?;
326        let comments = self
327            .0
328            .get(&format!(
329                "/repos/{}/{}/issues/{}/comments",
330                owner, repo, number
331            ))
332            .await?;
333
334        let mut out = format!(
335            "#{} [{}] {}\nAuthor:  {}\nBranch:  {} → {}\nCreated: {}\nURL:     {}\nChanges: +{} -{} in {} file(s)\n\n{}\n",
336            pr["number"].as_u64().unwrap_or(0),
337            pr["state"].as_str().unwrap_or("open"),
338            pr["title"].as_str().unwrap_or("(no title)"),
339            pr["user"]["login"].as_str().unwrap_or("?"),
340            pr["head"]["ref"].as_str().unwrap_or("?"),
341            pr["base"]["ref"].as_str().unwrap_or("?"),
342            pr["created_at"].as_str().unwrap_or("?"),
343            pr["html_url"].as_str().unwrap_or("?"),
344            pr["additions"].as_u64().unwrap_or(0),
345            pr["deletions"].as_u64().unwrap_or(0),
346            pr["changed_files"].as_u64().unwrap_or(0),
347            pr["body"].as_str().unwrap_or("(no description)"),
348        );
349
350        if let Some(comment_list) = comments.as_array().filter(|list| !list.is_empty()) {
351            out.push_str(&format!("\n--- {} comment(s) ---\n", comment_list.len()));
352            for comment in comment_list {
353                out.push_str(&format!(
354                    "\n[{}] {}:\n{}\n",
355                    comment["created_at"].as_str().unwrap_or("?"),
356                    comment["user"]["login"].as_str().unwrap_or("?"),
357                    comment["body"].as_str().unwrap_or("(empty)"),
358                ));
359            }
360        }
361
362        Ok(ToolOutput {
363            success: true,
364            output: out,
365        })
366    }
367}
368
369#[derive(serde::Deserialize)]
370struct ContentResponse {
371    content: Option<String>,
372    encoding: Option<String>,
373    message: Option<String>,
374}
375
376struct GithubGetFileTool(Arc<GithubClient>);
377
378#[async_trait]
379impl Tool for GithubGetFileTool {
380    fn name(&self) -> &'static str {
381        "github_get_file"
382    }
383
384    fn definition(&self) -> ToolDefinition {
385        ToolDefinition::function(
386            self.name(),
387            "Get the raw contents of a file from a GitHub repository",
388            json!({
389                "type": "object",
390                "properties": {
391                    "repo": { "type": "string", "description": "Repository in 'owner/repo' format" },
392                    "path": { "type": "string", "description": "Path to the file within the repository" },
393                    "ref": { "type": "string", "description": "Branch, tag, or commit SHA" }
394                },
395                "required": ["repo", "path"]
396            }),
397        )
398    }
399
400    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
401        let (owner, repo) = repo_arg(args)?;
402        let path = args["path"].as_str().context("Missing 'path' argument")?;
403        let r#ref = args["ref"].as_str();
404
405        let api_path = r#ref.map_or_else(
406            || format!("/repos/{}/{}/contents/{}", owner, repo, path),
407            |git_ref| {
408                format!(
409                    "/repos/{}/{}/contents/{}?ref={}",
410                    owner, repo, path, git_ref
411                )
412            },
413        );
414
415        let raw = self.0.get(&api_path).await?;
416        let resp: ContentResponse =
417            serde_json::from_value(raw).context("Failed to parse contents response")?;
418
419        if let Some(msg) = resp.message {
420            bail!("GitHub API error: {}", msg);
421        }
422
423        let content = resp.content.context("No content in response")?;
424        let encoding = resp.encoding.as_deref().unwrap_or("none");
425
426        if encoding == "base64" {
427            use base64::Engine as _;
428            let cleaned = content.replace('\n', "");
429            let decoded = base64::engine::general_purpose::STANDARD
430                .decode(&cleaned)
431                .context("Failed to decode base64 content")?;
432            let text = String::from_utf8(decoded).context("File content is not valid UTF-8")?;
433            Ok(ToolOutput {
434                success: true,
435                output: text,
436            })
437        } else {
438            Ok(ToolOutput {
439                success: true,
440                output: content,
441            })
442        }
443    }
444}
445
446pub struct GithubSkill;
447
448#[async_trait]
449impl Skill for GithubSkill {
450    fn name(&self) -> &'static str {
451        "github"
452    }
453
454    async fn build_tools(&self, config: Option<&toml::Value>) -> Result<Vec<Box<dyn Tool>>> {
455        let token = config
456            .and_then(|cfg| cfg.get("token"))
457            .and_then(|val| val.as_str())
458            .context(
459                "GitHub skill requires a token. Add it to your profile:\n\
460                 [profiles.<name>.skills.github]\n\
461                 token = \"ghp_...\"",
462            )?;
463
464        let client = Arc::new(GithubClient::new(token)?);
465
466        Ok(vec![
467            Box::new(GithubGetRepoTool(client.clone())),
468            Box::new(GithubListIssuesTool(client.clone())),
469            Box::new(GithubGetIssueTool(client.clone())),
470            Box::new(GithubListPrsTool(client.clone())),
471            Box::new(GithubGetPrTool(client.clone())),
472            Box::new(GithubGetFileTool(client)),
473        ])
474    }
475}