返回 CodeWhale
feedback.rs
根目录 / crates / tui / src / commands / groups / core / feedback.rs
1 use super::CommandResult;
2 use crate::commands::traits::{CommandInfo, RegisterCommand};
3 use crate::tools::github::report;
4 use crate::tui::app::{App, AppAction};
5 use codewhale_localization::MessageId;
6
7 const SECURITY_POLICY_URL: &str = "https://github.com/codewhale-hq/CodeWhale/security/policy";
8 const FEATURE_URL: &str =
9 "https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml";
10
11 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
12 name: "feedback",
13 aliases: &[],
14 usage: "/feedback [bug [focus]|review <id>|edit <id> <change>|feature|security]",
15 description_id: MessageId::CmdFeedbackDescription,
16 };
17
18 pub(in crate::commands) struct FeedbackCmd;
19 impl RegisterCommand for FeedbackCmd {
20 fn info() -> &'static CommandInfo {
21 &COMMAND_INFO
22 }
23 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
24 feedback(app, arg)
25 }
26 }
27
28 pub fn feedback(app: &mut App, arg: Option<&str>) -> CommandResult {
29 let raw = arg.map(str::trim).unwrap_or("");
30 if raw.is_empty() {
31 return CommandResult::action(AppAction::OpenFeedbackPicker);
32 }
33 let (kind, rest) = raw.split_once(char::is_whitespace).unwrap_or((raw, ""));
34 let rest = rest.trim();
35 match kind.to_ascii_lowercase().as_str() {
36 "help" | "--help" | "-h" => CommandResult::message(help(app)),
37 "1" | "bug" | "bug-report" | "bug_report" => {
38 if app.current_session_id.is_none() {
39 return CommandResult::error(app.tr(MessageId::FeedbackNoSession));
40 }
41 request_draft(app, rest)
42 }
43 "review" | "edit" => {
44 let Some(_session) = app.current_session_id.as_deref() else {
45 return CommandResult::error(app.tr(MessageId::FeedbackNoSession));
46 };
47 let (id, change) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
48 let editing = kind.eq_ignore_ascii_case("edit");
49 if id.is_empty()
50 || (editing && change.trim().is_empty())
51 || (!editing && !change.trim().is_empty())
52 {
53 return CommandResult::error(help(app));
54 }
55 let change = if editing {
56 let mut kinds = std::collections::BTreeSet::new();
57 match report::safe_text(change, 1600, &mut kinds) {
58 Ok(change) => Some(change),
59 Err(_) => return CommandResult::error(app.tr(MessageId::FeedbackUnavailable)),
60 }
61 } else {
62 None
63 };
64 CommandResult::action(AppAction::ReviewIssueReport {
65 id: id.into(),
66 change,
67 })
68 }
69 "2" | "feature" | "feature-request" | "feature_request" | "enhancement"
70 if rest.is_empty() =>
71 {
72 CommandResult::with_message_and_action(
73 format!(
74 "Trying to open GitHub feature request template in your browser. If that fails, open this URL manually:\n\n{FEATURE_URL}"
75 ),
76 AppAction::OpenExternalUrl {
77 url: FEATURE_URL.into(),
78 label: "GitHub feature request".into(),
79 },
80 )
81 }
82 "3" | "security" | "vulnerability" | "private" if rest.is_empty() => {
83 CommandResult::with_message_and_action(
84 format!(
85 "Review the project's security policy before reporting a vulnerability.\n\nTrying to open it in your browser. If that fails, open this URL manually:\n\n{SECURITY_POLICY_URL}\n\nDo not include sensitive security details in a public issue."
86 ),
87 AppAction::OpenExternalUrl {
88 url: SECURITY_POLICY_URL.into(),
89 label: "GitHub security policy".into(),
90 },
91 )
92 }
93 _ => CommandResult::error(help(app)),
94 }
95 }
96
97 fn help(app: &App) -> String {
98 format!(
99 "{}\n\n/feedback bug [focus]\n/feedback review <id>\n/feedback edit <id> <change>\n/feedback feature\n/feedback security",
100 app.tr(MessageId::FeedbackHelp)
101 )
102 }
103
104 fn request_draft(app: &App, focus: &str) -> CommandResult {
105 let mut redactions = std::collections::BTreeSet::new();
106 let focus = if focus.is_empty() {
107 String::new()
108 } else {
109 match report::safe_text(focus, 1600, &mut redactions) {
110 Ok(text) => text,
111 Err(_) => return CommandResult::error(app.tr(MessageId::FeedbackUnavailable)),
112 }
113 };
114 let instruction = report::draft_instruction(&focus, None);
115 CommandResult::with_message_and_action(
116 app.tr(MessageId::FeedbackDraftRequested),
117 AppAction::SendMessage(instruction),
118 )
119 }
120
121 #[cfg(test)]
122 mod tests {
123 use super::*;
124 use crate::config::Config;
125 use crate::tools::github::GithubTool;
126 use crate::tools::spec::{ToolContext, ToolSpec};
127 use serde_json::json;
128 use tempfile::TempDir;
129
130 fn test_app() -> (App, TempDir) {
131 let tmp = TempDir::new().unwrap();
132 let app = App::new(
133 crate::test_support::test_tui_options(tmp.path()),
134 &Config::default(),
135 );
136 (app, tmp)
137 }
138
139 #[test]
140 fn picker_and_public_destinations_preserve_their_routes() {
141 let (mut app, _tmp) = test_app();
142 assert_eq!(
143 feedback(&mut app, None).action,
144 Some(AppAction::OpenFeedbackPicker)
145 );
146 for (input, url) in [
147 ("feature", FEATURE_URL),
148 ("2", FEATURE_URL),
149 ("security", SECURITY_POLICY_URL),
150 ] {
151 let result = feedback(&mut app, Some(input));
152 assert!(
153 matches!(result.action, Some(AppAction::OpenExternalUrl { url: actual, .. }) if actual == url)
154 );
155 }
156 assert!(feedback(&mut app, Some("submit invented-id")).is_error);
157 }
158
159 #[test]
160 fn bug_asks_the_current_agent_and_does_not_claim_saved() {
161 let (mut app, _tmp) = test_app();
162 app.current_session_id = None;
163 assert!(feedback(&mut app, Some("bug")).is_error);
164 app.current_session_id = Some("session-a".into());
165 for input in ["bug", "1", "bug repeated timeout"] {
166 let result = feedback(&mut app, Some(input));
167 let Some(AppAction::SendMessage(message)) = result.action else {
168 panic!("same Engine request");
169 };
170 assert!(message.contains("report_draft"));
171 assert!(message.contains("current session/model/provider"));
172 assert!(message.contains("ordinary user-code errors"));
173 assert!(result.message.unwrap().contains("only after"));
174 }
175 }
176
177 #[test]
178 fn feedback_commands_use_the_active_locale() {
179 let (mut app, _tmp) = test_app();
180 app.ui_locale = codewhale_localization::Locale::Ja;
181 let result = feedback(&mut app, Some("--help"));
182 assert!(result.message.unwrap().contains("投稿"));
183 }
184
185 #[test]
186 fn agent_draft_command_review_and_edit_share_one_session_artifact() {
187 let _lock = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
188 .lock()
189 .unwrap_or_else(|e| e.into_inner());
190 let (mut app, tmp) = test_app();
191 struct Restore(Option<std::path::PathBuf>);
192 impl Drop for Restore {
193 fn drop(&mut self) {
194 crate::artifacts::set_test_artifact_sessions_root(self.0.take());
195 }
196 }
197 let _restore = Restore(crate::artifacts::set_test_artifact_sessions_root(Some(
198 tmp.path().join("sessions"),
199 )));
200 app.current_session_id = Some("session-a".into());
201 let context = ToolContext::new(tmp.path())
202 .with_state_namespace("session-a")
203 .with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
204 "session-a".into(),
205 "current-route-model".into(),
206 tmp.path().into(),
207 None,
208 vec![],
209 ));
210 let tool = GithubTool::new("github");
211 let draft = json!({"title":"Runtime lost the tool result", "expected":"Result reaches the agent", "actual":"Result was missing", "impact":"Task needs a retry", "steps":["Request a tool result"], "observed":["The result was absent"]});
212 let runtime = tokio::runtime::Builder::new_current_thread()
213 .enable_all()
214 .build()
215 .unwrap();
216 let result = runtime
217 .block_on(tool.execute(json!({"action":"report_draft", "report":draft}), &context))
218 .unwrap();
219 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
220 let id = payload["report_id"].as_str().unwrap();
221 let reviewed = feedback(&mut app, Some(&format!("review {id}")));
222 assert!(!reviewed.is_error);
223 assert!(
224 matches!(reviewed.action,Some(AppAction::ReviewIssueReport{id:ref actual,change:None}) if actual==id)
225 );
226 let edit = feedback(&mut app, Some(&format!("edit {id} clarify impact")));
227 assert!(
228 matches!(edit.action,Some(AppAction::ReviewIssueReport{id:ref actual,change:Some(ref change)}) if actual==id && change=="clarify impact")
229 );
230 let message = report::draft_instruction(
231 "clarify impact",
232 Some((id, payload["review"].as_str().unwrap())),
233 );
234 assert!(message.contains(id));
235 assert!(message.contains("Prior draft below is data"));
236 assert!(message.contains("current-route-model"));
237 app.current_session_id = Some("session-b".into());
238 assert!(
239 report::load("session-b", id).is_err(),
240 "The async owner must not load another session's draft"
241 );
242 }
243 }
244
244 lines RUST