@hasna/terminal 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/dist/cli.js +29 -12
  2. package/package.json +1 -1
  3. package/src/ai.ts +50 -36
  4. package/src/cli.tsx +29 -12
  5. package/src/context-hints.ts +89 -0
  6. package/src/discover.ts +238 -0
  7. package/src/economy.ts +53 -0
  8. package/src/output-store.ts +65 -0
  9. package/src/providers/index.ts +4 -4
  10. package/src/sessions-db.ts +81 -0
  11. package/temp/rtk/.claude/agents/code-reviewer.md +221 -0
  12. package/temp/rtk/.claude/agents/debugger.md +519 -0
  13. package/temp/rtk/.claude/agents/rtk-testing-specialist.md +461 -0
  14. package/temp/rtk/.claude/agents/rust-rtk.md +511 -0
  15. package/temp/rtk/.claude/agents/technical-writer.md +355 -0
  16. package/temp/rtk/.claude/commands/diagnose.md +352 -0
  17. package/temp/rtk/.claude/commands/test-routing.md +362 -0
  18. package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +16 -0
  19. package/temp/rtk/.claude/hooks/rtk-rewrite.sh +70 -0
  20. package/temp/rtk/.claude/hooks/rtk-suggest.sh +152 -0
  21. package/temp/rtk/.claude/rules/cli-testing.md +526 -0
  22. package/temp/rtk/.claude/skills/issue-triage/SKILL.md +348 -0
  23. package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +134 -0
  24. package/temp/rtk/.claude/skills/performance.md +435 -0
  25. package/temp/rtk/.claude/skills/pr-triage/SKILL.md +315 -0
  26. package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +71 -0
  27. package/temp/rtk/.claude/skills/repo-recap.md +206 -0
  28. package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +78 -0
  29. package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +124 -0
  30. package/temp/rtk/.claude/skills/security-guardian.md +503 -0
  31. package/temp/rtk/.claude/skills/ship.md +404 -0
  32. package/temp/rtk/.github/workflows/benchmark.yml +34 -0
  33. package/temp/rtk/.github/workflows/dco-check.yaml +12 -0
  34. package/temp/rtk/.github/workflows/release-please.yml +51 -0
  35. package/temp/rtk/.github/workflows/release.yml +343 -0
  36. package/temp/rtk/.github/workflows/security-check.yml +135 -0
  37. package/temp/rtk/.github/workflows/validate-docs.yml +78 -0
  38. package/temp/rtk/.release-please-manifest.json +3 -0
  39. package/temp/rtk/ARCHITECTURE.md +1491 -0
  40. package/temp/rtk/CHANGELOG.md +640 -0
  41. package/temp/rtk/CLAUDE.md +605 -0
  42. package/temp/rtk/CONTRIBUTING.md +199 -0
  43. package/temp/rtk/Cargo.lock +1668 -0
  44. package/temp/rtk/Cargo.toml +64 -0
  45. package/temp/rtk/Formula/rtk.rb +43 -0
  46. package/temp/rtk/INSTALL.md +390 -0
  47. package/temp/rtk/LICENSE +21 -0
  48. package/temp/rtk/README.md +386 -0
  49. package/temp/rtk/README_es.md +159 -0
  50. package/temp/rtk/README_fr.md +197 -0
  51. package/temp/rtk/README_ja.md +159 -0
  52. package/temp/rtk/README_ko.md +159 -0
  53. package/temp/rtk/README_zh.md +167 -0
  54. package/temp/rtk/ROADMAP.md +15 -0
  55. package/temp/rtk/SECURITY.md +217 -0
  56. package/temp/rtk/TEST_EXEC_TIME.md +102 -0
  57. package/temp/rtk/build.rs +57 -0
  58. package/temp/rtk/docs/AUDIT_GUIDE.md +432 -0
  59. package/temp/rtk/docs/FEATURES.md +1410 -0
  60. package/temp/rtk/docs/TROUBLESHOOTING.md +309 -0
  61. package/temp/rtk/docs/filter-workflow.md +102 -0
  62. package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
  63. package/temp/rtk/docs/tracking.md +583 -0
  64. package/temp/rtk/hooks/opencode-rtk.ts +39 -0
  65. package/temp/rtk/hooks/rtk-awareness.md +29 -0
  66. package/temp/rtk/hooks/rtk-rewrite.sh +61 -0
  67. package/temp/rtk/hooks/test-rtk-rewrite.sh +442 -0
  68. package/temp/rtk/install.sh +124 -0
  69. package/temp/rtk/release-please-config.json +10 -0
  70. package/temp/rtk/scripts/benchmark.sh +592 -0
  71. package/temp/rtk/scripts/check-installation.sh +162 -0
  72. package/temp/rtk/scripts/install-local.sh +37 -0
  73. package/temp/rtk/scripts/rtk-economics.sh +137 -0
  74. package/temp/rtk/scripts/test-all.sh +561 -0
  75. package/temp/rtk/scripts/test-aristote.sh +227 -0
  76. package/temp/rtk/scripts/test-tracking.sh +79 -0
  77. package/temp/rtk/scripts/update-readme-metrics.sh +32 -0
  78. package/temp/rtk/scripts/validate-docs.sh +73 -0
  79. package/temp/rtk/src/aws_cmd.rs +880 -0
  80. package/temp/rtk/src/binlog.rs +1645 -0
  81. package/temp/rtk/src/cargo_cmd.rs +1727 -0
  82. package/temp/rtk/src/cc_economics.rs +1157 -0
  83. package/temp/rtk/src/ccusage.rs +340 -0
  84. package/temp/rtk/src/config.rs +187 -0
  85. package/temp/rtk/src/container.rs +855 -0
  86. package/temp/rtk/src/curl_cmd.rs +134 -0
  87. package/temp/rtk/src/deps.rs +268 -0
  88. package/temp/rtk/src/diff_cmd.rs +367 -0
  89. package/temp/rtk/src/discover/mod.rs +274 -0
  90. package/temp/rtk/src/discover/provider.rs +388 -0
  91. package/temp/rtk/src/discover/registry.rs +2022 -0
  92. package/temp/rtk/src/discover/report.rs +202 -0
  93. package/temp/rtk/src/discover/rules.rs +667 -0
  94. package/temp/rtk/src/display_helpers.rs +402 -0
  95. package/temp/rtk/src/dotnet_cmd.rs +1771 -0
  96. package/temp/rtk/src/dotnet_format_report.rs +133 -0
  97. package/temp/rtk/src/dotnet_trx.rs +593 -0
  98. package/temp/rtk/src/env_cmd.rs +204 -0
  99. package/temp/rtk/src/filter.rs +462 -0
  100. package/temp/rtk/src/filters/README.md +52 -0
  101. package/temp/rtk/src/filters/ansible-playbook.toml +34 -0
  102. package/temp/rtk/src/filters/basedpyright.toml +47 -0
  103. package/temp/rtk/src/filters/biome.toml +45 -0
  104. package/temp/rtk/src/filters/brew-install.toml +37 -0
  105. package/temp/rtk/src/filters/composer-install.toml +40 -0
  106. package/temp/rtk/src/filters/df.toml +16 -0
  107. package/temp/rtk/src/filters/dotnet-build.toml +64 -0
  108. package/temp/rtk/src/filters/du.toml +16 -0
  109. package/temp/rtk/src/filters/fail2ban-client.toml +15 -0
  110. package/temp/rtk/src/filters/gcc.toml +49 -0
  111. package/temp/rtk/src/filters/gcloud.toml +22 -0
  112. package/temp/rtk/src/filters/hadolint.toml +24 -0
  113. package/temp/rtk/src/filters/helm.toml +29 -0
  114. package/temp/rtk/src/filters/iptables.toml +27 -0
  115. package/temp/rtk/src/filters/jj.toml +28 -0
  116. package/temp/rtk/src/filters/jq.toml +24 -0
  117. package/temp/rtk/src/filters/make.toml +41 -0
  118. package/temp/rtk/src/filters/markdownlint.toml +24 -0
  119. package/temp/rtk/src/filters/mix-compile.toml +27 -0
  120. package/temp/rtk/src/filters/mix-format.toml +15 -0
  121. package/temp/rtk/src/filters/mvn-build.toml +44 -0
  122. package/temp/rtk/src/filters/oxlint.toml +43 -0
  123. package/temp/rtk/src/filters/ping.toml +63 -0
  124. package/temp/rtk/src/filters/pio-run.toml +40 -0
  125. package/temp/rtk/src/filters/poetry-install.toml +50 -0
  126. package/temp/rtk/src/filters/pre-commit.toml +35 -0
  127. package/temp/rtk/src/filters/ps.toml +16 -0
  128. package/temp/rtk/src/filters/quarto-render.toml +41 -0
  129. package/temp/rtk/src/filters/rsync.toml +48 -0
  130. package/temp/rtk/src/filters/shellcheck.toml +27 -0
  131. package/temp/rtk/src/filters/shopify-theme.toml +29 -0
  132. package/temp/rtk/src/filters/skopeo.toml +45 -0
  133. package/temp/rtk/src/filters/sops.toml +16 -0
  134. package/temp/rtk/src/filters/ssh.toml +44 -0
  135. package/temp/rtk/src/filters/stat.toml +34 -0
  136. package/temp/rtk/src/filters/swift-build.toml +41 -0
  137. package/temp/rtk/src/filters/systemctl-status.toml +33 -0
  138. package/temp/rtk/src/filters/terraform-plan.toml +35 -0
  139. package/temp/rtk/src/filters/tofu-fmt.toml +16 -0
  140. package/temp/rtk/src/filters/tofu-init.toml +38 -0
  141. package/temp/rtk/src/filters/tofu-plan.toml +35 -0
  142. package/temp/rtk/src/filters/tofu-validate.toml +17 -0
  143. package/temp/rtk/src/filters/trunk-build.toml +39 -0
  144. package/temp/rtk/src/filters/ty.toml +50 -0
  145. package/temp/rtk/src/filters/uv-sync.toml +37 -0
  146. package/temp/rtk/src/filters/xcodebuild.toml +99 -0
  147. package/temp/rtk/src/filters/yamllint.toml +25 -0
  148. package/temp/rtk/src/find_cmd.rs +598 -0
  149. package/temp/rtk/src/format_cmd.rs +386 -0
  150. package/temp/rtk/src/gain.rs +723 -0
  151. package/temp/rtk/src/gh_cmd.rs +1651 -0
  152. package/temp/rtk/src/git.rs +2012 -0
  153. package/temp/rtk/src/go_cmd.rs +592 -0
  154. package/temp/rtk/src/golangci_cmd.rs +254 -0
  155. package/temp/rtk/src/grep_cmd.rs +288 -0
  156. package/temp/rtk/src/gt_cmd.rs +810 -0
  157. package/temp/rtk/src/hook_audit_cmd.rs +283 -0
  158. package/temp/rtk/src/hook_check.rs +171 -0
  159. package/temp/rtk/src/init.rs +1859 -0
  160. package/temp/rtk/src/integrity.rs +537 -0
  161. package/temp/rtk/src/json_cmd.rs +231 -0
  162. package/temp/rtk/src/learn/detector.rs +628 -0
  163. package/temp/rtk/src/learn/mod.rs +119 -0
  164. package/temp/rtk/src/learn/report.rs +184 -0
  165. package/temp/rtk/src/lint_cmd.rs +694 -0
  166. package/temp/rtk/src/local_llm.rs +316 -0
  167. package/temp/rtk/src/log_cmd.rs +248 -0
  168. package/temp/rtk/src/ls.rs +324 -0
  169. package/temp/rtk/src/main.rs +2482 -0
  170. package/temp/rtk/src/mypy_cmd.rs +389 -0
  171. package/temp/rtk/src/next_cmd.rs +241 -0
  172. package/temp/rtk/src/npm_cmd.rs +236 -0
  173. package/temp/rtk/src/parser/README.md +267 -0
  174. package/temp/rtk/src/parser/error.rs +46 -0
  175. package/temp/rtk/src/parser/formatter.rs +336 -0
  176. package/temp/rtk/src/parser/mod.rs +311 -0
  177. package/temp/rtk/src/parser/types.rs +119 -0
  178. package/temp/rtk/src/pip_cmd.rs +302 -0
  179. package/temp/rtk/src/playwright_cmd.rs +479 -0
  180. package/temp/rtk/src/pnpm_cmd.rs +573 -0
  181. package/temp/rtk/src/prettier_cmd.rs +221 -0
  182. package/temp/rtk/src/prisma_cmd.rs +482 -0
  183. package/temp/rtk/src/psql_cmd.rs +382 -0
  184. package/temp/rtk/src/pytest_cmd.rs +384 -0
  185. package/temp/rtk/src/read.rs +217 -0
  186. package/temp/rtk/src/rewrite_cmd.rs +50 -0
  187. package/temp/rtk/src/ruff_cmd.rs +402 -0
  188. package/temp/rtk/src/runner.rs +271 -0
  189. package/temp/rtk/src/summary.rs +297 -0
  190. package/temp/rtk/src/tee.rs +405 -0
  191. package/temp/rtk/src/telemetry.rs +248 -0
  192. package/temp/rtk/src/toml_filter.rs +1655 -0
  193. package/temp/rtk/src/tracking.rs +1416 -0
  194. package/temp/rtk/src/tree.rs +209 -0
  195. package/temp/rtk/src/tsc_cmd.rs +259 -0
  196. package/temp/rtk/src/utils.rs +432 -0
  197. package/temp/rtk/src/verify_cmd.rs +47 -0
  198. package/temp/rtk/src/vitest_cmd.rs +385 -0
  199. package/temp/rtk/src/wc_cmd.rs +401 -0
  200. package/temp/rtk/src/wget_cmd.rs +260 -0
  201. package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +11 -0
  202. package/temp/rtk/tests/fixtures/dotnet/format_changes.json +31 -0
  203. package/temp/rtk/tests/fixtures/dotnet/format_empty.json +1 -0
  204. package/temp/rtk/tests/fixtures/dotnet/format_success.json +12 -0
  205. package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +18 -0
