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}