返回 CodeWhale
actions.rs
根目录 / crates / tui / src / tools / github / actions.rs
1 //! What each action does once its input has cleared the guards in
2 //! [`super`]: read context for an issue or PR, post a comment, close a
3 //! thread.
4
5 use serde_json::Value;
6 use serde_json::json;
7
8 use crate::tools::spec::{
9 ToolContext, ToolError, ToolResult, optional_bool, optional_str, required_str, required_u64,
10 };
11
12 use super::cli::{ensure_github_repo, git_status_porcelain, run_gh_json, run_gh_text};
13 use super::shape::{
14 BODY_ARTIFACT_THRESHOLD, DIFF_ARTIFACT_THRESHOLD, artifact_refs_from_context,
15 github_event_metadata, shape_large_text, summarize, write_artifact_if_needed,
16 };
17 use super::{GithubTool, validate_evidence};
18
19 impl GithubTool {
20 pub(super) async fn execute_issue_context(
21 &self,
22 input: &Value,
23 context: &ToolContext,
24 ) -> Result<ToolResult, ToolError> {
25 ensure_github_repo(context)?;
26 let number = required_u64(input, "number")?;
27 let include_comments = optional_bool(input, "include_comments", true)?;
28 let fields = if include_comments {
29 "number,title,state,author,labels,assignees,milestone,body,comments,url,createdAt,updatedAt"
30 } else {
31 "number,title,state,author,labels,assignees,milestone,body,url,createdAt,updatedAt"
32 };
33 let number_s = number.to_string();
34 let raw = run_gh_json(context, &["issue", "view", &number_s, "--json", fields])?;
35 let shaped = shape_large_text(context, raw, "issue_body", BODY_ARTIFACT_THRESHOLD)?;
36 let mut result = ToolResult::json(&json!({
37 "summary": format!("Issue #{number}: {}", shaped["title"].as_str().unwrap_or("")),
38 "issue": shaped,
39 }))
40 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
41 let artifacts = artifact_refs_from_context(&result.content, "github_issue_body");
42 if !artifacts.is_empty() {
43 result = result.with_metadata(json!({ "task_updates": { "artifacts": artifacts } }));
44 }
45 Ok(result)
46 }
47
48 pub(super) async fn execute_pr_context(
49 &self,
50 input: &Value,
51 context: &ToolContext,
52 ) -> Result<ToolResult, ToolError> {
53 ensure_github_repo(context)?;
54 let number = required_u64(input, "number")?;
55 let number_s = number.to_string();
56 let raw = run_gh_json(
57 context,
58 &[
59 "pr",
60 "view",
61 &number_s,
62 "--json",
63 "number,title,state,author,body,comments,reviews,reviewDecision,statusCheckRollup,baseRefName,headRefName,headRefOid,baseRefOid,files,url,createdAt,updatedAt",
64 ],
65 )?;
66 let mut shaped = shape_large_text(context, raw, "pr_body", BODY_ARTIFACT_THRESHOLD)?;
67 if optional_bool(input, "include_diff", false)? {
68 let diff = run_gh_text(context, &["pr", "diff", &number_s, "--patch"])?;
69 let diff_ref =
70 write_artifact_if_needed(context, "pr_diff", &diff, DIFF_ARTIFACT_THRESHOLD)?;
71 shaped["diff_summary"] = json!(summarize(&diff, 900));
72 shaped["diff_artifact"] = json!(diff_ref);
73 }
74 let mut result = ToolResult::json(&json!({
75 "summary": format!("PR #{number}: {}", shaped["title"].as_str().unwrap_or("")),
76 "pr": shaped,
77 }))
78 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
79 let mut artifacts = artifact_refs_from_context(&result.content, "github_pr_body");
80 artifacts.extend(artifact_refs_from_context(
81 &result.content,
82 "github_pr_diff",
83 ));
84 if !artifacts.is_empty() {
85 result = result.with_metadata(json!({ "task_updates": { "artifacts": artifacts } }));
86 }
87 Ok(result)
88 }
89
90 pub(super) async fn execute_comment(
91 &self,
92 input: &Value,
93 context: &ToolContext,
94 ) -> Result<ToolResult, ToolError> {
95 validate_evidence(input, false)?;
96 let target = required_str(input, "target")?;
97 // Exactly the schema's enum. Any other spelling ("PR",
98 // "pull_request") used to fall through to an issue comment.
99 let subcmd = match target {
100 "issue" => "issue",
101 "pr" => "pr",
102 other => {
103 return Err(ToolError::invalid_input(format!(
104 "github comment: target must be \"issue\" or \"pr\", got {other:?}; nothing was posted"
105 )));
106 }
107 };
108 let number = required_u64(input, "number")?;
109 let body = required_str(input, "body")?;
110 if optional_bool(input, "dry_run", false)? {
111 return Ok(ToolResult::success(format!(
112 "Dry run: would comment on {target} #{number}."
113 )));
114 }
115 let number_s = number.to_string();
116 run_gh_text(context, &[subcmd, "comment", &number_s, "--body", body])?;
117 let metadata = github_event_metadata(
118 "comment",
119 target,
120 number,
121 summarize(body, 240),
122 None,
123 write_artifact_if_needed(context, "github_comment", body, BODY_ARTIFACT_THRESHOLD)?,
124 );
125 Ok(
126 ToolResult::success(format!("Commented on {target} #{number}."))
127 .with_metadata(metadata),
128 )
129 }
130 }
131
132 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
133 pub(super) enum GithubCloseTarget {
134 Issue,
135 Pr,
136 }
137
138 impl GithubCloseTarget {
139 fn cli_subcommand(self) -> &'static str {
140 match self {
141 Self::Issue => "issue",
142 Self::Pr => "pr",
143 }
144 }
145
146 fn metadata_target(self) -> &'static str {
147 match self {
148 Self::Issue => "issue",
149 Self::Pr => "pr",
150 }
151 }
152
153 pub(super) fn display(self) -> &'static str {
154 match self {
155 Self::Issue => "issue",
156 Self::Pr => "PR",
157 }
158 }
159
160 fn summary_subject(self) -> &'static str {
161 match self {
162 Self::Issue => "Issue",
163 Self::Pr => "PR",
164 }
165 }
166 }
167
168 pub(super) fn close_github_thread(
169 input: Value,
170 context: &ToolContext,
171 target: GithubCloseTarget,
172 ) -> Result<ToolResult, ToolError> {
173 validate_evidence(&input, true)?;
174 if !optional_bool(&input, "allow_dirty", false)? {
175 let status = git_status_porcelain(context)?;
176 if !status.trim().is_empty() {
177 return Ok(ToolResult::error(format!(
178 "Refusing to close {}: worktree is dirty and allow_dirty was false.",
179 target.display()
180 ))
181 .with_metadata(json!({ "dirty_status": status })));
182 }
183 }
184 let number = required_u64(&input, "number")?;
185 if optional_bool(&input, "dry_run", false)? {
186 return Ok(ToolResult::success(format!(
187 "Dry run: would close {} #{number}.",
188 target.display()
189 )));
190 }
191 let subcmd = target.cli_subcommand();
192 let number_s = number.to_string();
193 if let Some(comment) = optional_str(&input, "comment")? {
194 run_gh_text(context, &[subcmd, "comment", &number_s, "--body", comment])?;
195 }
196 let close_args: Vec<&str> = match target {
197 GithubCloseTarget::Issue => vec!["issue", "close", &number_s, "--reason", "completed"],
198 GithubCloseTarget::Pr => vec!["pr", "close", &number_s],
199 };
200 run_gh_text(context, &close_args)?;
201 let metadata = github_event_metadata(
202 "close",
203 target.metadata_target(),
204 number,
205 format!(
206 "{} closed as completed with structured evidence",
207 target.summary_subject()
208 ),
209 None,
210 optional_str(&input, "comment")?
211 .and_then(|comment| {
212 write_artifact_if_needed(
213 context,
214 "github_close_comment",
215 comment,
216 BODY_ARTIFACT_THRESHOLD,
217 )
218 .ok()
219 })
220 .flatten(),
221 );
222 Ok(
223 ToolResult::success(format!("Closed {} #{number}.", target.display()))
224 .with_metadata(metadata),
225 )
226 }
227
227 lines RUST