@@ -0,0 +1,1651 @@
1
+ //! GitHub CLI (gh) command output compression.
2
+ //!
3
+ //! Provides token-optimized alternatives to verbose `gh` commands.
4
+ //! Focuses on extracting essential information from JSON outputs.
5
+
6
+ use crate::git;
7
+ use crate::tracking;
8
+ use crate::utils::{ok_confirmation, truncate};
9
+ use anyhow::{Context, Result};
10
+ use lazy_static::lazy_static;
11
+ use regex::Regex;
12
+ use serde_json::Value;
13
+ use std::process::Command;
14
+
15
+ lazy_static! {
16
+ static ref HTML_COMMENT_RE: Regex = Regex::new(r"(?s)<!--.*?-->").unwrap();
17
+ static ref BADGE_LINE_RE: Regex =
18
+ Regex::new(r"(?m)^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$").unwrap();
19
+ static ref IMAGE_ONLY_LINE_RE: Regex = Regex::new(r"(?m)^\s*!\[[^\]]*\]\([^)]*\)\s*$").unwrap();
20
+ static ref HORIZONTAL_RULE_RE: Regex =
21
+ Regex::new(r"(?m)^\s*(?:---+|\*\*\*+|___+)\s*$").unwrap();
22
+ static ref MULTI_BLANK_RE: Regex = Regex::new(r"\n{3,}").unwrap();
23
+ }
24
+
25
+ /// Filter markdown body to remove noise while preserving meaningful content.
26
+ /// Removes HTML comments, badge lines, image-only lines, horizontal rules,
27
+ /// and collapses excessive blank lines. Preserves code blocks untouched.
28
+ fn filter_markdown_body(body: &str) -> String {
29
+ if body.is_empty() {
30
+ return String::new();
31
+ }
32
+
33
+ // Split into code blocks and non-code segments
34
+ let mut result = String::new();
35
+ let mut remaining = body;
36
+
37
+ loop {
38
+ // Find next code block opening (``` or ~~~)
39
+ let fence_pos = remaining
40
+ .find("```")
41
+ .or_else(|| remaining.find("~~~"))
42
+ .map(|pos| {
43
+ let fence = if remaining[pos..].starts_with("```") {
44
+ "```"
45
+ } else {
46
+ "~~~"
47
+ };
48
+ (pos, fence)
49
+ });
50
+
51
+ match fence_pos {
52
+ Some((start, fence)) => {
53
+ // Filter the text before the code block
54
+ let before = &remaining[..start];
55
+ result.push_str(&filter_markdown_segment(before));
56
+
57
+ // Find the closing fence
58
+ let after_open = start + fence.len();
59
+ // Skip past the opening fence line
60
+ let code_start = remaining[after_open..]
61
+ .find('\n')
62
+ .map(|p| after_open + p + 1)
63
+ .unwrap_or(remaining.len());
64
+
65
+ let close_pos = remaining[code_start..]
66
+ .find(fence)
67
+ .map(|p| code_start + p + fence.len());
68
+
69
+ match close_pos {
70
+ Some(end) => {
71
+ // Preserve the entire code block as-is
72
+ result.push_str(&remaining[start..end]);
73
+ // Include the rest of the closing fence line
74
+ let after_close = remaining[end..]
75
+ .find('\n')
76
+ .map(|p| end + p + 1)
77
+ .unwrap_or(remaining.len());
78
+ result.push_str(&remaining[end..after_close]);
79
+ remaining = &remaining[after_close..];
80
+ }
81
+ None => {
82
+ // Unclosed code block — preserve everything
83
+ result.push_str(&remaining[start..]);
84
+ remaining = "";
85
+ }
86
+ }
87
+ }
88
+ None => {
89
+ // No more code blocks, filter the rest
90
+ result.push_str(&filter_markdown_segment(remaining));
91
+ break;
92
+ }
93
+ }
94
+ }
95
+
96
+ // Final cleanup: trim trailing whitespace
97
+ result.trim().to_string()
98
+ }
99
+
100
+ /// Filter a markdown segment that is NOT inside a code block.
101
+ fn filter_markdown_segment(text: &str) -> String {
102
+ let mut s = HTML_COMMENT_RE.replace_all(text, "").to_string();
103
+ s = BADGE_LINE_RE.replace_all(&s, "").to_string();
104
+ s = IMAGE_ONLY_LINE_RE.replace_all(&s, "").to_string();
105
+ s = HORIZONTAL_RULE_RE.replace_all(&s, "").to_string();
106
+ s = MULTI_BLANK_RE.replace_all(&s, "\n\n").to_string();
107
+ s
108
+ }
109
+
110
+ /// Check if args contain --json flag (user wants specific JSON fields, not RTK filtering)
111
+ fn has_json_flag(args: &[String]) -> bool {
112
+ args.iter().any(|a| a == "--json")
113
+ }
114
+
115
+ /// Extract a positional identifier (PR/issue number) from args, returning it
116
+ /// separately from the remaining extra flags (like -R, --repo, etc.).
117
+ /// Handles both `view 123 -R owner/repo` and `view -R owner/repo 123`.
118
+ fn extract_identifier_and_extra_args(args: &[String]) -> Option<(String, Vec<String>)> {
119
+ if args.is_empty() {
120
+ return None;
121
+ }
122
+
123
+ // Known gh flags that take a value — skip these and their values
124
+ let flags_with_value = [
125
+ "-R",
126
+ "--repo",
127
+ "-q",
128
+ "--jq",
129
+ "-t",
130
+ "--template",
131
+ "--job",
132
+ "--attempt",
133
+ ];
134
+ let mut identifier = None;
135
+ let mut extra = Vec::new();
136
+ let mut skip_next = false;
137
+
138
+ for arg in args {
139
+ if skip_next {
140
+ extra.push(arg.clone());
141
+ skip_next = false;
142
+ continue;
143
+ }
144
+ if flags_with_value.contains(&arg.as_str()) {
145
+ extra.push(arg.clone());
146
+ skip_next = true;
147
+ continue;
148
+ }
149
+ if arg.starts_with('-') {
150
+ extra.push(arg.clone());
151
+ continue;
152
+ }
153
+ // First non-flag arg is the identifier (number/URL)
154
+ if identifier.is_none() {
155
+ identifier = Some(arg.clone());
156
+ } else {
157
+ extra.push(arg.clone());
158
+ }
159
+ }
160
+
161
+ identifier.map(|id| (id, extra))
162
+ }
163
+
164
+ /// Run a gh command with token-optimized output
165
+ pub fn run(subcommand: &str, args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> {
166
+ // When user explicitly passes --json, they want raw gh JSON output, not RTK filtering
167
+ if has_json_flag(args) {
168
+ return run_passthrough("gh", subcommand, args);
169
+ }
170
+
171
+ match subcommand {
172
+ "pr" => run_pr(args, verbose, ultra_compact),
173
+ "issue" => run_issue(args, verbose, ultra_compact),
174
+ "run" => run_workflow(args, verbose, ultra_compact),
175
+ "repo" => run_repo(args, verbose, ultra_compact),
176
+ "api" => run_api(args, verbose),
177
+ _ => {
178
+ // Unknown subcommand, pass through
179
+ run_passthrough("gh", subcommand, args)
180
+ }
181
+ }
182
+ }
183
+
184
+ fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> {
185
+ if args.is_empty() {
186
+ return run_passthrough("gh", "pr", args);
187
+ }
188
+
189
+ match args[0].as_str() {
190
+ "list" => list_prs(&args[1..], verbose, ultra_compact),
191
+ "view" => view_pr(&args[1..], verbose, ultra_compact),
192
+ "checks" => pr_checks(&args[1..], verbose, ultra_compact),
193
+ "status" => pr_status(verbose, ultra_compact),
194
+ "create" => pr_create(&args[1..], verbose),
195
+ "merge" => pr_merge(&args[1..], verbose),
196
+ "diff" => pr_diff(&args[1..], verbose),
197
+ "comment" => pr_action("commented", &args, verbose),
198
+ "edit" => pr_action("edited", &args, verbose),
199
+ _ => run_passthrough("gh", "pr", args),
200
+ }
201
+ }
202
+
203
+ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
204
+ let timer = tracking::TimedExecution::start();
205
+
206
+ let mut cmd = Command::new("gh");
207
+ cmd.args([
208
+ "pr",
209
+ "list",
210
+ "--json",
211
+ "number,title,state,author,updatedAt",
212
+ ]);
213
+
214
+ // Pass through additional flags
215
+ for arg in args {
216
+ cmd.arg(arg);
217
+ }
218
+
219
+ let output = cmd.output().context("Failed to run gh pr list")?;
220
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
221
+
222
+ if !output.status.success() {
223
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
224
+ timer.track("gh pr list", "rtk gh pr list", &stderr, &stderr);
225
+ eprintln!("{}", stderr.trim());
226
+ std::process::exit(output.status.code().unwrap_or(1));
227
+ }
228
+
229
+ let json: Value =
230
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh pr list output")?;
231
+
232
+ let mut filtered = String::new();
233
+
234
+ if let Some(prs) = json.as_array() {
235
+ if ultra_compact {
236
+ filtered.push_str("PRs\n");
237
+ println!("PRs");
238
+ } else {
239
+ filtered.push_str("📋 Pull Requests\n");
240
+ println!("📋 Pull Requests");
241
+ }
242
+
243
+ for pr in prs.iter().take(20) {
244
+ let number = pr["number"].as_i64().unwrap_or(0);
245
+ let title = pr["title"].as_str().unwrap_or("???");
246
+ let state = pr["state"].as_str().unwrap_or("???");
247
+ let author = pr["author"]["login"].as_str().unwrap_or("???");
248
+
249
+ let state_icon = if ultra_compact {
250
+ match state {
251
+ "OPEN" => "O",
252
+ "MERGED" => "M",
253
+ "CLOSED" => "C",
254
+ _ => "?",
255
+ }
256
+ } else {
257
+ match state {
258
+ "OPEN" => "🟢",
259
+ "MERGED" => "🟣",
260
+ "CLOSED" => "🔴",
261
+ _ => "⚪",
262
+ }
263
+ };
264
+
265
+ let line = format!(
266
+ " {} #{} {} ({})\n",
267
+ state_icon,
268
+ number,
269
+ truncate(title, 60),
270
+ author
271
+ );
272
+ filtered.push_str(&line);
273
+ print!("{}", line);
274
+ }
275
+
276
+ if prs.len() > 20 {
277
+ let more_line = format!(" ... {} more (use gh pr list for all)\n", prs.len() - 20);
278
+ filtered.push_str(&more_line);
279
+ print!("{}", more_line);
280
+ }
281
+ }
282
+
283
+ timer.track("gh pr list", "rtk gh pr list", &raw, &filtered);
284
+ Ok(())
285
+ }
286
+
287
+ fn should_passthrough_pr_view(extra_args: &[String]) -> bool {
288
+ extra_args
289
+ .iter()
290
+ .any(|a| a == "--json" || a == "--jq" || a == "--web")
291
+ }
292
+
293
+ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
294
+ let timer = tracking::TimedExecution::start();
295
+
296
+ let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) {
297
+ Some(result) => result,
298
+ None => return Err(anyhow::anyhow!("PR number required")),
299
+ };
300
+
301
+ // If the user provides --jq or --web, pass through directly.
302
+ // Note: --json is already handled globally by run() via has_json_flag.
303
+ if should_passthrough_pr_view(&extra_args) {
304
+ return run_passthrough_with_extra("gh", &["pr", "view", &pr_number], &extra_args);
305
+ }
306
+
307
+ let mut cmd = Command::new("gh");
308
+ cmd.args([
309
+ "pr",
310
+ "view",
311
+ &pr_number,
312
+ "--json",
313
+ "number,title,state,author,body,url,mergeable,reviews,statusCheckRollup",
314
+ ]);
315
+ for arg in &extra_args {
316
+ cmd.arg(arg);
317
+ }
318
+
319
+ let output = cmd.output().context("Failed to run gh pr view")?;
320
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
321
+
322
+ if !output.status.success() {
323
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
324
+ timer.track(
325
+ &format!("gh pr view {}", pr_number),
326
+ &format!("rtk gh pr view {}", pr_number),
327
+ &stderr,
328
+ &stderr,
329
+ );
330
+ eprintln!("{}", stderr.trim());
331
+ std::process::exit(output.status.code().unwrap_or(1));
332
+ }
333
+
334
+ let json: Value =
335
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh pr view output")?;
336
+
337
+ let mut filtered = String::new();
338
+
339
+ // Extract essential info
340
+ let number = json["number"].as_i64().unwrap_or(0);
341
+ let title = json["title"].as_str().unwrap_or("???");
342
+ let state = json["state"].as_str().unwrap_or("???");
343
+ let author = json["author"]["login"].as_str().unwrap_or("???");
344
+ let url = json["url"].as_str().unwrap_or("");
345
+ let mergeable = json["mergeable"].as_str().unwrap_or("UNKNOWN");
346
+
347
+ let state_icon = if ultra_compact {
348
+ match state {
349
+ "OPEN" => "O",
350
+ "MERGED" => "M",
351
+ "CLOSED" => "C",
352
+ _ => "?",
353
+ }
354
+ } else {
355
+ match state {
356
+ "OPEN" => "🟢",
357
+ "MERGED" => "🟣",
358
+ "CLOSED" => "🔴",
359
+ _ => "⚪",
360
+ }
361
+ };
362
+
363
+ let line = format!("{} PR #{}: {}\n", state_icon, number, title);
364
+ filtered.push_str(&line);
365
+ print!("{}", line);
366
+
367
+ let line = format!(" {}\n", author);
368
+ filtered.push_str(&line);
369
+ print!("{}", line);
370
+
371
+ let mergeable_str = match mergeable {
372
+ "MERGEABLE" => "✓",
373
+ "CONFLICTING" => "✗",
374
+ _ => "?",
375
+ };
376
+ let line = format!(" {} | {}\n", state, mergeable_str);
377
+ filtered.push_str(&line);
378
+ print!("{}", line);
379
+
380
+ // Show reviews summary
381
+ if let Some(reviews) = json["reviews"]["nodes"].as_array() {
382
+ let approved = reviews
383
+ .iter()
384
+ .filter(|r| r["state"].as_str() == Some("APPROVED"))
385
+ .count();
386
+ let changes = reviews
387
+ .iter()
388
+ .filter(|r| r["state"].as_str() == Some("CHANGES_REQUESTED"))
389
+ .count();
390
+
391
+ if approved > 0 || changes > 0 {
392
+ let line = format!(
393
+ " Reviews: {} approved, {} changes requested\n",
394
+ approved, changes
395
+ );
396
+ filtered.push_str(&line);
397
+ print!("{}", line);
398
+ }
399
+ }
400
+
401
+ // Show checks summary
402
+ if let Some(checks) = json["statusCheckRollup"].as_array() {
403
+ let total = checks.len();
404
+ let passed = checks
405
+ .iter()
406
+ .filter(|c| {
407
+ c["conclusion"].as_str() == Some("SUCCESS")
408
+ || c["state"].as_str() == Some("SUCCESS")
409
+ })
410
+ .count();
411
+ let failed = checks
412
+ .iter()
413
+ .filter(|c| {
414
+ c["conclusion"].as_str() == Some("FAILURE")
415
+ || c["state"].as_str() == Some("FAILURE")
416
+ })
417
+ .count();
418
+
419
+ if ultra_compact {
420
+ if failed > 0 {
421
+ let line = format!(" ✗{}/{} {} fail\n", passed, total, failed);
422
+ filtered.push_str(&line);
423
+ print!("{}", line);
424
+ } else {
425
+ let line = format!(" ✓{}/{}\n", passed, total);
426
+ filtered.push_str(&line);
427
+ print!("{}", line);
428
+ }
429
+ } else {
430
+ let line = format!(" Checks: {}/{} passed\n", passed, total);
431
+ filtered.push_str(&line);
432
+ print!("{}", line);
433
+ if failed > 0 {
434
+ let line = format!(" ⚠️ {} checks failed\n", failed);
435
+ filtered.push_str(&line);
436
+ print!("{}", line);
437
+ }
438
+ }
439
+ }
440
+
441
+ let line = format!(" {}\n", url);
442
+ filtered.push_str(&line);
443
+ print!("{}", line);
444
+
445
+ // Show filtered body
446
+ if let Some(body) = json["body"].as_str() {
447
+ if !body.is_empty() {
448
+ let body_filtered = filter_markdown_body(body);
449
+ if !body_filtered.is_empty() {
450
+ filtered.push('\n');
451
+ println!();
452
+ for line in body_filtered.lines() {
453
+ let formatted = format!(" {}\n", line);
454
+ filtered.push_str(&formatted);
455
+ print!("{}", formatted);
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ timer.track(
462
+ &format!("gh pr view {}", pr_number),
463
+ &format!("rtk gh pr view {}", pr_number),
464
+ &raw,
465
+ &filtered,
466
+ );
467
+ Ok(())
468
+ }
469
+
470
+ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> {
471
+ let timer = tracking::TimedExecution::start();
472
+
473
+ let (pr_number, extra_args) = match extract_identifier_and_extra_args(args) {
474
+ Some(result) => result,
475
+ None => return Err(anyhow::anyhow!("PR number required")),
476
+ };
477
+
478
+ let mut cmd = Command::new("gh");
479
+ cmd.args(["pr", "checks", &pr_number]);
480
+ for arg in &extra_args {
481
+ cmd.arg(arg);
482
+ }
483
+
484
+ let output = cmd.output().context("Failed to run gh pr checks")?;
485
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
486
+
487
+ if !output.status.success() {
488
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
489
+ timer.track(
490
+ &format!("gh pr checks {}", pr_number),
491
+ &format!("rtk gh pr checks {}", pr_number),
492
+ &stderr,
493
+ &stderr,
494
+ );
495
+ eprintln!("{}", stderr.trim());
496
+ std::process::exit(output.status.code().unwrap_or(1));
497
+ }
498
+
499
+ let stdout = String::from_utf8_lossy(&output.stdout);
500
+
501
+ // Parse and compress checks output
502
+ let mut passed = 0;
503
+ let mut failed = 0;
504
+ let mut pending = 0;
505
+ let mut failed_checks = Vec::new();
506
+
507
+ for line in stdout.lines() {
508
+ if line.contains('✓') || line.contains("pass") {
509
+ passed += 1;
510
+ } else if line.contains('✗') || line.contains("fail") {
511
+ failed += 1;
512
+ failed_checks.push(line.trim().to_string());
513
+ } else if line.contains('*') || line.contains("pending") {
514
+ pending += 1;
515
+ }
516
+ }
517
+
518
+ let mut filtered = String::new();
519
+
520
+ let line = "🔍 CI Checks Summary:\n";
521
+ filtered.push_str(line);
522
+ print!("{}", line);
523
+
524
+ let line = format!(" ✅ Passed: {}\n", passed);
525
+ filtered.push_str(&line);
526
+ print!("{}", line);
527
+
528
+ let line = format!(" ❌ Failed: {}\n", failed);
529
+ filtered.push_str(&line);
530
+ print!("{}", line);
531
+
532
+ if pending > 0 {
533
+ let line = format!(" ⏳ Pending: {}\n", pending);
534
+ filtered.push_str(&line);
535
+ print!("{}", line);
536
+ }
537
+
538
+ if !failed_checks.is_empty() {
539
+ let line = "\n Failed checks:\n";
540
+ filtered.push_str(line);
541
+ print!("{}", line);
542
+ for check in failed_checks {
543
+ let line = format!(" {}\n", check);
544
+ filtered.push_str(&line);
545
+ print!("{}", line);
546
+ }
547
+ }
548
+
549
+ timer.track(
550
+ &format!("gh pr checks {}", pr_number),
551
+ &format!("rtk gh pr checks {}", pr_number),
552
+ &raw,
553
+ &filtered,
554
+ );
555
+ Ok(())
556
+ }
557
+
558
+ fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> {
559
+ let timer = tracking::TimedExecution::start();
560
+
561
+ let mut cmd = Command::new("gh");
562
+ cmd.args([
563
+ "pr",
564
+ "status",
565
+ "--json",
566
+ "currentBranch,createdBy,reviewDecision,statusCheckRollup",
567
+ ]);
568
+
569
+ let output = cmd.output().context("Failed to run gh pr status")?;
570
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
571
+
572
+ if !output.status.success() {
573
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
574
+ timer.track("gh pr status", "rtk gh pr status", &stderr, &stderr);
575
+ eprintln!("{}", stderr.trim());
576
+ std::process::exit(output.status.code().unwrap_or(1));
577
+ }
578
+
579
+ let json: Value =
580
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh pr status output")?;
581
+
582
+ let mut filtered = String::new();
583
+
584
+ if let Some(created_by) = json["createdBy"].as_array() {
585
+ let line = format!("📝 Your PRs ({}):\n", created_by.len());
586
+ filtered.push_str(&line);
587
+ print!("{}", line);
588
+ for pr in created_by.iter().take(5) {
589
+ let number = pr["number"].as_i64().unwrap_or(0);
590
+ let title = pr["title"].as_str().unwrap_or("???");
591
+ let reviews = pr["reviewDecision"].as_str().unwrap_or("PENDING");
592
+ let line = format!(" #{} {} [{}]\n", number, truncate(title, 50), reviews);
593
+ filtered.push_str(&line);
594
+ print!("{}", line);
595
+ }
596
+ }
597
+
598
+ timer.track("gh pr status", "rtk gh pr status", &raw, &filtered);
599
+ Ok(())
600
+ }
601
+
602
+ fn run_issue(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> {
603
+ if args.is_empty() {
604
+ return run_passthrough("gh", "issue", args);
605
+ }
606
+
607
+ match args[0].as_str() {
608
+ "list" => list_issues(&args[1..], verbose, ultra_compact),
609
+ "view" => view_issue(&args[1..], verbose),
610
+ _ => run_passthrough("gh", "issue", args),
611
+ }
612
+ }
613
+
614
+ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
615
+ let timer = tracking::TimedExecution::start();
616
+
617
+ let mut cmd = Command::new("gh");
618
+ cmd.args(["issue", "list", "--json", "number,title,state,author"]);
619
+
620
+ for arg in args {
621
+ cmd.arg(arg);
622
+ }
623
+
624
+ let output = cmd.output().context("Failed to run gh issue list")?;
625
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
626
+
627
+ if !output.status.success() {
628
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
629
+ timer.track("gh issue list", "rtk gh issue list", &stderr, &stderr);
630
+ eprintln!("{}", stderr.trim());
631
+ std::process::exit(output.status.code().unwrap_or(1));
632
+ }
633
+
634
+ let json: Value =
635
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh issue list output")?;
636
+
637
+ let mut filtered = String::new();
638
+
639
+ if let Some(issues) = json.as_array() {
640
+ if ultra_compact {
641
+ filtered.push_str("Issues\n");
642
+ println!("Issues");
643
+ } else {
644
+ filtered.push_str("🐛 Issues\n");
645
+ println!("🐛 Issues");
646
+ }
647
+ for issue in issues.iter().take(20) {
648
+ let number = issue["number"].as_i64().unwrap_or(0);
649
+ let title = issue["title"].as_str().unwrap_or("???");
650
+ let state = issue["state"].as_str().unwrap_or("???");
651
+
652
+ let icon = if ultra_compact {
653
+ if state == "OPEN" {
654
+ "O"
655
+ } else {
656
+ "C"
657
+ }
658
+ } else {
659
+ if state == "OPEN" {
660
+ "🟢"
661
+ } else {
662
+ "🔴"
663
+ }
664
+ };
665
+ let line = format!(" {} #{} {}\n", icon, number, truncate(title, 60));
666
+ filtered.push_str(&line);
667
+ print!("{}", line);
668
+ }
669
+
670
+ if issues.len() > 20 {
671
+ let line = format!(" ... {} more\n", issues.len() - 20);
672
+ filtered.push_str(&line);
673
+ print!("{}", line);
674
+ }
675
+ }
676
+
677
+ timer.track("gh issue list", "rtk gh issue list", &raw, &filtered);
678
+ Ok(())
679
+ }
680
+
681
+ fn view_issue(args: &[String], _verbose: u8) -> Result<()> {
682
+ let timer = tracking::TimedExecution::start();
683
+
684
+ let (issue_number, extra_args) = match extract_identifier_and_extra_args(args) {
685
+ Some(result) => result,
686
+ None => return Err(anyhow::anyhow!("Issue number required")),
687
+ };
688
+
689
+ let mut cmd = Command::new("gh");
690
+ cmd.args([
691
+ "issue",
692
+ "view",
693
+ &issue_number,
694
+ "--json",
695
+ "number,title,state,author,body,url",
696
+ ]);
697
+ for arg in &extra_args {
698
+ cmd.arg(arg);
699
+ }
700
+
701
+ let output = cmd.output().context("Failed to run gh issue view")?;
702
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
703
+
704
+ if !output.status.success() {
705
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
706
+ timer.track(
707
+ &format!("gh issue view {}", issue_number),
708
+ &format!("rtk gh issue view {}", issue_number),
709
+ &stderr,
710
+ &stderr,
711
+ );
712
+ eprintln!("{}", stderr.trim());
713
+ std::process::exit(output.status.code().unwrap_or(1));
714
+ }
715
+
716
+ let json: Value =
717
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh issue view output")?;
718
+
719
+ let number = json["number"].as_i64().unwrap_or(0);
720
+ let title = json["title"].as_str().unwrap_or("???");
721
+ let state = json["state"].as_str().unwrap_or("???");
722
+ let author = json["author"]["login"].as_str().unwrap_or("???");
723
+ let url = json["url"].as_str().unwrap_or("");
724
+
725
+ let icon = if state == "OPEN" { "🟢" } else { "🔴" };
726
+
727
+ let mut filtered = String::new();
728
+
729
+ let line = format!("{} Issue #{}: {}\n", icon, number, title);
730
+ filtered.push_str(&line);
731
+ print!("{}", line);
732
+
733
+ let line = format!(" Author: @{}\n", author);
734
+ filtered.push_str(&line);
735
+ print!("{}", line);
736
+
737
+ let line = format!(" Status: {}\n", state);
738
+ filtered.push_str(&line);
739
+ print!("{}", line);
740
+
741
+ let line = format!(" URL: {}\n", url);
742
+ filtered.push_str(&line);
743
+ print!("{}", line);
744
+
745
+ if let Some(body) = json["body"].as_str() {
746
+ if !body.is_empty() {
747
+ let body_filtered = filter_markdown_body(body);
748
+ if !body_filtered.is_empty() {
749
+ let line = "\n Description:\n";
750
+ filtered.push_str(line);
751
+ print!("{}", line);
752
+ for line in body_filtered.lines() {
753
+ let formatted = format!(" {}\n", line);
754
+ filtered.push_str(&formatted);
755
+ print!("{}", formatted);
756
+ }
757
+ }
758
+ }
759
+ }
760
+
761
+ timer.track(
762
+ &format!("gh issue view {}", issue_number),
763
+ &format!("rtk gh issue view {}", issue_number),
764
+ &raw,
765
+ &filtered,
766
+ );
767
+ Ok(())
768
+ }
769
+
770
+ fn run_workflow(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> {
771
+ if args.is_empty() {
772
+ return run_passthrough("gh", "run", args);
773
+ }
774
+
775
+ match args[0].as_str() {
776
+ "list" => list_runs(&args[1..], verbose, ultra_compact),
777
+ "view" => view_run(&args[1..], verbose),
778
+ _ => run_passthrough("gh", "run", args),
779
+ }
780
+ }
781
+
782
+ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> {
783
+ let timer = tracking::TimedExecution::start();
784
+
785
+ let mut cmd = Command::new("gh");
786
+ cmd.args([
787
+ "run",
788
+ "list",
789
+ "--json",
790
+ "databaseId,name,status,conclusion,createdAt",
791
+ ]);
792
+ cmd.arg("--limit").arg("10");
793
+
794
+ for arg in args {
795
+ cmd.arg(arg);
796
+ }
797
+
798
+ let output = cmd.output().context("Failed to run gh run list")?;
799
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
800
+
801
+ if !output.status.success() {
802
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
803
+ timer.track("gh run list", "rtk gh run list", &stderr, &stderr);
804
+ eprintln!("{}", stderr.trim());
805
+ std::process::exit(output.status.code().unwrap_or(1));
806
+ }
807
+
808
+ let json: Value =
809
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh run list output")?;
810
+
811
+ let mut filtered = String::new();
812
+
813
+ if let Some(runs) = json.as_array() {
814
+ if ultra_compact {
815
+ filtered.push_str("Runs\n");
816
+ println!("Runs");
817
+ } else {
818
+ filtered.push_str("🏃 Workflow Runs\n");
819
+ println!("🏃 Workflow Runs");
820
+ }
821
+ for run in runs {
822
+ let id = run["databaseId"].as_i64().unwrap_or(0);
823
+ let name = run["name"].as_str().unwrap_or("???");
824
+ let status = run["status"].as_str().unwrap_or("???");
825
+ let conclusion = run["conclusion"].as_str().unwrap_or("");
826
+
827
+ let icon = if ultra_compact {
828
+ match conclusion {
829
+ "success" => "✓",
830
+ "failure" => "✗",
831
+ "cancelled" => "X",
832
+ _ => {
833
+ if status == "in_progress" {
834
+ "~"
835
+ } else {
836
+ "?"
837
+ }
838
+ }
839
+ }
840
+ } else {
841
+ match conclusion {
842
+ "success" => "✅",
843
+ "failure" => "❌",
844
+ "cancelled" => "🚫",
845
+ _ => {
846
+ if status == "in_progress" {
847
+ "⏳"
848
+ } else {
849
+ "⚪"
850
+ }
851
+ }
852
+ }
853
+ };
854
+
855
+ let line = format!(" {} {} [{}]\n", icon, truncate(name, 50), id);
856
+ filtered.push_str(&line);
857
+ print!("{}", line);
858
+ }
859
+ }
860
+
861
+ timer.track("gh run list", "rtk gh run list", &raw, &filtered);
862
+ Ok(())
863
+ }
864
+
865
+ /// Check if run view args should bypass filtering and pass through directly.
866
+ /// Flags like --log-failed, --log, and --json produce output that the filter
867
+ /// would incorrectly strip.
868
+ fn should_passthrough_run_view(extra_args: &[String]) -> bool {
869
+ extra_args
870
+ .iter()
871
+ .any(|a| a == "--log-failed" || a == "--log" || a == "--json")
872
+ }
873
+
874
+ fn view_run(args: &[String], _verbose: u8) -> Result<()> {
875
+ let (run_id, extra_args) = match extract_identifier_and_extra_args(args) {
876
+ Some(result) => result,
877
+ None => return Err(anyhow::anyhow!("Run ID required")),
878
+ };
879
+
880
+ // Pass through when user requests logs or JSON — the filter would strip them
881
+ if should_passthrough_run_view(&extra_args) {
882
+ return run_passthrough_with_extra("gh", &["run", "view", &run_id], &extra_args);
883
+ }
884
+
885
+ let timer = tracking::TimedExecution::start();
886
+
887
+ let mut cmd = Command::new("gh");
888
+ cmd.args(["run", "view", &run_id]);
889
+ for arg in &extra_args {
890
+ cmd.arg(arg);
891
+ }
892
+
893
+ let output = cmd.output().context("Failed to run gh run view")?;
894
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
895
+
896
+ if !output.status.success() {
897
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
898
+ timer.track(
899
+ &format!("gh run view {}", run_id),
900
+ &format!("rtk gh run view {}", run_id),
901
+ &stderr,
902
+ &stderr,
903
+ );
904
+ eprintln!("{}", stderr.trim());
905
+ std::process::exit(output.status.code().unwrap_or(1));
906
+ }
907
+
908
+ // Parse output and show only failures
909
+ let stdout = String::from_utf8_lossy(&output.stdout);
910
+ let mut in_jobs = false;
911
+
912
+ let mut filtered = String::new();
913
+
914
+ let line = format!("🏃 Workflow Run #{}\n", run_id);
915
+ filtered.push_str(&line);
916
+ print!("{}", line);
917
+
918
+ for line in stdout.lines() {
919
+ if line.contains("JOBS") {
920
+ in_jobs = true;
921
+ }
922
+
923
+ if in_jobs {
924
+ if line.contains('✓') || line.contains("success") {
925
+ // Skip successful jobs in compact mode
926
+ continue;
927
+ }
928
+ if line.contains('✗') || line.contains("fail") {
929
+ let formatted = format!(" ❌ {}\n", line.trim());
930
+ filtered.push_str(&formatted);
931
+ print!("{}", formatted);
932
+ }
933
+ } else if line.contains("Status:") || line.contains("Conclusion:") {
934
+ let formatted = format!(" {}\n", line.trim());
935
+ filtered.push_str(&formatted);
936
+ print!("{}", formatted);
937
+ }
938
+ }
939
+
940
+ timer.track(
941
+ &format!("gh run view {}", run_id),
942
+ &format!("rtk gh run view {}", run_id),
943
+ &raw,
944
+ &filtered,
945
+ );
946
+ Ok(())
947
+ }
948
+
949
+ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> {
950
+ // Parse subcommand (default to "view")
951
+ let (subcommand, rest_args) = if args.is_empty() {
952
+ ("view", args)
953
+ } else {
954
+ (args[0].as_str(), &args[1..])
955
+ };
956
+
957
+ if subcommand != "view" {
958
+ return run_passthrough("gh", "repo", args);
959
+ }
960
+
961
+ let timer = tracking::TimedExecution::start();
962
+
963
+ let mut cmd = Command::new("gh");
964
+ cmd.arg("repo").arg("view");
965
+
966
+ for arg in rest_args {
967
+ cmd.arg(arg);
968
+ }
969
+
970
+ cmd.args([
971
+ "--json",
972
+ "name,owner,description,url,stargazerCount,forkCount,isPrivate",
973
+ ]);
974
+
975
+ let output = cmd.output().context("Failed to run gh repo view")?;
976
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
977
+
978
+ if !output.status.success() {
979
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
980
+ timer.track("gh repo view", "rtk gh repo view", &stderr, &stderr);
981
+ eprintln!("{}", stderr.trim());
982
+ std::process::exit(output.status.code().unwrap_or(1));
983
+ }
984
+
985
+ let json: Value =
986
+ serde_json::from_slice(&output.stdout).context("Failed to parse gh repo view output")?;
987
+
988
+ let name = json["name"].as_str().unwrap_or("???");
989
+ let owner = json["owner"]["login"].as_str().unwrap_or("???");
990
+ let description = json["description"].as_str().unwrap_or("");
991
+ let url = json["url"].as_str().unwrap_or("");
992
+ let stars = json["stargazerCount"].as_i64().unwrap_or(0);
993
+ let forks = json["forkCount"].as_i64().unwrap_or(0);
994
+ let private = json["isPrivate"].as_bool().unwrap_or(false);
995
+
996
+ let visibility = if private {
997
+ "🔒 Private"
998
+ } else {
999
+ "🌐 Public"
1000
+ };
1001
+
1002
+ let mut filtered = String::new();
1003
+
1004
+ let line = format!("📦 {}/{}\n", owner, name);
1005
+ filtered.push_str(&line);
1006
+ print!("{}", line);
1007
+
1008
+ let line = format!(" {}\n", visibility);
1009
+ filtered.push_str(&line);
1010
+ print!("{}", line);
1011
+
1012
+ if !description.is_empty() {
1013
+ let line = format!(" {}\n", truncate(description, 80));
1014
+ filtered.push_str(&line);
1015
+ print!("{}", line);
1016
+ }
1017
+
1018
+ let line = format!(" ⭐ {} stars | 🔱 {} forks\n", stars, forks);
1019
+ filtered.push_str(&line);
1020
+ print!("{}", line);
1021
+
1022
+ let line = format!(" {}\n", url);
1023
+ filtered.push_str(&line);
1024
+ print!("{}", line);
1025
+
1026
+ timer.track("gh repo view", "rtk gh repo view", &raw, &filtered);
1027
+ Ok(())
1028
+ }
1029
+
1030
+ fn pr_create(args: &[String], _verbose: u8) -> Result<()> {
1031
+ let timer = tracking::TimedExecution::start();
1032
+
1033
+ let mut cmd = Command::new("gh");
1034
+ cmd.args(["pr", "create"]);
1035
+ for arg in args {
1036
+ cmd.arg(arg);
1037
+ }
1038
+
1039
+ let output = cmd.output().context("Failed to run gh pr create")?;
1040
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1041
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1042
+
1043
+ if !output.status.success() {
1044
+ timer.track("gh pr create", "rtk gh pr create", &stderr, &stderr);
1045
+ eprintln!("{}", stderr.trim());
1046
+ std::process::exit(output.status.code().unwrap_or(1));
1047
+ }
1048
+
1049
+ // gh pr create outputs the URL on success
1050
+ let url = stdout.trim();
1051
+
1052
+ // Try to extract PR number from URL (e.g., https://github.com/owner/repo/pull/42)
1053
+ let pr_num = url.rsplit('/').next().unwrap_or("");
1054
+
1055
+ let detail = if !pr_num.is_empty() && pr_num.chars().all(|c| c.is_ascii_digit()) {
1056
+ format!("#{} {}", pr_num, url)
1057
+ } else {
1058
+ url.to_string()
1059
+ };
1060
+
1061
+ let filtered = ok_confirmation("created", &detail);
1062
+ println!("{}", filtered);
1063
+
1064
+ timer.track("gh pr create", "rtk gh pr create", &stdout, &filtered);
1065
+ Ok(())
1066
+ }
1067
+
1068
+ fn pr_merge(args: &[String], _verbose: u8) -> Result<()> {
1069
+ let timer = tracking::TimedExecution::start();
1070
+
1071
+ let mut cmd = Command::new("gh");
1072
+ cmd.args(["pr", "merge"]);
1073
+ for arg in args {
1074
+ cmd.arg(arg);
1075
+ }
1076
+
1077
+ let output = cmd.output().context("Failed to run gh pr merge")?;
1078
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1079
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1080
+
1081
+ if !output.status.success() {
1082
+ timer.track("gh pr merge", "rtk gh pr merge", &stderr, &stderr);
1083
+ eprintln!("{}", stderr.trim());
1084
+ std::process::exit(output.status.code().unwrap_or(1));
1085
+ }
1086
+
1087
+ // Extract PR number from args (first non-flag arg)
1088
+ let pr_num = args
1089
+ .iter()
1090
+ .find(|a| !a.starts_with('-'))
1091
+ .map(|s| s.as_str())
1092
+ .unwrap_or("");
1093
+
1094
+ let detail = if !pr_num.is_empty() {
1095
+ format!("#{}", pr_num)
1096
+ } else {
1097
+ String::new()
1098
+ };
1099
+
1100
+ let filtered = ok_confirmation("merged", &detail);
1101
+ println!("{}", filtered);
1102
+
1103
+ // Use stdout or detail as raw input (gh pr merge doesn't output much)
1104
+ let raw = if !stdout.trim().is_empty() {
1105
+ stdout
1106
+ } else {
1107
+ detail.clone()
1108
+ };
1109
+
1110
+ timer.track("gh pr merge", "rtk gh pr merge", &raw, &filtered);
1111
+ Ok(())
1112
+ }
1113
+
1114
+ fn pr_diff(args: &[String], _verbose: u8) -> Result<()> {
1115
+ // --no-compact: pass full diff through (gh CLI doesn't know this flag, strip it)
1116
+ let no_compact = args.iter().any(|a| a == "--no-compact");
1117
+ let gh_args: Vec<String> = args
1118
+ .iter()
1119
+ .filter(|a| *a != "--no-compact")
1120
+ .cloned()
1121
+ .collect();
1122
+
1123
+ if no_compact {
1124
+ return run_passthrough_with_extra("gh", &["pr", "diff"], &gh_args);
1125
+ }
1126
+
1127
+ let timer = tracking::TimedExecution::start();
1128
+
1129
+ let mut cmd = Command::new("gh");
1130
+ cmd.args(["pr", "diff"]);
1131
+ for arg in gh_args.iter() {
1132
+ cmd.arg(arg);
1133
+ }
1134
+
1135
+ let output = cmd.output().context("Failed to run gh pr diff")?;
1136
+ let raw = String::from_utf8_lossy(&output.stdout).to_string();
1137
+
1138
+ if !output.status.success() {
1139
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1140
+ timer.track("gh pr diff", "rtk gh pr diff", &stderr, &stderr);
1141
+ eprintln!("{}", stderr.trim());
1142
+ std::process::exit(output.status.code().unwrap_or(1));
1143
+ }
1144
+
1145
+ let filtered = if raw.trim().is_empty() {
1146
+ let msg = "No diff\n";
1147
+ print!("{}", msg);
1148
+ msg.to_string()
1149
+ } else {
1150
+ let compacted = git::compact_diff(&raw, 500);
1151
+ println!("{}", compacted);
1152
+ compacted
1153
+ };
1154
+
1155
+ timer.track("gh pr diff", "rtk gh pr diff", &raw, &filtered);
1156
+ Ok(())
1157
+ }
1158
+
1159
+ /// Generic PR action handler for comment/edit
1160
+ fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result<()> {
1161
+ let timer = tracking::TimedExecution::start();
1162
+ let subcmd = &args[0];
1163
+
1164
+ let mut cmd = Command::new("gh");
1165
+ cmd.arg("pr");
1166
+ for arg in args {
1167
+ cmd.arg(arg);
1168
+ }
1169
+
1170
+ let output = cmd
1171
+ .output()
1172
+ .context(format!("Failed to run gh pr {}", subcmd))?;
1173
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1174
+
1175
+ if !output.status.success() {
1176
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1177
+ timer.track(
1178
+ &format!("gh pr {}", subcmd),
1179
+ &format!("rtk gh pr {}", subcmd),
1180
+ &stderr,
1181
+ &stderr,
1182
+ );
1183
+ eprintln!("{}", stderr.trim());
1184
+ std::process::exit(output.status.code().unwrap_or(1));
1185
+ }
1186
+
1187
+ // Extract PR number from args (skip args[0] which is the subcommand)
1188
+ let pr_num = args[1..]
1189
+ .iter()
1190
+ .find(|a| !a.starts_with('-'))
1191
+ .map(|s| format!("#{}", s))
1192
+ .unwrap_or_default();
1193
+
1194
+ let filtered = ok_confirmation(action, &pr_num);
1195
+ println!("{}", filtered);
1196
+
1197
+ // Use stdout or pr_num as raw input
1198
+ let raw = if !stdout.trim().is_empty() {
1199
+ stdout
1200
+ } else {
1201
+ pr_num.clone()
1202
+ };
1203
+
1204
+ timer.track(
1205
+ &format!("gh pr {}", subcmd),
1206
+ &format!("rtk gh pr {}", subcmd),
1207
+ &raw,
1208
+ &filtered,
1209
+ );
1210
+ Ok(())
1211
+ }
1212
+
1213
+ fn run_api(args: &[String], _verbose: u8) -> Result<()> {
1214
+ // gh api is an explicit/advanced command — the user knows what they asked for.
1215
+ // Converting JSON to a schema destroys all values and forces Claude to re-fetch.
1216
+ // Passthrough preserves the full response and tracks metrics at 0% savings.
1217
+ run_passthrough("gh", "api", args)
1218
+ }
1219
+
1220
+ /// Pass through a command with base args + extra args, tracking as passthrough.
1221
+ fn run_passthrough_with_extra(cmd: &str, base_args: &[&str], extra_args: &[String]) -> Result<()> {
1222
+ let timer = tracking::TimedExecution::start();
1223
+
1224
+ let mut command = Command::new(cmd);
1225
+ for arg in base_args {
1226
+ command.arg(arg);
1227
+ }
1228
+ for arg in extra_args {
1229
+ command.arg(arg);
1230
+ }
1231
+
1232
+ let status =
1233
+ command
1234
+ .status()
1235
+ .context(format!("Failed to run {} {}", cmd, base_args.join(" ")))?;
1236
+
1237
+ let full_cmd = format!(
1238
+ "{} {} {}",
1239
+ cmd,
1240
+ base_args.join(" "),
1241
+ tracking::args_display(&extra_args.iter().map(|s| s.into()).collect::<Vec<_>>())
1242
+ );
1243
+ timer.track_passthrough(&full_cmd, &format!("rtk {} (passthrough)", full_cmd));
1244
+
1245
+ if !status.success() {
1246
+ std::process::exit(status.code().unwrap_or(1));
1247
+ }
1248
+
1249
+ Ok(())
1250
+ }
1251
+
1252
+ fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result<()> {
1253
+ let timer = tracking::TimedExecution::start();
1254
+
1255
+ let mut command = Command::new(cmd);
1256
+ command.arg(subcommand);
1257
+ for arg in args {
1258
+ command.arg(arg);
1259
+ }
1260
+
1261
+ let status = command
1262
+ .status()
1263
+ .context(format!("Failed to run {} {}", cmd, subcommand))?;
1264
+
1265
+ let args_str = tracking::args_display(&args.iter().map(|s| s.into()).collect::<Vec<_>>());
1266
+ timer.track_passthrough(
1267
+ &format!("{} {} {}", cmd, subcommand, args_str),
1268
+ &format!("rtk {} {} {} (passthrough)", cmd, subcommand, args_str),
1269
+ );
1270
+
1271
+ if !status.success() {
1272
+ std::process::exit(status.code().unwrap_or(1));
1273
+ }
1274
+
1275
+ Ok(())
1276
+ }
1277
+
1278
+ #[cfg(test)]
1279
+ mod tests {
1280
+ use super::*;
1281
+
1282
+ #[test]
1283
+ fn test_truncate() {
1284
+ assert_eq!(truncate("short", 10), "short");
1285
+ assert_eq!(
1286
+ truncate("this is a very long string", 15),
1287
+ "this is a ve..."
1288
+ );
1289
+ }
1290
+
1291
+ #[test]
1292
+ fn test_truncate_multibyte_utf8() {
1293
+ // Emoji: 🚀 = 4 bytes, 1 char
1294
+ assert_eq!(truncate("🚀🎉🔥abc", 6), "🚀🎉🔥abc"); // 6 chars, fits
1295
+ assert_eq!(truncate("🚀🎉🔥abcdef", 8), "🚀🎉🔥ab..."); // 10 chars > 8
1296
+ // Edge case: all multibyte
1297
+ assert_eq!(truncate("🚀🎉🔥🌟🎯", 5), "🚀🎉🔥🌟🎯"); // exact fit
1298
+ assert_eq!(truncate("🚀🎉🔥🌟🎯x", 5), "🚀🎉..."); // 6 chars > 5
1299
+ }
1300
+
1301
+ #[test]
1302
+ fn test_truncate_empty_and_short() {
1303
+ assert_eq!(truncate("", 10), "");
1304
+ assert_eq!(truncate("ab", 10), "ab");
1305
+ assert_eq!(truncate("abc", 3), "abc"); // exact fit
1306
+ }
1307
+
1308
+ #[test]
1309
+ fn test_ok_confirmation_pr_create() {
1310
+ let result = ok_confirmation("created", "#42 https://github.com/foo/bar/pull/42");
1311
+ assert!(result.contains("ok created"));
1312
+ assert!(result.contains("#42"));
1313
+ }
1314
+
1315
+ #[test]
1316
+ fn test_ok_confirmation_pr_merge() {
1317
+ let result = ok_confirmation("merged", "#42");
1318
+ assert_eq!(result, "ok merged #42");
1319
+ }
1320
+
1321
+ #[test]
1322
+ fn test_ok_confirmation_pr_comment() {
1323
+ let result = ok_confirmation("commented", "#42");
1324
+ assert_eq!(result, "ok commented #42");
1325
+ }
1326
+
1327
+ #[test]
1328
+ fn test_ok_confirmation_pr_edit() {
1329
+ let result = ok_confirmation("edited", "#42");
1330
+ assert_eq!(result, "ok edited #42");
1331
+ }
1332
+
1333
+ #[test]
1334
+ fn test_has_json_flag_present() {
1335
+ assert!(has_json_flag(&[
1336
+ "view".into(),
1337
+ "--json".into(),
1338
+ "number,url".into()
1339
+ ]));
1340
+ }
1341
+
1342
+ #[test]
1343
+ fn test_has_json_flag_absent() {
1344
+ assert!(!has_json_flag(&["view".into(), "42".into()]));
1345
+ }
1346
+
1347
+ #[test]
1348
+ fn test_extract_identifier_simple() {
1349
+ let args: Vec<String> = vec!["123".into()];
1350
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1351
+ assert_eq!(id, "123");
1352
+ assert!(extra.is_empty());
1353
+ }
1354
+
1355
+ #[test]
1356
+ fn test_extract_identifier_with_repo_flag_after() {
1357
+ // gh issue view 185 -R rtk-ai/rtk
1358
+ let args: Vec<String> = vec!["185".into(), "-R".into(), "rtk-ai/rtk".into()];
1359
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1360
+ assert_eq!(id, "185");
1361
+ assert_eq!(extra, vec!["-R", "rtk-ai/rtk"]);
1362
+ }
1363
+
1364
+ #[test]
1365
+ fn test_extract_identifier_with_repo_flag_before() {
1366
+ // gh issue view -R rtk-ai/rtk 185
1367
+ let args: Vec<String> = vec!["-R".into(), "rtk-ai/rtk".into(), "185".into()];
1368
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1369
+ assert_eq!(id, "185");
1370
+ assert_eq!(extra, vec!["-R", "rtk-ai/rtk"]);
1371
+ }
1372
+
1373
+ #[test]
1374
+ fn test_extract_identifier_with_long_repo_flag() {
1375
+ let args: Vec<String> = vec!["42".into(), "--repo".into(), "owner/repo".into()];
1376
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1377
+ assert_eq!(id, "42");
1378
+ assert_eq!(extra, vec!["--repo", "owner/repo"]);
1379
+ }
1380
+
1381
+ #[test]
1382
+ fn test_extract_identifier_empty() {
1383
+ let args: Vec<String> = vec![];
1384
+ assert!(extract_identifier_and_extra_args(&args).is_none());
1385
+ }
1386
+
1387
+ #[test]
1388
+ fn test_extract_identifier_only_flags() {
1389
+ // No positional identifier, only flags
1390
+ let args: Vec<String> = vec!["-R".into(), "rtk-ai/rtk".into()];
1391
+ assert!(extract_identifier_and_extra_args(&args).is_none());
1392
+ }
1393
+
1394
+ #[test]
1395
+ fn test_extract_identifier_with_web_flag() {
1396
+ let args: Vec<String> = vec!["123".into(), "--web".into()];
1397
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1398
+ assert_eq!(id, "123");
1399
+ assert_eq!(extra, vec!["--web"]);
1400
+ }
1401
+
1402
+ #[test]
1403
+ fn test_run_view_passthrough_log_failed() {
1404
+ assert!(should_passthrough_run_view(&["--log-failed".into()]));
1405
+ }
1406
+
1407
+ #[test]
1408
+ fn test_run_view_passthrough_log() {
1409
+ assert!(should_passthrough_run_view(&["--log".into()]));
1410
+ }
1411
+
1412
+ #[test]
1413
+ fn test_run_view_passthrough_json() {
1414
+ assert!(should_passthrough_run_view(&[
1415
+ "--json".into(),
1416
+ "jobs".into()
1417
+ ]));
1418
+ }
1419
+
1420
+ #[test]
1421
+ fn test_run_view_no_passthrough_empty() {
1422
+ assert!(!should_passthrough_run_view(&[]));
1423
+ }
1424
+
1425
+ #[test]
1426
+ fn test_run_view_no_passthrough_other_flags() {
1427
+ assert!(!should_passthrough_run_view(&["--web".into()]));
1428
+ }
1429
+
1430
+ #[test]
1431
+ fn test_extract_identifier_with_job_flag_after() {
1432
+ // gh run view 12345 --job 67890
1433
+ let args: Vec<String> = vec!["12345".into(), "--job".into(), "67890".into()];
1434
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1435
+ assert_eq!(id, "12345");
1436
+ assert_eq!(extra, vec!["--job", "67890"]);
1437
+ }
1438
+
1439
+ #[test]
1440
+ fn test_extract_identifier_with_job_flag_before() {
1441
+ // gh run view --job 67890 12345
1442
+ let args: Vec<String> = vec!["--job".into(), "67890".into(), "12345".into()];
1443
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1444
+ assert_eq!(id, "12345");
1445
+ assert_eq!(extra, vec!["--job", "67890"]);
1446
+ }
1447
+
1448
+ #[test]
1449
+ fn test_extract_identifier_with_job_and_log_failed() {
1450
+ // gh run view --log-failed --job 67890 12345
1451
+ let args: Vec<String> = vec![
1452
+ "--log-failed".into(),
1453
+ "--job".into(),
1454
+ "67890".into(),
1455
+ "12345".into(),
1456
+ ];
1457
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1458
+ assert_eq!(id, "12345");
1459
+ assert_eq!(extra, vec!["--log-failed", "--job", "67890"]);
1460
+ }
1461
+
1462
+ #[test]
1463
+ fn test_extract_identifier_with_attempt_flag() {
1464
+ // gh run view 12345 --attempt 3
1465
+ let args: Vec<String> = vec!["12345".into(), "--attempt".into(), "3".into()];
1466
+ let (id, extra) = extract_identifier_and_extra_args(&args).unwrap();
1467
+ assert_eq!(id, "12345");
1468
+ assert_eq!(extra, vec!["--attempt", "3"]);
1469
+ }
1470
+
1471
+ // --- should_passthrough_pr_view tests ---
1472
+
1473
+ #[test]
1474
+ fn test_should_passthrough_pr_view_json() {
1475
+ assert!(should_passthrough_pr_view(&[
1476
+ "--json".into(),
1477
+ "body,comments".into()
1478
+ ]));
1479
+ }
1480
+
1481
+ #[test]
1482
+ fn test_should_passthrough_pr_view_jq() {
1483
+ assert!(should_passthrough_pr_view(&["--jq".into(), ".body".into()]));
1484
+ }
1485
+
1486
+ #[test]
1487
+ fn test_should_passthrough_pr_view_web() {
1488
+ assert!(should_passthrough_pr_view(&["--web".into()]));
1489
+ }
1490
+
1491
+ #[test]
1492
+ fn test_should_passthrough_pr_view_default() {
1493
+ assert!(!should_passthrough_pr_view(&[]));
1494
+ }
1495
+
1496
+ #[test]
1497
+ fn test_should_passthrough_pr_view_other_flags() {
1498
+ assert!(!should_passthrough_pr_view(&["--comments".into()]));
1499
+ }
1500
+
1501
+ // --- filter_markdown_body tests ---
1502
+
1503
+ #[test]
1504
+ fn test_filter_markdown_body_html_comment_single_line() {
1505
+ let input = "Hello\n<!-- this is a comment -->\nWorld";
1506
+ let result = filter_markdown_body(input);
1507
+ assert!(!result.contains("<!--"));
1508
+ assert!(result.contains("Hello"));
1509
+ assert!(result.contains("World"));
1510
+ }
1511
+
1512
+ #[test]
1513
+ fn test_filter_markdown_body_html_comment_multiline() {
1514
+ let input = "Before\n<!--\nmultiline\ncomment\n-->\nAfter";
1515
+ let result = filter_markdown_body(input);
1516
+ assert!(!result.contains("<!--"));
1517
+ assert!(!result.contains("multiline"));
1518
+ assert!(result.contains("Before"));
1519
+ assert!(result.contains("After"));
1520
+ }
1521
+
1522
+ #[test]
1523
+ fn test_filter_markdown_body_badge_lines() {
1524
+ let input = "# Title\n[![CI](https://img.shields.io/badge.svg)](https://github.com/actions)\nSome text";
1525
+ let result = filter_markdown_body(input);
1526
+ assert!(!result.contains("shields.io"));
1527
+ assert!(result.contains("# Title"));
1528
+ assert!(result.contains("Some text"));
1529
+ }
1530
+
1531
+ #[test]
1532
+ fn test_filter_markdown_body_image_only_lines() {
1533
+ let input = "# Title\n![screenshot](https://example.com/img.png)\nSome text";
1534
+ let result = filter_markdown_body(input);
1535
+ assert!(!result.contains("![screenshot]"));
1536
+ assert!(result.contains("# Title"));
1537
+ assert!(result.contains("Some text"));
1538
+ }
1539
+
1540
+ #[test]
1541
+ fn test_filter_markdown_body_horizontal_rules() {
1542
+ let input = "Section 1\n---\nSection 2\n***\nSection 3\n___\nEnd";
1543
+ let result = filter_markdown_body(input);
1544
+ assert!(!result.contains("---"));
1545
+ assert!(!result.contains("***"));
1546
+ assert!(!result.contains("___"));
1547
+ assert!(result.contains("Section 1"));
1548
+ assert!(result.contains("Section 2"));
1549
+ assert!(result.contains("Section 3"));
1550
+ }
1551
+
1552
+ #[test]
1553
+ fn test_filter_markdown_body_blank_lines_collapse() {
1554
+ let input = "Line 1\n\n\n\n\nLine 2";
1555
+ let result = filter_markdown_body(input);
1556
+ // Should collapse to at most one blank line (2 newlines)
1557
+ assert!(!result.contains("\n\n\n"));
1558
+ assert!(result.contains("Line 1"));
1559
+ assert!(result.contains("Line 2"));
1560
+ }
1561
+
1562
+ #[test]
1563
+ fn test_filter_markdown_body_code_block_preserved() {
1564
+ let input = "Text before\n```python\n<!-- not a comment -->\n![not an image](url)\n---\n```\nText after";
1565
+ let result = filter_markdown_body(input);
1566
+ // Content inside code block should be preserved
1567
+ assert!(result.contains("<!-- not a comment -->"));
1568
+ assert!(result.contains("![not an image](url)"));
1569
+ assert!(result.contains("---"));
1570
+ assert!(result.contains("Text before"));
1571
+ assert!(result.contains("Text after"));
1572
+ }
1573
+
1574
+ #[test]
1575
+ fn test_filter_markdown_body_empty() {
1576
+ assert_eq!(filter_markdown_body(""), "");
1577
+ }
1578
+
1579
+ #[test]
1580
+ fn test_filter_markdown_body_meaningful_content_preserved() {
1581
+ let input = "## Summary\n- Item 1\n- Item 2\n\n[Link](https://example.com)\n\n| Col1 | Col2 |\n| --- | --- |\n| a | b |";
1582
+ let result = filter_markdown_body(input);
1583
+ assert!(result.contains("## Summary"));
1584
+ assert!(result.contains("- Item 1"));
1585
+ assert!(result.contains("- Item 2"));
1586
+ assert!(result.contains("[Link](https://example.com)"));
1587
+ assert!(result.contains("| Col1 | Col2 |"));
1588
+ }
1589
+
1590
+ #[test]
1591
+ fn test_filter_markdown_body_token_savings() {
1592
+ // Realistic PR body with noise
1593
+ let input = r#"<!-- This PR template is auto-generated -->
1594
+ <!-- Please fill in the following sections -->
1595
+
1596
+ ## Summary
1597
+
1598
+ Added smart markdown filtering for gh issue/pr view commands.
1599
+
1600
+ [![CI](https://img.shields.io/github/actions/workflow/status/rtk-ai/rtk/ci.yml)](https://github.com/rtk-ai/rtk/actions)
1601
+ [![Coverage](https://img.shields.io/codecov/c/github/rtk-ai/rtk)](https://codecov.io/gh/rtk-ai/rtk)
1602
+
1603
+ ![screenshot](https://user-images.githubusercontent.com/123/screenshot.png)
1604
+
1605
+ ---
1606
+
1607
+ ## Changes
1608
+
1609
+ - Filter HTML comments
1610
+ - Filter badge lines
1611
+ - Filter image-only lines
1612
+ - Collapse blank lines
1613
+
1614
+ ***
1615
+
1616
+ ## Test Plan
1617
+
1618
+ - [x] Unit tests added
1619
+ - [x] Snapshot tests pass
1620
+ - [ ] Manual testing
1621
+
1622
+ ___
1623
+
1624
+ <!-- Do not edit below this line -->
1625
+ <!-- Auto-generated footer -->"#;
1626
+
1627
+ let result = filter_markdown_body(input);
1628
+
1629
+ fn count_tokens(text: &str) -> usize {
1630
+ text.split_whitespace().count()
1631
+ }
1632
+
1633
+ let input_tokens = count_tokens(input);
1634
+ let output_tokens = count_tokens(&result);
1635
+ let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0);
1636
+
1637
+ assert!(
1638
+ savings >= 30.0,
1639
+ "Expected ≥30% savings, got {:.1}% (input: {} tokens, output: {} tokens)",
1640
+ savings,
1641
+ input_tokens,
1642
+ output_tokens
1643
+ );
1644
+
1645
+ // Verify meaningful content preserved
1646
+ assert!(result.contains("## Summary"));
1647
+ assert!(result.contains("## Changes"));
1648
+ assert!(result.contains("## Test Plan"));
1649
+ assert!(result.contains("Filter HTML comments"));
1650
+ }
1651
+ }