返回 DeepSeek-TUI-2026
github.rs
根目录 / crates / tui / src / tools / github.rs
1 //! GitHub context and guarded write tools backed by the `gh` CLI.
2
3 use std::path::{Path, PathBuf};
4 use std::process::Command;
5
6 use async_trait::async_trait;
7 use chrono::Utc;
8 use serde_json::{Value, json};
9 use uuid::Uuid;
10
11 use crate::task_manager::{TaskArtifactRef, TaskGithubEvent};
12 use crate::tools::spec::{
13 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
14 optional_bool, optional_str, required_str, required_u64,
15 };
16
17 const DEFAULT_GH: &str = "/opt/homebrew/bin/gh";
18 const BODY_ARTIFACT_THRESHOLD: usize = 4_000;
19 const DIFF_ARTIFACT_THRESHOLD: usize = 8_000;
20
21 pub struct GithubIssueContextTool;
22 pub struct GithubPrContextTool;
23 pub struct GithubCommentTool;
24 pub struct GithubCloseIssueTool;
25
26 #[async_trait]
27 impl ToolSpec for GithubIssueContextTool {
28 fn name(&self) -> &'static str {
29 "github_issue_context"
30 }
31
32 fn description(&self) -> &'static str {
33 "Read GitHub issue context using gh. Read-only: body/comments/labels/state are summarized and large bodies become task artifacts when a durable task is active."
34 }
35
36 fn input_schema(&self) -> Value {
37 json!({
38 "type": "object",
39 "properties": {
40 "number": { "type": "integer", "minimum": 1 },
41 "include_comments": { "type": "boolean", "default": true }
42 },
43 "required": ["number"],
44 "additionalProperties": false
45 })
46 }
47
48 fn capabilities(&self) -> Vec<ToolCapability> {
49 vec![ToolCapability::ReadOnly, ToolCapability::Network]
50 }
51
52 fn approval_requirement(&self) -> ApprovalRequirement {
53 ApprovalRequirement::Auto
54 }
55
56 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
57 ensure_github_repo(context)?;
58 let number = required_u64(&input, "number")?;
59 let include_comments = optional_bool(&input, "include_comments", true);
60 let fields = if include_comments {
61 "number,title,state,author,labels,assignees,milestone,body,comments,url,createdAt,updatedAt"
62 } else {
63 "number,title,state,author,labels,assignees,milestone,body,url,createdAt,updatedAt"
64 };
65 let number_s = number.to_string();
66 let raw = run_gh_json(context, &["issue", "view", &number_s, "--json", fields])?;
67 let shaped = shape_large_text(context, raw, "issue_body", BODY_ARTIFACT_THRESHOLD)?;
68 let mut result = ToolResult::json(&json!({
69 "summary": format!("Issue #{number}: {}", shaped["title"].as_str().unwrap_or("")),
70 "issue": shaped,
71 }))
72 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
73 let artifacts = artifact_refs_from_context(&result.content, "github_issue_body");
74 if !artifacts.is_empty() {
75 result = result.with_metadata(json!({ "task_updates": { "artifacts": artifacts } }));
76 }
77 Ok(result)
78 }
79 }
80
81 #[async_trait]
82 impl ToolSpec for GithubPrContextTool {
83 fn name(&self) -> &'static str {
84 "github_pr_context"
85 }
86
87 fn description(&self) -> &'static str {
88 "Read GitHub PR context using gh: body/comments/reviews/check status/files and optional diff artifact. Read-only; no push/merge/close."
89 }
90
91 fn input_schema(&self) -> Value {
92 json!({
93 "type": "object",
94 "properties": {
95 "number": { "type": "integer", "minimum": 1 },
96 "include_diff": { "type": "boolean", "default": false }
97 },
98 "required": ["number"],
99 "additionalProperties": false
100 })
101 }
102
103 fn capabilities(&self) -> Vec<ToolCapability> {
104 vec![ToolCapability::ReadOnly, ToolCapability::Network]
105 }
106
107 fn approval_requirement(&self) -> ApprovalRequirement {
108 ApprovalRequirement::Auto
109 }
110
111 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
112 ensure_github_repo(context)?;
113 let number = required_u64(&input, "number")?;
114 let number_s = number.to_string();
115 let raw = run_gh_json(
116 context,
117 &[
118 "pr",
119 "view",
120 &number_s,
121 "--json",
122 "number,title,state,author,body,comments,reviews,reviewDecision,statusCheckRollup,baseRefName,headRefName,headRefOid,baseRefOid,files,url,createdAt,updatedAt",
123 ],
124 )?;
125 let mut shaped = shape_large_text(context, raw, "pr_body", BODY_ARTIFACT_THRESHOLD)?;
126 if optional_bool(&input, "include_diff", false) {
127 let diff = run_gh_text(context, &["pr", "diff", &number_s, "--patch"])?;
128 let diff_ref =
129 write_artifact_if_needed(context, "pr_diff", &diff, DIFF_ARTIFACT_THRESHOLD)?;
130 shaped["diff_summary"] = json!(summarize(&diff, 900));
131 shaped["diff_artifact"] = json!(diff_ref);
132 }
133 let mut result = ToolResult::json(&json!({
134 "summary": format!("PR #{number}: {}", shaped["title"].as_str().unwrap_or("")),
135 "pr": shaped,
136 }))
137 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
138 let mut artifacts = artifact_refs_from_context(&result.content, "github_pr_body");
139 artifacts.extend(artifact_refs_from_context(
140 &result.content,
141 "github_pr_diff",
142 ));
143 if !artifacts.is_empty() {
144 result = result.with_metadata(json!({ "task_updates": { "artifacts": artifacts } }));
145 }
146 Ok(result)
147 }
148 }
149
150 #[async_trait]
151 impl ToolSpec for GithubCommentTool {
152 fn name(&self) -> &'static str {
153 "github_comment"
154 }
155
156 fn description(&self) -> &'static str {
157 "Post an evidence-backed GitHub issue/PR comment with gh. Requires approval. Use blocker comments for partial work; do not claim closure without evidence."
158 }
159
160 fn input_schema(&self) -> Value {
161 json!({
162 "type": "object",
163 "properties": {
164 "target": { "type": "string", "enum": ["issue", "pr"] },
165 "number": { "type": "integer", "minimum": 1 },
166 "body": { "type": "string" },
167 "evidence": { "type": "object" },
168 "dry_run": { "type": "boolean", "default": false }
169 },
170 "required": ["target", "number", "body", "evidence"],
171 "additionalProperties": false
172 })
173 }
174
175 fn capabilities(&self) -> Vec<ToolCapability> {
176 vec![ToolCapability::Network, ToolCapability::RequiresApproval]
177 }
178
179 fn approval_requirement(&self) -> ApprovalRequirement {
180 ApprovalRequirement::Required
181 }
182
183 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
184 validate_evidence(&input, false)?;
185 let target = required_str(&input, "target")?;
186 let number = required_u64(&input, "number")?;
187 let body = required_str(&input, "body")?;
188 if optional_bool(&input, "dry_run", false) {
189 return Ok(ToolResult::success(format!(
190 "Dry run: would comment on {target} #{number}."
191 )));
192 }
193 let subcmd = if target == "pr" { "pr" } else { "issue" };
194 let number_s = number.to_string();
195 run_gh_text(context, &[subcmd, "comment", &number_s, "--body", body])?;
196 let metadata = github_event_metadata(
197 "comment",
198 target,
199 number,
200 summarize(body, 240),
201 None,
202 write_artifact_if_needed(context, "github_comment", body, BODY_ARTIFACT_THRESHOLD)?,
203 );
204 Ok(
205 ToolResult::success(format!("Commented on {target} #{number}."))
206 .with_metadata(metadata),
207 )
208 }
209 }
210
211 #[async_trait]
212 impl ToolSpec for GithubCloseIssueTool {
213 fn name(&self) -> &'static str {
214 "github_close_issue"
215 }
216
217 fn description(&self) -> &'static str {
218 "Close a GitHub issue only when structured acceptance evidence is present and approved. Never close merely because the agent is stopping."
219 }
220
221 fn input_schema(&self) -> Value {
222 json!({
223 "type": "object",
224 "properties": {
225 "number": { "type": "integer", "minimum": 1 },
226 "acceptance_criteria": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
227 "evidence": {
228 "type": "object",
229 "properties": {
230 "files_changed": { "type": "array", "items": { "type": "string" } },
231 "tests_run": { "type": "array", "items": { "type": "string" } },
232 "commits": { "type": "array", "items": { "type": "string" } },
233 "final_status": { "type": "string" }
234 },
235 "required": ["files_changed", "tests_run", "final_status"]
236 },
237 "comment": { "type": "string" },
238 "allow_dirty": { "type": "boolean", "default": false },
239 "dry_run": { "type": "boolean", "default": false }
240 },
241 "required": ["number", "acceptance_criteria", "evidence"],
242 "additionalProperties": false
243 })
244 }
245
246 fn capabilities(&self) -> Vec<ToolCapability> {
247 vec![ToolCapability::Network, ToolCapability::RequiresApproval]
248 }
249
250 fn approval_requirement(&self) -> ApprovalRequirement {
251 ApprovalRequirement::Required
252 }
253
254 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
255 validate_evidence(&input, true)?;
256 if !optional_bool(&input, "allow_dirty", false) {
257 let status = git_status_porcelain(context)?;
258 if !status.trim().is_empty() {
259 return Ok(ToolResult::error(
260 "Refusing to close issue: worktree is dirty and allow_dirty was false.",
261 )
262 .with_metadata(json!({ "dirty_status": status })));
263 }
264 }
265 let number = required_u64(&input, "number")?;
266 if optional_bool(&input, "dry_run", false) {
267 return Ok(ToolResult::success(format!(
268 "Dry run: would close issue #{number}."
269 )));
270 }
271 if let Some(comment) = optional_str(&input, "comment") {
272 let number_s = number.to_string();
273 run_gh_text(context, &["issue", "comment", &number_s, "--body", comment])?;
274 }
275 let number_s = number.to_string();
276 run_gh_text(
277 context,
278 &["issue", "close", &number_s, "--reason", "completed"],
279 )?;
280 let metadata = github_event_metadata(
281 "close",
282 "issue",
283 number,
284 "Issue closed as completed with structured evidence".to_string(),
285 None,
286 optional_str(&input, "comment")
287 .and_then(|comment| {
288 write_artifact_if_needed(
289 context,
290 "github_close_comment",
291 comment,
292 BODY_ARTIFACT_THRESHOLD,
293 )
294 .ok()
295 })
296 .flatten(),
297 );
298 Ok(ToolResult::success(format!("Closed issue #{number}.")).with_metadata(metadata))
299 }
300 }
301
302 fn gh_bin() -> String {
303 std::env::var("DEEPSEEK_GH_BIN").unwrap_or_else(|_| DEFAULT_GH.to_string())
304 }
305
306 fn run_gh_text(context: &ToolContext, args: &[&str]) -> Result<String, ToolError> {
307 let out = Command::new(gh_bin())
308 .args(args)
309 .current_dir(&context.workspace)
310 .output()
311 .map_err(|e| {
312 if e.kind() == std::io::ErrorKind::NotFound {
313 ToolError::not_available("gh CLI is not installed at /opt/homebrew/bin/gh")
314 } else {
315 ToolError::execution_failed(format!("failed to run gh: {e}"))
316 }
317 })?;
318 if !out.status.success() {
319 return Err(ToolError::execution_failed(format!(
320 "gh {} failed: {}",
321 args.join(" "),
322 String::from_utf8_lossy(&out.stderr).trim()
323 )));
324 }
325 Ok(String::from_utf8_lossy(&out.stdout).to_string())
326 }
327
328 fn run_gh_json(context: &ToolContext, args: &[&str]) -> Result<Value, ToolError> {
329 let text = run_gh_text(context, args)?;
330 serde_json::from_str(&text).map_err(|e| ToolError::execution_failed(e.to_string()))
331 }
332
333 fn ensure_github_repo(context: &ToolContext) -> Result<(), ToolError> {
334 let out = Command::new("git")
335 .args(["rev-parse", "--is-inside-work-tree"])
336 .current_dir(&context.workspace)
337 .output()
338 .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
339 if out.status.success() {
340 Ok(())
341 } else {
342 Err(ToolError::not_available(
343 "current workspace is not a git repository",
344 ))
345 }
346 }
347
348 fn git_status_porcelain(context: &ToolContext) -> Result<String, ToolError> {
349 let out = Command::new("git")
350 .args(["status", "--porcelain"])
351 .current_dir(&context.workspace)
352 .output()
353 .map_err(|e| ToolError::execution_failed(format!("failed to run git status: {e}")))?;
354 Ok(String::from_utf8_lossy(&out.stdout).to_string())
355 }
356
357 fn shape_large_text(
358 context: &ToolContext,
359 mut value: Value,
360 label: &str,
361 threshold: usize,
362 ) -> Result<Value, ToolError> {
363 let body = value
364 .get("body")
365 .and_then(Value::as_str)
366 .map(ToString::to_string);
367 if let Some(body) = body
368 && body.len() > threshold
369 {
370 let artifact = write_artifact_if_needed(context, label, &body, threshold)?;
371 value["body_summary"] = json!(summarize(&body, 900));
372 value["body_artifact"] = json!(artifact);
373 value["body"] = json!(summarize(&body, 1200));
374 }
375 Ok(value)
376 }
377
378 fn write_artifact_if_needed(
379 context: &ToolContext,
380 label: &str,
381 content: &str,
382 threshold: usize,
383 ) -> Result<Option<PathBuf>, ToolError> {
384 if content.len() <= threshold {
385 return Ok(None);
386 }
387 let Some(task_id) = context.runtime.active_task_id.as_deref() else {
388 return Ok(None);
389 };
390 if let Some(manager) = context.runtime.task_manager.as_ref() {
391 return manager
392 .write_task_artifact(task_id, label, content)
393 .map(Some)
394 .map_err(|e| ToolError::execution_failed(e.to_string()));
395 }
396 let Some(data_dir) = context.runtime.task_data_dir.as_ref() else {
397 return Ok(None);
398 };
399 let dir = data_dir.join("artifacts").join(task_id);
400 std::fs::create_dir_all(&dir)
401 .map_err(|e| ToolError::execution_failed(format!("create artifact dir: {e}")))?;
402 let absolute = dir.join(format!(
403 "{}_{}.txt",
404 Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
405 sanitize_filename(label)
406 ));
407 std::fs::write(&absolute, content)
408 .map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
409 Ok(Some(
410 absolute
411 .strip_prefix(data_dir)
412 .map(Path::to_path_buf)
413 .unwrap_or(absolute),
414 ))
415 }
416
417 fn artifact_refs_from_context(content: &str, label: &str) -> Vec<TaskArtifactRef> {
418 let Ok(value) = serde_json::from_str::<Value>(content) else {
419 return Vec::new();
420 };
421 let (path_key, summary_key) = if label.ends_with("_diff") {
422 ("diff_artifact", "diff_summary")
423 } else {
424 ("body_artifact", "body_summary")
425 };
426 let mut refs = Vec::new();
427 collect_artifact_refs(&value, path_key, summary_key, label, &mut refs);
428 refs
429 }
430
431 fn collect_artifact_refs(
432 value: &Value,
433 path_key: &str,
434 summary_key: &str,
435 label: &str,
436 refs: &mut Vec<TaskArtifactRef>,
437 ) {
438 match value {
439 Value::Object(map) => {
440 if let Some(path) = map.get(path_key).and_then(Value::as_str) {
441 let summary = map
442 .get(summary_key)
443 .and_then(Value::as_str)
444 .map(ToString::to_string)
445 .unwrap_or_else(|| format!("GitHub {label} artifact"));
446 refs.push(TaskArtifactRef {
447 label: label.to_string(),
448 path: PathBuf::from(path),
449 summary,
450 created_at: Utc::now(),
451 });
452 }
453 for child in map.values() {
454 collect_artifact_refs(child, path_key, summary_key, label, refs);
455 }
456 }
457 Value::Array(items) => {
458 for child in items {
459 collect_artifact_refs(child, path_key, summary_key, label, refs);
460 }
461 }
462 _ => {}
463 }
464 }
465
466 fn github_event_metadata(
467 action: &str,
468 target: &str,
469 number: u64,
470 summary: String,
471 url: Option<String>,
472 artifact: Option<PathBuf>,
473 ) -> Value {
474 let artifacts = artifact
475 .map(|path| {
476 json!([TaskArtifactRef {
477 label: format!("github_{action}"),
478 path,
479 summary: summary.clone(),
480 created_at: Utc::now(),
481 }])
482 })
483 .unwrap_or_else(|| json!([]));
484 json!({
485 "task_updates": {
486 "github_event": TaskGithubEvent {
487 id: format!("gh_{}", &Uuid::new_v4().to_string()[..8]),
488 action: action.to_string(),
489 target: target.to_string(),
490 number,
491 summary,
492 url,
493 recorded_at: Utc::now(),
494 },
495 "artifacts": artifacts
496 }
497 })
498 }
499
500 fn validate_evidence(input: &Value, closing: bool) -> Result<(), ToolError> {
501 let evidence = input
502 .get("evidence")
503 .and_then(Value::as_object)
504 .ok_or_else(|| ToolError::invalid_input("evidence object is required"))?;
505 if closing {
506 let criteria = input
507 .get("acceptance_criteria")
508 .and_then(Value::as_array)
509 .filter(|items| !items.is_empty())
510 .ok_or_else(|| ToolError::invalid_input("acceptance_criteria must be non-empty"))?;
511 if criteria
512 .iter()
513 .any(|item| item.as_str().unwrap_or("").trim().is_empty())
514 {
515 return Err(ToolError::invalid_input(
516 "acceptance_criteria entries must be non-empty",
517 ));
518 }
519 for key in ["files_changed", "tests_run", "final_status"] {
520 if !evidence.contains_key(key) {
521 return Err(ToolError::invalid_input(format!(
522 "closure evidence missing {key}"
523 )));
524 }
525 }
526 }
527 Ok(())
528 }
529
530 fn summarize(text: &str, limit: usize) -> String {
531 let mut out = String::new();
532 for (idx, ch) in text.chars().enumerate() {
533 if idx >= limit.saturating_sub(3) {
534 out.push_str("...");
535 return out;
536 }
537 if ch.is_control() && ch != '\n' && ch != '\t' {
538 continue;
539 }
540 out.push(ch);
541 }
542 out
543 }
544
545 fn sanitize_filename(input: &str) -> String {
546 let mut out = String::new();
547 for ch in input.chars() {
548 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
549 out.push(ch);
550 } else {
551 out.push('_');
552 }
553 }
554 if out.is_empty() {
555 "artifact".to_string()
556 } else {
557 out
558 }
559 }
560
561 #[cfg(test)]
562 mod tests {
563 use super::*;
564 use crate::tools::spec::ToolSpec;
565
566 #[test]
567 fn close_schema_requires_structured_evidence() {
568 let schema = GithubCloseIssueTool.input_schema();
569 assert!(
570 schema["properties"]["evidence"]["required"]
571 .as_array()
572 .expect("required")
573 .contains(&json!("tests_run"))
574 );
575 }
576
577 #[test]
578 fn missing_close_evidence_refuses() {
579 let input = json!({
580 "number": 1,
581 "acceptance_criteria": ["done"],
582 "evidence": { "files_changed": [] }
583 });
584 let err = validate_evidence(&input, true).expect_err("should refuse");
585 assert!(err.to_string().contains("tests_run"));
586 }
587 }
588
588 lines RUST